feat: add new theme #61

Merged
reya merged 14 commits from new-theme into master 2026-10-10 00:30:44 +00:00
94 changed files with 3337 additions and 4456 deletions
+5
View File
@@ -2,7 +2,10 @@
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `GlobalState::global(cx)` inline.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
@@ -81,6 +84,8 @@ Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
## Elements
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
Generated
+1 -1
View File
@@ -8042,7 +8042,7 @@ dependencies = [
"gpui-base",
"gpui-pre",
"instant",
"serde",
"log",
"smallvec",
"theme",
"uuid",
-144
View File
@@ -1,144 +0,0 @@
{
"id": "aurora",
"name": "Aurora",
"author": "Coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fdfcfeff",
"surface_background": "#f8f8ffff",
"elevated_surface_background": "#f0f1feff",
"panel_background": "#fdfcfeff",
"overlay": "#211f4300",
"title_bar": "#f0f1feff",
"title_bar_inactive": "#fdfcfeff",
"window_border": "#dadcffff",
"border": "#dadcffff",
"border_variant": "#cbcdffff",
"border_focused": "#5b5bd6ff",
"border_selected": "#5b5bd6ff",
"border_transparent": "#00000000",
"border_disabled": "#e6e7ffff",
"ring": "#5151cdff",
"text": "#1f2d5cff",
"text_muted": "#5753c6ff",
"text_placeholder": "#9b9ef0ff",
"text_accent": "#5b5bd6ff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#5753c6ff",
"icon_muted": "#9b9ef0ff",
"icon_accent": "#5151cdff",
"element_foreground": "#ffffffff",
"element_background": "#5b5bd6ff",
"element_hover": "#5151cdff",
"element_active": "#6e56cfff",
"element_selected": "#654dc4ff",
"element_disabled": "#5b5bd64d",
"secondary_foreground": "#1f2d5cff",
"secondary_background": "#f0f1feff",
"secondary_hover": "#e6e7ffff",
"secondary_active": "#dadcffff",
"secondary_selected": "#dadcffff",
"secondary_disabled": "#5b5bd64d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f0f1feff",
"ghost_element_hover": "#211f430d",
"ghost_element_active": "#211f431a",
"ghost_element_selected": "#211f431a",
"ghost_element_disabled": "#211f4305",
"tab_background": "#f0f1feff",
"tab_foreground": "#5753c6ff",
"tab_hover_background": "#211f430d",
"tab_active_background": "#fdfcfeff",
"tab_active_foreground": "#1f2d5cff",
"scrollbar_thumb_background": "#211f431a",
"scrollbar_thumb_hover_background": "#211f4326",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#5b5bd61a",
"cursor": "#5b5bd6ff",
"selection": "#5b5bd640"
},
"dark": {
"background": "#14121fff",
"surface_background": "#1b1525ff",
"elevated_surface_background": "#291f43ff",
"panel_background": "#14121fff",
"overlay": "#baa7ff1a",
"title_bar": "#291f43ff",
"title_bar_inactive": "#14121fff",
"window_border": "#473876ff",
"border": "#473876ff",
"border_variant": "#3c2e69ff",
"border_focused": "#7d66d9ff",
"border_selected": "#7d66d9ff",
"border_transparent": "#00000000",
"border_disabled": "#33255bff",
"ring": "#6e56cfff",
"text": "#e2ddfeff",
"text_muted": "#baa7ffff",
"text_placeholder": "#6958adff",
"text_accent": "#baa7ffff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#baa7ffff",
"icon_muted": "#6958adff",
"icon_accent": "#6e56cfff",
"element_foreground": "#14121fff",
"element_background": "#7d66d9ff",
"element_hover": "#baa7ffff",
"element_active": "#6e56cfff",
"element_selected": "#654dc4ff",
"element_disabled": "#7d66d94d",
"secondary_foreground": "#e2ddfeff",
"secondary_background": "#291f43ff",
"secondary_hover": "#33255bff",
"secondary_active": "#3c2e69ff",
"secondary_selected": "#3c2e69ff",
"secondary_disabled": "#7d66d94d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#291f43ff",
"ghost_element_hover": "#baa7ff0d",
"ghost_element_active": "#baa7ff1a",
"ghost_element_selected": "#baa7ff1a",
"ghost_element_disabled": "#baa7ff05",
"tab_background": "#291f43ff",
"tab_foreground": "#baa7ffff",
"tab_hover_background": "#baa7ff0d",
"tab_active_background": "#14121fff",
"tab_active_foreground": "#e2ddfeff",
"scrollbar_thumb_background": "#baa7ff1a",
"scrollbar_thumb_hover_background": "#baa7ff26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#baa7ff1a",
"cursor": "#baa7ffff",
"selection": "#baa7ff40"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "catppuccin-frappe",
"name": "Catppuccin Frappé",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"text_danger": "#e78284",
"text_warning": "#ef9f76",
"icon": "#a5adce",
"icon_muted": "#838ba7",
"icon_accent": "#babbf1",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#babbf1",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#ef9f76",
"warning_hover": "#e5c890",
"warning_active": "#a6d189",
"warning_selected": "#81c8be",
"warning_disabled": "#ef9f764d",
"ghost_element_background": "#c6d0f500",
"ghost_element_background_alt": "#292c3c",
"ghost_element_hover": "#c6d0f50d",
"ghost_element_active": "#c6d0f51a",
"ghost_element_selected": "#c6d0f51a",
"ghost_element_disabled": "#c6d0f505",
"tab_background": "#232634",
"tab_foreground": "#a5adce",
"tab_hover_background": "#c6d0f50d",
"tab_active_background": "#303446",
"tab_active_foreground": "#c6d0f5",
"scrollbar_thumb_background": "#c6d0f51a",
"scrollbar_thumb_hover_background": "#c6d0f526",
"scrollbar_thumb_border": "#c6d0f500",
"scrollbar_track_background": "#c6d0f500",
"scrollbar_track_border": "#c6d0f500",
"drop_target_background": "#8caaee1a",
"cursor": "#8caaee",
"selection": "#8caaee40"
},
"dark": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"text_danger": "#e78284",
"text_warning": "#ef9f76",
"icon": "#a5adce",
"icon_muted": "#838ba7",
"icon_accent": "#babbf1",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#babbf1",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#ef9f76",
"warning_hover": "#e5c890",
"warning_active": "#a6d189",
"warning_selected": "#81c8be",
"warning_disabled": "#ef9f764d",
"ghost_element_background": "#c6d0f500",
"ghost_element_background_alt": "#292c3c",
"ghost_element_hover": "#c6d0f50d",
"ghost_element_active": "#c6d0f51a",
"ghost_element_selected": "#c6d0f51a",
"ghost_element_disabled": "#c6d0f505",
"tab_background": "#232634",
"tab_foreground": "#a5adce",
"tab_hover_background": "#c6d0f50d",
"tab_active_background": "#303446",
"tab_active_foreground": "#c6d0f5",
"scrollbar_thumb_background": "#c6d0f51a",
"scrollbar_thumb_hover_background": "#c6d0f526",
"scrollbar_thumb_border": "#c6d0f500",
"scrollbar_track_background": "#c6d0f500",
"scrollbar_track_border": "#c6d0f500",
"drop_target_background": "#8caaee1a",
"cursor": "#8caaee",
"selection": "#8caaee40"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#4c4f69",
"secondary_background": "#ccd0da",
"secondary_hover": "#bcc0cc",
"secondary_active": "#acb0be",
"secondary_selected": "#acb0be",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#eff1f5",
"warning_background": "#fe640b",
"warning_hover": "#df8e1d",
"warning_active": "#40a02b",
"warning_selected": "#179299",
"warning_disabled": "#fe640b4d",
"ghost_element_background": "#4c4f6900",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f690d",
"ghost_element_active": "#4c4f691a",
"ghost_element_selected": "#4c4f691a",
"ghost_element_disabled": "#4c4f6905",
"tab_background": "#e6e9ef",
"tab_foreground": "#6c6f85",
"tab_hover_background": "#4c4f690d",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"scrollbar_thumb_background": "#4c4f691a",
"scrollbar_thumb_hover_background": "#4c4f6926",
"scrollbar_thumb_border": "#4c4f6900",
"scrollbar_track_background": "#4c4f6900",
"scrollbar_track_border": "#4c4f6900",
"drop_target_background": "#1e66f51a",
"cursor": "#1e66f5",
"selection": "#1e66f540"
},
"dark": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#4c4f69",
"secondary_background": "#ccd0da",
"secondary_hover": "#bcc0cc",
"secondary_active": "#acb0be",
"secondary_selected": "#acb0be",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#eff1f5",
"warning_background": "#fe640b",
"warning_hover": "#df8e1d",
"warning_active": "#40a02b",
"warning_selected": "#179299",
"warning_disabled": "#fe640b4d",
"ghost_element_background": "#4c4f6900",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f690d",
"ghost_element_active": "#4c4f691a",
"ghost_element_selected": "#4c4f691a",
"ghost_element_disabled": "#4c4f6905",
"tab_background": "#e6e9ef",
"tab_foreground": "#6c6f85",
"tab_hover_background": "#4c4f690d",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"scrollbar_thumb_background": "#4c4f691a",
"scrollbar_thumb_hover_background": "#4c4f6926",
"scrollbar_thumb_border": "#4c4f6900",
"scrollbar_track_background": "#4c4f6900",
"scrollbar_track_border": "#4c4f6900",
"drop_target_background": "#1e66f51a",
"cursor": "#1e66f5",
"selection": "#1e66f540"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "catppuccin-macchiato",
"name": "Catppuccin Macchiato",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"text_danger": "#ed8796",
"text_warning": "#f5a97f",
"icon": "#a5adcb",
"icon_muted": "#8087a2",
"icon_accent": "#b7bdf8",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#b7bdf8",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#f5a97f",
"warning_hover": "#eed49f",
"warning_active": "#a6da95",
"warning_selected": "#8bd5ca",
"warning_disabled": "#f5a97f4d",
"ghost_element_background": "#cad3f500",
"ghost_element_background_alt": "#1e2030",
"ghost_element_hover": "#cad3f50d",
"ghost_element_active": "#cad3f51a",
"ghost_element_selected": "#cad3f51a",
"ghost_element_disabled": "#cad3f505",
"tab_background": "#181926",
"tab_foreground": "#a5adcb",
"tab_hover_background": "#cad3f50d",
"tab_active_background": "#24273a",
"tab_active_foreground": "#cad3f5",
"scrollbar_thumb_background": "#cad3f51a",
"scrollbar_thumb_hover_background": "#cad3f526",
"scrollbar_thumb_border": "#cad3f500",
"scrollbar_track_background": "#cad3f500",
"scrollbar_track_border": "#cad3f500",
"drop_target_background": "#8aadf41a",
"cursor": "#8aadf4",
"selection": "#8aadf440"
},
"dark": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"text_danger": "#ed8796",
"text_warning": "#f5a97f",
"icon": "#a5adcb",
"icon_muted": "#8087a2",
"icon_accent": "#b7bdf8",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#b7bdf8",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#f5a97f",
"warning_hover": "#eed49f",
"warning_active": "#a6da95",
"warning_selected": "#8bd5ca",
"warning_disabled": "#f5a97f4d",
"ghost_element_background": "#cad3f500",
"ghost_element_background_alt": "#1e2030",
"ghost_element_hover": "#cad3f50d",
"ghost_element_active": "#cad3f51a",
"ghost_element_selected": "#cad3f51a",
"ghost_element_disabled": "#cad3f505",
"tab_background": "#181926",
"tab_foreground": "#a5adcb",
"tab_hover_background": "#cad3f50d",
"tab_active_background": "#24273a",
"tab_active_foreground": "#cad3f5",
"scrollbar_thumb_background": "#cad3f51a",
"scrollbar_thumb_hover_background": "#cad3f526",
"scrollbar_thumb_border": "#cad3f500",
"scrollbar_track_background": "#cad3f500",
"scrollbar_track_border": "#cad3f500",
"drop_target_background": "#8aadf41a",
"cursor": "#8aadf4",
"selection": "#8aadf440"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "catppuccin-mocha",
"name": "Catppuccin Mocha",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"text_danger": "#f38ba8",
"text_warning": "#fab387",
"icon": "#a6adc8",
"icon_muted": "#7f849c",
"icon_accent": "#b4befe",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#b4befe",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#fab387",
"warning_hover": "#f9e2af",
"warning_active": "#a6e3a1",
"warning_selected": "#94e2d5",
"warning_disabled": "#fab3874d",
"ghost_element_background": "#cdd6f400",
"ghost_element_background_alt": "#181825",
"ghost_element_hover": "#cdd6f40d",
"ghost_element_active": "#cdd6f41a",
"ghost_element_selected": "#cdd6f41a",
"ghost_element_disabled": "#cdd6f405",
"tab_background": "#11111b",
"tab_foreground": "#a6adc8",
"tab_hover_background": "#cdd6f40d",
"tab_active_background": "#1e1e2e",
"tab_active_foreground": "#cdd6f4",
"scrollbar_thumb_background": "#cdd6f41a",
"scrollbar_thumb_hover_background": "#cdd6f426",
"scrollbar_thumb_border": "#cdd6f400",
"scrollbar_track_background": "#cdd6f400",
"scrollbar_track_border": "#cdd6f400",
"drop_target_background": "#89b4fa1a",
"cursor": "#89b4fa",
"selection": "#89b4fa40"
},
"dark": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"text_danger": "#f38ba8",
"text_warning": "#fab387",
"icon": "#a6adc8",
"icon_muted": "#7f849c",
"icon_accent": "#b4befe",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#b4befe",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#fab387",
"warning_hover": "#f9e2af",
"warning_active": "#a6e3a1",
"warning_selected": "#94e2d5",
"warning_disabled": "#fab3874d",
"ghost_element_background": "#cdd6f400",
"ghost_element_background_alt": "#181825",
"ghost_element_hover": "#cdd6f40d",
"ghost_element_active": "#cdd6f41a",
"ghost_element_selected": "#cdd6f41a",
"ghost_element_disabled": "#cdd6f405",
"tab_background": "#11111b",
"tab_foreground": "#a6adc8",
"tab_hover_background": "#cdd6f40d",
"tab_active_background": "#1e1e2e",
"tab_active_foreground": "#cdd6f4",
"scrollbar_thumb_background": "#cdd6f41a",
"scrollbar_thumb_hover_background": "#cdd6f426",
"scrollbar_thumb_border": "#cdd6f400",
"scrollbar_track_background": "#cdd6f400",
"scrollbar_track_border": "#cdd6f400",
"drop_target_background": "#89b4fa1a",
"cursor": "#89b4fa",
"selection": "#89b4fa40"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Steph Ango (ported by Coop)",
"url": "https://stephango.com/flexoki",
"light": {
"background": "#FFFCF0",
"surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0",
"overlay": "#100F0F1A",
"title_bar": "#E6E4D9",
"title_bar_inactive": "#FFFCF0",
"window_border": "#CECDC3",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#24837B",
"border_selected": "#24837B",
"border_transparent": "#100F0F00",
"border_disabled": "#E6E4D9",
"ring": "#3AA99F",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#B7B5AC",
"text_accent": "#24837B",
"text_danger": "#AF3029",
"text_warning": "#BC5215",
"icon": "#6F6E69",
"icon_muted": "#B7B5AC",
"icon_accent": "#3AA99F",
"element_foreground": "#FFFCF0",
"element_background": "#24837B",
"element_hover": "#3AA99F",
"element_active": "#1C1B1A",
"element_selected": "#100F0F",
"element_disabled": "#24837B4D",
"secondary_foreground": "#100F0F",
"secondary_background": "#E6E4D9",
"secondary_hover": "#DAD8CE",
"secondary_active": "#CECDC3",
"secondary_selected": "#CECDC3",
"secondary_disabled": "#24837B4D",
"danger_foreground": "#FFFCF0",
"danger_background": "#AF3029",
"danger_hover": "#D14D41",
"danger_active": "#1C1B1A",
"danger_selected": "#100F0F",
"danger_disabled": "#AF30294D",
"warning_foreground": "#FFFCF0",
"warning_background": "#BC5215",
"warning_hover": "#DA702C",
"warning_active": "#1C1B1A",
"warning_selected": "#100F0F",
"warning_disabled": "#BC52154D",
"ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#F2F0E5",
"ghost_element_hover": "#100F0F0D",
"ghost_element_active": "#100F0F1A",
"ghost_element_selected": "#100F0F1A",
"ghost_element_disabled": "#100F0F05",
"tab_background": "#E6E4D9",
"tab_foreground": "#6F6E69",
"tab_hover_background": "#100F0F0D",
"tab_active_background": "#FFFCF0",
"tab_active_foreground": "#100F0F",
"scrollbar_thumb_background": "#100F0F1A",
"scrollbar_thumb_hover_background": "#100F0F26",
"scrollbar_thumb_border": "#100F0F00",
"scrollbar_track_background": "#100F0F00",
"scrollbar_track_border": "#100F0F00",
"drop_target_background": "#24837B1A",
"cursor": "#24837B",
"selection": "#24837B40"
},
"dark": {
"background": "#100F0F",
"surface_background": "#1C1B1A",
"elevated_surface_background": "#282726",
"panel_background": "#100F0F",
"overlay": "#FFFCF01A",
"title_bar": "#282726",
"title_bar_inactive": "#100F0F",
"window_border": "#403E3C",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#3AA99F",
"border_selected": "#3AA99F",
"border_transparent": "#100F0F00",
"border_disabled": "#282726",
"ring": "#24837B",
"text": "#CECDC3",
"text_muted": "#878580",
"text_placeholder": "#575653",
"text_accent": "#3AA99F",
"text_danger": "#D14D41",
"text_warning": "#DA702C",
"icon": "#878580",
"icon_muted": "#575653",
"icon_accent": "#24837B",
"element_foreground": "#100F0F",
"element_background": "#3AA99F",
"element_hover": "#24837B",
"element_active": "#CECDC3",
"element_selected": "#F2F0E5",
"element_disabled": "#3AA99F4D",
"secondary_foreground": "#CECDC3",
"secondary_background": "#1C1B1A",
"secondary_hover": "#282726",
"secondary_active": "#343331",
"secondary_selected": "#343331",
"secondary_disabled": "#3AA99F4D",
"danger_foreground": "#100F0F",
"danger_background": "#D14D41",
"danger_hover": "#AF3029",
"danger_active": "#CECDC3",
"danger_selected": "#F2F0E5",
"danger_disabled": "#D14D414D",
"warning_foreground": "#100F0F",
"warning_background": "#DA702C",
"warning_hover": "#BC5215",
"warning_active": "#CECDC3",
"warning_selected": "#F2F0E5",
"warning_disabled": "#DA702C4D",
"ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#1C1B1A",
"ghost_element_hover": "#FFFCF00D",
"ghost_element_active": "#FFFCF01A",
"ghost_element_selected": "#FFFCF01A",
"ghost_element_disabled": "#FFFCF005",
"tab_background": "#282726",
"tab_foreground": "#878580",
"tab_hover_background": "#FFFCF00D",
"tab_active_background": "#100F0F",
"tab_active_foreground": "#CECDC3",
"scrollbar_thumb_background": "#FFFCF01A",
"scrollbar_thumb_hover_background": "#FFFCF026",
"scrollbar_thumb_border": "#100F0F00",
"scrollbar_track_background": "#100F0F00",
"scrollbar_track_border": "#100F0F00",
"drop_target_background": "#3AA99F1A",
"cursor": "#3AA99F",
"selection": "#3AA99F40"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "forest",
"name": "Forest",
"author": "Coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fbfefcff",
"surface_background": "#f4fbf6ff",
"elevated_surface_background": "#e9f6e9ff",
"panel_background": "#fbfefcff",
"overlay": "#193b2d1a",
"title_bar": "#e9f6e9ff",
"title_bar_inactive": "#fbfefcff",
"window_border": "#c4e8d1ff",
"border": "#c4e8d1ff",
"border_variant": "#b2ddb5ff",
"border_focused": "#30a46cff",
"border_selected": "#30a46cff",
"border_transparent": "#00000000",
"border_disabled": "#e0f3e6ff",
"ring": "#2b9a66ff",
"text": "#193b2dff",
"text_muted": "#2f7c57ff",
"text_placeholder": "#8eceaaff",
"text_accent": "#30a46cff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#2f7c57ff",
"icon_muted": "#8eceaaff",
"icon_accent": "#2b9a66ff",
"element_foreground": "#ffffffff",
"element_background": "#30a46cff",
"element_hover": "#2b9a66ff",
"element_active": "#2a7e3bff",
"element_selected": "#218358ff",
"element_disabled": "#30a46c4d",
"secondary_foreground": "#193b2dff",
"secondary_background": "#e9f6e9ff",
"secondary_hover": "#daf1dbff",
"secondary_active": "#c4e8d1ff",
"secondary_selected": "#c4e8d1ff",
"secondary_disabled": "#30a46c4d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e9f6e9ff",
"ghost_element_hover": "#193b2d0d",
"ghost_element_active": "#193b2d1a",
"ghost_element_selected": "#193b2d1a",
"ghost_element_disabled": "#193b2d05",
"tab_background": "#e9f6e9ff",
"tab_foreground": "#2f7c57ff",
"tab_hover_background": "#193b2d0d",
"tab_active_background": "#fbfefcff",
"tab_active_foreground": "#193b2dff",
"scrollbar_thumb_background": "#193b2d1a",
"scrollbar_thumb_hover_background": "#193b2d26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#30a46c1a",
"cursor": "#30a46cff",
"selection": "#30a46c40"
},
"dark": {
"background": "#0e1512ff",
"surface_background": "#121b17ff",
"elevated_surface_background": "#132d21ff",
"panel_background": "#0e1512ff",
"overlay": "#b1f1cb1a",
"title_bar": "#132d21ff",
"title_bar_inactive": "#0e1512ff",
"window_border": "#20573eff",
"border": "#20573eff",
"border_variant": "#174933ff",
"border_focused": "#33b074ff",
"border_selected": "#33b074ff",
"border_transparent": "#00000000",
"border_disabled": "#113b29ff",
"ring": "#33b074ff",
"text": "#b1f1cbff",
"text_muted": "#71d083ff",
"text_placeholder": "#2f7c57ff",
"text_accent": "#3dd68cff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#71d083ff",
"icon_muted": "#2f7c57ff",
"icon_accent": "#33b074ff",
"element_foreground": "#0e1512ff",
"element_background": "#3dd68cff",
"element_hover": "#71d083ff",
"element_active": "#33b074ff",
"element_selected": "#30a46cff",
"element_disabled": "#3dd68c4d",
"secondary_foreground": "#b1f1cbff",
"secondary_background": "#132d21ff",
"secondary_hover": "#113b29ff",
"secondary_active": "#174933ff",
"secondary_selected": "#174933ff",
"secondary_disabled": "#3dd68c4d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#132d21ff",
"ghost_element_hover": "#b1f1cb0d",
"ghost_element_active": "#b1f1cb1a",
"ghost_element_selected": "#b1f1cb1a",
"ghost_element_disabled": "#b1f1cb05",
"tab_background": "#132d21ff",
"tab_foreground": "#71d083ff",
"tab_hover_background": "#b1f1cb0d",
"tab_active_background": "#0e1512ff",
"tab_active_foreground": "#b1f1cbff",
"scrollbar_thumb_background": "#b1f1cb1a",
"scrollbar_thumb_hover_background": "#b1f1cb26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#3dd68c1a",
"cursor": "#3dd68cff",
"selection": "#3dd68c40"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
"id": "ocean",
"name": "Ocean",
"author": "Coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fafefeff",
"surface_background": "#f2fbfaff",
"elevated_surface_background": "#e6f7f7ff",
"panel_background": "#fafefeff",
"overlay": "#00333f1a",
"title_bar": "#e6f7f7ff",
"title_bar_inactive": "#fafefeff",
"window_border": "#cce5e9ff",
"border": "#cce5e9ff",
"border_variant": "#b8dde3ff",
"border_focused": "#00a2c7ff",
"border_selected": "#00a2c7ff",
"border_transparent": "#00000000",
"border_disabled": "#e0f0f2ff",
"ring": "#0797b9ff",
"text": "#0d3c48ff",
"text_muted": "#107d98ff",
"text_placeholder": "#60b3d7ff",
"text_accent": "#00a2c7ff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#107d98ff",
"icon_muted": "#60b3d7ff",
"icon_accent": "#0797b9ff",
"element_foreground": "#ffffffff",
"element_background": "#00a2c7ff",
"element_hover": "#0797b9ff",
"element_active": "#12667eff",
"element_selected": "#0d4a5cff",
"element_disabled": "#00a2c74d",
"secondary_foreground": "#0d4a5cff",
"secondary_background": "#ddf9f2ff",
"secondary_hover": "#c8f4e9ff",
"secondary_active": "#b3ecdeff",
"secondary_selected": "#b3ecdeff",
"secondary_disabled": "#00a2c74d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6f7f7ff",
"ghost_element_hover": "#00333f0d",
"ghost_element_active": "#00333f1a",
"ghost_element_selected": "#00333f1a",
"ghost_element_disabled": "#00333f05",
"tab_background": "#e6f7f7ff",
"tab_foreground": "#107d98ff",
"tab_hover_background": "#00333f0d",
"tab_active_background": "#fafefeff",
"tab_active_foreground": "#0d3c48ff",
"scrollbar_thumb_background": "#00333f1a",
"scrollbar_thumb_hover_background": "#00333f26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#00a2c71a",
"cursor": "#00a2c7ff",
"selection": "#00a2c740"
},
"dark": {
"background": "#0b161aff",
"surface_background": "#101b20ff",
"elevated_surface_background": "#082c36ff",
"panel_background": "#0b161aff",
"overlay": "#c2f3ff1a",
"title_bar": "#082c36ff",
"title_bar_inactive": "#0b161aff",
"window_border": "#1b537bff",
"border": "#1b537bff",
"border_variant": "#154467ff",
"border_focused": "#00a2c7ff",
"border_selected": "#00a2c7ff",
"border_transparent": "#00000000",
"border_disabled": "#112840ff",
"ring": "#23afd0ff",
"text": "#b6ecf7ff",
"text_muted": "#4ccce6ff",
"text_placeholder": "#197caeff",
"text_accent": "#7ce2feff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#4ccce6ff",
"icon_muted": "#197caeff",
"icon_accent": "#23afd0ff",
"element_foreground": "#0b161aff",
"element_background": "#7ce2feff",
"element_hover": "#a8eeffff",
"element_active": "#23afd0ff",
"element_selected": "#00a2c7ff",
"element_disabled": "#7ce2fe4d",
"secondary_foreground": "#adf0ddff",
"secondary_background": "#0d2d2aff",
"secondary_hover": "#023b37ff",
"secondary_active": "#084843ff",
"secondary_selected": "#084843ff",
"secondary_disabled": "#7ce2fe4d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#082c36ff",
"ghost_element_hover": "#c2f3ff0d",
"ghost_element_active": "#c2f3ff1a",
"ghost_element_selected": "#c2f3ff1a",
"ghost_element_disabled": "#c2f3ff05",
"tab_background": "#082c36ff",
"tab_foreground": "#4ccce6ff",
"tab_hover_background": "#c2f3ff0d",
"tab_active_background": "#0b161aff",
"tab_active_foreground": "#b6ecf7ff",
"scrollbar_thumb_background": "#c2f3ff1a",
"scrollbar_thumb_hover_background": "#c2f3ff26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#7ce2fe1a",
"cursor": "#7ce2feff",
"selection": "#7ce2fe40"
}
}
-1
View File
@@ -7,7 +7,6 @@ use rust_embed::RustEmbed;
#[include = "fonts/**/*"]
#[include = "brand/**/*"]
#[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
+83 -82
View File
@@ -24,7 +24,7 @@ use smallvec::{SmallVec, smallvec};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
@@ -903,9 +903,9 @@ impl ChatPanel {
h_flex()
.h_10()
.justify_center()
.text_sm()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.text_style(TextStyle::Callout)
.bg(cx.theme().surface_raised)
.rounded(px(theme::panel_radius()))
.child("Message isn't traced yet"),
)
})
@@ -918,10 +918,10 @@ impl ChatPanel {
h_flex()
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.text_sm()
.gap(px(theme::SPACE))
.bg(cx.theme().surface_raised)
.rounded(px(theme::control_radius()))
.text_style(TextStyle::Callout)
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
@@ -950,10 +950,10 @@ impl ChatPanel {
h_flex()
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.text_sm()
.gap(px(theme::SPACE))
.bg(cx.theme().surface_raised)
.rounded(px(theme::control_radius()))
.text_style(TextStyle::Callout)
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
@@ -975,7 +975,7 @@ impl ChatPanel {
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().ghost_element_background_alt),
.text_color(cx.theme().surface_raised),
)
.message(PRIVATE_NOTICE)
.into_any_element()
@@ -990,14 +990,14 @@ impl ChatPanel {
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.line_height(relative(1.3))
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().ghost_element_active),
.text_color(cx.theme().element_active),
)
.child(PRIVATE_NOTICE)
.into_any_element()
@@ -1013,15 +1013,15 @@ impl ChatPanel {
h_flex()
.w_full()
.gap_3()
.text_sm()
.text_style(TextStyle::Callout)
.child(
h_flex()
.flex_shrink_0()
.size_8()
.justify_center()
.rounded_full()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_muted)
.text_color(cx.theme().warning)
.child(Icon::new(IconName::Warning).small()),
)
.child(
@@ -1161,7 +1161,7 @@ impl ChatPanel {
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent),
.bg(gpui::transparent_black()),
)
.overlay(self.render_actions(&id, &pk, cx))
.on_mouse_down(
@@ -1187,10 +1187,10 @@ impl ChatPanel {
return div().child(
img(media[0].clone())
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border_faint)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
.rounded(px(theme::bubble_radius())),
);
}
@@ -1201,7 +1201,7 @@ impl ChatPanel {
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.gap(px(theme::SPACE))
.children({
let mut items = vec![];
@@ -1215,8 +1215,8 @@ impl ChatPanel {
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius),
.border_color(cx.theme().border_faint)
.rounded(px(theme::bubble_radius())),
),
);
}
@@ -1245,7 +1245,7 @@ impl ChatPanel {
.px_2()
.border_l_2()
.border_color(cx.theme().element_active)
.text_sm()
.text_style(TextStyle::Callout)
.child(div().font_semibold().child(author.name()))
.child(
div()
@@ -1254,7 +1254,7 @@ impl ChatPanel {
.line_clamp(1)
.child(message.preview()),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.hover(|this| this.bg(cx.theme().surface_raised))
.on_click({
let id = *id;
cx.listener(move |this, _event, _window, _cx| {
@@ -1287,16 +1287,16 @@ impl ChatPanel {
.unwrap_or(false);
h_flex()
.gap_2()
.gap(px(theme::SPACE))
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.text_xs()
.rounded(px(theme::control_radius()))
.text_style(TextStyle::Caption)
.border_1()
.when(has_reacted, |this| {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_background)
.border_color(cx.theme().secondary_active)
this.text_color(cx.theme().text)
.bg(cx.theme().surface_raised)
.border_color(cx.theme().element_active)
})
.when(!has_reacted, |this| this.border_color(cx.theme().border))
.child(emoji)
@@ -1330,7 +1330,7 @@ impl ChatPanel {
div()
.id(SharedString::from(id.to_hex()))
.child(label)
.when(failed, |this| this.text_color(cx.theme().text_danger))
.when(failed, |this| this.text_color(cx.theme().danger_muted))
.when_some(reports, |this, reports| {
this.when(!pending, |this| {
this.on_click(move |_e, window, cx| {
@@ -1361,12 +1361,12 @@ impl ChatPanel {
let avatar = profile.avatar();
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.child(
h_flex()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(SharedString::from("Sent to:"))
.child(
h_flex()
@@ -1384,17 +1384,17 @@ impl ChatPanel {
.p_1()
.h_16()
.w_full()
.text_sm()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.text_style(TextStyle::Callout)
.rounded(px(theme::surface_radius()))
.bg(cx.theme().warning_muted)
.text_color(cx.theme().warning)
.child(div().flex_1().w_full().text_center().child(error)),
)
})
.when_some(report.output().cloned(), |this, output| {
this.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.children({
let mut items = Vec::with_capacity(output.failed.len());
@@ -1405,19 +1405,19 @@ impl ChatPanel {
.gap_0p5()
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().danger_background)
.rounded(px(theme::surface_radius()))
.bg(cx.theme().danger.opacity(0.06))
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from(url.to_string())),
)
.child(
div()
.text_xs()
.text_color(cx.theme().danger_foreground)
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger_muted.opacity(0.9))
.line_height(relative(1.25))
.child(SharedString::from(msg.to_string())),
),
@@ -1435,18 +1435,18 @@ impl ChatPanel {
.gap_0p5()
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.rounded(px(theme::surface_radius()))
.bg(cx.theme().surface_card)
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from(url.0.to_string())),
)
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.line_height(relative(1.25))
.child(SharedString::from("Successfully")),
),
@@ -1473,10 +1473,10 @@ impl ChatPanel {
.right_4()
.top_neg_2()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.rounded(px(theme::surface_radius()))
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.bg(cx.theme().bg)
.children({
let mut items = vec![];
@@ -1553,7 +1553,7 @@ impl ChatPanel {
img(url.as_str())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_lg())
.rounded(cx.theme().radius)
.rounded(px(theme::control_radius()))
.object_fit(ObjectFit::Cover),
)
.child(
@@ -1611,10 +1611,10 @@ impl ChatPanel {
.child(
img(path.clone())
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border_faint)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
.rounded(px(theme::bubble_radius())),
)
.into_any_element();
}
@@ -1642,22 +1642,22 @@ impl ChatPanel {
.self_start()
.items_start()
.min_w_0()
.gap_2()
.gap(px(theme::SPACE))
.p_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
.border_color(cx.theme().border_faint)
.rounded(px(theme::control_radius()))
.child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
.child(
v_flex()
.min_w_0()
.overflow_hidden()
.text_sm()
.text_style(TextStyle::Callout)
.child(div().line_height(relative(1.2)).child(file.display_name()))
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(label),
),
)
@@ -1688,7 +1688,7 @@ impl ChatPanel {
img(pending.path.clone())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.rounded(px(theme::control_radius()))
.object_fit(ObjectFit::Cover),
)
} else {
@@ -1698,11 +1698,11 @@ impl ChatPanel {
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.rounded(px(theme::control_radius()))
.border_1()
.border_color(cx.theme().border_variant)
.bg(cx.theme().surface_background)
.text_xs()
.border_color(cx.theme().border_faint)
.bg(cx.theme().surface)
.text_style(TextStyle::Caption)
.text_center()
.child("Preview not available"),
)
@@ -1718,7 +1718,7 @@ impl ChatPanel {
.justify_center()
.rounded_full()
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border_faint)
.bg(gpui::green())
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
)
@@ -1764,14 +1764,10 @@ impl ChatPanel {
.flex()
.items_baseline()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child("Replying to:")
.child(
div()
.text_color(cx.theme().text_accent)
.child(profile.name()),
),
.child(div().text_color(cx.theme().accent).child(profile.name())),
)
.child(
Button::new("remove-reply")
@@ -1789,7 +1785,7 @@ impl ChatPanel {
.child(
div()
.w_full()
.text_sm()
.text_style(TextStyle::Callout)
.text_ellipsis()
.line_clamp(1)
.child(text.preview()),
@@ -1885,12 +1881,17 @@ impl Panel for ChatPanel {
h_flex()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.child(Avatar::new(picture).seed(seed).xsmall())
.child(label)
.into_any_element()
})
.unwrap_or(div().text_xs().child("Unknown").into_any_element())
.unwrap_or(
div()
.text_style(TextStyle::Caption)
.child("Unknown")
.into_any_element(),
)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
@@ -1937,10 +1938,10 @@ impl Render for ChatPanel {
.h_12()
.w_full()
.px_2()
.gap_2()
.gap(px(theme::SPACE))
.border_b_1()
.border_color(cx.theme().border)
.child(Input::new(&self.subject_input).text_sm().small())
.child(Input::new(&self.subject_input).small())
.child(
Button::new("change")
.icon(IconName::CheckCircle)
@@ -1993,8 +1994,8 @@ impl Render for ChatPanel {
this.child(
div()
.px_1()
.text_xs()
.text_color(cx.theme().text_warning)
.text_style(TextStyle::Caption)
.text_color(cx.theme().warning)
.child(WARNING),
)
})
+11 -11
View File
@@ -3,7 +3,7 @@ use gpui::{
relative,
};
use nip29::{Group, GroupMetadata};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::avatar::Avatar;
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
@@ -22,7 +22,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
};
let group = entity.read(cx);
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
let mut body = v_flex().gap_3().text_style(TextStyle::Callout).child(header(group, cx));
if let Some(metadata) = group.metadata() {
if let Some(about) = metadata.about.as_ref() {
@@ -76,7 +76,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
"Supported kinds",
if items.is_empty() {
div()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.child("None")
.into_any_element()
} else {
@@ -137,15 +137,15 @@ fn header(group: &Group, cx: &App) -> AnyElement {
.min_w_0()
.child(
div()
.text_base()
.text_style(TextStyle::Body)
.font_semibold()
.truncate()
.child(group.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.truncate()
.child(SharedString::from(format!(
"{} · {}",
@@ -162,7 +162,7 @@ fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
.gap_1()
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_medium()
.text_color(cx.theme().text_muted)
.child(label),
@@ -183,10 +183,10 @@ fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.text_xs()
.rounded(px(control_radius()))
.bg(cx.theme().surface_raised)
.text_color(cx.theme().text)
.text_style(TextStyle::Caption)
.line_height(relative(1.))
.child(label.into())
.into_any_element()
+2 -2
View File
@@ -5,7 +5,7 @@ use gpui::{
};
use nip29::Group;
use nostr_sdk::prelude::SecretKey;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
use ui::notification::Notification;
@@ -40,7 +40,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(
v_flex().gap_3().child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child("Share the code with whoever should be able to join. The relay must accept it."),
)
+17 -17
View File
@@ -16,7 +16,7 @@ use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariant, ButtonVariants};
use ui::dialog::DialogButtonProps;
@@ -168,7 +168,7 @@ pub fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App
)
.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.child("You will stop receiving messages from this group."),
)
.on_ok(move |_event, _window, cx| {
@@ -567,16 +567,16 @@ impl GroupPanel {
h_flex()
.flex_shrink_0()
.w_full()
.gap_2()
.gap(px(theme::SPACE))
.px_3()
.py_2()
.text_sm()
.text_style(TextStyle::Callout)
.border_b_1()
.border_color(cx.theme().border)
.child(
Icon::new(IconName::Info)
.small()
.text_color(cx.theme().icon_muted),
.text_color(cx.theme().text_faint),
)
.child(div().flex_1().min_w_0().truncate().child(message))
.child(
@@ -615,9 +615,9 @@ impl GroupPanel {
div()
.px_1p5()
.py_0p5()
.rounded(cx.theme().radius)
.bg(cx.theme().element_background)
.text_xs()
.rounded(px(control_radius()))
.bg(cx.theme().solid)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(pin_label(group, pin))
}))
@@ -695,10 +695,10 @@ impl GroupPanel {
.w_full()
.justify_center()
.items_center()
.gap_2()
.gap(px(theme::SPACE))
.px_3()
.py_2()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted);
match notice {
@@ -878,9 +878,9 @@ impl GroupPanel {
.h_8()
.px_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.text_xs()
.border_color(cx.theme().border_faint)
.rounded(px(control_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::Link).xsmall())
.child(
@@ -922,12 +922,12 @@ impl Panel for GroupPanel {
h_flex()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.child(Avatar::from_source(picture).seed(seed).xsmall())
.child(display_name(group))
.into_any_element()
})
.unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element())
.unwrap_or_else(|| div().text_style(TextStyle::Caption).child("Unknown").into_any_element())
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
@@ -987,8 +987,8 @@ impl Render for GroupPanel {
None => h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_faint)
.child("No messages yet")
.into_any_element(),
}),
+18 -18
View File
@@ -9,7 +9,7 @@ use nip29::{Activity, Group};
use nostr_sdk::prelude::{EventId, Kind};
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius};
use ui::avatar::Avatar;
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
use ui::message::MessageRow;
@@ -138,10 +138,10 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
h_flex()
.id(ix)
.w_full()
.gap_2()
.gap(px(theme::SPACE))
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().surface_background))
.hover(|this| this.bg(cx.theme().element_hover))
.child(badge(activity, cx))
.when(!hide_avatar, |this| {
this.child(
@@ -155,7 +155,7 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
div()
.min_w_0()
.truncate()
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.child(subject.name()),
)
@@ -163,16 +163,16 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
div()
.min_w_0()
.truncate()
.text_sm()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_faint)
.child(SharedString::from(action(activity))),
)
.child(
div()
.flex_shrink_0()
.ml_1()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(activity.created_at.to_ago()),
)
.into_any_element()
@@ -180,11 +180,11 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
fn badge(activity: &Activity, cx: &App) -> AnyElement {
let (icon, color) = match activity.kind {
Kind::GroupJoinRequest => (IconName::Login, cx.theme().icon_accent),
Kind::GroupPutUser => (IconName::Login, cx.theme().icon_accent),
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().text_danger),
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().text_danger),
_ => (IconName::Info, cx.theme().icon_muted),
Kind::GroupJoinRequest => (IconName::Login, cx.theme().accent),
Kind::GroupPutUser => (IconName::Login, cx.theme().accent),
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().danger),
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().danger),
_ => (IconName::Info, cx.theme().text_faint),
};
h_flex()
@@ -211,10 +211,10 @@ fn media(media: &[SharedUri], cx: &App) -> AnyElement {
.child(
img(only.clone())
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border_faint)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
.rounded(px(bubble_radius())),
)
.into_any_element();
}
@@ -225,14 +225,14 @@ fn media(media: &[SharedUri], cx: &App) -> AnyElement {
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.gap(px(theme::SPACE))
.children(media.iter().enumerate().map(|(ix, item)| {
div().id(format!("media-{ix}")).child(
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius),
.border_color(cx.theme().border_faint)
.rounded(px(bubble_radius())),
)
}))
.into_any_element()
+2 -2
View File
@@ -1,4 +1,4 @@
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window};
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
use nip29::{Group, GroupMetadata};
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
@@ -73,7 +73,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(field("Picture", &picture, cx))
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.child(
Switch::new("private")
.label("Private")
+2 -2
View File
@@ -2,7 +2,7 @@ use anyhow::Result;
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
use nip29::{Group, Pin, Row};
use nostr_sdk::prelude::Url;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
@@ -52,7 +52,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child(label)
.child(Input::new(input))
+15 -15
View File
@@ -8,14 +8,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
SharedString, Styled, Subscription, Task, Window, div, px, relative,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::avatar::Avatar;
use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
@@ -720,25 +720,25 @@ impl DeviceRegistry {
.title("Encryption Key Request")
.content(move |_this, _window, cx| {
v_flex()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.line_height(relative(1.25))
.child(SharedString::from(MSG)),
)
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.child(
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.child(
div()
.font_semibold()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from("From:")),
)
@@ -747,11 +747,11 @@ impl DeviceRegistry {
.h_8()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.rounded(px(control_radius()))
.bg(cx.theme().surface_card)
.child(
h_flex()
.gap_2()
.gap(px(theme::SPACE))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
@@ -764,11 +764,11 @@ impl DeviceRegistry {
.child(
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.child(
div()
.font_semibold()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from("Code:")),
)
@@ -777,8 +777,8 @@ impl DeviceRegistry {
.h_8()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.rounded(px(control_radius()))
.bg(cx.theme().surface_card)
.child(code_label.clone()),
),
),
+41 -51
View File
@@ -1,12 +1,10 @@
use std::rc::Rc;
use anyhow::{Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
use theme::{AppExt as _, AppearanceMode};
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
@@ -53,7 +51,7 @@ macro_rules! setting_getters {
}
setting_accessors! {
pub theme_mode: ThemeMode,
pub appearance_mode: AppearanceMode,
pub hide_avatar: bool,
pub screening: bool,
pub nip4e: bool,
@@ -119,11 +117,11 @@ impl RoomConfig {
/// Settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// Theme
pub theme: Option<String>,
/// Theme mode
pub theme_mode: ThemeMode,
/// Which appearance to paint: follow the OS, or pin light or dark.
/// `alias` reads the pre-bezel `theme_mode` key so old config files keep
/// their pinned choice.
#[serde(default, alias = "theme_mode")]
pub appearance_mode: AppearanceMode,
/// Hide user avatars
pub hide_avatar: bool,
@@ -152,8 +150,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
theme: None,
theme_mode: ThemeMode::default(),
appearance_mode: AppearanceMode::default(),
hide_avatar: false,
screening: true,
nip4e: false,
@@ -243,7 +240,7 @@ impl AppSettings {
// Update settings
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
this.apply_theme(None, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
@@ -264,44 +261,10 @@ impl AppSettings {
}
}
/// Set theme
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<String>,
{
// Update settings
self.inner.update(cx, |this, cx| {
this.theme = Some(theme.into());
cx.notify();
});
// Apply the new theme
self.apply_theme(Some(window), cx);
}
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
this.theme = None;
cx.notify();
});
self.apply_theme(Some(window), cx);
}
/// Apply theme
pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
Theme::change(mode, window, cx);
} else {
log::info!("Failed to load theme: {name}");
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
}
/// Apply the persisted appearance mode to the app
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
let mode = self.inner.read(cx).appearance_mode;
cx.set_appearance_mode(mode);
}
/// Check if decoupling encryption key is enabled
@@ -347,3 +310,30 @@ impl AppSettings {
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
// Every field the persisted format has always required; `partial`
// contributes the fields under test.
serde_json::from_str(&format!(
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
partial
))
}
#[test]
fn old_theme_mode_key_keeps_its_pinned_choice() {
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
#[test]
fn missing_appearance_mode_falls_back_to_dark() {
let settings = json_with("").unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
}
-1
View File
@@ -1 +0,0 @@
../../LICENSE
-1
View File
@@ -1 +0,0 @@
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
+151
View File
@@ -0,0 +1,151 @@
//! Light/dark switching, adapted from bezel
//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel
//! contributors): what the user asked for, what the OS reports, and the
//! plumbing that turns a change in either into a repaint.
use gpui::{App, Global, Subscription, Window};
use serde::{Deserialize, Serialize};
use crate::{Theme, ThemeMode};
/// The user's appearance preference. Serde-serializable so callers can persist
/// it wherever their settings live; this crate never touches disk.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode {
/// Follow the OS — matches every other native app on the machine,
/// including when the OS is set to switch at sunset.
System,
Light,
/// Paint dark regardless of the OS.
#[default]
Dark,
}
impl AppearanceMode {
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
/// Menu/label text.
pub fn label(self) -> &'static str {
match self {
Self::System => "System",
Self::Light => "Light",
Self::Dark => "Dark",
}
}
}
/// Global state behind the current theme: what the user chose, and what the OS
/// last said. Kept separate from [`Theme`] itself so that flipping the OS
/// appearance while the user has pinned Light still records the new system
/// value (and takes effect the moment they switch back to `System`).
pub struct AppearanceState {
pub mode: AppearanceMode,
pub system: ThemeMode,
}
impl Global for AppearanceState {}
/// Combine the user's choice with the OS state.
pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode {
match mode {
AppearanceMode::System => system,
AppearanceMode::Light => ThemeMode::Light,
AppearanceMode::Dark => ThemeMode::Dark,
}
}
/// Install the appearance globals and the matching theme. Call once at boot,
/// before any window opens, so the first frame is already the right palette
/// (installing later produces a visible dark-to-light flash).
pub fn init(mode: AppearanceMode, cx: &mut App) {
let system = ThemeMode::from_window(cx.window_appearance());
cx.set_global(AppearanceState { mode, system });
Theme::change(resolve(mode, system), None, cx);
}
/// The mode currently in effect (defaults to `Dark` before [`init`]).
pub fn mode(cx: &App) -> AppearanceMode {
cx.try_global::<AppearanceState>()
.map(|state| state.mode)
.unwrap_or_default()
}
/// Change the user's preference and repaint if that changed the palette.
/// Persisting the choice is the caller's job.
pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
if !cx.has_global::<AppearanceState>() {
return;
}
let state = cx.global_mut::<AppearanceState>();
if state.mode == mode {
return;
}
state.mode = mode;
// Coming back to `System`, re-read the OS: a pinned mode holds an
// appearance override, so `system` may be stale from before the pin.
if mode == AppearanceMode::System {
let system = ThemeMode::from_window(cx.window_appearance());
cx.global_mut::<AppearanceState>().system = system;
}
apply(cx);
}
/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
/// must outlive the window — callers typically `.detach()` it.
///
/// The notification is *per window*, but the appearance it reports is a system
/// setting, so any one window is enough to learn about the change; re-applying
/// is idempotent when several fire.
pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
// Reconcile against the *window's* appearance before subscribing: `init`
// runs before any window exists and can only ask the app, which on some
// platforms is not reliably populated that early in launch.
record_system(window.appearance().into(), cx);
window.observe_window_appearance(move |window, cx| {
record_system(window.appearance().into(), cx);
})
}
/// Record the OS appearance and re-apply if it moved. `window`, when given,
/// is the more authoritative source for what the OS currently reports.
pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) {
let appearance = match window {
Some(window) => window.appearance(),
None => cx.window_appearance(),
};
record_system(ThemeMode::from_window(appearance), cx);
}
/// Record the OS appearance and re-apply if it moved. Only recorded while the
/// user's mode is `System` — a pinned mode ignores the OS anyway, and
/// recording an override read back would overwrite what the OS said with what
/// we asked for.
fn record_system(system: ThemeMode, cx: &mut App) {
if !cx.has_global::<AppearanceState>() {
return;
}
let state = cx.global_mut::<AppearanceState>();
if state.mode != AppearanceMode::System || state.system == system {
return;
}
state.system = system;
apply(cx);
}
/// Re-resolve the palette and, if it moved, swap the theme and force a full
/// repaint. A no-op when the resolved appearance is unchanged — the OS fires
/// the appearance notification for other changes too, and repainting every
/// window for those would be a visible hitch for nothing.
pub fn apply(cx: &mut App) {
let Some(state) = cx.try_global::<AppearanceState>() else {
return;
};
let wanted = resolve(state.mode, state.system);
let changed = !cx
.try_global::<Theme>()
.is_some_and(|theme| theme.mode == wanted);
if changed {
Theme::change(wanted, None, cx);
cx.refresh_windows();
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Brand, ported from bezel (`crates/theme/src/brand.rs`, MIT License,
//! Copyright (c) 2026 bezel contributors): one hue for the greys, one for the
//! accent, one radius, and the text ink.
//!
//! A brand moves hue alone: every lightness in `palette.rs` stays as the
//! palette set it.
use gpui::{App, Global, Hsla};
use crate::{ThemeColors, ThemeMode, color, layout};
/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Tint {
/// oklch hue, in degrees.
pub hue: f32,
/// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
pub chroma: f32,
}
impl Tint {
pub const NONE: Self = Self {
hue: 0.0,
chroma: 0.0,
};
pub const fn new(hue: f32, chroma: f32) -> Self {
Self { hue, chroma }
}
}
/// The greys a UI is built on, as oklch hue and chroma.
///
/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors)
/// — the same list shadcn offers as its base colour, and the reason these are
/// quoted rather than invented: a neutral that carries hue is a judgement
/// someone else has already made five times.
pub const BASE_COLORS: [(&str, Tint); 5] = [
("Neutral", Tint::NONE),
("Stone", Tint::new(58.071, 0.013)),
("Zinc", Tint::new(285.938, 0.016)),
("Gray", Tint::new(264.364, 0.027)),
("Slate", Tint::new(257.417, 0.046)),
];
/// The alpha of each text role over the appearance's ink — white in dark,
/// black in light.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextInk {
/// [`ThemeColors::text`].
pub text: f32,
/// [`ThemeColors::text_muted`].
pub muted: f32,
/// [`ThemeColors::text_faint`].
pub faint: f32,
}
impl TextInk {
/// The role at `alpha` over `appearance`'s ink.
pub fn color(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
ThemeMode::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
}
}
/// Writes [`ThemeColors::text`], [`ThemeColors::text_muted`] and
/// [`ThemeColors::text_faint`] for the appearance.
pub fn paint(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
colors.text = Self::color(appearance, self.text);
colors.text_muted = Self::color(appearance, self.muted);
colors.text_faint = Self::color(appearance, self.faint);
}
}
/// Text ink for both appearances.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ink {
pub dark: TextInk,
pub light: TextInk,
}
impl Ink {
/// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
/// [`ThemeColors::bg`] and [`ThemeColors::surface`] in both appearances.
pub const ACCESSIBLE: Self = Self {
dark: TextInk {
text: 0.85,
muted: 0.52,
faint: 0.40,
},
light: TextInk {
text: 0.85,
muted: 0.56,
faint: 0.43,
},
};
/// AppKit's label, secondary and tertiary label colours. Muted is under
/// 4.5:1 on white, and faint is under 3:1 in both appearances.
pub const APPKIT: Self = Self {
dark: TextInk {
text: 0.85,
muted: 0.55,
faint: 0.25,
},
light: TextInk {
text: 0.85,
muted: 0.50,
faint: 0.26,
},
};
pub fn for_appearance(&self, appearance: ThemeMode) -> TextInk {
match appearance {
ThemeMode::Dark => self.dark,
ThemeMode::Light => self.light,
}
}
}
/// What an app changes about the shipped palette without redesigning it.
///
/// Installed as a gpui [`Global`] via [`crate::AppExt::set_brand`]; every
/// palette install applies it, so it survives a light/dark switch and composes
/// with [`crate::AppExt::set_palette`] rather than competing with it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Brand {
/// The hue every grey in the palette carries.
pub tint: Tint,
/// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
/// any other grey — which is what the shipped palette already is.
pub accent: Tint,
/// The base corner radius; every other corner is a ratio of it. See
/// [`crate::BASE_RADIUS`].
pub radius: f32,
/// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
pub ink: Ink,
}
impl Global for Brand {}
impl Default for Brand {
fn default() -> Self {
Self {
tint: Tint::NONE,
accent: Tint::NONE,
radius: layout::BASE_RADIUS,
ink: Ink::APPKIT,
}
}
}
impl Brand {
/// Whether applying this brand would change nothing — the shipped palette
/// already carries these values, so a JSON theme's own text colors must be
/// left alone.
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The shipped palette for an appearance, rotated onto this brand.
pub fn branded(&self, appearance: ThemeMode) -> ThemeColors {
let mut colors = ThemeColors::for_appearance(appearance);
// apply() re-asserts this brand's ink, so the default brand — whose
// ink is the shipped one — must skip it to stay a strict no-op.
if !self.is_default() {
self.apply(appearance, &mut colors);
}
colors
}
/// Rotate a palette onto this brand's hues.
pub fn apply(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
self.ink
.for_appearance(appearance)
.paint(appearance, colors);
// Every colour token, with the rule doing the choosing: a token that is
// already grey takes the tint, and one that already carries a hue —
// danger, warning, success — is semantic and keeps it. Translucent ink
// is skipped because it paints over whatever is beneath it, which is
// tinted already.
let tokens: [&mut Hsla; 41] = [
&mut colors.bg,
&mut colors.surface,
&mut colors.surface_raised,
&mut colors.surface_card,
&mut colors.surface_dialog,
&mut colors.surface_overlay,
&mut colors.element_hover,
&mut colors.element_active,
&mut colors.border_faint,
&mut colors.border,
&mut colors.border_strong,
&mut colors.text,
&mut colors.text_muted,
&mut colors.text_faint,
&mut colors.text_dim,
&mut colors.solid,
&mut colors.on_solid,
&mut colors.accent,
&mut colors.accent_strong,
&mut colors.on_accent,
&mut colors.danger,
&mut colors.danger_muted,
&mut colors.warning,
&mut colors.warning_muted,
&mut colors.success,
&mut colors.busy,
&mut colors.success_muted,
&mut colors.surface_raised_hover,
&mut colors.band,
&mut colors.input_bg,
&mut colors.selection,
&mut colors.cursor,
&mut colors.caret,
&mut colors.ring,
&mut colors.drop_target,
&mut colors.danger_strong,
&mut colors.code_text,
&mut colors.code_wash,
&mut colors.diff_add,
&mut colors.diff_del,
&mut colors.diff_hunk_bg,
];
let syntax: [&mut Hsla; 24] = [
&mut colors.syntax.comment,
&mut colors.syntax.keyword,
&mut colors.syntax.string,
&mut colors.syntax.string_special,
&mut colors.syntax.escape,
&mut colors.syntax.number,
&mut colors.syntax.boolean,
&mut colors.syntax.type_name,
&mut colors.syntax.type_builtin,
&mut colors.syntax.constructor,
&mut colors.syntax.function,
&mut colors.syntax.function_builtin,
&mut colors.syntax.macro_name,
&mut colors.syntax.property,
&mut colors.syntax.constant,
&mut colors.syntax.variable,
&mut colors.syntax.variable_special,
&mut colors.syntax.parameter,
&mut colors.syntax.operator,
&mut colors.syntax.punctuation,
&mut colors.syntax.tag,
&mut colors.syntax.attribute,
&mut colors.syntax.label,
&mut colors.syntax.invalid,
];
for slot in tokens.into_iter().chain(syntax) {
if slot.a == 1.0 && slot.s <= f32::EPSILON {
*slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
}
}
if self.accent.chroma > 0.0 {
let (accent_l, plate_l) = if appearance.is_light() {
(ACCENT_L.1, PLATE_L.1)
} else {
(ACCENT_L.0, PLATE_L.0)
};
colors.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
colors.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
// Whichever label the plate can actually hold. The shipped accent is
// the maximum-contrast neutral, where the answer is always the
// inverse; a chromatic plate at a yellow hue is bright enough that
// the inverse would be the unreadable one.
colors.on_accent = label_on(colors.accent_strong, colors);
}
}
}
/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
/// the two steps `palette.rs` picked so an accent clears WCAG AA on its own
/// background rather than glowing on one and vanishing on the other.
const ACCENT_L: (f32, f32) = (0.673, 0.511);
/// The lightness of a *plate* carrying [`ThemeColors::on_accent`], taken from
/// `danger_strong` — the palette's existing chromatic plate, already tuned to
/// hold a label in both appearances.
const PLATE_L: (f32, f32) = (0.58, 0.51);
/// Whichever of the palette's two extremes the plate can actually hold.
fn label_on(plate: Hsla, colors: &ThemeColors) -> Hsla {
let (a, b) = (colors.solid, colors.on_solid);
if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
a
} else {
b
}
}
/// Read the installed brand (the default before one is set).
pub fn installed(cx: &App) -> Brand {
cx.try_global::<Brand>().copied().unwrap_or_default()
}
+215
View File
@@ -0,0 +1,215 @@
//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License,
//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters
//! behind the token constructors, WCAG contrast for the palette tests, and
//! small paint helpers.
use gpui::{Hsla, hsla};
/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
/// float-noise saturation).
pub fn neutral(lightness: f32) -> Hsla {
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
hsla(0.0, 0.0, v, 1.0)
}
/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
/// — for surfaces matched against reference samples.
pub fn grey(value: u8) -> Hsla {
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
}
/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
let [r, g, b] = oklch_to_srgb(l, c, h_deg);
let (h, s, l) = rgb_to_hsl(r, g, b);
hsla(h, s, l, 1.0)
}
/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
let [r, g, b] = oklch_to_linear(l, c, h_deg);
[gamma_encode(r), gamma_encode(g), gamma_encode(b)]
}
/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
/// display cannot make, which is what [`fit_chroma`] tests for.
fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
let h = h_deg.to_radians();
let a = c * h.cos();
let b = c * h.sin();
// OKLab → LMS (cube roots undone)
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
// LMS → linear sRGB
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
[r, g, b]
}
fn gamma_encode(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
}
}
fn gamma_decode(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
if x <= 0.040_449_936 {
x / 12.92
} else {
((x + 0.055) / 1.055).powf(2.4)
}
}
/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
///
/// Only greys round-trip through this. A grey's three channels are equal, and
/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
/// collapses to one cube root — no inverse matrix needed.
pub fn lightness(color: Hsla) -> f32 {
gamma_decode(color.l).cbrt()
}
/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
///
/// Near black and near white the gamut is a needle: asking for a mid-ramp
/// chroma there produces an out-of-range component, and clamping it per channel
/// shifts the hue instead of dropping the saturation.
fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
let fits = |c: f32| {
oklch_to_linear(l, c, hue)
.iter()
.all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
};
if fits(chroma) {
return chroma;
}
let (mut lo, mut hi) = (0.0, chroma);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if fits(mid) { lo = mid } else { hi = mid }
}
lo
}
/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
/// lightness can hold. Alpha rides through untouched.
pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
if chroma <= 0.0 {
return color;
}
let l = lightness(color);
let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
out.a = color.a;
out
}
/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let delta = max - min;
if delta < f32::EPSILON {
return (0.0, 0.0, l);
}
let s = if l > 0.5 {
delta / (2.0 - max - min)
} else {
delta / (max + min)
};
let h = if (max - r).abs() < f32::EPSILON {
((g - b) / delta).rem_euclid(6.0)
} else if (max - g).abs() < f32::EPSILON {
(b - r) / delta + 2.0
} else {
(r - g) / delta + 4.0
} / 6.0;
(h, s, l)
}
/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
if s <= f32::EPSILON {
return [l, l, l];
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
let hue = |mut t: f32| {
t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
}
/// WCAG 2.1 relative luminance of an opaque color.
pub fn relative_luminance(color: Hsla) -> f32 {
let lin = |c: f32| {
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
///
/// Used by the palette tests to prove each light token reproduces the contrast
/// its dark counterpart had, rather than merely looking plausible.
pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
/// opaque result — the color the eye actually receives.
pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
let a = fg.a.clamp(0.0, 1.0);
let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
let (h, s, l) = rgb_to_hsl(
fr * a + br * (1.0 - a),
fg_ * a + bg_ * (1.0 - a),
fb * a + bb * (1.0 - a),
);
hsla(h, s, l, 1.0)
}
/// Linear per-component mix of two colors.
pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
let t = t.clamp(0.0, 1.0);
let lerp = |x: f32, y: f32| x + (y - x) * t;
hsla(
lerp(a.h, b.h),
lerp(a.s, b.s),
lerp(a.l, b.l),
lerp(a.a, b.a),
)
}
+99 -564
View File
@@ -1,583 +1,118 @@
use gpui::{Hsla, Rgba, hsla};
use gpui::Hsla;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::scale::{ColorScale, ColorScaleSet};
use crate::syntax::SyntaxPalette;
use crate::{ThemeMode, palette};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(default)]
pub struct ThemeColors {
// Surface colors
pub background: Hsla,
pub surface_background: Hsla,
pub elevated_surface_background: Hsla,
pub panel_background: Hsla,
pub overlay: Hsla,
pub title_bar: Hsla,
pub title_bar_inactive: Hsla,
pub window_border: Hsla,
/// The main content plane.
pub bg: Hsla,
/// The shell / sidebar plane.
pub surface: Hsla,
/// Opaque pills and chips raised above the panel.
pub surface_raised: Hsla,
/// An inline card resting on the main plane.
pub surface_card: Hsla,
/// A modal dialog, over the scrim.
pub surface_dialog: Hsla,
/// Popovers and menus — the highest plane.
pub surface_overlay: Hsla,
/// Row hover — translucent ink over whatever is beneath.
pub element_hover: Hsla,
/// Pressed or selected, one rung past [`ThemeColors::element_hover`].
pub element_active: Hsla,
// Border colors
/// The standard hairline border.
pub border: Hsla,
pub border_variant: Hsla,
pub border_focused: Hsla,
pub border_selected: Hsla,
pub border_transparent: Hsla,
pub border_disabled: Hsla,
/// Dividers inside cards, nested frames.
pub border_faint: Hsla,
/// Focused or raised edges.
pub border_strong: Hsla,
/// The focus ring.
pub ring: Hsla,
// Text colors
/// Primary text.
pub text: Hsla,
/// Secondary text.
pub text_muted: Hsla,
pub text_placeholder: Hsla,
pub text_accent: Hsla,
pub text_danger: Hsla,
pub text_warning: Hsla,
/// Placeholders and disabled text.
pub text_faint: Hsla,
/// Diff file paths — one notch below [`ThemeColors::text_muted`].
pub text_dim: Hsla,
// Icon colors
pub icon: Hsla,
pub icon_muted: Hsla,
pub icon_accent: Hsla,
// Element colors
pub element_foreground: Hsla,
pub element_background: Hsla,
pub element_hover: Hsla,
pub element_active: Hsla,
pub element_selected: Hsla,
pub element_disabled: Hsla,
// Secondary element colors
pub secondary_foreground: Hsla,
pub secondary_background: Hsla,
pub secondary_hover: Hsla,
pub secondary_active: Hsla,
pub secondary_selected: Hsla,
pub secondary_disabled: Hsla,
// Danger element colors
pub danger_foreground: Hsla,
pub danger_background: Hsla,
pub danger_hover: Hsla,
pub danger_active: Hsla,
pub danger_selected: Hsla,
pub danger_disabled: Hsla,
// Warning element colors
pub warning_foreground: Hsla,
pub warning_background: Hsla,
pub warning_hover: Hsla,
pub warning_active: Hsla,
pub warning_selected: Hsla,
pub warning_disabled: Hsla,
// Ghost element colors
pub ghost_element_background: Hsla,
pub ghost_element_background_alt: Hsla,
pub ghost_element_hover: Hsla,
pub ghost_element_active: Hsla,
pub ghost_element_selected: Hsla,
pub ghost_element_disabled: Hsla,
// Tab colors
pub tab_background: Hsla,
pub tab_foreground: Hsla,
pub tab_hover_background: Hsla,
pub tab_active_background: Hsla,
pub tab_active_foreground: Hsla,
// Scrollbar colors
pub scrollbar_thumb_background: Hsla,
pub scrollbar_thumb_hover_background: Hsla,
pub scrollbar_thumb_border: Hsla,
pub scrollbar_track_background: Hsla,
pub scrollbar_track_border: Hsla,
// Interactive colors
pub drop_target_background: Hsla,
/// The text cursor.
pub cursor: Hsla,
/// The selected-text wash.
pub selection: Hsla,
/// The primary button plate — the maximum-contrast fill.
pub solid: Hsla,
/// Label ink on [`ThemeColors::solid`].
pub on_solid: Hsla,
/// Emphasis for text and icons; neutral by default.
pub accent: Hsla,
/// Accent fills carrying [`ThemeColors::on_accent`].
pub accent_strong: Hsla,
/// Label ink on [`ThemeColors::accent_strong`].
pub on_accent: Hsla,
/// Errors and destructive actions.
pub danger: Hsla,
/// Inline error copy.
pub danger_muted: Hsla,
/// The destructive plate — white ink in both appearances.
pub danger_strong: Hsla,
/// Warnings.
pub warning: Hsla,
/// The translucent warning wash behind chips and banners.
pub warning_muted: Hsla,
/// Success and online presence.
pub success: Hsla,
/// Text on success-tinted chips.
pub success_muted: Hsla,
/// Working or streaming states.
pub busy: Hsla,
/// Hover over [`ThemeColors::surface_raised`].
pub surface_raised_hover: Hsla,
/// A recessed header / footer strip.
pub band: Hsla,
/// Input backgrounds.
pub input_bg: Hsla,
/// The text caret.
pub caret: Hsla,
/// The line marking where a drag will land.
pub drop_line: Hsla,
/// The fill of the region a dragged pane would dock into.
pub drop_target: Hsla,
/// Inline-code text.
pub code_text: Hsla,
/// The wash behind [`ThemeColors::code_text`].
pub code_wash: Hsla,
/// The shared syntax palette, read at paint time.
pub syntax: SyntaxPalette,
/// Diff additions.
pub diff_add: Hsla,
/// Diff deletions.
pub diff_del: Hsla,
/// The diff hunk-header wash.
pub diff_hunk_bg: Hsla,
}
impl Default for ThemeColors {
fn default() -> Self {
palette::dark()
}
}
/// The default colors for the theme.
///
/// Themes that do not specify all colors are refined off of these defaults.
impl ThemeColors {
/// Returns the default colors for light themes.
///
/// Themes that do not specify all colors are refined off of these defaults.
pub(crate) fn light() -> Self {
Self {
background: neutral().light().step_1(),
surface_background: neutral().light().step_2(),
elevated_surface_background: neutral().light().step_3(),
panel_background: neutral().light().step_1(),
overlay: neutral().light_alpha().step_3(),
title_bar: neutral().light().step_2(),
title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0),
border: neutral().light().step_6(),
border_variant: neutral().light().step_4(),
border_focused: brand().light().step_7(),
border_selected: brand().light().step_7(),
border_transparent: gpui::transparent_black(),
border_disabled: neutral().light().step_3(),
ring: brand().light().step_8(),
text: neutral().light().step_12(),
text_muted: neutral().light().step_11(),
text_placeholder: neutral().light().step_10(),
text_accent: brand().light().step_11(),
text_danger: danger().light().step_9(),
text_warning: warning().light().step_9(),
icon: neutral().light().step_11(),
icon_muted: neutral().light().step_10(),
icon_accent: brand().light().step_11(),
element_foreground: brand().light().step_12(),
element_background: brand().light().step_9(),
element_hover: brand().light_alpha().step_10(),
element_active: brand().light().step_10(),
element_selected: brand().light().step_11(),
element_disabled: brand().light_alpha().step_3(),
secondary_foreground: brand().light().step_11(),
secondary_background: brand().light().step_3(),
secondary_hover: brand().light_alpha().step_4(),
secondary_active: brand().light().step_5(),
secondary_selected: brand().light().step_5(),
secondary_disabled: brand().light_alpha().step_3(),
danger_foreground: danger().light().step_12(),
danger_background: danger().light().step_3(),
danger_hover: danger().light_alpha().step_4(),
danger_active: danger().light().step_5(),
danger_selected: danger().light().step_5(),
danger_disabled: danger().light_alpha().step_3(),
warning_foreground: warning().light().step_12(),
warning_background: warning().light().step_3(),
warning_hover: warning().light_alpha().step_4(),
warning_active: warning().light().step_5(),
warning_selected: warning().light().step_5(),
warning_disabled: warning().light_alpha().step_3(),
ghost_element_background: gpui::transparent_black(),
ghost_element_background_alt: neutral().light().step_3(),
ghost_element_hover: neutral().light_alpha().step_4(),
ghost_element_active: neutral().light().step_5(),
ghost_element_selected: neutral().light().step_5(),
ghost_element_disabled: neutral().light_alpha().step_2(),
tab_background: neutral().light().step_3(),
tab_foreground: neutral().light().step_11(),
tab_hover_background: neutral().light_alpha().step_4(),
tab_active_background: neutral().light().step_1(),
tab_active_foreground: neutral().light().step_12(),
scrollbar_thumb_background: neutral().light_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: gpui::transparent_black(),
scrollbar_track_border: gpui::transparent_black(),
drop_target_background: brand().light_alpha().step_2(),
cursor: hsl(200., 100., 50.),
selection: hsl(200., 100., 50.).alpha(0.25),
}
}
/// Returns the default colors for dark themes.
///
/// Themes that do not specify all colors are refined off of these defaults.
pub(crate) fn dark() -> Self {
Self {
background: neutral().dark().step_1(),
surface_background: neutral().dark().step_2(),
elevated_surface_background: neutral().dark().step_3(),
panel_background: neutral().dark().step_1(),
overlay: neutral().dark_alpha().step_3(),
title_bar: neutral().dark().step_2(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
border: neutral().dark().step_6(),
border_variant: neutral().dark().step_4(),
border_focused: brand().dark().step_7(),
border_selected: brand().dark().step_7(),
border_transparent: gpui::transparent_black(),
border_disabled: neutral().dark().step_3(),
ring: brand().dark().step_8(),
text: neutral().dark().step_12(),
text_muted: neutral().dark().step_11(),
text_placeholder: neutral().dark().step_10(),
text_accent: brand().dark().step_11(),
text_danger: danger().dark().step_9(),
text_warning: warning().dark().step_9(),
icon: neutral().dark().step_11(),
icon_muted: neutral().dark().step_10(),
icon_accent: brand().dark().step_11(),
element_foreground: brand().dark().step_1(),
element_background: brand().dark().step_9(),
element_hover: brand().dark_alpha().step_10(),
element_active: brand().dark().step_10(),
element_selected: brand().dark().step_11(),
element_disabled: brand().dark_alpha().step_3(),
secondary_foreground: brand().dark().step_12(),
secondary_background: brand().dark().step_3(),
secondary_hover: brand().dark_alpha().step_4(),
secondary_active: brand().dark().step_5(),
secondary_selected: brand().dark().step_5(),
secondary_disabled: brand().dark_alpha().step_3(),
danger_foreground: danger().dark().step_12(),
danger_background: danger().dark().step_3(),
danger_hover: danger().dark_alpha().step_4(),
danger_active: danger().dark().step_5(),
danger_selected: danger().dark().step_5(),
danger_disabled: danger().dark_alpha().step_3(),
warning_foreground: warning().dark().step_12(),
warning_background: warning().dark().step_3(),
warning_hover: warning().dark_alpha().step_4(),
warning_active: warning().dark().step_5(),
warning_selected: warning().dark().step_5(),
warning_disabled: warning().dark_alpha().step_3(),
ghost_element_background: gpui::transparent_black(),
ghost_element_background_alt: neutral().dark().step_3(),
ghost_element_hover: neutral().dark_alpha().step_4(),
ghost_element_active: neutral().dark().step_5(),
ghost_element_selected: neutral().dark().step_5(),
ghost_element_disabled: neutral().dark_alpha().step_2(),
tab_background: neutral().dark().step_3(),
tab_foreground: neutral().dark().step_11(),
tab_hover_background: neutral().dark_alpha().step_4(),
tab_active_background: neutral().dark().step_1(),
tab_active_foreground: neutral().dark().step_12(),
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: gpui::transparent_black(),
scrollbar_track_border: gpui::transparent_black(),
drop_target_background: brand().dark_alpha().step_2(),
cursor: hsl(200., 100., 50.),
selection: hsl(200., 100., 50.).alpha(0.25),
}
/// Returns the default colors for an appearance.
pub fn for_appearance(appearance: ThemeMode) -> Self {
palette::for_appearance(appearance)
}
}
fn neutral() -> ColorScaleSet {
gray()
}
fn brand() -> ColorScaleSet {
yellow()
}
fn danger() -> ColorScaleSet {
tomato()
}
fn warning() -> ColorScaleSet {
orange()
}
/// Make a [gpui::Hsla] color.
///
/// - h: 0..360.0
/// - s: 0.0..100.0
/// - l: 0.0..100.0
pub(crate) fn hsl(h: f32, s: f32, l: f32) -> Hsla {
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
}
type StaticColorScale = [&'static str; 12];
struct StaticColorScaleSet {
light: StaticColorScale,
light_alpha: StaticColorScale,
dark: StaticColorScale,
dark_alpha: StaticColorScale,
}
impl TryFrom<StaticColorScaleSet> for ColorScaleSet {
type Error = anyhow::Error;
fn try_from(value: StaticColorScaleSet) -> Result<Self, Self::Error> {
fn to_color_scale(scale: StaticColorScale) -> Result<ColorScale, anyhow::Error> {
scale
.into_iter()
.map(|color| Rgba::try_from(color).map(Hsla::from))
.collect::<Result<Vec<_>, _>>()
.map(ColorScale::from_iter)
}
Ok(Self::new(
to_color_scale(value.light)?,
to_color_scale(value.light_alpha)?,
to_color_scale(value.dark)?,
to_color_scale(value.dark_alpha)?,
))
}
}
pub(crate) fn gray() -> ColorScaleSet {
StaticColorScaleSet {
light: [
"#fcfcfcff",
"#f9f9f9ff",
"#f0f0f0ff",
"#e8e8e8ff",
"#e0e0e0ff",
"#d9d9d9ff",
"#cececeff",
"#bbbbbbff",
"#8d8d8dff",
"#838383ff",
"#646464ff",
"#202020ff",
],
light_alpha: [
"#00000003",
"#00000006",
"#0000000f",
"#00000017",
"#0000001f",
"#00000026",
"#00000031",
"#00000044",
"#00000072",
"#0000007c",
"#0000009b",
"#000000df",
],
dark: [
"#111111ff",
"#191919ff",
"#222222ff",
"#2a2a2aff",
"#313131ff",
"#3a3a3aff",
"#484848ff",
"#606060ff",
"#6e6e6eff",
"#7b7b7bff",
"#b4b4b4ff",
"#eeeeeeff",
],
dark_alpha: [
"#00000000",
"#ffffff09",
"#ffffff12",
"#ffffff1b",
"#ffffff22",
"#ffffff2c",
"#ffffff3b",
"#ffffff55",
"#ffffff64",
"#ffffff72",
"#ffffffaf",
"#ffffffed",
],
}
.try_into()
.unwrap()
}
pub(crate) fn yellow() -> ColorScaleSet {
StaticColorScaleSet {
light: [
"#fdfdf9ff",
"#fefce9ff",
"#fffab8ff",
"#fff394ff",
"#ffe770ff",
"#f3d768ff",
"#e4c767ff",
"#d5ae39ff",
"#ffe629ff",
"#ffdc00ff",
"#9e6c00ff",
"#473b1fff",
],
light_alpha: [
"#aaaa0006",
"#f4dd0016",
"#ffee0047",
"#ffe3016b",
"#ffd5008f",
"#ebbc0097",
"#d2a10098",
"#c99700c6",
"#ffe100d6",
"#ffdc00ff",
"#9e6c00ff",
"#2e2000e0",
],
dark: [
"#14120bff",
"#1b180fff",
"#2d2305ff",
"#362b00ff",
"#433500ff",
"#524202ff",
"#665417ff",
"#836a21ff",
"#ffe629ff",
"#ffff57ff",
"#f5e147ff",
"#f6eeb4ff",
],
dark_alpha: [
"#d1510004",
"#f9b4000b",
"#ffaa001e",
"#fdb70028",
"#febb0036",
"#fec40046",
"#fdcb225c",
"#fdca327b",
"#ffe629ff",
"#ffff57ff",
"#fee949f5",
"#fef6baf6",
],
}
.try_into()
.unwrap()
}
pub(crate) fn orange() -> ColorScaleSet {
StaticColorScaleSet {
light: [
"#fefcfbff",
"#fff7edff",
"#ffefd6ff",
"#ffdfb5ff",
"#ffd19aff",
"#ffc182ff",
"#f5ae73ff",
"#ec9455ff",
"#f76b15ff",
"#ef5f00ff",
"#cc4e00ff",
"#582d1dff",
],
light_alpha: [
"#c0400004",
"#ff8e0012",
"#ff9c0029",
"#ff91014a",
"#ff8b0065",
"#ff81007d",
"#ed6c008c",
"#e35f00aa",
"#f65e00ea",
"#ef5f00ff",
"#cc4e00ff",
"#431200e2",
],
dark: [
"#17120eff",
"#1e160fff",
"#331e0bff",
"#462100ff",
"#562800ff",
"#66350cff",
"#7e451dff",
"#a35829ff",
"#f76b15ff",
"#ff801fff",
"#ffa057ff",
"#ffe0c2ff",
],
dark_alpha: [
"#ec360007",
"#fe6d000e",
"#fb6a0025",
"#ff590039",
"#ff61004a",
"#fd75045c",
"#ff832c75",
"#fe84389d",
"#fe6d15f7",
"#ff801fff",
"#ffa057ff",
"#ffe0c2ff",
],
}
.try_into()
.unwrap()
}
pub(crate) fn tomato() -> ColorScaleSet {
StaticColorScaleSet {
light: [
"#fffcfcff",
"#fff8f7ff",
"#feebe7ff",
"#ffdcd3ff",
"#ffcdc2ff",
"#fdbdafff",
"#f5a898ff",
"#ec8e7bff",
"#e54d2eff",
"#dd4425ff",
"#d13415ff",
"#5c271fff",
],
light_alpha: [
"#ff000003",
"#ff200008",
"#f52b0018",
"#ff35002c",
"#ff2e003d",
"#f92d0050",
"#e7280067",
"#db250084",
"#df2600d1",
"#d72400da",
"#cd2200ea",
"#460900e0",
],
dark: [
"#181111ff",
"#1f1513ff",
"#391714ff",
"#4e1511ff",
"#5e1c16ff",
"#6e2920ff",
"#853a2dff",
"#ac4d39ff",
"#e54d2eff",
"#ec6142ff",
"#ff977dff",
"#fbd3cbff",
],
dark_alpha: [
"#f1121208",
"#ff55330f",
"#ff35232b",
"#fd201142",
"#fe332153",
"#ff4f3864",
"#fd644a7d",
"#fe6d4ea7",
"#fe5431e4",
"#ff6847eb",
"#ff977dff",
"#ffd6cefb",
],
}
.try_into()
.unwrap()
}
+87
View File
@@ -0,0 +1,87 @@
//! Layout constants, ported from bezel
//! (`crates/theme/src/theme/layout.rs`, MIT License, Copyright (c) 2026 bezel
//! contributors). Numbers drive layout, colors are paint: these live as plain
//! numbers and never depend on which color is painted.
use std::sync::atomic::{AtomicU32, Ordering};
/// The branded base radius behind every radius accessor, as raw `f32` bits.
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
/// Point the radius accessors at a base. Called when a palette is installed.
pub(crate) fn set_base_radius(radius: f32) {
BASE.store(radius.to_bits(), Ordering::Relaxed);
}
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
/// all report 8.
pub const SPACE: f32 = 8.0;
/// The margin from content to its container's edge. Same measurement,
/// visual format's `|-`.
pub const CONTENT_MARGIN: f32 = 20.0;
/// From a column's edge to the glyph of the control at the end of a row,
/// measured to the glyph rather than the button. Mirrored at the trailing end.
pub const EDGE: f32 = 12.0;
/// Button, text field and select-trigger height. Measured:
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
/// `.regular` — the body line box with 4 above and below.
pub const BUTTON_HEIGHT: f32 = 24.0;
/// The same controls at `.small`.
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
/// Every radius below is a ratio of this, so a brand radius moves the whole
/// set together and keeps the concentric relationships intact.
pub const BASE_RADIUS: f32 = 8.0;
/// Message bubble corner radius (2x).
pub fn bubble_radius() -> f32 {
radius(2.0)
}
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
pub fn surface_radius() -> f32 {
radius(1.5)
}
/// Panel / card corner radius (1.25x).
pub fn panel_radius() -> f32 {
radius(1.25)
}
/// Button, text field and select-trigger radius (1x).
pub fn button_radius() -> f32 {
radius(1.0)
}
/// Small control radius (chips, tags, steppers) — a size down from
/// [`button_radius`], for things that sit inside a control rather than being one
/// (0.75x).
pub fn control_radius() -> f32 {
radius(0.75)
}
/// A corner as a multiple of the branded base radius.
///
/// Read from a process-wide mirror rather than the theme global for the reason
/// [`crate::paint::current_appearance`] is: the element builders that round a
/// corner are free functions with no `cx` in scope, and a radius is one number
/// for the whole app.
fn radius(ratio: f32) -> f32 {
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
}
/// The concentric child of a surface: a row inset by `inset` inside a
/// container of radius `outer` keeps its corners parallel to the container's,
/// rather than looking pasted onto it.
///
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
/// has no container shape to inherit at paint time, so the relationship is
/// stated where the child is *defined* instead of resolved at runtime.
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
if outer > inset { outer - inset } else { 0.0 }
}
+135 -99
View File
@@ -1,25 +1,51 @@
use std::ops::{Deref, DerefMut};
use std::rc::Rc;
use gpui::{App, Global, Pixels, SharedString, Window, px};
mod brand;
mod color;
mod colors;
mod geometry;
mod layout;
mod notification;
mod paint;
mod palette;
mod platform_kind;
mod registry;
mod scale;
mod scrollbar_mode;
mod syntax;
mod theme;
mod typography;
pub mod appearance;
pub use appearance::AppearanceMode;
pub use brand::{BASE_COLORS, Brand, Ink, TextInk, Tint};
pub use color::{
contrast_ratio, flatten, grey, hsl_to_rgb, lightness, mix, neutral, oklch, oklch_to_srgb,
relative_luminance, rgb_to_hsl, tint,
};
pub use colors::*;
pub use geometry::*;
pub use layout::{
BASE_RADIUS, BUTTON_HEIGHT, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SPACE, bubble_radius,
button_radius, control_radius, inset_radius, panel_radius, surface_radius,
};
pub use notification::*;
pub use paint::{
INK_FILL_SCALE, INK_HAIRLINE_SCALE, SCRIM_ALPHA_DARK, band, current_appearance, hairline, ink,
plate_active, plate_hover, scrim, set_current_appearance, surface_shadows, wash,
};
pub use platform_kind::PlatformKind;
pub use registry::*;
pub use scale::*;
pub use scrollbar_mode::*;
pub use syntax::{HighlightKind, SyntaxPalette};
pub use theme::*;
pub use typography::{
DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size, set_base_text_size,
};
/// Which appearance the app is painting. Bezel names this enum `Appearance`;
/// coop's `ThemeMode` predates the port and carries the same two variants.
pub type Appearance = ThemeMode;
/// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
@@ -40,25 +66,57 @@ pub const TABBAR_HEIGHT: Pixels = px(36.0);
pub const SIDEBAR_WIDTH: Pixels = px(240.);
pub fn init(cx: &mut App) {
registry::init(cx);
Theme::sync_system_appearance(None, cx);
appearance::init(AppearanceMode::default(), cx);
Theme::sync_scrollbar_appearance(cx);
}
/// An alternative palette source.
pub type PaletteBuilder = fn(ThemeMode) -> ThemeColors;
struct GlobalPaletteBuilder(Option<PaletteBuilder>);
impl Global for GlobalPaletteBuilder {}
/// Install a brand or an alternative palette on the app.
pub trait AppExt {
fn set_brand(&mut self, brand: Brand);
fn set_palette(&mut self, build: PaletteBuilder);
fn set_appearance_mode(&mut self, mode: AppearanceMode);
fn set_base_text_size(&mut self, points: f32);
}
impl AppExt for App {
fn set_brand(&mut self, brand: Brand) {
self.set_global(brand);
reinstall(self);
}
fn set_palette(&mut self, build: PaletteBuilder) {
self.set_global(GlobalPaletteBuilder(Some(build)));
reinstall(self);
}
fn set_appearance_mode(&mut self, mode: AppearanceMode) {
appearance::set_mode(mode, self);
}
fn set_base_text_size(&mut self, points: f32) {
typography::set_base_text_size(points);
self.refresh_windows();
}
}
/// Re-install the palette for the current appearance.
fn reinstall(cx: &mut App) {
let mode = cx
.try_global::<Theme>()
.map(|theme| theme.mode)
.unwrap_or_default();
Theme::change(mode, None, cx);
cx.refresh_windows();
}
/// Mirror the active coop theme into the `gpui-base` global theme.
///
/// Base paints a few things from its own tokens -- the focus ring, the wash
/// behind selected text, scrollbars, and overlay backdrops -- so the two
/// globals have to agree or those details drift away from the palette.
///
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
/// mapping would be invented rather than derived.
///
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
/// authoritative hook that keeps the projection current.
pub fn sync_base(cx: &mut App) {
let Some(theme) = cx.try_global::<Theme>() else {
return;
@@ -75,28 +133,28 @@ pub fn sync_base(cx: &mut App) {
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
};
let colors = theme.colors;
let font_family = theme.font_family.clone();
let font_sans = theme.font_sans.clone();
let base = gpui_base::Theme::global_mut(cx);
base.appearance = appearance;
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
base.tokens.typography.sans = font_family;
base.tokens.typography.sans = font_sans;
let tokens = &mut base.tokens.colors;
tokens.background = colors.background;
tokens.background = colors.bg;
tokens.foreground = colors.text;
tokens.surface = colors.surface_background;
tokens.surface = colors.surface;
tokens.surface_foreground = colors.text;
tokens.primary = colors.element_background;
tokens.primary_foreground = colors.element_foreground;
tokens.secondary = colors.secondary_background;
tokens.secondary_foreground = colors.secondary_foreground;
tokens.muted = colors.ghost_element_background_alt;
tokens.primary = colors.solid;
tokens.primary_foreground = colors.on_solid;
tokens.secondary = colors.surface_raised;
tokens.secondary_foreground = colors.text;
tokens.muted = colors.surface_card;
tokens.muted_foreground = colors.text_muted;
tokens.accent = colors.ghost_element_hover;
tokens.accent = colors.element_hover;
tokens.accent_foreground = colors.text;
tokens.destructive = colors.danger_background;
tokens.destructive_foreground = colors.danger_foreground;
tokens.destructive = colors.danger_strong;
tokens.destructive_foreground = gpui::white();
tokens.border = colors.border;
tokens.input = colors.border;
tokens.ring = colors.ring;
@@ -116,34 +174,12 @@ impl ActiveTheme for App {
#[derive(Debug, Clone)]
pub struct Theme {
/// Theme colors
pub colors: ThemeColors,
/// Theme family
pub theme: Rc<ThemeFamily>,
/// The appearance of the theme (light or dark).
pub mode: ThemeMode,
/// The font family for the application.
pub font_family: SharedString,
/// The root font size for the application, default is 15px.
pub font_size: Pixels,
/// Radius for the general elements.
pub radius: Pixels,
/// Radius for the large elements, e.g.: dialog, notification.
pub radius_lg: Pixels,
/// Enable shadow for the general elements. default is true
pub font_sans: SharedString,
pub font_mono: SharedString,
pub shadow: bool,
/// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
}
@@ -181,12 +217,7 @@ impl Theme {
/// Sync the theme with the system appearance
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
let appearance = window
.as_ref()
.map(|window| window.appearance())
.unwrap_or_else(|| cx.window_appearance());
Self::change(appearance, window, cx);
appearance::record_system_appearance(window.as_deref(), cx);
}
/// Sync the Scrollbar showing behavior with the system
@@ -198,79 +229,84 @@ impl Theme {
};
}
/// Apply a new theme to the application.
pub fn apply_theme(new_theme: Rc<ThemeFamily>, window: Option<&mut Window>, cx: &mut App) {
let theme = cx.global_mut::<Theme>();
let mode = theme.mode;
// Update the theme
theme.theme = new_theme;
// Emit a theme change event
Self::change(mode, window, cx);
}
/// Change the app's appearance
pub fn change<M>(mode: M, window: Option<&mut Window>, cx: &mut App)
where
M: Into<ThemeMode>,
{
if !cx.has_global::<Theme>() {
let default_theme = ThemeFamily::default();
let theme = Theme::from(default_theme);
cx.set_global(theme);
cx.set_global(Theme::default());
}
let mode = mode.into();
let theme = cx.global_mut::<Theme>();
let builder = cx
.try_global::<GlobalPaletteBuilder>()
.and_then(|builder| builder.0);
let mut colors = match builder {
Some(build) => build(mode),
None => ThemeColors::for_appearance(mode),
};
// A default brand changes nothing; anything else rotates the tokens.
let installed_brand = brand::installed(cx);
if !installed_brand.is_default() {
installed_brand.apply(mode, &mut colors);
}
let theme = Theme::global_mut(cx);
// Set the theme mode
theme.mode = mode;
// Set the theme colors
if mode.is_dark() {
theme.colors = *theme.theme.dark();
} else {
theme.colors = *theme.theme.light();
}
theme.colors = colors;
// Refresh the window if available
if let Some(window) = window {
window.refresh();
}
// Keep the context-free paint helpers and the radius accessors in step
paint::set_current_appearance(mode);
layout::set_base_radius(installed_brand.radius);
// Keep the base-layer projection in step with the coop palette
sync_base(cx);
}
}
impl From<ThemeFamily> for Theme {
fn from(family: ThemeFamily) -> Self {
impl Default for Theme {
fn default() -> Self {
let platform = PlatformKind::platform();
let mode = ThemeMode::default();
// Define the font family based on the platform.
let font_family = match platform {
// The system UI font, by platform.
let font_sans = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
};
// Define the theme colors based on the appearance
let colors = match mode {
ThemeMode::Light => family.light(),
ThemeMode::Dark => family.dark(),
};
Theme {
font_size: px(15.),
font_family: font_family.into(),
radius: px(6.),
radius_lg: px(10.),
Self {
font_sans: font_sans.into(),
font_mono: system_mono().into(),
shadow: true,
scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(),
mode,
colors: *colors,
theme: Rc::new(family),
colors: ThemeColors::for_appearance(mode),
}
}
}
/// The mono face has no alias of its own, so each backend is named here.
fn system_mono() -> &'static str {
if cfg!(target_family = "wasm") {
".ZedMono"
} else if cfg!(target_os = "macos") {
".AppleSystemUIFontMonospaced"
} else if cfg!(target_os = "windows") {
"Cascadia Mono"
} else {
"DejaVu Sans Mono"
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Context-free paint helpers, ported from bezel
//! (`crates/theme/src/paint.rs`, MIT License, Copyright (c) 2026 bezel
//! contributors): the process-wide appearance mirror and the free functions
//! ([`ink`], [`hairline`], [`wash`], …) that element builders call without a
//! `cx` in scope.
use std::sync::atomic::{AtomicU8, Ordering};
use gpui::{BoxShadow, Hsla, hsla, point, px};
use crate::ThemeMode;
/// Process-wide mirror of the installed theme's appearance.
///
/// The paint helpers are free functions called from deep inside element
/// builders that have no `cx` in scope, so they read the appearance from here
/// instead of the gpui global. Appearance is genuinely process-wide — one
/// setting for every window — so a single mirror is sound; [`Theme::change`]
/// is the only writer outside tests.
static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
/// The appearance the context-free paint helpers are painting for.
pub fn current_appearance() -> ThemeMode {
match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
1 => ThemeMode::Light,
_ => ThemeMode::Dark,
}
}
/// Point the context-free paint helpers at an appearance. Called by
/// [`Theme::change`]; exposed for tests that build a palette without an `App`.
pub fn set_current_appearance(appearance: ThemeMode) {
let encoded = match appearance {
ThemeMode::Dark => 0,
ThemeMode::Light => 1,
};
CURRENT_APPEARANCE.store(encoded, Ordering::Relaxed);
}
/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill
/// backgrounds).
///
/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%,
/// selected ≈ 14% black — which is where the dark palette's white alphas already
/// sit. So the honest multiplier is 1: the same number in both appearances, with
/// only the *tone* flipping. Any per-state correction belongs in that state's
/// token, not in a blanket multiplier.
pub const INK_FILL_SCALE: f32 = 1.0;
/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings).
/// Opposite of fills: a 1px edge has to hold its own against a bright surround,
/// and the dark palette's white hairlines are deliberately faint. Scaling up
/// keeps separators legible instead of dissolving into the panel.
pub const INK_HAIRLINE_SCALE: f32 = 1.35;
/// Lightness of [`hairline`] ink, dark then light.
const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25);
/// Translucent **fill** ink for interactive states and chip plates: soft-white on
/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
///
/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
/// the tuned one — and the light value is derived. Callers keep one number and
/// both appearances stay in the relationship the dark tuning established.
pub fn ink(alpha: f32) -> Hsla {
ink_for(current_appearance(), alpha)
}
pub(crate) fn ink_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 1.0, alpha),
ThemeMode::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
}
}
/// Translucent **hairline** ink for borders, dividers and rings: a light grey on
/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never
/// pure white or black.
///
/// Separate from [`ink`] because edges and fills scale in opposite directions
/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
/// less.
pub fn hairline(alpha: f32) -> Hsla {
hairline_for(current_appearance(), alpha)
}
pub(crate) fn hairline_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha),
ThemeMode::Light => hsla(
0.0,
0.0,
HAIRLINE_TONE.1,
(alpha * INK_HAIRLINE_SCALE).min(0.5),
),
}
}
/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
/// white so hover plates read as tinted glass rather than paint.
pub fn wash(alpha: f32) -> Hsla {
wash_for(current_appearance(), alpha)
}
pub(crate) fn wash_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.92, alpha),
ThemeMode::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
}
}
/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
pub const SCRIM_ALPHA_DARK: f32 = 0.60;
/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
///
/// Black in both appearances — a scrim's job is to darken what is behind it, and
/// a "light scrim" of white would wash the modal out rather than seat it. What
/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
/// blackout, so light mode scales to roughly half.
pub fn scrim(alpha_dark: f32) -> Hsla {
scrim_for(current_appearance(), alpha_dark)
}
pub(crate) fn scrim_for(appearance: ThemeMode, alpha_dark: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
}
}
/// Recessed band behind a picker header or footer strip.
///
/// A free function as well as a token so the chrome that paints it can be built
/// from context-free helpers; both resolve to the same value.
pub fn band() -> Hsla {
band_for(current_appearance())
}
pub(crate) fn band_for(appearance: ThemeMode) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, 0.16),
// A recessed strip on white needs far less ink than on near-black; the
// dark 16% would read as a bruise.
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.045),
}
}
/// The elevation shadow a floating surface casts. Painted cut to outside the
/// shape, because the same drop shadow under a translucent fill reads as a grey
/// plate.
pub fn surface_shadows() -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.1),
offset: point(px(0.0), px(10.0)),
blur_radius: px(15.0),
spread_radius: px(-3.0),
inset: false,
},
BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.1),
offset: point(px(0.0), px(4.0)),
blur_radius: px(6.0),
spread_radius: px(-4.0),
inset: false,
},
]
}
/// Hover over an opaque plate: brighten the plate in dark mode, darken it in
/// light — never swap an opaque plate for a translucent wash.
pub(crate) fn plate_hover_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
crate::color::flatten(ink_for(appearance, 0.10), plate)
}
/// Pressed over an opaque plate, one rung above [`plate_hover_for`].
pub(crate) fn plate_active_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
crate::color::flatten(ink_for(appearance, 0.16), plate)
}
/// Hover over an opaque plate, for call sites outside this crate.
pub fn plate_hover(plate: gpui::Hsla) -> gpui::Hsla {
plate_hover_for(current_appearance(), plate)
}
/// Pressed over an opaque plate, for call sites outside this crate.
pub fn plate_active(plate: gpui::Hsla) -> gpui::Hsla {
plate_active_for(current_appearance(), plate)
}
+137
View File
@@ -0,0 +1,137 @@
use gpui::{hsla, rgb};
use crate::paint::{band_for, hairline_for};
use crate::syntax::SyntaxPalette;
use crate::{Ink, TextInk, ThemeColors, ThemeMode, color};
pub(crate) fn dark() -> ThemeColors {
let warning = color::oklch(0.828, 0.189, 84.429);
ThemeColors {
bg: color::grey(6),
surface: color::grey(13),
surface_raised: color::neutral(0.235),
surface_card: color::grey(0x0e),
surface_dialog: color::grey(0x10),
surface_overlay: color::grey(0x16),
element_hover: hsla(0.0, 0.0, 1.0, 0.08),
element_active: hsla(0.0, 0.0, 1.0, 0.12),
border_faint: hairline_for(ThemeMode::Dark, 0.06),
border: hairline_for(ThemeMode::Dark, 0.08),
border_strong: hairline_for(ThemeMode::Dark, 0.14),
text: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.text),
text_muted: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.muted),
text_faint: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.faint),
text_dim: color::grey(0x98),
// The restored Radix yellow brand: #ffe629 plate, #14120b label.
solid: color::oklch(0.918, 0.184, 100.9),
on_solid: color::grey(0x0e),
// The restored Radix yellow brand: #f5e147 ink, #ffe629 plate.
accent: color::oklch(0.900, 0.166, 101.7),
accent_strong: color::oklch(0.918, 0.184, 100.9),
on_accent: color::grey(0x0e),
danger: color::oklch(0.704, 0.191, 22.216),
danger_muted: color::oklch(0.808, 0.114, 19.571),
danger_strong: color::oklch(0.58, 0.16, 25.0),
success: color::oklch(0.765, 0.177, 163.223),
success_muted: color::oklch(0.845, 0.143, 164.978),
busy: color::oklch(0.718, 0.202, 349.761),
surface_raised_hover: color::neutral(0.29),
band: band_for(ThemeMode::Dark),
input_bg: hsla(0.0, 0.0, 1.0, 0.03),
selection: rgb(0x3f638b).into(),
cursor: color::neutral(0.94),
caret: color::neutral(0.673),
ring: hairline_for(ThemeMode::Dark, 0.35),
drop_line: hairline_for(ThemeMode::Dark, 0.35),
drop_target: hsla(0.0, 0.0, 1.0, 0.10),
code_text: color::neutral(0.94),
code_wash: hsla(0.0, 0.0, 1.0, 0.08),
syntax: SyntaxPalette::dark(
color::neutral(0.922),
color::neutral(0.60),
color::oklch(0.704, 0.191, 22.216),
),
diff_add: color::oklch(0.765, 0.177, 163.223),
diff_del: color::oklch(0.704, 0.191, 22.216),
diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
warning,
warning_muted: warning.alpha(0.16),
}
}
pub(crate) fn light() -> ThemeColors {
let warning = color::oklch(0.555, 0.163, 48.998);
ThemeColors {
bg: color::grey(0xff),
surface: color::neutral(0.968),
surface_raised: color::neutral(0.940),
surface_card: color::grey(0xff),
surface_dialog: color::grey(0xff),
surface_overlay: color::grey(0xff),
element_hover: hsla(0.0, 0.0, 0.0, 0.04),
element_active: hsla(0.0, 0.0, 0.0, 0.06),
border_faint: hairline_for(ThemeMode::Light, 0.06),
border: hairline_for(ThemeMode::Light, 0.08),
border_strong: hairline_for(ThemeMode::Light, 0.14),
text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text),
text_muted: color::neutral(0.45),
text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint),
text_dim: color::neutral(0.50),
// The restored Radix yellow brand: #ffe629 plate, #473b1f label.
solid: color::oklch(0.918, 0.184, 100.9),
on_solid: color::oklch(0.358, 0.046, 86.9),
// The restored Radix yellow brand: #9e6c00 ink, #ffe629 plate.
accent: color::oklch(0.569, 0.119, 76.8),
accent_strong: color::oklch(0.918, 0.184, 100.9),
on_accent: color::neutral(0.205),
danger: color::oklch(0.577, 0.245, 27.325),
danger_muted: color::oklch(0.505, 0.213, 27.518),
danger_strong: color::oklch(0.51, 0.20, 25.0),
warning,
warning_muted: warning.alpha(0.10),
success: color::oklch(0.596, 0.145, 163.225),
success_muted: color::oklch(0.508, 0.118, 165.612),
busy: color::oklch(0.592, 0.249, 0.584),
surface_raised_hover: color::neutral(0.900),
band: band_for(ThemeMode::Light),
input_bg: color::neutral(0.950),
selection: rgb(0xb3d7ff).into(),
cursor: color::neutral(0.205),
caret: color::neutral(0.511),
ring: hairline_for(ThemeMode::Light, 0.35),
drop_line: hairline_for(ThemeMode::Light, 0.35),
drop_target: hsla(0.0, 0.0, 0.0, 0.06),
code_text: color::neutral(0.18),
code_wash: hsla(0.0, 0.0, 0.0, 0.06),
syntax: SyntaxPalette::light(
color::neutral(0.25),
color::neutral(0.48),
color::oklch(0.505, 0.213, 27.518),
),
diff_add: color::oklch(0.596, 0.145, 163.225),
diff_del: color::oklch(0.577, 0.245, 27.325),
diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
}
}
pub(crate) fn for_appearance(appearance: ThemeMode) -> ThemeColors {
match appearance {
ThemeMode::Dark => dark(),
ThemeMode::Light => light(),
}
}
-70
View File
@@ -1,70 +0,0 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error};
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
use crate::ThemeFamily;
pub fn init(cx: &mut App) {
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
}
struct GlobalThemeRegistry(Entity<ThemeRegistry>);
impl Global for GlobalThemeRegistry {}
pub struct ThemeRegistry {
/// Map of theme names to theme families
themes: HashMap<SharedString, Rc<ThemeFamily>>,
}
impl ThemeRegistry {
/// Retrieve the global theme registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalThemeRegistry>().0.clone()
}
/// Set the global theme registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalThemeRegistry(state));
}
/// Create a new theme registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut themes = HashMap::new();
let asset = cx.asset_source();
if let Ok(paths) = asset.list("themes") {
for path in paths.into_iter() {
match Self::load(&path, asset) {
Ok(theme) => {
themes.insert(path, Rc::new(theme));
}
Err(e) => {
log::error!("Failed to load theme: {path}. Error: {e}");
}
}
}
}
Self { themes }
}
/// Load a theme from the asset source.
fn load(path: &str, asset: &Arc<dyn AssetSource>) -> Result<ThemeFamily, Error> {
// Load the theme file from the assets
let content = asset.load(path)?.context("Theme not found")?;
// Parse the JSON content into a Theme Family struct
let theme: ThemeFamily = serde_json::from_slice(&content)?;
Ok(theme)
}
/// Returns a reference to the map of themes.
pub fn themes(&self) -> &HashMap<SharedString, Rc<ThemeFamily>> {
&self.themes
}
}
-177
View File
@@ -1,177 +0,0 @@
use gpui::Hsla;
/// A collection of colors that are used to style the UI.
///
/// Each step has a semantic meaning, and is used to style different parts of the UI.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub struct ColorScaleStep(usize);
impl ColorScaleStep {
pub const EIGHT: Self = Self(8);
pub const ELEVEN: Self = Self(11);
pub const FIVE: Self = Self(5);
pub const FOUR: Self = Self(4);
pub const NINE: Self = Self(9);
pub const ONE: Self = Self(1);
pub const SEVEN: Self = Self(7);
pub const SIX: Self = Self(6);
pub const TEN: Self = Self(10);
pub const THREE: Self = Self(3);
pub const TWELVE: Self = Self(12);
pub const TWO: Self = Self(2);
}
/// A scale of colors for a given [`ColorScaleSet`].
///
/// Each [`ColorScale`] contains exactly 12 colors. Refer to
/// [`ColorScaleStep`] for a reference of what each step is used for.
pub struct ColorScale(Vec<Hsla>);
impl FromIterator<Hsla> for ColorScale {
fn from_iter<T: IntoIterator<Item = Hsla>>(iter: T) -> Self {
Self(Vec::from_iter(iter))
}
}
impl ColorScale {
/// Returns the specified step in the [`ColorScale`].
#[inline]
pub fn step(&self, step: ColorScaleStep) -> Hsla {
// Steps are one-based, so we need convert to the zero-based vec index.
self.0[step.0 - 1]
}
/// `Step 1` - Used for main application backgrounds.
///
/// This step provides a neutral base for any overlaying components, ideal for applications' main backdrop or empty spaces such as canvas areas.
///
#[inline]
pub fn step_1(&self) -> Hsla {
self.step(ColorScaleStep::ONE)
}
/// `Step 2` - Used for both main application backgrounds and subtle component backgrounds.
///
/// Like `Step 1`, this step allows variations in background styles, from striped tables, sidebar backgrounds, to card backgrounds.
#[inline]
pub fn step_2(&self) -> Hsla {
self.step(ColorScaleStep::TWO)
}
/// `Step 3` - Used for UI component backgrounds in their normal states.
///
/// This step maintains accessibility by guaranteeing a contrast ratio of 4.5:1 with steps 11 and 12 for text. It could also suit hover states for transparent components.
#[inline]
pub fn step_3(&self) -> Hsla {
self.step(ColorScaleStep::THREE)
}
/// `Step 4` - Used for UI component backgrounds in their hover states.
///
/// Also suited for pressed or selected states of components with a transparent background.
#[inline]
pub fn step_4(&self) -> Hsla {
self.step(ColorScaleStep::FOUR)
}
/// `Step 5` - Used for UI component backgrounds in their pressed or selected states.
#[inline]
pub fn step_5(&self) -> Hsla {
self.step(ColorScaleStep::FIVE)
}
/// `Step 6` - Used for subtle borders on non-interactive components.
///
/// Its usage spans from sidebars' borders, headers' dividers, cards' outlines, to alerts' edges and separators.
#[inline]
pub fn step_6(&self) -> Hsla {
self.step(ColorScaleStep::SIX)
}
/// `Step 7` - Used for subtle borders on interactive components.
///
/// This step subtly delineates the boundary of elements users interact with.
#[inline]
pub fn step_7(&self) -> Hsla {
self.step(ColorScaleStep::SEVEN)
}
/// `Step 8` - Used for stronger borders on interactive components and focus rings.
///
/// It strengthens the visibility and accessibility of active elements and their focus states.
#[inline]
pub fn step_8(&self) -> Hsla {
self.step(ColorScaleStep::EIGHT)
}
/// `Step 9` - Used for solid backgrounds.
///
/// `Step 9` is the most saturated step, having the least mix of white or black.
///
/// Due to its high chroma, `Step 9` is versatile and particularly useful for semantic colors such as
/// error, warning, and success indicators.
#[inline]
pub fn step_9(&self) -> Hsla {
self.step(ColorScaleStep::NINE)
}
/// `Step 10` - Used for hovered or active solid backgrounds, particularly when `Step 9` is their normal state.
///
/// May also be used for extremely low contrast text. This should be used sparingly, as it may be difficult to read.
#[inline]
pub fn step_10(&self) -> Hsla {
self.step(ColorScaleStep::TEN)
}
/// `Step 11` - Used for text and icons requiring low contrast or less emphasis.
#[inline]
pub fn step_11(&self) -> Hsla {
self.step(ColorScaleStep::ELEVEN)
}
/// `Step 12` - Used for text and icons requiring high contrast or prominence.
#[inline]
pub fn step_12(&self) -> Hsla {
self.step(ColorScaleStep::TWELVE)
}
}
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale.
pub struct ColorScaleSet {
light: ColorScale,
dark: ColorScale,
light_alpha: ColorScale,
dark_alpha: ColorScale,
}
impl ColorScaleSet {
pub(crate) fn new(
light: ColorScale,
light_alpha: ColorScale,
dark: ColorScale,
dark_alpha: ColorScale,
) -> Self {
Self {
light,
light_alpha,
dark,
dark_alpha,
}
}
pub(crate) fn light(&self) -> &ColorScale {
&self.light
}
pub(crate) fn light_alpha(&self) -> &ColorScale {
&self.light_alpha
}
pub(crate) fn dark(&self) -> &ColorScale {
&self.dark
}
pub(crate) fn dark_alpha(&self) -> &ColorScale {
&self.dark_alpha
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Paint-only syntax colors, ported from bezel
//! (`crates/theme/src/theme/syntax.rs`, MIT License, Copyright (c) 2026 bezel
//! contributors). The hues follow the Git history graph's lane palette
//! (indigo, pink, emerald, amber, red, neutral), while light-mode variants are
//! darkened enough to remain readable as text on white.
use gpui::Hsla;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::color;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SyntaxPalette {
pub comment: Hsla,
pub keyword: Hsla,
pub string: Hsla,
pub string_special: Hsla,
pub escape: Hsla,
pub number: Hsla,
pub boolean: Hsla,
pub type_name: Hsla,
pub type_builtin: Hsla,
pub constructor: Hsla,
pub function: Hsla,
pub function_builtin: Hsla,
pub macro_name: Hsla,
pub property: Hsla,
pub constant: Hsla,
pub variable: Hsla,
pub variable_special: Hsla,
pub parameter: Hsla,
pub operator: Hsla,
pub punctuation: Hsla,
pub tag: Hsla,
pub attribute: Hsla,
pub label: Hsla,
pub invalid: Hsla,
}
/// The token category a highlighter paints with, mirroring [`SyntaxPalette`]
/// field-for-field so classification can name a kind without knowing its color.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HighlightKind {
Comment,
Keyword,
String,
StringSpecial,
Escape,
Number,
Boolean,
TypeName,
TypeBuiltin,
Constructor,
Function,
FunctionBuiltin,
MacroName,
Property,
Constant,
Variable,
VariableSpecial,
Parameter,
Operator,
Punctuation,
Tag,
Attribute,
Label,
Invalid,
}
impl SyntaxPalette {
/// The palette color for a token kind.
pub fn color(&self, kind: HighlightKind) -> Hsla {
match kind {
HighlightKind::Comment => self.comment,
HighlightKind::Keyword => self.keyword,
HighlightKind::String => self.string,
HighlightKind::StringSpecial => self.string_special,
HighlightKind::Escape => self.escape,
HighlightKind::Number => self.number,
HighlightKind::Boolean => self.boolean,
HighlightKind::TypeName => self.type_name,
HighlightKind::TypeBuiltin => self.type_builtin,
HighlightKind::Constructor => self.constructor,
HighlightKind::Function => self.function,
HighlightKind::FunctionBuiltin => self.function_builtin,
HighlightKind::MacroName => self.macro_name,
HighlightKind::Property => self.property,
HighlightKind::Constant => self.constant,
HighlightKind::Variable => self.variable,
HighlightKind::VariableSpecial => self.variable_special,
HighlightKind::Parameter => self.parameter,
HighlightKind::Operator => self.operator,
HighlightKind::Punctuation => self.punctuation,
HighlightKind::Tag => self.tag,
HighlightKind::Attribute => self.attribute,
HighlightKind::Label => self.label,
HighlightKind::Invalid => self.invalid,
}
}
pub(crate) fn dark(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
let indigo = git_graph_tone(color::oklch(0.673, 0.182, 276.935));
let pink = git_graph_tone(color::oklch(0.718, 0.202, 349.761));
let emerald = git_graph_tone(color::oklch(0.765, 0.177, 163.223));
let amber = git_graph_tone(color::oklch(0.828, 0.189, 84.429));
let red = git_graph_tone(danger);
Self {
comment,
keyword: indigo,
string: emerald,
string_special: pink,
escape: pink,
number: amber,
boolean: amber,
type_name: amber,
type_builtin: emerald,
constructor: amber,
function: indigo,
function_builtin: pink,
macro_name: pink,
property: amber,
constant: emerald,
variable: text,
variable_special: pink,
parameter: text,
operator: text,
punctuation: text,
tag: pink,
attribute: amber,
label: amber,
invalid: red,
}
}
pub(crate) fn light(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
let indigo = git_graph_tone(color::oklch(0.47, 0.20, 276.966));
let pink = git_graph_tone(color::oklch(0.47, 0.17, 0.584));
let emerald = git_graph_tone(color::oklch(0.46, 0.11, 163.225));
let amber = git_graph_tone(color::oklch(0.47, 0.12, 48.998));
let red = git_graph_tone(danger);
Self {
comment,
keyword: indigo,
string: emerald,
string_special: pink,
escape: pink,
number: amber,
boolean: amber,
type_name: amber,
type_builtin: emerald,
constructor: amber,
function: indigo,
function_builtin: pink,
macro_name: pink,
property: amber,
constant: emerald,
variable: text,
variable_special: pink,
parameter: text,
operator: text,
punctuation: text,
tag: pink,
attribute: amber,
label: amber,
invalid: red,
}
}
}
/// Git history intentionally softens lane saturation so the graph remains
/// colorful without competing with content. Syntax uses the same treatment.
fn git_graph_tone(mut color: Hsla) -> Hsla {
color.s *= 0.72;
color
}
+12 -329
View File
@@ -1,16 +1,12 @@
use std::path::Path;
use gpui::{SharedString, WindowAppearance};
use schemars::JsonSchema;
use gpui::WindowAppearance;
use serde::{Deserialize, Serialize};
use crate::ThemeColors;
/// Which appearance the palette is painted for.
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)]
pub enum ThemeMode {
#[default]
Light,
Dark,
Light,
}
impl ThemeMode {
@@ -18,6 +14,15 @@ impl ThemeMode {
matches!(self, Self::Dark)
}
pub fn is_light(&self) -> bool {
matches!(self, Self::Light)
}
/// Map a gpui window appearance onto ours.
pub fn from_window(appearance: WindowAppearance) -> Self {
Self::from(appearance)
}
/// Return theme name: `light`, `dark`.
pub fn name(&self) -> &'static str {
match self {
@@ -35,325 +40,3 @@ impl From<WindowAppearance> for ThemeMode {
}
}
}
/// Theme family
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
pub struct ThemeFamily {
/// The unique identifier for the theme.
pub id: String,
/// The name of the theme.
pub name: SharedString,
/// The author of the theme.
pub author: SharedString,
/// The URL of the theme.
pub url: String,
/// The light colors for the theme.
pub light: ThemeColors,
/// The dark colors for the theme.
pub dark: ThemeColors,
}
impl Default for ThemeFamily {
fn default() -> Self {
ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
}
}
impl ThemeFamily {
/// Returns the light colors for the theme.
#[inline(always)]
pub fn light(&self) -> &ThemeColors {
&self.light
}
/// Returns the dark colors for the theme.
#[inline(always)]
pub fn dark(&self) -> &ThemeColors {
&self.dark
}
/// Load a theme family from a JSON file.
///
/// # Arguments
///
/// * `path` - Path to the JSON file containing the theme family. This can be
/// an absolute path or a path relative to the current working directory.
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Load from a relative path
/// let theme = ThemeFamily::from_file("assets/themes/my-theme.json")?;
///
/// // Load from an absolute path
/// let theme = ThemeFamily::from_file("/path/to/themes/my-theme.json")?;
/// # Ok(())
/// # }
/// ```
pub(crate) fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let json_data = std::fs::read(path)?;
let theme_family = serde_json::from_slice(&json_data)?;
Ok(theme_family)
}
/// Load a theme family from a JSON file in the assets/themes directory.
///
/// This function looks for the file at `assets/themes/{name}.json` relative
/// to the current working directory. This is useful for loading themes
/// from the standard theme directory in the project structure.
///
/// # Arguments
///
/// * `name` - Name of the theme file (without the .json extension)
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Assuming the file exists at `assets/themes/my-theme.json`
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?;
///
/// println!("Loaded theme: {}", theme.name);
/// # Ok(())
/// # }
/// ```
pub fn from_assets(target: &str) -> anyhow::Result<Self> {
let path = format!("assets/{target}");
Self::from_file(path)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn test_from_file() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("test-theme.json");
// Create a minimal valid theme JSON with hex colors
// Using simple hex colors that Hsla can parse
// Note: We need to escape the # characters in the raw string
let json_data = r##"{
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/reyakov/coop",
"light": {
"background": "#ffffff",
"surface_background": "#fafafa",
"elevated_surface_background": "#f5f5f5",
"panel_background": "#ffffff",
"overlay": "#0000001a",
"title_bar": "#00000000",
"title_bar_inactive": "#ffffff",
"window_border": "#c7c7cf",
"border": "#dbdbdb",
"border_variant": "#d1d1d1",
"border_focused": "#3366cc",
"border_selected": "#3366cc",
"border_transparent": "#00000000",
"border_disabled": "#e6e6e6",
"ring": "#4d79d6",
"text": "#1a1a1a",
"text_muted": "#4d4d4d",
"text_placeholder": "#808080",
"text_accent": "#3366cc",
"icon": "#4d4d4d",
"icon_muted": "#808080",
"icon_accent": "#3366cc",
"element_foreground": "#ffffff",
"element_background": "#3366cc",
"element_hover": "#3366cce6",
"element_active": "#2e5cb8",
"element_selected": "#2952a3",
"element_disabled": "#3366cc4d",
"secondary_foreground": "#2952a3",
"secondary_background": "#e6ecf5",
"secondary_hover": "#3366cc1a",
"secondary_active": "#d9e2f0",
"secondary_selected": "#d9e2f0",
"secondary_disabled": "#3366cc4d",
"danger_foreground": "#ffffff",
"danger_background": "#f5e6e6",
"danger_hover": "#cc33331a",
"danger_active": "#f0d9d9",
"danger_selected": "#f0d9d9",
"danger_disabled": "#cc33334d",
"warning_foreground": "#1a1a1a",
"warning_background": "#f5f0e6",
"warning_hover": "#cc99331a",
"warning_active": "#f0ead9",
"warning_selected": "#f0ead9",
"warning_disabled": "#cc99334d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e6e6",
"ghost_element_hover": "#0000001a",
"ghost_element_active": "#d9d9d9",
"ghost_element_selected": "#d9d9d9",
"ghost_element_disabled": "#0000000d",
"tab_inactive_background": "#e6e6e6",
"tab_hover_background": "#e0e0e0",
"tab_active_background": "#d9d9d9",
"scrollbar_thumb_background": "#00000033",
"scrollbar_thumb_hover_background": "#0000004d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#d9d9d9",
"drop_target_background": "#3366cc1a",
"cursor": "#3399ff",
"selection": "#3399ff40"
},
"dark": {
"background": "#1a1a1a",
"surface_background": "#1f1f1f",
"elevated_surface_background": "#242424",
"panel_background": "#262626",
"overlay": "#ffffff1a",
"title_bar": "#00000000",
"title_bar_inactive": "#1a1a1a",
"window_border": "#404046",
"border": "#404040",
"border_variant": "#383838",
"border_focused": "#4d79d6",
"border_selected": "#4d79d6",
"border_transparent": "#00000000",
"border_disabled": "#2e2e2e",
"ring": "#668cdf",
"text": "#f2f2f2",
"text_muted": "#b3b3b3",
"text_placeholder": "#808080",
"text_accent": "#668cdf",
"icon": "#b3b3b3",
"icon_muted": "#808080",
"icon_accent": "#668cdf",
"element_foreground": "#ffffff",
"element_background": "#4d79d6",
"element_hover": "#4d79d6e6",
"element_active": "#456dc1",
"element_selected": "#3e62ac",
"element_disabled": "#4d79d64d",
"secondary_foreground": "#3e62ac",
"secondary_background": "#2a3652",
"secondary_hover": "#4d79d61a",
"secondary_active": "#303d5c",
"secondary_selected": "#303d5c",
"secondary_disabled": "#4d79d64d",
"danger_foreground": "#ffffff",
"danger_background": "#522a2a",
"danger_hover": "#d64d4d1a",
"danger_active": "#5c3030",
"danger_selected": "#5c3030",
"danger_disabled": "#d64d4d4d",
"warning_foreground": "#f2f2f2",
"warning_background": "#52482a",
"warning_hover": "#d6b34d1a",
"warning_active": "#5c5430",
"warning_selected": "#5c5430",
"warning_disabled": "#d6b34d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2e2e2e",
"ghost_element_hover": "#ffffff1a",
"ghost_element_active": "#383838",
"ghost_element_selected": "#383838",
"ghost_element_disabled": "#ffffff0d",
"tab_inactive_background": "#2e2e2e",
"tab_hover_background": "#333333",
"tab_active_background": "#383838",
"scrollbar_thumb_background": "#ffffff33",
"scrollbar_thumb_hover_background": "#ffffff4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#383838",
"drop_target_background": "#4d79d61a",
"cursor": "#4db3ff",
"selection": "#4db3ff40"
}
}"##;
// Write the JSON to the file
fs::write(&file_path, json_data).unwrap();
// Test loading the theme from file
let theme = ThemeFamily::from_file(&file_path).unwrap();
// Verify the loaded theme
assert_eq!(theme.id, "test-theme");
assert_eq!(theme.name, "Test Theme");
// Clean up
dir.close().unwrap();
}
#[test]
fn test_from_file_nonexistent() {
// Test that loading a non-existent file returns an error
let result = ThemeFamily::from_file("non-existent-file.json");
assert!(result.is_err());
}
#[test]
fn test_from_file_invalid_json() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("invalid-theme.json");
// Write invalid JSON
fs::write(&file_path, "invalid json").unwrap();
// Test that loading invalid JSON returns an error
let result = ThemeFamily::from_file(&file_path);
assert!(result.is_err());
// Clean up
dir.close().unwrap();
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::sync::atomic::{AtomicU32, Ordering};
use gpui::{FontWeight, Styled, px};
/// The base the ladder starts at; bezel's measured body is 13, coop keeps 15.
pub const DEFAULT_BASE_TEXT_SIZE: f32 = 15.0;
/// The body size every painted role is scaled against, as raw `f32` bits.
static BASE: AtomicU32 = AtomicU32::new(DEFAULT_BASE_TEXT_SIZE.to_bits());
/// Point the ladder at a body size in points; the caller refreshes windows.
pub fn set_base_text_size(points: f32) {
BASE.store(points.max(1.0).to_bits(), Ordering::Relaxed);
}
/// The body size in points every painted role is scaled against.
pub fn base_text_size() -> f32 {
f32::from_bits(BASE.load(Ordering::Relaxed))
}
/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TextStyle {
LargeTitle,
Title,
Title2,
Title3,
Headline,
Subheadline,
Body,
Callout,
Footnote,
Caption,
Caption2,
}
impl TextStyle {
/// The role's measured size in points.
pub const fn size(self) -> f32 {
match self {
Self::LargeTitle => 26.0,
Self::Title => 22.0,
Self::Title2 => 17.0,
Self::Title3 => 15.0,
Self::Headline | Self::Body => 13.0,
Self::Callout => 12.0,
Self::Subheadline => 11.0,
Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
}
}
pub fn painted(self) -> f32 {
self.size() * base_text_size() / Self::Body.size()
}
pub const fn line_height(self) -> f32 {
match self {
Self::LargeTitle => 32.0,
Self::Title => 26.0,
Self::Title2 => 22.0,
Self::Title3 => 20.0,
Self::Headline | Self::Body => 16.0,
Self::Callout => 15.0,
Self::Subheadline => 14.0,
Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
}
}
pub fn painted_line_height(self) -> f32 {
self.line_height() * base_text_size() / Self::Body.size()
}
pub const fn weight(self) -> FontWeight {
match self {
Self::Headline => FontWeight::BOLD,
Self::Caption2 => FontWeight::MEDIUM,
_ => FontWeight::NORMAL,
}
}
}
/// One role as it is set: a rung on the ladder, its leading, weight and scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metrics {
pub role: TextStyle,
pub leading: f32,
pub weight: FontWeight,
pub scale: f32,
}
impl Metrics {
pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
Self {
role,
leading,
weight,
scale: 1.0,
}
}
pub const fn scaled(self, scale: f32) -> Self {
Self { scale, ..self }
}
pub fn size(self) -> f32 {
self.role.painted() * self.scale
}
pub fn line_height(self) -> f32 {
self.size() * self.leading
}
}
impl From<TextStyle> for Metrics {
/// The ladder's own setting for a role: its measured leading and weight.
fn from(role: TextStyle) -> Self {
Self::new(role, role.line_height() / role.size(), role.weight())
}
}
/// The ladder, on anything styled.
pub trait Typeset: Styled + Sized {
/// Size, line height and weight together, from [`TextStyle`].
fn text_style(self, style: TextStyle) -> Self {
self.text_size(px(style.painted()))
.line_height(px(style.painted_line_height()))
.font_weight(style.weight())
}
}
impl<E: Styled> Typeset for E {}
+1 -1
View File
@@ -10,8 +10,8 @@ theme = { path = "../theme" }
gpui.workspace = true
gpui-base.workspace = true
instant.workspace = true
serde.workspace = true
smallvec.workspace = true
log.workspace = true
uuid = "1.10"
-18
View File
@@ -1,18 +0,0 @@
/// A cubic bezier function like CSS `cubic-bezier`.
///
/// Builder:
///
/// https://cubic-bezier.com
pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
move |t: f32| {
let one_t = 1.0 - t;
let one_t2 = one_t * one_t;
let t2 = t * t;
let t3 = t2 * t;
// The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1
let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3;
3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3
}
}
+6 -25
View File
@@ -1,4 +1,3 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
@@ -24,7 +23,7 @@ const FALLBACK_SEED: &str = "coop";
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
@@ -36,7 +35,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// A deterministic, offline pixel-art avatar derived from a seed.
#[derive(IntoElement)]
pub(crate) struct PixelAvatar {
struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
@@ -44,7 +43,7 @@ pub(crate) struct PixelAvatar {
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
pub fn new(seed: impl AsRef<str>) -> Self {
fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
@@ -188,8 +187,7 @@ fn paint_polygons<'a>(
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon,
/// wound so that its interior is on the left of every directed edge.
/// Approximates the circle of `radius` around `center` as a convex polygon wound left-interior.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
@@ -354,11 +352,9 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
pub struct Avatar {
base: Div,
picture: Option<ImageSource>,
grayscale: bool,
seed: Option<String>,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
selected: bool,
}
@@ -377,11 +373,9 @@ impl Avatar {
Avatar {
base: div(),
picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
selected: false,
}
}
@@ -424,27 +418,17 @@ impl InteractiveElement for Avatar {
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
px(2.)
} else {
px(0.)
};
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size + border_width * 2.;
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
let grayscale = self.grayscale;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.grayscale(grayscale)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
@@ -453,12 +437,9 @@ impl RenderOnce for Avatar {
div()
.flex_shrink_0()
.size(container_size)
.size(image_size)
.rounded_full()
.overflow_hidden()
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.child(content)
}
}
+109 -112
View File
@@ -4,10 +4,13 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, relative,
Styled, Window, div, px, relative,
};
use gpui_base::Button as BaseButton;
use theme::ActiveTheme;
use theme::{
ActiveTheme, TextStyle, Typeset as _, bubble_radius, button_radius, plate_active, plate_hover,
wash,
};
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
@@ -24,10 +27,10 @@ pub struct ButtonCustomVariant {
impl ButtonCustomVariant {
pub fn new(_window: &Window, cx: &App) -> Self {
Self {
color: cx.theme().element_background,
foreground: cx.theme().element_foreground,
hover: cx.theme().element_hover,
active: cx.theme().element_active,
color: cx.theme().solid,
foreground: cx.theme().on_solid,
hover: plate_hover(cx.theme().solid),
active: plate_active(cx.theme().solid),
}
}
@@ -70,47 +73,49 @@ pub enum ButtonVariant {
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
/// With the primary style for the Button.
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
/// With the secondary style for the Button.
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the warning style for the Button.
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
/// Which side of the label the icon sits on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconPosition {
/// Left of the label.
#[default]
Start,
/// Right of the label.
End,
}
/// A Button element.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
@@ -127,13 +132,10 @@ pub struct Button {
rounded: bool,
compact: bool,
caret: bool,
indicator: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize,
tab_stop: bool,
pub(crate) selected: bool,
selected: bool,
icon_position: IconPosition,
}
impl From<Button> for AnyElement {
@@ -151,22 +153,19 @@ impl Button {
variant: ButtonVariant::default(),
disabled: false,
selected: false,
indicator: false,
compact: false,
caret: false,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
on_hover: None,
loading: false,
children: Vec::new(),
tab_index: 0,
tab_stop: true,
icon_position: IconPosition::Start,
}
}
/// Make the button rounded.
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
@@ -178,19 +177,23 @@ impl Button {
self
}
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
/// Set the icon of the button, if the Button has no label, the button will be in Icon Button mode.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the tooltip of the button.
/// Set which side of the label the icon sits on (default [`IconPosition::Start`]).
pub fn icon_position(mut self, position: IconPosition) -> Self {
self.icon_position = position;
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
/// Set true to show the loading indicator.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
@@ -202,13 +205,11 @@ impl Button {
self
}
/// Set true to show the caret indicator.
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
/// Add click handler.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -217,9 +218,7 @@ impl Button {
self
}
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
///
/// Default is true.
/// Set whether the button can be focused with the tab key (default true).
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
@@ -229,11 +228,6 @@ impl Button {
fn clickable(&self) -> bool {
!(self.disabled || self.loading) && self.on_click.is_some()
}
#[inline]
fn hoverable(&self) -> bool {
!(self.disabled || self.loading) && self.on_hover.is_some()
}
}
impl Disableable for Button {
@@ -288,24 +282,23 @@ impl InteractiveElement for Button {
impl RenderOnce for Button {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let style = self.variant;
let clickable = self.clickable();
let hoverable = self.hoverable();
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Large => Size::Medium,
_ => self.size,
};
let icon_start = self.icon_position == IconPosition::Start;
self.base
.tab_index(self.tab_index)
.tab_index(0)
.tab_stop(self.tab_stop)
.disabled(self.disabled)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if !clickable {
cx.stop_propagation();
return;
@@ -322,9 +315,16 @@ impl RenderOnce for Button {
.cursor_default()
.overflow_hidden()
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
false => this.rounded(px(button_radius())),
true => this.rounded(px(bubble_radius())),
})
.when_some(
match style {
ButtonVariant::Secondary => Some(cx.theme().border),
_ => None,
},
|this, border_color| this.border_1().border_color(border_color),
)
.when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
@@ -337,7 +337,7 @@ impl RenderOnce for Button {
}
} else {
// Normal Button
match self.size {
let this = match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => {
if self.icon.is_some() {
@@ -367,12 +367,21 @@ impl RenderOnce for Button {
this.h_10().px_3()
}
}
};
if !icon_start && self.icon.is_some() {
match self.size {
Size::XSmall | Size::Small => this.pl_2p5().pr_2(),
Size::Medium | Size::Large => this.pl_3p5().pr_3(),
Size::Size(_) => this,
}
} else {
this
}
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if self.disabled {
cx.stop_propagation();
return;
@@ -380,30 +389,35 @@ impl RenderOnce for Button {
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
})
})
.child({
h_flex()
.id("label")
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.text_xs().gap_1(),
Size::Small => this.text_sm().gap_1p5(),
_ => this.text_sm().gap_2(),
Size::XSmall => this.text_style(TextStyle::Caption).gap_1(),
Size::Small => this.text_style(TextStyle::Callout).gap_1p5(),
_ => this.text_style(TextStyle::Callout).gap(px(theme::SPACE)),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {
.when(!self.loading && icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading, |this| this.child(Indicator::new()))
.when(self.loading && icon_start, |this| {
this.child(Indicator::new())
})
.when_some(self.label, |this, label| {
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.children(self.children)
.when(!self.loading && !icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading && !icon_start, |this| {
this.child(Indicator::new())
})
.when(self.caret, |this| {
this.justify_between().gap_0p5().child(
Icon::new(IconName::ChevronDown)
@@ -413,17 +427,6 @@ impl RenderOnce for Button {
})
})
.text_color(normal_style.fg)
.when(self.indicator && !self.disabled, |this| {
this.child(
div()
.absolute()
.bottom_px()
.right_px()
.size_1()
.rounded_full()
.bg(gpui::green()),
)
})
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
@@ -470,15 +473,15 @@ impl ButtonVariant {
fn bg_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_background,
ButtonVariant::Secondary => cx.theme().secondary_background,
ButtonVariant::Danger => cx.theme().danger_background,
ButtonVariant::Warning => cx.theme().warning_background,
ButtonVariant::Primary => cx.theme().solid,
ButtonVariant::Secondary => cx.theme().surface_raised,
ButtonVariant::Danger => cx.theme().danger_strong,
ButtonVariant::Warning => cx.theme().warning_muted,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().ghost_element_background_alt
cx.theme().surface_raised
} else {
cx.theme().ghost_element_background
gpui::transparent_black()
}
}
ButtonVariant::Custom(colors) => colors.color,
@@ -488,11 +491,15 @@ impl ButtonVariant {
fn text_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_foreground,
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Danger => cx.theme().danger_foreground,
ButtonVariant::Warning => cx.theme().warning_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Primary => cx.theme().on_solid,
ButtonVariant::Secondary => cx.theme().text,
// Bezel's destructive pairing: the red plate carries white ink in
// both appearances, not the appearance-flipping on_accent.
ButtonVariant::Danger => gpui::white(),
// warning_muted is a translucent amber wash, so the label is the
// amber token itself — dark amber in light, bright in dark.
ButtonVariant::Warning => cx.theme().warning,
ButtonVariant::Transparent => cx.theme().text_faint,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().text
@@ -506,19 +513,19 @@ impl ButtonVariant {
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_hover,
ButtonVariant::Secondary => cx.theme().secondary_hover,
ButtonVariant::Danger => cx.theme().danger_hover,
ButtonVariant::Warning => cx.theme().warning_hover,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_hover,
ButtonVariant::Primary => plate_hover(cx.theme().solid),
ButtonVariant::Secondary => cx.theme().surface_raised_hover,
ButtonVariant::Danger => plate_hover(cx.theme().danger_strong),
ButtonVariant::Warning => plate_hover(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_hover,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.hover,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -527,18 +534,18 @@ impl ButtonVariant {
fn active(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().secondary_active,
ButtonVariant::Danger => cx.theme().danger_active,
ButtonVariant::Warning => cx.theme().warning_active,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_active,
ButtonVariant::Primary => plate_active(cx.theme().solid),
ButtonVariant::Secondary => plate_active(cx.theme().surface_raised),
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -547,18 +554,18 @@ impl ButtonVariant {
fn selected(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_selected,
ButtonVariant::Secondary => cx.theme().secondary_selected,
ButtonVariant::Danger => cx.theme().danger_selected,
ButtonVariant::Warning => cx.theme().warning_selected,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_selected,
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().element_active,
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -566,18 +573,8 @@ impl ButtonVariant {
}
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Danger => cx.theme().danger_disabled,
ButtonVariant::Warning => cx.theme().warning_disabled,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_disabled,
ButtonVariant::Secondary => cx.theme().secondary_disabled,
_ => cx.theme().element_disabled,
};
let fg = match self {
ButtonVariant::Primary => cx.theme().text_muted,
_ => cx.theme().text_muted,
};
let bg = wash(0.08);
let fg = cx.theme().text_muted;
ButtonVariantStyle { bg, fg }
}
+34 -90
View File
@@ -2,20 +2,17 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle,
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window,
div, hsla, point, px,
AnimationExt as _, AnyElement, App, ClickEvent, Div, FocusHandle, IntoElement, ParentElement,
Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, px, transparent_black,
};
use gpui_base::{DialogBackdrop, DialogPopup, DialogTitle};
use instant::Duration;
use theme::ActiveTheme;
use theme::{ActiveTheme, SCRIM_ALPHA_DARK, scrim, surface_radius, surface_shadows};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::motion::DIALOG_IN;
use crate::scroll::ScrollableElement;
use crate::{IconName, Root, StyledExt, WindowExtension, h_flex, v_flex};
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
@@ -70,7 +67,6 @@ pub struct Dialog {
width: Pixels,
max_width: Option<Pixels>,
on_close: OnClose,
on_ok: OnOk,
on_cancel: OnCancel,
@@ -80,10 +76,10 @@ pub struct Dialog {
show_close: bool,
button_props: DialogButtonProps,
/// This will be change when open the dialog, the focus handle is create when open the dialog.
pub focus_handle: FocusHandle,
pub layer_ix: usize,
pub overlay_visible: bool,
/// The focus handle created when the dialog is opened, owned by the `Root`.
pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize,
pub(crate) overlay_visible: bool,
}
impl Dialog {
@@ -100,7 +96,6 @@ impl Dialog {
keyboard: true,
layer_ix: 0,
overlay_visible: false,
on_close: Rc::new(|_, _, _| {}),
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: DialogButtonProps::default(),
@@ -115,15 +110,8 @@ impl Dialog {
self
}
/// Set the footer of the dialog.
///
/// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
///
/// - First `RenderButtonFn` is the render function for the OK button.
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
///
/// When you set the footer, the footer will be placed default footer buttons.
pub fn footer<E, F>(mut self, footer: F) -> Self
/// Sets a custom footer, replacing the default footer buttons.
fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
@@ -150,20 +138,7 @@ impl Dialog {
self
}
/// Sets the callback for when the dialog is closed.
///
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Rc::new(on_close);
self
}
/// Sets the callback for when the dialog is has been confirmed.
///
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
/// Sets the confirm callback, returning `false` keeps the dialog open.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
@@ -172,9 +147,7 @@ impl Dialog {
self
}
/// Sets the callback for when the dialog is has been canceled.
///
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
/// Sets the cancel callback, returning `false` keeps the dialog open.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
@@ -189,15 +162,13 @@ impl Dialog {
self
}
/// Sets the width of the dialog, defaults to 480px.
/// Sets the width of the dialog, defaults to 380px.
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Set the overlay closable of the dialog, defaults to `true`.
///
/// When the overlay is clicked, the dialog will be closed.
/// Sets whether clicking the overlay closes the dialog, defaults to `true`.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
@@ -209,7 +180,7 @@ impl Dialog {
self
}
pub fn has_overlay(&self) -> bool {
pub(crate) fn has_overlay(&self) -> bool {
self.overlay
}
}
@@ -232,13 +203,11 @@ impl RenderOnce for Dialog {
let is_topmost = layer_ix + 1 == Root::read(window, cx).active_dialogs.len();
let has_footer = self.footer.is_some();
let on_close = self.on_close.clone();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_variant = self.button_props.ok_variant;
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
@@ -249,7 +218,6 @@ impl RenderOnce for Dialog {
.font_semibold()
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok
@@ -258,7 +226,6 @@ impl RenderOnce for Dialog {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}
})
@@ -268,7 +235,6 @@ impl RenderOnce for Dialog {
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_variant = self.button_props.cancel_variant;
let cancel_text = self
.button_props
@@ -281,13 +247,11 @@ impl RenderOnce for Dialog {
.with_variant(cancel_variant)
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}
})
@@ -306,25 +270,24 @@ impl RenderOnce for Dialog {
padding_right = pr.to_pixels(self.width.into(), window.rem_size());
}
let animation = Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
let backdrop = DialogBackdrop::new()
.absolute()
.inset_0()
.when(self.overlay_visible, |this| this.bg(cx.theme().overlay))
.with_animation("fade-in", animation.clone(), move |this, delta| {
.when(self.overlay_visible, |this| {
this.bg(scrim(SCRIM_ALPHA_DARK))
})
.with_animation("fade-in", DIALOG_IN.animation(), move |this, delta| {
this.opacity(delta)
});
let card = DialogPopup::new()
.flex()
.flex_col()
.bg(cx.theme().background)
.bg(cx.theme().surface_dialog)
.border_1()
.border_color(cx.theme().border.alpha(0.4))
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xl())
.border_color(cx.theme().border)
.rounded(px(surface_radius()))
.when(cx.theme().shadow, |this| this.shadow(surface_shadows()))
.min_h_24()
.refine_style(&self.style)
.relative()
@@ -349,7 +312,6 @@ impl RenderOnce for Dialog {
})
.when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.child(
div()
@@ -364,14 +326,13 @@ impl RenderOnce for Dialog {
.icon(IconName::CloseCircleFill)
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
.color(cx.theme().ghost_element_background)
.hover(cx.theme().ghost_element_background)
.active(cx.theme().ghost_element_background),
.foreground(cx.theme().text_faint)
.color(transparent_black())
.hover(cx.theme().element_hover)
.active(cx.theme().element_active),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}),
),
@@ -407,26 +368,12 @@ impl RenderOnce for Dialog {
.children(footer(render_ok, render_cancel, window, cx)),
)
})
.with_animation("slide-down", animation, move |this, delta| {
// Settle at the target offset; only the entrance is animated.
let y_offset = px(30.) * (delta - 1.);
// This is equivalent to `shadow_xl` with an extra opacity.
let shadow = vec![
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(20.)),
blur_radius: px(25.),
spread_radius: px(-5.),
inset: false,
},
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)),
blur_radius: px(10.),
spread_radius: px(-6.),
inset: false,
},
];
.with_animation("slide-down", DIALOG_IN.animation(), move |this, delta| {
let y_offset = px(2.) * (delta - 1.);
let mut shadow = surface_shadows();
for shadow in &mut shadow {
shadow.color.a *= delta;
}
this.top(y_offset).shadow(shadow)
});
@@ -442,9 +389,6 @@ impl RenderOnce for Dialog {
None => has_footer,
})
.on_cancel(move |event, window, cx| on_cancel(event, window, cx))
.on_close(move |event, window, cx| {
on_close(event, window, cx);
window.close_dialog(cx);
})
.on_close(move |_, window, cx| window.close_dialog(cx))
}
}
+17 -16
View File
@@ -17,7 +17,7 @@ use gpui_base::dock::{
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{HandleEdge, Placement, ResizeHandleContext};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _, button_radius, panel_radius};
use crate::button::{Button, ButtonVariants as _};
use crate::resizable::{resize_handle, resize_handle_appearance};
@@ -83,9 +83,11 @@ pub fn add_panel_to(
window: &mut Window,
cx: &mut App,
) {
let _ = dock.update(cx, |area, cx| {
if let Err(error) = dock.update(cx, |area, cx| {
add_panel(area, panel, placement, window, cx);
});
}) {
log::warn!("Failed to add panel to dock: {error}");
}
}
/// The panel in any region of `area` whose logical id is `key`.
@@ -346,12 +348,12 @@ impl Render for DragPreview {
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_sm()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.bg(cx.theme().background)
.bg(cx.theme().bg)
.child(panel_title(&self.panel, cx))
}
}
@@ -446,7 +448,7 @@ impl TabGroupSkin {
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().drop_target))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
@@ -465,7 +467,7 @@ impl TabGroupSkin {
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
@@ -537,7 +539,7 @@ impl TabGroupSkin {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().border)
.border_color(cx.theme().drop_line)
})
.on_drop({
let group = TabGroupContext::clone(group);
@@ -567,7 +569,7 @@ impl TabGroupSkin {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
@@ -670,8 +672,7 @@ impl TabGroupRenderer for TabGroupSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// The left dock's only panel draws bare, so its content can own the
// window's top-left corner instead of a tab bar doing so.
// The plain left group draws bare so its content can own the window's top-left corner.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
@@ -717,7 +718,7 @@ impl TabGroupRenderer for TabGroupSkin {
.child(
div()
.size_full()
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
@@ -758,10 +759,10 @@ impl TabGroupRenderer for TabGroupSkin {
.top(top)
.w(width)
.h(height)
.rounded(cx.theme().radius_lg)
.rounded(px(panel_radius()))
.border_1()
.border_color(cx.theme().element_disabled)
.bg(cx.theme().drop_target_background)
.border_color(cx.theme().drop_line)
.bg(cx.theme().drop_target)
.into_any_element(),
)
}
+7 -56
View File
@@ -8,14 +8,9 @@ use gpui::{
use gpui_base::dock::{PanelId, PanelState};
use crate::button::Button;
use crate::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent {
ZoomIn,
ZoomOut,
LayoutChanged,
}
pub enum PanelEvent {}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
@@ -31,44 +26,29 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
true
}
/// Return true if the panel is zoomable, default is `false`.
/// Whether the panel is zoomable, default is `true`.
fn zoomable(&self, _cx: &App) -> bool {
true
}
/// Return false to hide panel, true to show panel, default is `true`.
///
/// This method called in Panel render, we should make sure it is fast.
fn visible(&self, _cx: &App) -> bool {
true
}
/// Set active state of the panel.
///
/// This method will be called when the panel is active or inactive.
///
/// The last_active_panel and current_active_panel will be touched when the panel is active.
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
/// Set zoomed state of the panel.
///
/// This method will be called when the panel is zoomed or unzoomed.
///
/// Only current Panel will touch this method.
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
/// The addition popup menu of the panel, default is `None`.
fn popup_menu(&self, this: PopupMenu, _cx: &App) -> PopupMenu {
this
}
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
/// Toolbar buttons shown at the right of the title bar.
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
pub trait PanelView: 'static + Send + Sync {
pub(crate) trait PanelView: 'static + Send + Sync {
fn panel_id(&self, cx: &App) -> SharedString;
fn title(&self, cx: &App) -> AnyElement;
fn closable(&self, cx: &App) -> bool;
@@ -76,8 +56,6 @@ pub trait PanelView: 'static + Send + Sync {
fn visible(&self, cx: &App) -> bool;
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu;
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button>;
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
}
@@ -115,14 +93,6 @@ impl<T: Panel> PanelView for Entity<T> {
})
}
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
self.read(cx).popup_menu(menu, cx)
}
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button> {
self.read(cx).toolbar_buttons(window, cx)
}
fn view(&self) -> AnyView {
self.clone().into()
}
@@ -132,24 +102,6 @@ impl<T: Panel> PanelView for Entity<T> {
}
}
impl From<&dyn PanelView> for AnyView {
fn from(handle: &dyn PanelView) -> Self {
handle.view()
}
}
impl<T: Panel> From<&dyn PanelView> for Entity<T> {
fn from(value: &dyn PanelView) -> Self {
value.view().downcast::<T>().unwrap()
}
}
impl PartialEq for dyn PanelView {
fn eq(&self, other: &Self) -> bool {
self.view() == other.view()
}
}
#[derive(Clone)]
pub struct PanelHandle {
id: PanelId,
@@ -164,13 +116,12 @@ impl PanelHandle {
}
}
/// Recover the coop handle behind one of base's.
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
/// Downcast a base handle back to the coop handle.
pub(crate) fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
panel.as_any().downcast_ref::<Self>()
}
/// The coop panel behind this handle.
pub fn panel(&self) -> &Arc<dyn PanelView> {
pub(crate) fn panel(&self) -> &Arc<dyn PanelView> {
&self.panel
}
}
+6 -20
View File
@@ -1,10 +1,10 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window, div, relative,
StyleRefinement, Styled, Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{StyledExt as _, v_flex};
@@ -22,12 +22,6 @@ pub trait GroupBoxVariants: Sized {
#[must_use]
fn with_variant(self, variant: GroupBoxVariant) -> Self;
/// Set to use [`GroupBoxVariant::Normal`] to GroupBox.
#[must_use]
fn normal(self) -> Self {
self.with_variant(GroupBoxVariant::Normal)
}
/// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
#[must_use]
fn fill(self) -> Self {
@@ -35,16 +29,13 @@ pub trait GroupBoxVariants: Sized {
}
}
/// GroupBox is a styled container element that with
/// an optional title to groups related content together.
/// A styled container with an optional title that groups related content together.
#[derive(IntoElement)]
pub struct GroupBox {
id: Option<ElementId>,
variant: GroupBoxVariant,
style: StyleRefinement,
title_style: StyleRefinement,
title: Option<AnyElement>,
content_style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
}
@@ -55,8 +46,6 @@ impl GroupBox {
id: None,
variant: GroupBoxVariant::default(),
style: StyleRefinement::default(),
title_style: StyleRefinement::default(),
content_style: StyleRefinement::default(),
title: None,
children: SmallVec::new(),
}
@@ -106,7 +95,7 @@ impl RenderOnce for GroupBox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (bg, has_paddings) = match self.variant {
GroupBoxVariant::Normal => (None, false),
GroupBoxVariant::Fill => (Some(cx.theme().surface_background), true),
GroupBoxVariant::Fill => (Some(cx.theme().surface), true),
};
v_flex()
@@ -119,9 +108,7 @@ impl RenderOnce for GroupBox {
this.child(
div()
.text_color(cx.theme().text_muted)
.line_height(relative(1.))
.refine_style(&self.title_style)
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.child(title),
)
@@ -132,8 +119,7 @@ impl RenderOnce for GroupBox {
.text_color(cx.theme().text)
.when(has_paddings, |this| this.p_2())
.gap_4()
.rounded(cx.theme().radius_lg)
.refine_style(&self.content_style)
.rounded(px(theme::panel_radius()))
.children(self.children),
)
}
+4 -46
View File
@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Context, Hsla, IntoElement, Radians, Render, RenderOnce, SharedString,
StyleRefinement, Styled, Svg, Transformation, Window, svg,
AnyElement, App, Context, Hsla, IntoElement, Render, RenderOnce, SharedString, StyleRefinement,
Styled, Svg, Window, svg,
};
use theme::ActiveTheme;
@@ -18,13 +18,11 @@ pub enum IconName {
ArrowLeft,
ArrowRight,
ArrowDownCircle,
Boom,
Book,
Ban,
ChevronDown,
CaretDown,
CaretRight,
CaretUp,
Check,
CheckCircle,
Close,
@@ -32,17 +30,14 @@ pub enum IconName {
CloseCircleFill,
Compass,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Folder,
Input,
Info,
Invite,
Inbox,
InboxFill,
Link,
Loader,
Lock,
@@ -54,23 +49,14 @@ pub enum IconName {
PlusChat,
PlusCircle,
Profile,
Reset,
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
Hashtag,
History,
UserKey,
Upload,
Usb,
Edit,
PanelLeft,
PanelLeftOpen,
@@ -84,9 +70,6 @@ pub enum IconName {
WindowMaximize,
WindowMinimize,
WindowRestore,
Fistbump,
FistbumpFill,
Zoom,
}
impl IconName {
@@ -95,13 +78,11 @@ impl IconName {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::Ban => "icons/ban.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
Self::Check => "icons/check.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
@@ -109,17 +90,14 @@ impl IconName {
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Folder => "icons/folder.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
@@ -131,23 +109,14 @@ impl IconName {
Self::PlusChat => "icons/plus-chat.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::Hashtag => "icons/hashtag.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
@@ -161,9 +130,6 @@ impl IconName {
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
Self::Fistbump => "icons/fistbump.svg",
Self::FistbumpFill => "icons/fistbump-fill.svg",
Self::Zoom => "icons/zoom.svg",
}
.into()
}
@@ -188,7 +154,6 @@ pub struct Icon {
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
rotation: Option<Radians>,
}
impl Default for Icon {
@@ -199,7 +164,6 @@ impl Default for Icon {
path: "".into(),
text_color: None,
size: None,
rotation: None,
}
}
}
@@ -208,7 +172,6 @@ impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
this.style = self.style.clone();
this.rotation = self.rotation;
this.size = self.size;
this.text_color = self.text_color;
this
@@ -224,9 +187,7 @@ impl Icon {
Self::default().path(name.path())
}
/// Set the icon path of the Assets bundle
///
/// For example: `icons/foo.svg`
/// Sets the icon path, relative to the assets bundle (for example `icons/foo.svg`).
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
self.path = path.into();
self
@@ -291,7 +252,7 @@ impl From<Icon> for AnyElement {
impl Render for Icon {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
let text_color = self.text_color.unwrap_or_else(|| cx.theme().text_muted);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
@@ -309,8 +270,5 @@ impl Render for Icon {
Size::Large => this.size_6(),
})
.path(self.path.clone())
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})
}
}
+13 -14
View File
@@ -1,10 +1,11 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _,
Transformation, Window, div, ease_in_out, percentage,
App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, Window, div,
percentage,
};
use instant::Duration;
use crate::motion::{EASE_IN_OUT, MotionSpec, Painter, pulse_delta};
use crate::{Icon, IconName, Sizable, Size};
#[derive(IntoElement)]
@@ -15,6 +16,12 @@ pub struct Indicator {
color: Option<Hsla>,
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Indicator {
pub fn new() -> Self {
Self {
@@ -31,12 +38,6 @@ impl Indicator {
}
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Sizable for Indicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
@@ -45,16 +46,14 @@ impl Sizable for Indicator {
}
impl RenderOnce for Indicator {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let spec = MotionSpec::new(self.speed.as_millis().max(1) as u64, EASE_IN_OUT);
let phase = pulse_delta(&spec, Painter::from(window.current_view()), cx);
div().child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.with_animation(
"circle",
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
.transform(Transformation::rotate(percentage(phase))),
)
}
}
+1 -1
View File
@@ -11,5 +11,5 @@ pub(crate) fn clear_button(cx: &App) -> Button {
.ghost()
.xsmall()
.tab_stop(false)
.text_color(cx.theme().icon_muted)
.text_color(cx.theme().text_faint)
}
+16 -83
View File
@@ -1,17 +1,16 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement,
MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign,
Window, div, px,
App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div,
px,
};
use gpui_base::InputBase;
use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode};
use theme::ActiveTheme;
use theme::{ActiveTheme, button_radius};
use crate::button::{Button, ButtonVariants as _};
use crate::indicator::Indicator;
use crate::input::clear_button;
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn primary_paste_offset(
@@ -45,35 +44,27 @@ fn primary_paste_offset(
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
cx.theme().surface_background
cx.theme().surface
} else {
cx.theme().elevated_surface_background
cx.theme().input_bg
}
}
/// The colors base paints input text with, read from the coop theme.
///
/// Base fills in any color left transparent from its own palette, and that
/// palette is only a projection of this one, so every color coop paints with is
/// named here rather than left to resolve.
fn input_editor_style(cx: &App) -> InputEditorStyle {
let theme = cx.theme();
InputEditorStyle {
foreground: theme.text,
muted_foreground: theme.text_muted,
background: theme.elevated_surface_background,
border: theme.border,
muted_foreground: theme.text_faint,
background: theme.input_bg,
border: theme.border_faint,
selection: theme.selection,
caret: theme.cursor,
caret: theme.caret,
..InputEditorStyle::default()
}
}
/// The input's own padding, resolved to pixels.
///
/// Base applies the multi-line padding itself so that the text, the gutter, and
/// the scrollbar share one inset, and the single-line frame carries its own.
/// Both come from the same size table, resolved through the window's rem size.
fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges<Pixels> {
let mut probe = div().input_px(size).input_py(size).refine_style(style);
let padding = probe.style().padding.clone();
@@ -94,23 +85,14 @@ fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges
}
/// A text input element bound to an [`InputState`] or a [`TextareaState`].
///
/// The editing kind lives on the state, so `Input::new` accepts either and
/// infers which one is rendered.
#[derive(IntoElement)]
pub struct Input<M: InputModeKind = InputMode> {
state: Entity<InputBaseState<M>>,
style: StyleRefinement,
size: Size,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
height: Option<DefiniteLength>,
appearance: bool,
cleanable: bool,
mask_toggle: bool,
disabled: bool,
tab_index: isize,
selected: bool,
}
/// A styled multi-line text input.
@@ -123,17 +105,6 @@ impl<M: InputModeKind> Sizable for Input<M> {
}
}
impl<M: InputModeKind> Selectable for Input<M> {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl<M: InputModeKind> Input<M> {
/// Create a new [`Input`] element bind to the given state.
pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
@@ -141,28 +112,12 @@ impl<M: InputModeKind> Input<M> {
state: state.clone(),
size: Size::default(),
style: StyleRefinement::default(),
prefix: None,
suffix: None,
height: None,
appearance: true,
cleanable: false,
mask_toggle: false,
disabled: false,
tab_index: 0,
selected: false,
}
}
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set the appearance of the input field, if false the input field will no border, background.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
@@ -180,18 +135,6 @@ impl<M: InputModeKind> Input<M> {
self.disabled = disabled;
self
}
fn render_toggle_mask_button(state: &Entity<InputBaseState<M>>) -> impl IntoElement {
Button::new("toggle-mask")
.icon(IconName::Eye)
.xsmall()
.ghost()
.tab_stop(false)
.on_click({
let state = state.clone();
move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx))
})
}
}
impl<M: InputModeKind> Styled for Input<M> {
@@ -235,10 +178,8 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
let background = input_background(disabled, cx);
let show_clear_button =
self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line;
let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button;
let has_suffix = loading || show_clear_button;
let prefix = self.prefix;
let suffix = self.suffix;
let state_entity = self.state.clone();
InputBase::new(("input", self.state.entity_id()))
@@ -294,19 +235,15 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
},
)
.items_center()
.when(multi_line, |this| {
this.h_auto()
.when_some(self.height, |this, height| this.h(height))
})
.when(multi_line, |this| this.h_auto())
.when(self.appearance, |this| {
this.bg(background)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius)
.rounded(px(button_radius()))
})
.tab_index(self.tab_index)
.tab_index(0)
.gap(gap_x)
.refine_style(&self.style)
.children(prefix)
.when(!multi_line, |this| this.child(state_entity.clone()))
.when(multi_line, |this| {
this.child(
@@ -322,9 +259,6 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
.gap(gap_x)
.items_center()
.when(loading, |this| this.child(Indicator::new()))
.when(self.mask_toggle, |this| {
this.child(Self::render_toggle_mask_button(&state_entity))
})
.when(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = state_entity.clone();
@@ -335,8 +269,7 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
})
}
}))
})
.children(suffix),
}),
)
})
}
+11 -38
View File
@@ -1,29 +1,20 @@
use gpui::{
Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _,
RenderOnce, StyleRefinement, Styled, Window, div, relative,
Action, AsKeystroke, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce,
StyleRefinement, Styled, Window, div,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::StyledExt;
/// A key binding tag
#[derive(IntoElement, Clone, Debug)]
pub struct Kbd {
pub(crate) struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
}
impl From<Keystroke> for Kbd {
fn from(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
}
}
}
impl Kbd {
pub fn new(stroke: Keystroke) -> Self {
fn new(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
@@ -31,7 +22,7 @@ impl Kbd {
}
/// Return the first keybinding for the given action and context.
pub fn binding_for_action(
pub(crate) fn binding_for_action(
action: &dyn Action,
context: Option<&str>,
window: &Window,
@@ -50,25 +41,8 @@ impl Kbd {
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the first keybinding for the given action and focus handle.
pub fn binding_for_action_in(
action: &dyn Action,
focus_handle: &FocusHandle,
window: &Window,
) -> Option<Self> {
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the Platform specific keybinding string by KeyStroke
///
/// - macOS: https://support.apple.com/en-us/HT201236
/// - Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
pub fn format(key: &Keystroke) -> String {
/// Return the platform specific keybinding string for a keystroke.
fn format(key: &Keystroke) -> String {
#[cfg(target_os = "macos")]
const DIVIDER: &str = "";
#[cfg(not(target_os = "macos"))]
@@ -197,16 +171,15 @@ impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
div()
.border_1()
.border_color(cx.theme().border)
.border_color(cx.theme().border_faint)
.text_color(cx.theme().text_muted)
.bg(cx.theme().surface_background)
.bg(cx.theme().surface_raised)
.py_0p5()
.px_1()
.min_w_5()
.text_center()
.rounded_sm()
.line_height(relative(1.))
.text_xs()
.text_style(TextStyle::Caption)
.whitespace_normal()
.flex_shrink_0()
.refine_style(&self.style)
+1 -1
View File
@@ -23,9 +23,9 @@ pub mod switch;
pub mod tab;
pub mod tooltip;
mod animation;
mod icon;
mod kbd;
pub mod motion;
mod popover;
mod resizable;
mod root;
+18 -14
View File
@@ -1,7 +1,8 @@
use std::ops::Range;
use gpui::{
AnyElement, App, ElementId, IntoElement, SharedString, StyleRefinement, Styled as _, Window,
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement,
Styled as _, Window,
};
use gpui_base::{TextView, TextViewStyle};
use theme::ActiveTheme as _;
@@ -62,15 +63,28 @@ impl RenderedText {
/// Render the message as a GPUI Kit text view.
pub fn element(&self, id: ElementId, _window: &Window, cx: &App) -> AnyElement {
let theme = cx.theme();
let code_block = StyleRefinement::default().font_family(code_font_family());
let code_block = StyleRefinement::default().font_family(theme.font_mono.clone());
let muted_heading = theme.text_muted;
TextView::markdown(id, self.text.clone())
.style(
TextViewStyle::default()
.with_foreground(theme.text)
.with_muted_foreground(theme.text_muted)
.with_link(theme.text_accent)
.with_code_background(theme.elevated_surface_background)
// Deeply nested headings read as secondary text.
.with_heading(move |level| {
if level >= 4 {
StyleRefinement::default().text_color(muted_heading)
} else {
StyleRefinement::default()
}
})
.with_link(theme.accent)
.with_code_background(theme.code_wash)
.with_inline_code(HighlightStyle {
color: Some(theme.code_text),
..Default::default()
})
.with_border(theme.border)
.with_code_block(code_block)
.with_dark(theme.is_dark()),
@@ -101,13 +115,3 @@ fn is_web_url(url: &str) -> bool {
let url = url.to_ascii_lowercase();
url.starts_with("http://") || url.starts_with("https://")
}
fn code_font_family() -> &'static str {
if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
}
}
+4 -30
View File
@@ -75,12 +75,6 @@ where
}
}
/// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the style refinement for the dropdown menu trigger.
fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.style = style;
@@ -88,13 +82,10 @@ where
}
}
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
/// (right by default), keeping the child's own click handler intact.
/// Opens a [`PopupMenu`] when its child is right-clicked, keeping the child's own click handler.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
anchor: Anchor,
mouse_button: MouseButton,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
@@ -107,24 +98,10 @@ impl ContextMenu {
) -> Self {
Self {
id: id.into(),
anchor: Anchor::TopLeft,
mouse_button: MouseButton::Right,
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)]
@@ -132,10 +109,7 @@ struct MenuState {
menu: Option<Entity<PopupMenu>>,
}
/// Builds the menu once and reuses it until it is dismissed.
///
/// The popover content closure runs on every render, so rebuilding the menu
/// entity each time would drop its focus and selection state.
/// Builds the menu once and reuses it until dismissed, preserving focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
@@ -196,8 +170,8 @@ impl RenderOnce for ContextMenu {
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.anchor(self.anchor)
.mouse_button(self.mouse_button)
.anchor(Anchor::TopLeft)
.mouse_button(MouseButton::Right)
.trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
+12 -15
View File
@@ -5,7 +5,7 @@ use gpui::{
Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Disableable, StyledExt, h_flex};
@@ -39,18 +39,12 @@ impl MenuItemElement {
}
}
/// Set ListItem as the selected item style.
/// Set the selected style of the item.
pub(crate) fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set the disabled state of the MenuItem.
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set a handler for when the MenuItem is clicked.
pub(crate) fn on_click(
mut self,
@@ -61,7 +55,10 @@ impl MenuItemElement {
}
/// Set a handler for when the mouse enters the MenuItem.
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
pub(crate) fn on_hover(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
@@ -93,7 +90,7 @@ impl RenderOnce for MenuItemElement {
.group(&self.group_name)
.gap_x_1()
.p_1()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.relative()
.items_center()
@@ -104,12 +101,12 @@ impl RenderOnce for MenuItemElement {
})
.when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
.when(self.selected, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
this.bg(cx.theme().element_active)
.text_color(cx.theme().text)
})
.when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
@@ -118,7 +115,7 @@ impl RenderOnce for MenuItemElement {
.on_click(on_click)
})
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.when(self.disabled, |this| this.text_color(cx.theme().text_faint))
.children(self.children)
}
}
+63 -309
View File
@@ -2,22 +2,22 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
KeyBinding, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored, div, px, rems,
Action, Anchor, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent, Edges,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Styled, WeakEntity, Window,
anchored, div, px, rems,
};
use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use theme::{ActiveTheme, Side};
use theme::{ActiveTheme, Side, TextStyle, Typeset as _};
use crate::kbd::Kbd;
use crate::menu::menu_item::MenuItemElement;
use crate::scroll::ScrollableElement;
use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
use crate::{Disableable, ElementExt, Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
const CONTEXT: &str = "PopupMenu";
const MAX_WIDTH: Pixels = px(500.);
pub fn init(cx: &mut App) {
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
@@ -28,7 +28,7 @@ pub fn init(cx: &mut App) {
]);
}
/// An menu item in a popup menu.
/// A menu item in a popup menu.
pub enum PopupMenuItem {
/// A menu separator item.
Separator,
@@ -40,26 +40,11 @@ pub enum PopupMenuItem {
label: SharedString,
disabled: bool,
checked: bool,
is_link: bool,
action: Option<Box<dyn Action>>,
// For link item
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
},
/// A menu item with custom element render.
ElementItem {
icon: Option<Icon>,
disabled: bool,
checked: bool,
action: Option<Box<dyn Action>>,
#[allow(clippy::type_complexity)]
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
},
/// A submenu item that opens another popup menu.
///
/// NOTE: This is only supported when the parent menu is not `scrollable`.
Submenu {
icon: Option<Icon>,
label: SharedString,
@@ -80,14 +65,13 @@ impl PopupMenuItem {
disabled: false,
checked: false,
action: None,
is_link: false,
handler: None,
}
}
/// Create a new submenu item that opens another popup menu.
#[inline]
pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
PopupMenuItem::Submenu {
icon: None,
label: label.into(),
@@ -98,27 +82,22 @@ impl PopupMenuItem {
/// Create a separator menu item.
#[inline]
pub fn separator() -> Self {
fn separator() -> Self {
PopupMenuItem::Separator
}
/// Creates a label menu item.
/// Create a label menu item.
#[inline]
pub fn label(label: impl Into<SharedString>) -> Self {
fn label(label: impl Into<SharedString>) -> Self {
PopupMenuItem::Label(label.into())
}
/// Set the icon for the menu item.
///
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
fn icon(mut self, icon: impl Into<Icon>) -> Self {
match &mut self {
PopupMenuItem::Item { icon: i, .. } => {
*i = Some(icon.into());
}
PopupMenuItem::ElementItem { icon: i, .. } => {
*i = Some(icon.into());
}
PopupMenuItem::Submenu { icon: i, .. } => {
*i = Some(icon.into());
}
@@ -128,32 +107,19 @@ impl PopupMenuItem {
}
/// Set the action for the menu item.
///
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
pub fn action(mut self, action: Box<dyn Action>) -> Self {
match &mut self {
PopupMenuItem::Item { action: a, .. } => {
fn action(mut self, action: Box<dyn Action>) -> Self {
if let PopupMenuItem::Item { action: a, .. } = &mut self {
*a = Some(action);
}
PopupMenuItem::ElementItem { action: a, .. } => {
*a = Some(action);
}
_ => {}
}
self
}
/// Set the disabled state for the menu item.
///
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
pub fn disabled(mut self, disabled: bool) -> Self {
fn disabled(mut self, disabled: bool) -> Self {
match &mut self {
PopupMenuItem::Item { disabled: d, .. } => {
*d = disabled;
}
PopupMenuItem::ElementItem { disabled: d, .. } => {
*d = disabled;
}
PopupMenuItem::Submenu { disabled: d, .. } => {
*d = disabled;
}
@@ -162,52 +128,32 @@ impl PopupMenuItem {
self
}
/// Set checked state for the menu item.
///
/// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon.
pub fn checked(mut self, checked: bool) -> Self {
match &mut self {
PopupMenuItem::Item { checked: c, .. } => {
/// Set the checked state for the menu item.
fn checked(mut self, checked: bool) -> Self {
if let PopupMenuItem::Item { checked: c, .. } = &mut self {
*c = checked;
}
PopupMenuItem::ElementItem { checked: c, .. } => {
*c = checked;
}
_ => {}
}
self
}
/// Add a click handler for the menu item.
///
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
match &mut self {
PopupMenuItem::Item { handler: h, .. } => {
if let PopupMenuItem::Item { handler: h, .. } = &mut self {
*h = Some(Rc::new(handler));
}
PopupMenuItem::ElementItem { handler: h, .. } => {
*h = Some(Rc::new(handler));
}
_ => {}
}
self
}
#[inline]
fn is_clickable(&self) -> bool {
!matches!(self, PopupMenuItem::Separator)
&& matches!(
matches!(
self,
PopupMenuItem::Item {
disabled: false,
..
} | PopupMenuItem::ElementItem {
disabled: false,
..
} | PopupMenuItem::Submenu {
disabled: false,
..
@@ -220,14 +166,9 @@ impl PopupMenuItem {
matches!(self, PopupMenuItem::Separator)
}
fn has_left_icon(&self, check_side: Side) -> bool {
fn has_left_icon(&self) -> bool {
match self {
PopupMenuItem::Item { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::ElementItem { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::Item { icon, checked, .. } => icon.is_some() || *checked,
PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
_ => false,
}
@@ -235,66 +176,38 @@ impl PopupMenuItem {
#[inline]
fn is_checked(&self) -> bool {
match self {
PopupMenuItem::Item { checked, .. } => *checked,
PopupMenuItem::ElementItem { checked, .. } => *checked,
_ => false,
}
matches!(self, PopupMenuItem::Item { checked: true, .. })
}
}
pub struct PopupMenu {
pub(crate) focus_handle: FocusHandle,
pub(crate) menu_items: Vec<PopupMenuItem>,
/// The focus handle of Entity to handle actions.
pub(crate) action_context: Option<FocusHandle>,
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
axis: Axis,
selected_index: Option<usize>,
min_width: Option<Pixels>,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
bounds: Bounds<Pixels>,
size: Size,
check_side: Side,
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
scrollable: bool,
external_link_icon: bool,
scroll_handle: ScrollHandle,
/// This will update on render
submenu_anchor: (Anchor, Pixels),
_subscriptions: Vec<Subscription>,
}
impl PopupMenu {
pub(crate) fn new(cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
action_context: None,
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
axis: Axis::Vertical,
min_width: None,
max_width: None,
max_height: None,
check_side: Side::Left,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
external_link_icon: true,
size: Size::default(),
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
_subscriptions: vec![],
}
}
pub fn build(
pub(crate) fn build(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
@@ -302,45 +215,15 @@ impl PopupMenu {
cx.new(|cx| f(Self::new(cx), window, cx))
}
/// Set min width of the popup menu, default is 120px
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
self.min_width = Some(width.into());
self
}
/// Set max height of the popup menu, default is half of the window height
pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
self.max_height = Some(height.into());
self
}
/// Set the axis of children to horizontal.
pub fn horizontal(mut self) -> Self {
self.axis = Axis::Horizontal;
self
}
/// Set the menu to be scrollable to show vertical scrollbar.
///
/// NOTE: If this is true, the sub-menus will cannot be support.
pub fn scrollable(mut self, scrollable: bool) -> Self {
self.scrollable = scrollable;
self
}
/// Add Menu Item
pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.menu_with_disabled(label, action, false)
}
/// Add Menu Item with disabled state
pub fn menu_with_disabled(
mut self,
label: impl Into<SharedString>,
action: Box<dyn Action>,
disabled: bool,
) -> Self {
self.add_menu_item(label, None, action, disabled, false);
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action, false, false);
self
}
@@ -399,19 +282,7 @@ impl PopupMenu {
/// Add a Submenu
pub fn submenu(
self,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.submenu_with_icon(None, label, window, cx, f)
}
/// Add a Submenu item with icon
pub fn submenu_with_icon(
mut self,
icon: Option<Icon>,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -423,9 +294,7 @@ impl PopupMenu {
view.parent_menu = Some(parent_menu);
});
self.menu_items.push(
PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
);
self.menu_items.push(PopupMenuItem::submenu(label, submenu));
self
}
@@ -436,13 +305,6 @@ impl PopupMenu {
self
}
/// Use small size, the menu item will have smaller height.
#[allow(dead_code)]
pub(crate) fn small(mut self) -> Self {
self.size = Size::Small;
self
}
fn add_menu_item(
&mut self,
label: impl Into<SharedString>,
@@ -461,7 +323,7 @@ impl PopupMenu {
self
}
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.selected_index
&& let Some(item) = self.menu_items.get(ix)
{
@@ -489,52 +351,24 @@ impl PopupMenu {
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.selected_index {
let item = self.menu_items.get(index);
match item {
Some(PopupMenuItem::Item {
if let Some(index) = self.selected_index
&& let Some(PopupMenuItem::Item {
handler, action, ..
}) => {
}) = self.menu_items.get(index)
{
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
self.dispatch_confirm_action(action.as_ref(), window, cx);
}
self.dismiss(&Cancel, window, cx)
}
Some(PopupMenuItem::ElementItem {
handler, action, ..
}) => {
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
self.dispatch_confirm_action(action.as_ref(), window, cx);
}
self.dismiss(&Cancel, window, cx)
}
_ => {}
}
}
}
fn dispatch_confirm_action(
&self,
action: &dyn Action,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(context) = self.action_context.as_ref() {
context.focus(window, cx);
}
window.dispatch_action(action.boxed_clone(), cx);
}
self.dismiss(&Cancel, window, cx)
}
}
fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
if self.selected_index != Some(ix) {
self.selected_index = Some(ix);
self.scroll_handle.scroll_to_item(ix);
cx.notify();
}
}
@@ -675,6 +509,7 @@ impl PopupMenu {
}
}
#[expect(clippy::only_used_in_recursion)]
fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.active_submenu().is_some() {
return;
@@ -682,20 +517,17 @@ impl PopupMenu {
cx.emit(DismissEvent);
// Focus back to the previous focused handle.
if let Some(action_context) = self.action_context.as_ref() {
window.focus(action_context, cx);
}
let Some(parent_menu) = self.parent_menu.clone() else {
return;
};
// Dismiss parent menu, when this menu is dismissed
_ = parent_menu.update(cx, |view, cx| {
if let Err(error) = parent_menu.update(cx, |view, cx| {
view.selected_index = None;
view.dismiss(&Cancel, window, cx);
});
}) {
log::warn!("Failed to dismiss parent menu: {error}");
}
}
fn handle_dismiss(
@@ -732,16 +564,7 @@ impl PopupMenu {
) -> Option<Kbd> {
let action = action?;
match self
.action_context
.as_ref()
.and_then(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
{
Some(kbd) => Some(kbd),
// Fallback to App level key binding
None => Kbd::binding_for_action(action.as_ref(), None, window),
}
.map(|this| {
Kbd::binding_for_action(action.as_ref(), None, window).map(|this| {
this.p_0()
.flex_nowrap()
.border_0()
@@ -754,7 +577,7 @@ impl PopupMenu {
checked: bool,
icon: Option<Icon>,
_: &mut Window,
_: &mut Context<Self>,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !has_icon {
return None;
@@ -763,7 +586,7 @@ impl PopupMenu {
let icon = if let Some(icon) = icon {
icon.clone()
} else if checked {
Icon::new(IconName::Check)
Icon::new(IconName::Check).text_color(cx.theme().accent)
} else {
Icon::empty()
};
@@ -771,16 +594,10 @@ impl PopupMenu {
Some(icon)
}
#[inline]
fn max_width(&self) -> Pixels {
self.max_width.unwrap_or(px(500.))
}
/// Calculate the anchor corner and left offset for child submenu
fn update_submenu_menu_anchor(&mut self, window: &Window) {
let bounds = self.bounds;
let max_width = self.max_width();
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
let (anchor, left) = if MAX_WIDTH + bounds.origin.x > window.bounds().size.width {
(Anchor::TopRight, -px(16.))
} else {
(Anchor::TopLeft, bounds.size.width - px(8.))
@@ -803,12 +620,7 @@ impl PopupMenu {
cx: &mut Context<Self>,
) -> MenuItemElement {
let has_left_icon = options.has_left_icon;
let is_left_check = options.check_side.is_left() && item.is_checked();
let right_check_icon = if options.check_side.is_right() && item.is_checked() {
Some(Icon::new(IconName::Check).xsmall())
} else {
None
};
let is_checked = item.is_checked();
let selected = self.selected_index == Some(ix);
const EDGE_PADDING: Pixels = px(4.);
@@ -817,17 +629,14 @@ impl PopupMenu {
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
let (item_height, radius) = match self.size {
Size::Small => (px(20.), options.radius.half()),
_ => (px(26.), options.radius),
};
let item_height = px(26.);
let this = MenuItemElement::new(ix, &group_name)
.relative()
.text_sm()
.text_style(TextStyle::Callout)
.py_0()
.px(INNER_PADDING)
.rounded(radius)
.rounded(options.radius)
.items_center()
.selected(selected)
.on_hover(cx.listener(move |this, hovered, _, cx| {
@@ -848,45 +657,25 @@ impl PopupMenu {
.my_0p5()
.mx_neg_1()
.border_b(px(2.))
.border_color(cx.theme().border)
.border_color(cx.theme().border_faint)
.disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex().cursor_default().items_center().gap_x_1().child(
div()
.flex_1()
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
),
PopupMenuItem::ElementItem {
render, disabled, ..
} => this
.when(!disabled, |this| {
this.on_click(
cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
)
})
.disabled(*disabled)
.child(
h_flex()
.flex_1()
.min_h(item_height)
.items_center()
.gap_x_2()
.child((render)(window, cx))
.children(right_check_icon.map(|icon| icon.ml_3())),
),
PopupMenuItem::Item {
icon,
label,
action,
disabled,
is_link,
..
} => {
let show_link_icon = *is_link && self.external_link_icon;
let action = action.as_ref().map(|action| action.boxed_clone());
let key = self.render_key_binding(action, window, cx);
@@ -897,10 +686,10 @@ impl PopupMenu {
})
.disabled(*disabled)
.h(item_height)
.gap_x_2()
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
is_left_check,
is_checked,
icon.clone(),
window,
cx,
@@ -911,22 +700,7 @@ impl PopupMenu {
.gap_3()
.items_center()
.justify_between()
.when(!show_link_icon, |this| this.child(label.clone()))
.children(right_check_icon)
.when(show_link_icon, |this| {
this.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(label.clone())
.child(
Icon::new(IconName::Link)
.xsmall()
.text_color(cx.theme().text_muted),
),
)
})
.children(key),
)
}
@@ -944,7 +718,7 @@ impl PopupMenu {
.min_h(item_height)
.size_full()
.items_center()
.gap_x_2()
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
false,
@@ -955,7 +729,7 @@ impl PopupMenu {
.child(
h_flex()
.flex_1()
.gap_2()
.gap(px(theme::SPACE))
.items_center()
.justify_between()
.child(label.clone())
@@ -1002,7 +776,6 @@ impl Focusable for PopupMenu {
#[derive(Clone, Copy)]
struct RenderOptions {
has_left_icon: bool,
check_side: Side,
radius: Pixels,
}
@@ -1013,21 +786,12 @@ impl Render for PopupMenu {
let view = cx.entity().clone();
let items_count = self.menu_items.len();
let max_width = self.max_width();
let max_height = self.max_height.unwrap_or_else(|| {
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
window_half_height.min(px(450.))
});
let has_left_icon = self
.menu_items
.iter()
.any(|item| item.has_left_icon(self.check_side));
let has_left_icon = self.menu_items.iter().any(|item| item.has_left_icon());
let options = RenderOptions {
has_left_icon,
check_side: self.check_side,
radius: cx.theme().radius.min(px(8.)),
// Items sit p_1 (4px) inside the surface plate; keep corners concentric.
radius: px(theme::inset_radius(theme::surface_radius(), 4.0)),
};
v_flex()
@@ -1051,17 +815,11 @@ impl Render for PopupMenu {
.p_1()
.gap_y_0p5()
.min_w(rems(8.))
.when_some(self.min_width, |this, min_width| this.min_w(min_width))
.max_w(max_width)
.max_w(MAX_WIDTH)
.map(|this| match self.axis {
Axis::Horizontal => this.flex().flex_row().items_center(),
Axis::Vertical => this.flex().flex_col(),
})
.when(self.scrollable, |this| {
this.max_h(max_height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
})
.children(
self.menu_items
.iter()
@@ -1072,9 +830,5 @@ impl Render for PopupMenu {
)
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
)
.when(self.scrollable, |this| {
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
this.vertical_scrollbar(&self.scroll_handle)
})
}
}
+9 -21
View File
@@ -5,7 +5,7 @@ use gpui::{
div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
@@ -190,9 +190,9 @@ impl RenderOnce for MessageRow {
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_dim)
.when_some(author, |this, author| {
this.child(div().font_semibold().child(author))
})
@@ -212,7 +212,7 @@ impl RenderOnce for MessageRow {
.when_some(on_double_click, |this, listener| {
this.on_double_click(listener)
})
.hover(|this| this.bg(cx.theme().surface_background))
.hover(|this| this.bg(cx.theme().element_hover))
.into_any_element()
}
}
@@ -225,7 +225,6 @@ pub struct WelcomeMessage {
icon: Option<AnyElement>,
title: Option<SharedString>,
message: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
}
impl WelcomeMessage {
@@ -237,7 +236,6 @@ impl WelcomeMessage {
icon: None,
title: None,
message: None,
children: SmallVec::new(),
}
}
@@ -261,13 +259,6 @@ impl WelcomeMessage {
self.message = Some(message.into());
self
}
/// Append an element below the body text.
#[must_use]
pub fn child(mut self, child: impl IntoElement) -> Self {
self.children.push(child.into_any_element());
self
}
}
impl Styled for WelcomeMessage {
@@ -284,13 +275,12 @@ impl RenderOnce for WelcomeMessage {
icon,
title,
message,
children,
} = self;
v_flex()
.id(id)
.w_full()
.gap_2()
.gap(px(theme::SPACE))
.p_3()
.items_center()
.justify_center()
@@ -305,8 +295,7 @@ impl RenderOnce for WelcomeMessage {
.when_some(title, |this, title| {
this.child(
div()
.text_sm()
.font_semibold()
.text_style(TextStyle::Headline)
.text_color(cx.theme().text)
.child(title),
)
@@ -314,12 +303,11 @@ impl RenderOnce for WelcomeMessage {
.when_some(message, |this, message| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(message),
)
}),
)
.children(children)
}
}
+668
View File
@@ -0,0 +1,668 @@
//! The motion kit, ported from bezel (`crates/motion/src/lib.rs` and its
//! `app.rs`, MIT License, Copyright (c) 2026 bezel contributors). TranslateY
//! rides a relative `top` inset, which taffy applies after layout, so
//! siblings never move.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use gpui::{
Animation, AnimationElement, AnimationExt as _, App, Context, ElementId, EntityId, Global,
Hsla, IntoElement, Rgba, SharedString, Styled, Window, px,
};
use instant::Instant;
/// Redraw rate for anything driven by [`pulse_delta`].
const PULSE_FPS: f32 = 30.0;
/// How long a view stays on the tick list after its last pulse paint.
const PULSE_LEASE: Duration = Duration::from_millis(300);
/// Redraw rate for hover fades; [`HOVER_FADE`] paints in nine steps at it.
const HOVER_FPS: f32 = 60.0;
/// Floor on how long the clock sleeps between wake-ups.
const MIN_SLEEP: Duration = Duration::from_millis(1);
/// One view's claim on the clock.
struct Lease {
/// The fastest rate anything on this view has claimed.
period: Duration,
/// When this view is next owed a redraw.
due: Instant,
/// A notify is out and the render it provoked has not renewed yet.
in_flight: bool,
/// When the claim lapses if nothing renews it.
until: Instant,
}
#[derive(Default)]
struct PulseClock {
epoch: Option<Instant>,
leases: HashMap<EntityId, Lease>,
running: bool,
}
impl Global for PulseClock {}
impl PulseClock {
fn next_wake(&self) -> Option<Instant> {
self.leases
.values()
.map(|lease| lease.due.min(lease.until))
.min()
}
}
/// A component's line back to the view that paints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Painter(EntityId);
impl Painter {
/// The view this render belongs to; take it once where the state is built.
pub fn of<T: 'static>(cx: &Context<T>) -> Self {
Self(cx.entity_id())
}
/// Redraw this view once.
pub fn notify(self, cx: &mut App) {
cx.notify(self.0);
}
/// Claim `fps` redraws a second; renew from `render` so an unmounted
/// element drops off.
pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
lease(self.0, fps, until, cx);
}
/// Whether the clock is what asked for the render running now.
pub fn woken(self, cx: &App) -> bool {
cx.try_global::<PulseClock>()
.and_then(|clock| clock.leases.get(&self.0))
.is_some_and(|lease| lease.in_flight)
}
}
impl From<EntityId> for Painter {
fn from(entity_id: EntityId) -> Self {
Self(entity_id)
}
}
impl From<Painter> for EntityId {
fn from(painter: Painter) -> Self {
painter.0
}
}
fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
if cx.pause_when_inactive() && cx.active_window().is_none() {
return;
}
let now = cx.background_executor().now();
let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
let clock = cx.default_global::<PulseClock>();
clock
.leases
.entry(view)
.and_modify(|lease| {
lease.period = lease.period.min(period);
lease.due = lease.due.min(now + period);
lease.in_flight = false;
lease.until = lease.until.max(now + until);
})
.or_insert(Lease {
period,
due: now + period,
in_flight: false,
until: now + until,
});
if clock.running {
return;
}
clock.running = true;
cx.spawn(async move |cx| {
loop {
let sleep = cx.update(|cx| {
let now = cx.background_executor().now();
let clock = cx.default_global::<PulseClock>();
clock
.next_wake()
.map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
});
let Some(sleep) = sleep else { break };
cx.background_executor().timer(sleep).await;
let parked = cx.update(|cx| {
let now = cx.background_executor().now();
tick_hover_fades();
let clock = cx.default_global::<PulseClock>();
clock.leases.retain(|_, lease| lease.until > now);
if clock.leases.is_empty() {
clock.running = false;
return true;
}
let mut owed = Vec::new();
for (view, lease) in clock.leases.iter_mut() {
if lease.due > now {
continue;
}
lease.due += lease.period;
if lease.due <= now {
lease.due = now + lease.period;
}
lease.in_flight = true;
owed.push(*view);
}
for view in owed {
cx.notify(view);
}
false
});
if parked {
break;
}
}
})
.detach();
}
/// Current phase `[0,1)` of a repeating spec; reduced motion returns a
/// static 0 and schedules nothing.
pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
if cx.reduced_motion() {
return 0.0;
}
painter.lease(PULSE_FPS, PULSE_LEASE, cx);
let now = cx.background_executor().now();
let clock = cx.default_global::<PulseClock>();
let epoch = *clock.epoch.get_or_insert(now);
let period = spec.total().as_secs_f32();
((now - epoch).as_secs_f32() / period).fract()
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function, evaluated exactly.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl CubicBezier {
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
Self { x1, y1, x2, y2 }
}
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
let c = 3.0 * a;
let bb = 3.0 * (b - a) - c;
let aa = 1.0 - c - bb;
(aa, bb, c)
}
fn sample_x(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
((a * t + b) * t + c) * t
}
fn sample_y(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.y1, self.y2);
((a * t + b) * t + c) * t
}
fn sample_x_derivative(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
(3.0 * a * t + 2.0 * b) * t + c
}
fn solve_t_for_x(&self, x: f32) -> f32 {
let mut t = x;
for _ in 0..8 {
let err = self.sample_x(t) - x;
if err.abs() < 1e-6 {
return t;
}
let d = self.sample_x_derivative(t);
if d.abs() < 1e-6 {
break;
}
t -= err / d;
}
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
for _ in 0..32 {
let mid = (lo + hi) / 2.0;
if self.sample_x(mid) < x {
lo = mid
} else {
hi = mid
}
}
(lo + hi) / 2.0
}
/// Eased output for input progress `x ∈ [0,1]`, clamped hard — gpui's
/// animation element asserts its delta is in `[0,1]` and aborts.
pub fn eval(&self, x: f32) -> f32 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
}
/// This curve as a gpui easing closure.
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
move |x| self.eval(x)
}
}
/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
/// CSS `ease-out` — width/height transitions.
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
/// CSS `ease` — quick fades, menu/dialog pops.
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)`.
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
/// CSS `ease-in-out`.
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`.
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
/// One catalog entry: duration + optional delay + curve; gpui has no native
/// delay, so `delay_ms` holds progress at 0 until it elapses.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionSpec {
pub duration_ms: u64,
pub delay_ms: u64,
pub curve: CubicBezier,
}
impl MotionSpec {
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
Self {
duration_ms,
delay_ms: 0,
curve,
}
}
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
self.delay_ms = delay_ms;
self
}
/// Wall-clock span of the whole timeline (delay + duration).
pub fn total(&self) -> Duration {
Duration::from_millis(self.delay_ms + self.duration_ms)
}
/// Eased progress (0..1) for a raw timeline delta (0..1 across `total`).
pub fn progress(&self, raw_delta: f32) -> f32 {
let total = (self.delay_ms + self.duration_ms) as f32;
if total <= 0.0 || self.duration_ms == 0 {
return 1.0;
}
let t =
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
self.curve.eval(t.clamp(0.0, 1.0))
}
/// A oneshot gpui [`Animation`] for this spec.
pub fn animation(&self) -> Animation {
let spec = *self;
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
}
}
/// Entrances: 0.5s expo-out fade + 4px rise.
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
/// Quick fade: 0.15s.
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
/// Popover-in: 0.14s.
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
/// Popover-out: 0.1s — quicker than the entrance.
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
/// Dialog-in: 0.18s.
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
/// Boot-splash exit: 0.5s fade + 6px lift after a 0.15s hold.
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
/// Sidebar / pane width+height transitions: 200ms ease-out.
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
/// Tab drag-reorder sliding transforms: 150ms.
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
/// Per-pane collapse: 180ms height.
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
/// A gliding move the reader caused rather than one they are dragging.
pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
/// Chevron rotate: 200ms.
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
/// Scroll-to-row glide: 500ms ease-in-out.
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`].
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
/// Toast slide/fade, coop's own: 250ms over [`EASE_TAILWIND`].
pub const NOTIFICATION_IN: MotionSpec = MotionSpec::new(250, EASE_TAILWIND);
/// Pulse loader period: 2.4s.
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
/// Spinner wave period: 750ms.
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
})
}
/// Quick opacity-only fade over [`FADE_QUICK`].
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
}
/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_IN.animation(), |el, t| {
el.relative()
.opacity(0.3 + 0.7 * t)
.top(px(-2.0 * (1.0 - t)))
})
}
/// Popover exit over [`MENU_OUT`]; the caller supplies progress because
/// `with_animation` replays from 0 on remount.
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
})
}
/// Dialog entrance over [`DIALOG_IN`] (fade + 2px rise).
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
})
}
/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
el.opacity(1.0 - t).top(px(-6.0 * t))
})
}
/// Linear interpolation.
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * t
}
/// One element's hover fade progress over [`HOVER_FADE`].
#[derive(Debug, Clone, Copy)]
pub struct FadeEntry {
origin: f32,
target: f32,
started: Instant,
seen: u64,
}
impl FadeEntry {
fn value(&self, now: Instant, duration: Duration) -> f32 {
let elapsed = now.saturating_duration_since(self.started);
if duration.is_zero() || elapsed >= duration {
return self.target;
}
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
}
fn settled(&self, now: Instant, duration: Duration) -> bool {
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
}
}
/// Which fade: the view that paints it plus the element key — two views
/// using the same string would trade washes.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Fade {
pub painter: Painter,
pub key: SharedString,
}
impl Fade {
pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
Self {
painter,
key: key.into(),
}
}
}
/// Per-fade progress store.
#[derive(Default)]
pub struct HoverFades {
pub entries: HashMap<Fade, FadeEntry>,
frame: u64,
}
impl HoverFades {
pub fn duration() -> Duration {
HOVER_FADE.total().mul_f32(speed_scale())
}
/// Pointer entered (`hovered`) or left the element behind `fade`.
pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
let target = if hovered { 1.0 } else { 0.0 };
let duration = Self::duration();
let current = self
.entries
.get(fade)
.map(|e| e.value(now, duration))
.unwrap_or(0.0);
if target == 0.0 && !self.entries.contains_key(fade) {
return;
}
let origin = if reduced { target } else { current };
let seen = self.frame;
self.entries.insert(
fade.clone(),
FadeEntry {
origin,
target,
started: now,
seen,
},
);
}
/// Hover progress (0..1) for `fade` at `now`; stamps liveness.
pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
let frame = self.frame;
match self.entries.get_mut(fade) {
Some(entry) => {
entry.seen = frame;
entry.value(now, Self::duration())
}
None => 0.0,
}
}
/// Advance the frame counter and prune settled or unmounted entries.
pub fn tick_at(&mut self, now: Instant) -> bool {
self.frame += 1;
let frame = self.frame;
let duration = Self::duration();
let mut active = false;
self.entries.retain(|_, entry| {
if entry.seen + 1 < frame {
return false;
}
let settled = entry.settled(now, duration);
if !settled {
active = true;
}
!(settled && entry.target == 0.0)
});
active
}
}
thread_local! {
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
}
/// Hover progress (0..1) for `fade` this frame.
pub fn hover_t(fade: &Fade) -> f32 {
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
}
/// Record a hover flip for `fade`; prefer [`hover_listener`].
pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
HOVER_FADES.with(|fades| {
fades
.borrow_mut()
.set_at(fade, hovered, reduced, Instant::now())
});
}
/// An `.on_hover` listener driving the fade.
pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
move |hovered, _window, cx| {
set_hover(&fade, *hovered, cx.reduced_motion());
fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
}
}
/// Once-per-tick bookkeeping for the fade store, driven by the clock.
fn tick_hover_fades() {
HOVER_FADES.with(|fades| {
fades.borrow_mut().tick_at(Instant::now());
});
}
/// Blend two colors by `t` the way the browser transitions them: sRGB with
/// premultiplied alpha, so a wash fading in never passes through grey.
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
let t = t.clamp(0.0, 1.0);
if t <= 0.0 {
return from;
}
if t >= 1.0 {
return to;
}
let (f, g) = (Rgba::from(from), Rgba::from(to));
let a = lerp(f.a, g.a, t);
if a <= f32::EPSILON {
return Hsla::from(Rgba { a: 0.0, ..g });
}
Hsla::from(Rgba {
r: lerp(f.r * f.a, g.r * g.a, t) / a,
g: lerp(f.g * f.a, g.g * g.a, t) / a,
b: lerp(f.b * f.a, g.b * g.a, t) / a,
a,
})
}
/// The standard hover blend: rest → hover color at this fade's progress.
pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
mix(rest, hover, hover_t(fade))
}
/// Process-wide motion speed, as raw `f32` bits; an atomic mirror because the
/// timelines are read from free functions with no `cx` in scope.
static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
/// How far every timeline in the catalog is stretched. `1.0` is the designed
/// speed.
pub fn speed_scale() -> f32 {
f32::from_bits(SPEED.load(Ordering::Relaxed))
}
/// Stretch every catalog timeline by `scale`; clamped, and a non-finite
/// `scale` resets to `1.0`.
pub fn set_speed(scale: f32) {
let scale = if scale.is_finite() {
scale.clamp(0.01, 100.0)
} else {
1.0
};
SPEED.store(scale.to_bits(), Ordering::Relaxed);
}
/// Process-wide motion switches.
#[derive(Clone, Copy)]
struct MotionSettings {
reduced_motion: bool,
pause_when_inactive: bool,
}
impl Global for MotionSettings {}
impl MotionSettings {
/// Defaults: the OS flag rules reduce motion; the clock pauses when no
/// window is active.
fn get(cx: &App) -> Self {
cx.try_global::<MotionSettings>().copied().unwrap_or(Self {
reduced_motion: false,
pause_when_inactive: true,
})
}
}
/// App-level motion switches.
pub trait AppExt {
/// The OS reduce-motion flag ORed with the manual override.
fn reduced_motion(&self) -> bool;
/// Set the manual reduce-motion override.
fn set_reduced_motion(&mut self, reduced: bool);
/// Whether the leased clock claims frames while no window is active.
fn pause_when_inactive(&self) -> bool;
fn set_pause_when_inactive(&mut self, pause: bool);
}
impl AppExt for App {
fn reduced_motion(&self) -> bool {
self.reduce_motion() || MotionSettings::get(self).reduced_motion
}
fn set_reduced_motion(&mut self, reduced: bool) {
let mut settings = MotionSettings::get(self);
settings.reduced_motion = reduced;
self.set_global(settings);
}
fn pause_when_inactive(&self) -> bool {
MotionSettings::get(self).pause_when_inactive
}
fn set_pause_when_inactive(&mut self, pause: bool) {
let mut settings = MotionSettings::get(self);
settings.pause_when_inactive = pause;
self.set_global(settings);
}
}
+12 -9
View File
@@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
Styled, Window, div, px,
};
use theme::ActiveTheme;
@@ -44,8 +44,7 @@ impl Nav {
}
}
/// Marks the row as clickable even when it has no `on_click` handler, so it
/// still shows the hover affordance. Used by rows whose click opens a menu.
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
@@ -104,15 +103,21 @@ impl Styled for Nav {
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
v_flex().h_10().w_full().items_center().child(
v_flex().h_10().w_full().justify_center().child(
h_flex()
.id(self.id)
.h_9()
.w_full()
.px_1p5()
.gap_1p5()
.rounded(cx.theme().radius_lg)
.rounded(px(theme::button_radius()))
.text_color(text_color)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
@@ -126,10 +131,8 @@ impl RenderOnce for Nav {
)
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
+14 -20
View File
@@ -4,9 +4,9 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
Styled, Window, div, px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Selectable, StyledExt, h_flex};
@@ -21,7 +21,6 @@ pub struct NavItem {
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
@@ -43,18 +42,10 @@ impl NavItem {
label: label.into(),
suffix: None,
selected: false,
clickable: false,
on_click: None,
}
}
/// Marks the item as clickable even when it has no `on_click` handler, so it
/// still shows the hover affordance. Used by items whose click opens a menu.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
@@ -94,7 +85,12 @@ impl Styled for NavItem {
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
let clickable = self.on_click.is_some();
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
h_flex()
.id(self.id)
@@ -102,16 +98,16 @@ impl RenderOnce for NavItem {
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.text_color(cx.theme().text)
.gap(px(theme::SPACE))
.rounded(px(theme::button_radius()))
.text_color(text_color)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.text_style(TextStyle::Callout)
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
@@ -119,10 +115,8 @@ impl RenderOnce for NavItem {
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
+40 -78
View File
@@ -5,19 +5,19 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Subscription, Window, div, px, relative,
Anchor, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
Window, div, px, relative,
};
use gpui_base::{
Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
ToastTransitionStatus,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _};
use crate::motion::NOTIFICATION_IN;
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
/// How often the notification lifecycle clock is sampled.
@@ -40,20 +40,13 @@ pub enum NotificationKind {
impl NotificationKind {
fn icon(&self, cx: &App) -> Icon {
match self {
Self::Info => Icon::new(IconName::Info)
.with_size(Size::Medium)
.text_color(cx.theme().icon),
Self::Success => Icon::new(IconName::CheckCircle)
.with_size(Size::Medium)
.text_color(cx.theme().icon_accent),
Self::Warning => Icon::new(IconName::Warning)
.with_size(Size::Medium)
.text_color(cx.theme().text_warning),
Self::Error => Icon::new(IconName::CloseCircle)
.with_size(Size::Medium)
.text_color(cx.theme().danger_foreground),
}
let (name, color) = match self {
Self::Info => (IconName::Info, cx.theme().text_muted),
Self::Success => (IconName::CheckCircle, cx.theme().success),
Self::Warning => (IconName::Warning, cx.theme().warning),
Self::Error => (IconName::CloseCircle, gpui::white()),
};
Icon::new(name).with_size(Size::Medium).text_color(color)
}
}
@@ -78,10 +71,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
#[allow(clippy::type_complexity)]
/// A notification element.
pub struct Notification {
/// The id is used make the notification unique.
/// Then you push a notification with the same id, the previous notification will be replaced.
///
/// None means the notification will be added to the end of the list.
/// Pushing a notification with the same id replaces the previous one.
id: NotificationId,
style: StyleRefinement,
kind: Option<NotificationKind>,
@@ -128,9 +118,7 @@ impl From<(NotificationKind, SharedString)> for Notification {
struct DefaultIdType;
impl Notification {
/// Create a new notification.
///
/// The default id is a random UUID.
/// Creates a notification with a random UUID id.
pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
@@ -177,12 +165,7 @@ impl Notification {
.with_kind(NotificationKind::Error)
}
/// Set the type for unique identification of the notification.
///
/// ```rs
/// struct MyNotificationKind;
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
/// ```
/// Sets the type used to uniquely identify the notification.
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into();
self
@@ -194,15 +177,13 @@ impl Notification {
self
}
/// Set the title of the notification, default is None.
///
/// If title is None, the notification will not have a title.
/// Sets the title of the notification, default is None.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Set the type of the notification, default is NotificationType::Info.
/// Sets the kind of the notification, default is `NotificationKind::Info`.
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
self.kind = Some(kind);
self
@@ -214,9 +195,7 @@ impl Notification {
self
}
/// Set the action button of the notification.
///
/// When an action is set, the notification will not autohide.
/// Sets an action button; the notification will not autohide when set.
pub fn action<F>(mut self, action: F) -> Self
where
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
@@ -226,8 +205,8 @@ impl Notification {
self
}
/// Dismiss the notification.
pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
/// Emits a request for the notification list to dismiss this notification.
pub(crate) fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissRequest);
}
@@ -300,12 +279,14 @@ impl Render for Notification {
};
let background = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_background,
_ => cx.theme().surface_background,
Some(NotificationKind::Error) => cx.theme().danger_strong,
_ => cx.theme().surface_overlay,
};
let text_color = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_foreground,
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
Some(NotificationKind::Error) => gpui::white(),
_ => cx.theme().text,
};
@@ -326,10 +307,12 @@ impl Render for Notification {
.border_color(cx.theme().border)
.bg(background)
.text_color(text_color)
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded(px(theme::surface_radius()))
.when(cx.theme().shadow, |this| {
this.shadow(theme::surface_shadows())
})
.p_2()
.gap_2()
.gap(px(theme::SPACE))
.justify_start()
.items_start()
.when(only_message, |this| this.items_center())
@@ -343,12 +326,12 @@ impl Render for Notification {
.gap_1()
.overflow_hidden()
.when_some(self.title.clone(), |this, title| {
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
this.child(h_flex().h_5().text_style(TextStyle::Headline).child(title))
})
.when_some(self.message.clone(), |this, message| {
this.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.when(has_title, |this| this.text_color(cx.theme().text_muted))
.line_height(relative(1.3))
.child(message),
@@ -356,7 +339,7 @@ impl Render for Notification {
})
.when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| {
this.gap_2().child(
this.gap(px(theme::SPACE)).child(
h_flex()
.mt_2()
.w_full()
@@ -396,15 +379,11 @@ impl Render for Notification {
}))
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64),
Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
NOTIFICATION_IN.animation(),
move |this, delta| {
if closing {
let opacity = 1. - delta;
let that = this
.shadow_none()
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none());
let that = this.shadow_none().opacity(opacity);
match placement {
Anchor::TopRight | Anchor::BottomRight => {
let x_offset = px(0.) + delta * px(45.);
@@ -446,8 +425,7 @@ impl Render for Notification {
/// A list of notifications.
pub(crate) struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
notifications: ToastManager<NotificationId, Entity<Notification>>,
/// Measured geometry and interaction state of the visible stack.
stack_state: ToastStackState,
@@ -455,12 +433,11 @@ pub(crate) struct NotificationList {
/// Whether the lifecycle clock is running. The loop clears it as it exits.
is_advancing: bool,
/// Subscriptions
_subscriptions: HashMap<NotificationId, Subscription>,
}
impl NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
pub(crate) fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self {
notifications: ToastManager::new(ToastMotion::default()),
stack_state: ToastStackState::default(),
@@ -469,10 +446,7 @@ impl NotificationList {
}
}
/// Tick the toast lifecycle until the last notification is unmounted.
///
/// The stack expansion is sampled here because it reaches the list through
/// no event, and an idle window should arm no timer.
/// Ticks the toast lifecycle, including stack expansion which arrives through no event.
fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_advancing {
return;
@@ -520,7 +494,7 @@ impl NotificationList {
}
}
pub fn push(
pub(crate) fn push(
&mut self,
notification: impl Into<Notification>,
window: &mut Window,
@@ -575,18 +549,6 @@ impl NotificationList {
}
cx.notify();
}
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
for id in self
.notifications
.dismiss_all(cx.background_executor().now())
{
if let Some(note) = self.notifications.get(&id) {
note.update(cx, |note, cx| note.begin_close(cx));
}
}
cx.notify();
}
}
impl Render for NotificationList {
+34 -52
View File
@@ -6,13 +6,13 @@ use gpui::{
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
};
use gpui_base::Popover as BasePopover;
pub use gpui_base::PopoverState;
pub(crate) use gpui_base::PopoverState;
use crate::{Selectable, StyledExt as _, v_flex};
/// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
pub struct Popover {
pub(crate) struct Popover {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
@@ -26,8 +26,7 @@ pub struct Popover {
>,
>,
children: Vec<AnyElement>,
/// Style for trigger element.
/// This is used for hotfix the trigger element style to support w_full.
/// Style refinement applied to the trigger element.
trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton,
appearance: bool,
@@ -35,8 +34,8 @@ pub struct Popover {
}
impl Popover {
/// Create a new Popover with `view` mode.
pub fn new(id: impl Into<ElementId>) -> Self {
/// Creates a new Popover with the given id.
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
@@ -51,23 +50,20 @@ impl Popover {
}
}
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
///
/// This method is kept for backward compatibility with `Corner` type.
/// Internally, it converts `Corner` to `Anchor`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
/// Sets the anchor corner of the popover, default is `Anchor::TopLeft`.
pub(crate) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
/// Sets the mouse button that triggers the popover, default is `MouseButton::Left`.
pub(crate) fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
/// Set the trigger element of the popover.
pub fn trigger<T>(mut self, trigger: T) -> Self
/// Sets the trigger element, marked as selected while the popover is open.
pub(crate) fn trigger<T>(mut self, trigger: T) -> Self
where
T: Selectable + IntoElement + 'static,
{
@@ -78,12 +74,8 @@ impl Popover {
self
}
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
/// Sets the trigger from a builder, for elements that have no selected state.
pub(crate) fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
@@ -91,23 +83,20 @@ impl Popover {
self
}
/// Set the style for the trigger element.
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
/// Sets the style for the trigger element.
pub(crate) fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
self
}
/// Set whether clicking outside the popover will dismiss it, default is `true`.
pub fn overlay_closable(mut self, closable: bool) -> Self {
/// Sets whether clicking outside the popover dismisses it, default is `true`.
pub(crate) fn overlay_closable(mut self, closable: bool) -> Self {
self.overlay_closable = closable;
self
}
/// Set the content builder for content of the Popover.
///
/// This callback will called every time on render the popover.
/// So, you should avoid creating new elements or entities in the content closure.
pub fn content<F, E>(mut self, content: F) -> Self
/// Sets the content builder, called on every render of the popover.
pub(crate) fn content<F, E>(mut self, content: F) -> Self
where
E: IntoElement,
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
@@ -118,32 +107,13 @@ impl Popover {
self
}
/// Set whether the popover no style, default is `false`.
///
/// If no style:
///
/// - The popover will not have a bg, border, shadow, or padding.
/// - The click out of the popover will not dismiss it.
pub fn appearance(mut self, appearance: bool) -> Self {
/// Sets whether the popover renders with default styling, default is `true`.
pub(crate) fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Popover {
pub(crate) fn render_popover_content(
fn render_popover_content(
anchor: Anchor,
appearance: bool,
_: &mut Window,
@@ -162,6 +132,18 @@ impl Popover {
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Popover {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let anchor = self.anchor;
+3 -3
View File
@@ -12,15 +12,15 @@ pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
Rc::new(
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
let color = if context.is_active() {
cx.theme().border_selected
cx.theme().border_strong
} else {
cx.theme().border
cx.theme().element_hover
};
let axis = context.axis();
Some(
div()
.bg(cx.theme().border_variant)
.bg(cx.theme().border_faint)
.group_hover("handle", move |this| this.bg(color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
+9 -27
View File
@@ -4,14 +4,14 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, ElementId, Entity,
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
Window, canvas, div, point, px, size,
FocusHandle, HitboxBehavior, InteractiveElement, IntoElement, MouseButton, ParentElement as _,
Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle, Window, canvas, div,
point, px, size,
};
use gpui_base::TextSelectionLayer;
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
CLIENT_SIDE_DECORATION_SHADOW,
CLIENT_SIDE_DECORATION_SHADOW, base_text_size, surface_shadows,
};
use crate::dialog::Dialog;
@@ -230,19 +230,12 @@ impl Root {
});
cx.notify();
}
/// Clear all notifications from the notification layer.
pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) {
self.notification
.update(cx, |view, cx| view.clear(window, cx));
cx.notify();
}
}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rem_size = cx.theme().font_size;
let font_family = cx.theme().font_family.clone();
let rem_size = px(base_text_size());
let font_family = cx.theme().font_sans.clone();
let decorations = window.window_decorations();
// Set the base font size
@@ -361,19 +354,8 @@ impl Render for Root {
.when(!tiling.right, |div| {
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.is_tiled(), |div| {
div.shadow(vec![gpui::BoxShadow {
color: Hsla {
h: 0.,
s: 0.,
l: 0.,
a: 0.4,
},
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
spread_radius: px(0.),
offset: point(px(0.0), px(0.0)),
inset: false,
}])
.when(!tiling.is_tiled() && cx.theme().shadow, |div| {
div.shadow(surface_shadows())
}),
})
.on_mouse_move(|_e, _, cx| {
@@ -381,7 +363,7 @@ impl Render for Root {
})
.size_full()
.font_family(font_family)
.bg(cx.theme().surface_background)
.bg(cx.theme().bg)
.text_color(cx.theme().text)
.child(TextSelectionLayer)
.child(self.view.clone()),
+1 -1
View File
@@ -1,4 +1,4 @@
mod scrollable;
pub use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
pub use gpui_base::Scrollbar;
pub use scrollable::*;
+7 -125
View File
@@ -3,35 +3,14 @@ use std::panic::Location;
use gpui::prelude::FluentBuilder;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
use theme::ActiveTheme as _;
use gpui_base::{Scrollbar, ScrollbarAxis};
use crate::StyledExt as _;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
/// Adds a scrollbar to the element.
#[track_caller]
fn scrollbar<H: ScrollbarHandle + Clone>(
self,
scroll_handle: &H,
axis: impl Into<ScrollbarAxis>,
) -> Self {
self.child(ScrollbarLayer {
id: "scrollbar_layer".into(),
axis: axis.into(),
scroll_handle: scroll_handle.clone(),
})
}
/// Adds a vertical scrollbar to the element.
#[track_caller]
fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
#[track_caller]
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
@@ -39,72 +18,6 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme
}
}
/// The scrollbar the application's theme describes: a 10px rail holding a 6px
/// rounded thumb that grows to 8px under the pointer.
fn scrollbar<H: ScrollbarHandle + Clone>(
scroll_handle: &H,
axis: ScrollbarAxis,
cx: &App,
) -> Scrollbar {
let theme = cx.theme();
let (thumb_width, thumb_radius) = if theme.scrollbar_mode.is_scrolling() {
(px(6.), px(3.))
} else {
(px(8.), px(4.))
};
Scrollbar::new(scroll_handle).axis(axis).styles(|styles| {
styles
.track(|track| {
track
.bg(theme.scrollbar_track_background)
.border_color(theme.scrollbar_thumb_border)
.width(px(10.))
})
.track_hover(|track| track.bg(theme.scrollbar_thumb_background).width(px(10.)))
.thumb(|thumb| {
thumb
.bg(theme.scrollbar_thumb_background)
.width(thumb_width)
.inset(px(1.))
.radius(thumb_radius)
.min_length(px(48.))
})
.thumb_hover(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
.thumb_active(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
})
}
/// A scrollbar child that resolves the theme while rendering, which the
/// [`ScrollableElement`] helpers cannot do at the call site.
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: H,
}
impl<H> RenderOnce for ScrollbarLayer<H>
where
H: ScrollbarHandle + Clone + 'static,
{
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
scrollbar(&self.scroll_handle, self.axis, cx).id(self.id)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
@@ -128,15 +41,6 @@ where
}
}
impl<E> Styled for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.style()
}
}
impl<E> ParentElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
@@ -146,18 +50,6 @@ where
}
}
impl InteractiveElement for Scrollable<Div> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl InteractiveElement for Scrollable<Stateful<Div>> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl<E> RenderOnce for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element + 'static,
@@ -167,8 +59,6 @@ where
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone();
// Inherit the size from the element style.
let style = StyleRefinement {
size: self.element.style().size.clone(),
..Default::default()
@@ -190,22 +80,14 @@ where
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
ScrollbarAxis::Both => this.overflow_scroll(),
})
.child(self.element.size_auto().flex_1()),
)
.child(
self.element
// Refine element size to `flex_1`.
.size_auto()
.flex_1(),
),
Scrollbar::new(&scroll_handle)
.axis(self.axis)
.id("scrollbar"),
)
.child(scrollbar(&scroll_handle, self.axis, cx).id("scrollbar"))
}
}
impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E>
where
E: ParentElement + Styled + Element,
Self: InteractiveElement,
{
}
+15 -17
View File
@@ -1,7 +1,6 @@
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
pub use gpui_base::component_traits::{Disableable, Selectable};
use serde::{Deserialize, Serialize};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
/// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div {
@@ -15,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
div().my_1().w_full().h_px().bg(cx.theme().border_faint)
}
macro_rules! font_weight {
@@ -54,18 +53,20 @@ pub trait StyledExt: Styled + Sized {
/// Set as Popover style
#[inline]
fn popover_style(self, cx: &mut App) -> Self {
self.bg(cx.theme().background)
let shadows = cx.theme().shadow.then(surface_shadows).unwrap_or_default();
self.bg(cx.theme().surface_overlay)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.shadow(shadows)
.rounded(px(surface_radius()))
}
}
impl<E: Styled> StyledExt for E {}
/// A size for elements.
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug)]
pub enum Size {
Size(Pixels),
XSmall,
@@ -83,10 +84,7 @@ impl From<Pixels> for Size {
/// A trait for setting the size of an element.
pub trait Sizable: Sized {
/// Set the ui::Size of this element.
///
/// Also can receive a `ButtonSize` to convert to `IconSize`,
/// Or a `Pixels` to set a custom size: `px(30.)`
/// Set the element's [`Size`], or a custom pixel size (`px(30.)`).
fn with_size(self, size: impl Into<Size>) -> Self;
/// Set to Size::XSmall
@@ -130,18 +128,18 @@ impl<T: Styled> StyleSized<T> for T {
match size {
Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(),
Size::Large => self.py_5(),
Size::Large => self.py_1p5(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_6().text_xs(),
Size::Small => self.h_8().text_sm(),
Size::Medium => self.h_9().text_base(),
Size::Large => self.h_12().text_lg(),
Size::Size(size) => self.h(px(24.)).text_size(size),
Size::XSmall => self.h_6().text_style(TextStyle::Caption),
Size::Small => self.h_7().text_style(TextStyle::Callout),
Size::Medium => self.h_8().text_style(TextStyle::Body),
Size::Large => self.h_10().text_style(TextStyle::Title3),
Size::Size(size) => self.h(size).text_size(size * 0.5),
}
}
}
+25 -51
View File
@@ -4,12 +4,10 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
div, px, white,
div, px, transparent_black,
};
use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
use theme::{ActiveTheme, Side};
use crate::{Disableable, Sizable, Size};
use theme::{ActiveTheme, TextStyle, Typeset as _};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
@@ -17,12 +15,9 @@ type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
pub struct Switch {
id: ElementId,
checked: bool,
disabled: bool,
label: Option<SharedString>,
description: Option<SharedString>,
label_side: Side,
on_click: OnClick,
size: Size,
}
impl Switch {
@@ -32,12 +27,9 @@ impl Switch {
Self {
id: id.clone(),
checked: false,
disabled: false,
label: None,
description: None,
on_click: None,
label_side: Side::Left,
size: Size::Medium,
}
}
@@ -65,45 +57,27 @@ impl Switch {
}
}
impl Sizable for Switch {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Disableable for Switch {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl RenderOnce for Switch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let checked = self.checked;
let on_click = self.on_click.clone();
let (bg, toggle_bg) = match checked {
true => (cx.theme().element_background, white()),
false => (cx.theme().elevated_surface_background, white()),
let bg = if checked {
cx.theme().accent_strong
} else {
cx.theme().input_bg
};
let (bg, toggle_bg) = match self.disabled {
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
false => (bg, toggle_bg),
};
let (bg_width, bg_height) = match self.size {
Size::XSmall | Size::Small => (px(28.), px(16.)),
_ => (px(36.), px(20.)),
};
let bar_width = match self.size {
Size::XSmall | Size::Small => px(12.),
_ => px(16.),
let toggle_bg = cx.theme().on_solid;
// The border is structural (it insets the thumb); it only paints when off.
let track_border = if checked {
transparent_black()
} else {
cx.theme().border
};
let bg_width = px(36.);
let bg_height = px(20.);
let bar_width = px(16.);
let inset = px(2.);
let thumb_left = spring(
@@ -124,14 +98,13 @@ impl RenderOnce for Switch {
div().child(
BaseSwitch::new(self.id.clone())
.checked(checked)
.disabled(self.disabled)
.when_some(accessibility_label, |this, label| {
this.accessibility_label(label)
})
.when_some(on_click, |this, on_click| {
this.on_change(move |next, _event, window, cx| on_click(&next, window, cx))
})
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.flex_row_reverse()
.child(
div()
.w_full()
@@ -140,14 +113,16 @@ impl RenderOnce for Switch {
.items_center()
.gap_4()
.when_some(label, |this, label| {
// Label
this.child(div().text_sm().text_color(cx.theme().text).child(label))
this.child(
div()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.child(label),
)
})
.child(
// Switch Bar
SwitchTrack::new((self.id.clone(), "track"))
.checked(checked)
.disabled(self.disabled)
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
@@ -155,11 +130,10 @@ impl RenderOnce for Switch {
.flex()
.items_center()
.border(inset)
.border_color(cx.theme().border_transparent)
.border_color(track_border)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.cursor_pointer()
.child(
// Switch Toggle
SwitchThumb::new(checked)
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
@@ -173,7 +147,7 @@ impl RenderOnce for Switch {
this.child(
div()
.pr_3()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(description),
)
+23 -76
View File
@@ -5,9 +5,9 @@ use gpui::{
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Icon, IconName, Selectable, h_flex};
use crate::{Selectable, h_flex};
pub mod tab_bar;
@@ -17,61 +17,27 @@ pub mod tab_bar;
pub struct Tab {
ix: usize,
base: Div,
pub(super) label: Option<SharedString>,
icon: Option<Icon>,
prefix: Option<AnyElement>,
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
label: Option<SharedString>,
children: Vec<AnyElement>,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) segmented: bool,
tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
disabled: bool,
selected: bool,
segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl From<&'static str> for Tab {
fn from(label: &'static str) -> Self {
Self::new().label(label)
}
}
impl From<String> for Tab {
fn from(label: String) -> Self {
Self::new().label(label)
}
}
impl From<SharedString> for Tab {
fn from(label: SharedString) -> Self {
Self::new().label(label)
}
}
impl From<Icon> for Tab {
fn from(icon: Icon) -> Self {
Self::default().icon(icon)
}
}
impl From<IconName> for Tab {
fn from(icon_name: IconName) -> Self {
Self::default().icon(Icon::new(icon_name))
}
}
impl Default for Tab {
fn default() -> Self {
Self {
ix: 0,
base: div(),
label: None,
icon: None,
tab_bar_prefix: None,
children: Vec::new(),
disabled: false,
selected: false,
segmented: false,
prefix: None,
suffix: None,
on_click: None,
}
@@ -90,32 +56,20 @@ impl Tab {
self
}
/// Set icon for the tab.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set disabled state to the tab, default false.
pub fn disabled(mut self, disabled: bool) -> Self {
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the click handler for the tab.
pub fn on_click(
pub(crate) fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
@@ -179,8 +133,6 @@ impl RenderOnce for Tab {
ix,
base,
label,
icon,
prefix,
suffix,
children,
disabled,
@@ -193,9 +145,9 @@ impl RenderOnce for Tab {
let foreground = if disabled {
cx.theme().text_muted
} else if selected {
cx.theme().tab_active_foreground
cx.theme().text
} else {
cx.theme().tab_foreground
cx.theme().text_muted
};
let content = h_flex()
@@ -206,15 +158,11 @@ impl RenderOnce for Tab {
.overflow_hidden()
.when(segmented, |this| this.justify_center().px_1())
.when(!segmented, |this| this.justify_start())
.map(|this| match icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this
.map(|this| match label {
Some(label) => this.child(label),
None => this,
})
.children(children),
});
.children(children);
base.id(ix)
.group("tab")
@@ -222,16 +170,16 @@ impl RenderOnce for Tab {
.items_center()
.text_color(foreground)
.when(segmented, |this| {
this.text_xs()
this.text_style(TextStyle::Caption)
.flex_1()
.h_6()
.rounded(cx.theme().radius)
.rounded(px(theme::button_radius()))
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_active_background)
this.bg(cx.theme().bg)
.when(cx.theme().shadow, |this| this.shadow_sm())
})
.when(!selected && !disabled, |this| {
this.hover(|this| this.bg(cx.theme().tab_hover_background))
this.hover(|this| this.bg(cx.theme().element_hover))
})
})
.when(!segmented, |this| {
@@ -240,20 +188,19 @@ impl RenderOnce for Tab {
.h_7()
.gap_1()
.px_1p5()
.text_sm()
.rounded(cx.theme().radius)
.text_style(TextStyle::Callout)
.rounded(px(theme::button_radius()))
.overflow_hidden()
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_background)
this.bg(cx.theme().element_active)
})
.when(!selected && !disabled, |this| {
this.hover(|this| {
this.bg(cx.theme().tab_hover_background)
.text_color(cx.theme().tab_active_foreground)
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
})
})
.when_some(prefix, |this, prefix| this.child(prefix))
.child(content)
.when_some(suffix, |this, suffix| {
this.child(
+8 -10
View File
@@ -52,25 +52,25 @@ impl TabBar {
}
/// Track the scroll of the TabBar.
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
pub(crate) fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone());
self
}
/// Set the prefix element of the TabBar
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the suffix element of the TabBar
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Add children of the TabBar.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
pub(crate) fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
@@ -88,14 +88,12 @@ impl TabBar {
}
/// Set the last empty space element of the TabBar.
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
pub(crate) fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element();
self
}
/// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
///
/// When this is set, the children's on_click will be ignored.
/// Set the on_click callback of the TabBar; when set, the children's on_click is ignored.
pub fn on_click<F>(mut self, on_click: F) -> Self
where
F: Fn(&usize, &mut Window, &mut App) + 'static,
@@ -124,9 +122,9 @@ impl RenderOnce for TabBar {
.min_w_0()
.refine_style(&self.style)
.when(segmented, |this| {
this.bg(cx.theme().tab_background)
this.bg(cx.theme().surface)
.p_0p5()
.rounded(cx.theme().radius)
.rounded(px(theme::button_radius()))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
+24 -53
View File
@@ -1,31 +1,23 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ClickEvent, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
Styled, Window, WindowControlArea, div, px,
App, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, div,
px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, plate_active};
use crate::{Icon, IconName, Sizable as _, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
// If user clicked in the bounds, the window event will be triggered.
// Windows control buttons rely on the native window event, so no click handlers are needed.
#[derive(IntoElement, Clone)]
#[allow(clippy::type_complexity)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
},
Close,
}
impl ControlIcon {
@@ -41,9 +33,8 @@ impl ControlIcon {
Self::Maximize
}
#[allow(clippy::type_complexity)]
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
Self::Close { on_close_window }
fn close() -> Self {
Self::Close
}
fn id(&self) -> &'static str {
@@ -51,7 +42,7 @@ impl ControlIcon {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
Self::Close => "close",
}
}
@@ -60,7 +51,7 @@ impl ControlIcon {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
Self::Close => IconName::WindowClose,
}
}
@@ -68,18 +59,20 @@ impl ControlIcon {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close { .. } => WindowControlArea::Close,
Self::Close => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
matches!(self, Self::Close)
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
gpui::white()
} else {
cx.theme().text
}
@@ -88,18 +81,18 @@ impl ControlIcon {
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_background
cx.theme().danger_strong
} else {
cx.theme().ghost_element_hover
cx.theme().element_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
plate_active(cx.theme().danger_strong)
} else {
cx.theme().ghost_element_active
cx.theme().element_active
}
}
}
@@ -114,11 +107,6 @@ impl RenderOnce for ControlIcon {
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let on_close_window = match &self {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
@@ -144,13 +132,7 @@ impl RenderOnce for ControlIcon {
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), window, cx);
} else {
window.remove_window();
}
}
Self::Close => window.remove_window(),
}
})
})
@@ -159,15 +141,10 @@ impl RenderOnce for ControlIcon {
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub(crate) struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
pub(crate) struct WindowControls;
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
WindowControls
}
pub fn title_bar_drag_handlers(
@@ -232,16 +209,10 @@ impl RenderOnce for WindowControls {
} else {
ControlIcon::maximize()
})
.child(ControlIcon::close(self.on_close_window))
.child(ControlIcon::close())
}
}
struct TitleBarState {
should_move: bool,
}
impl Render for TitleBarState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
+7 -10
View File
@@ -1,10 +1,10 @@
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, relative,
Window, px,
};
use gpui_base::Tooltip as BaseTooltip;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
pub struct Tooltip {
text: SharedString,
@@ -18,20 +18,17 @@ impl Tooltip {
impl Render for Tooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(
BaseTooltip::new("tooltip")
.font_family(".SystemUIFont")
.font_family(cx.theme().font_sans.clone())
.m_3()
.p_1p5()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_background)
.bg(cx.theme().surface_overlay)
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.text_xs()
.rounded(px(control_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text)
.line_height(relative(1.25))
.child(self.text.clone()),
)
.child(self.text.clone())
}
}
-10
View File
@@ -31,9 +31,6 @@ pub trait WindowExtension: Sized {
key: impl Into<ElementId>,
cx: &mut App,
);
/// Clear all notifications
fn clear_notifications(&mut self, cx: &mut App);
}
impl WindowExtension for Window {
@@ -90,11 +87,4 @@ impl WindowExtension for Window {
root.clear_notification_by_id::<T>(key, window, cx);
})
}
#[inline]
fn clear_notifications(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.clear_notifications(window, cx);
})
}
}
+13 -13
View File
@@ -7,7 +7,7 @@ use gpui::{
use instant::Duration;
use nostr_connect::prelude::*;
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
@@ -223,8 +223,8 @@ impl Render for ImportIdentity {
v_flex()
.size_full()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(
v_flex()
.gap_1()
@@ -248,10 +248,10 @@ impl Render for ImportIdentity {
this.child(
div()
.p_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_xs()
.text_color(cx.theme().warning_foreground)
.rounded(px(surface_radius()))
.bg(cx.theme().warning_muted)
.text_style(TextStyle::Caption)
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(BUNKER_WARN)),
)
@@ -260,10 +260,10 @@ impl Render for ImportIdentity {
this.child(
div()
.p_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_xs()
.text_color(cx.theme().warning_foreground)
.rounded(px(surface_radius()))
.bg(cx.theme().warning_muted)
.text_style(TextStyle::Caption)
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(KEY_WARN)),
)
@@ -295,9 +295,9 @@ impl Render for ImportIdentity {
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_center()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
+4 -3
View File
@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
@@ -43,7 +43,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
.child(
v_flex()
.gap_3()
.child(Input::new(&input).cleanable(true).text_sm())
.child(Input::new(&input).cleanable(true))
.child(
h_flex()
.gap_1()
@@ -56,7 +56,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
)
.child(
Button::new("join")
.icon(IconName::CheckCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Join")
.primary()
.font_semibold()
+13 -12
View File
@@ -13,9 +13,9 @@ use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use state::NostrRegistry;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::dock::{DockArea, DockPlacement, PanelHandle};
use ui::input::{Input, InputEvent, InputState};
use ui::nav::Nav;
@@ -332,7 +332,7 @@ impl NewChat {
Nav::new(start + offset)
.label(profile.name())
.text_sm()
.text_style(TextStyle::Callout)
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.prefix(
Avatar::from_source(profile.avatar())
@@ -365,9 +365,9 @@ impl NewChat {
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().ghost_element_background_alt)
.text_size(px(10.))
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.bg(cx.theme().surface_raised)
.text_style(TextStyle::Caption2)
.hover(|this| this.bg(cx.theme().element_hover))
.child(
Avatar::from_source(profile.avatar())
.seed(profile.avatar_seed())
@@ -394,7 +394,7 @@ impl Render for NewChat {
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).flex_1().text_sm().cleanable(true))
.child(Input::new(&self.input).flex_1().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
@@ -414,15 +414,15 @@ impl Render for NewChat {
if let Some(error) = self.error.clone() {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_danger)
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error),
)
} else {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(notice),
)
}
@@ -449,7 +449,8 @@ impl Render for NewChat {
)
.child(
Button::new("start")
.icon(IconName::CheckCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Start")
.primary()
.font_semibold()
+5 -4
View File
@@ -3,8 +3,8 @@ use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px};
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
@@ -47,7 +47,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
)
.child(
Button::new("create")
.icon(IconName::CheckCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Create")
.primary()
.font_semibold()
@@ -67,7 +68,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child(label)
.child(Input::new(input))
+19 -13
View File
@@ -5,16 +5,16 @@ use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, px, retain_all,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::dock::DockPlacement;
use ui::input::{Input, InputState, Textarea, TextareaState};
use ui::notification::Notification;
@@ -366,12 +366,13 @@ impl Render for ProfileDialog {
.w_full()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.bg(cx.theme().surface)
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
.when(self.editing, |this| {
this.child(
Button::new("upload")
.icon(IconName::PlusCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Add an avatar")
.xsmall()
.ghost()
@@ -391,9 +392,9 @@ impl Render for ProfileDialog {
.w_full()
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().surface_background)
.text_sm()
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.text_style(TextStyle::Callout)
.child(shorten_pkey)
.child(
Button::new("copy")
@@ -464,6 +465,7 @@ impl Render for ProfileDialog {
this.child(
Button::new("update")
.icon(IconName::Edit)
.icon_position(IconPosition::End)
.label("Update")
.primary()
.font_semibold()
@@ -476,6 +478,7 @@ impl Render for ProfileDialog {
this.child(
Button::new("message")
.icon(IconName::Message)
.icon_position(IconPosition::End)
.label("Message")
.primary()
.font_semibold()
@@ -497,6 +500,7 @@ impl Render for ProfileDialog {
.child(
Button::new("save")
.icon(IconName::CheckCircle)
.icon_position(IconPosition::End)
.label("Save")
.primary()
.font_semibold()
@@ -513,7 +517,7 @@ impl Render for ProfileDialog {
fn label(label: &'static str, cx: &App) -> Div {
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.font_semibold()
.child(label)
@@ -530,8 +534,10 @@ fn field(
None => (SharedString::from(placeholder), cx.theme().text_muted),
};
v_flex()
.gap_1p5()
.child(label(text, cx))
.child(div().text_sm().text_color(color).child(content))
v_flex().gap_1p5().child(label(text, cx)).child(
div()
.text_style(TextStyle::Callout)
.text_color(color)
.child(content),
)
}
+20 -20
View File
@@ -11,8 +11,8 @@ use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputEvent, InputState};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::scroll::ScrollableElement;
@@ -108,10 +108,10 @@ impl RelayManager {
let manager = cx.entity().downgrade();
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(GOSSIP_MSG)),
)
@@ -132,7 +132,7 @@ impl RelayManager {
.tooltip("Relay metadata")
.ghost()
.h(rems(2.))
.text_xs()
.text_style(TextStyle::Caption)
.dropdown_menu(move |menu, _window, _cx| {
menu.item(metadata_item(&manager, RelayMetadata::Read))
.item(metadata_item(&manager, RelayMetadata::Write))
@@ -153,8 +153,8 @@ impl RelayManager {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
@@ -197,10 +197,10 @@ impl RelayManager {
fn render_inbox(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(INBOX_MSG)),
)
@@ -224,8 +224,8 @@ impl RelayManager {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
@@ -283,7 +283,8 @@ impl RelayManager {
h_flex().gap_1().justify_end().child(
Button::new("update")
.icon(IconName::CheckCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Update")
.primary()
.font_semibold()
@@ -607,7 +608,7 @@ impl Render for RelayManager {
v_flex()
.gap_4()
.text_sm()
.text_style(TextStyle::Callout)
.child(self.render_tab_bar(cx))
.child(content)
.child(self.render_actions(cx))
@@ -646,20 +647,19 @@ fn relay_row(
.h_8()
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().surface_background)
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.child(
h_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.child(label)
.when_some(badge, |this, badge| {
this.child(
div()
.p_0p5()
.rounded_xs()
.font_semibold()
.text_size(px(8.))
.text_style(TextStyle::Caption2)
.child(badge),
)
}),
@@ -682,8 +682,8 @@ fn empty_relays(cx: &App) -> impl IntoElement {
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_sm()
.rounded(px(panel_radius()))
.text_style(TextStyle::Callout)
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
+8 -7
View File
@@ -7,8 +7,8 @@ use gpui::{
use instant::Duration;
use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputEvent, InputState};
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
@@ -100,11 +100,11 @@ impl Render for RestoreEncryption {
v_flex()
.size_full()
.gap_3()
.text_sm()
.text_style(TextStyle::Callout)
.child(
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child("Secret Key")
.child(Input::new(&self.key_input)),
@@ -112,7 +112,8 @@ impl Render for RestoreEncryption {
.child(
h_flex().gap_1().justify_end().child(
Button::new("restore")
.icon(IconName::CheckCircle)
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Restore")
.primary()
.font_semibold()
@@ -124,9 +125,9 @@ impl Render for RestoreEncryption {
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_center()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
+21 -18
View File
@@ -12,7 +12,7 @@ use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
@@ -283,9 +283,9 @@ impl Screening {
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.hover(|this| this.bg(cx.theme().element_hover))
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
);
@@ -344,8 +344,8 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_sm()
.bg(cx.theme().surface_raised)
.text_style(TextStyle::Callout)
.truncate()
.text_ellipsis()
.text_center()
@@ -385,11 +385,14 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
v_flex()
.text_style(TextStyle::Callout)
.child("Contact")
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
@@ -406,12 +409,12 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.text_style(TextStyle::Callout)
.child(
h_flex()
.gap_0p5()
@@ -446,11 +449,11 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap_2()
.gap(px(theme::SPACE))
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.text_style(TextStyle::Callout)
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
@@ -481,11 +484,11 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap_2()
.gap(px(theme::SPACE))
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_sm()
.text_style(TextStyle::Callout)
.child(
div()
.line_clamp(1)
@@ -524,9 +527,9 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
if status {
cx.theme().icon_accent
cx.theme().accent
} else {
cx.theme().icon_muted
cx.theme().text_faint
}
}))
} else {
+23 -49
View File
@@ -1,10 +1,10 @@
use gpui::http_client::Url;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, px,
Window, div,
};
use settings::AppSettings;
use theme::{ActiveTheme, Theme, ThemeMode};
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::input::{Input, InputState};
@@ -47,10 +47,10 @@ impl Preferences {
}
}
/// Set the theme mode (light or dark)
fn set_theme_mode(mode: ThemeMode, window: &mut Window, cx: &mut App) {
AppSettings::update_theme_mode(mode, cx);
Theme::change(mode, Some(window), cx);
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
cx.set_appearance_mode(mode);
}
}
@@ -58,14 +58,13 @@ impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
const MODE: &str = "Follow the OS, or always use light or dark.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const RESET: &str = "Reset the theme to the default one.";
let screening = AppSettings::get_screening(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let theme_mode = AppSettings::get_theme_mode(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
v_flex()
.gap_4()
@@ -104,55 +103,30 @@ impl Render for Preferences {
.justify_between()
.child(
v_flex()
.child(div().text_sm().child(SharedString::from("Mode")))
.child(
div()
.text_xs()
.text_style(TextStyle::Callout)
.child(SharedString::from("Mode")),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(MODE)),
),
)
.child(
Button::new("theme-mode")
.label(theme_mode.name())
Button::new("appearance-mode")
.label(appearance_mode.label())
.ghost_alt()
.small()
.dropdown_menu(|this, _window, _cx| {
this.item(PopupMenuItem::new("Light").on_click(
|_, window, cx| {
Self::set_theme_mode(ThemeMode::Light, window, cx);
AppearanceMode::ALL.iter().fold(this, |menu, mode| {
menu.item(PopupMenuItem::new(mode.label()).on_click(
move |_, _window, cx| {
Self::set_appearance_mode(*mode, cx);
},
))
.item(
PopupMenuItem::new("Dark").on_click(|_, window, cx| {
Self::set_theme_mode(ThemeMode::Dark, window, cx);
}),
)
}),
),
)
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.child(div().text_sm().child(SharedString::from("Reset theme")))
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(RESET)),
),
)
.child(
Button::new("reset")
.label("Reset")
.ghost_alt()
.small()
.on_click(move |_ev, window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.reset_theme(window, cx);
})
}),
),
@@ -184,7 +158,7 @@ impl Render for Preferences {
.child(
h_flex()
.gap_1()
.child(Input::new(&self.file_input).text_xs().small())
.child(Input::new(&self.file_input).small())
.child(
Button::new("update-file-server")
.icon(IconName::Check)
@@ -197,9 +171,9 @@ impl Render for Preferences {
)
.child(
div()
.text_size(px(10.))
.text_style(TextStyle::Caption2)
.italic()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.child(SharedString::from("Only support blossom service")),
),
),
+5 -90
View File
@@ -1,6 +1,5 @@
use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
@@ -15,11 +14,11 @@ use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::button::{Button, ButtonVariants};
use theme::{ActiveTheme, TextStyle, Typeset as _, SIDEBAR_WIDTH, Theme};
use ui::button::Button;
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::notification::{Notification, NotificationKind};
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use ui::{Root, WindowExtension, v_flex};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
@@ -40,7 +39,6 @@ struct MsgRelayNotification;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
Update,
RefreshMessagingRelays,
BackupEncryption,
@@ -327,9 +325,6 @@ impl Workspace {
Command::ResetEncryption => {
self.confirm_reset_encryption(window, cx);
}
Command::ToggleTheme => {
self.theme_selector(window, cx);
}
Command::BackupEncryption => {
let device = DeviceRegistry::global(cx).downgrade();
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
@@ -420,12 +415,12 @@ impl Workspace {
.child(
v_flex()
.gap_1()
.text_sm()
.text_style(TextStyle::Callout)
.child(SharedString::from(ENC_MSG))
.child(
div()
.italic()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger)
.child(SharedString::from(ENC_WARN)),
),
)
@@ -448,86 +443,6 @@ impl Workspace {
.child(restore.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_dialog(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
let themes = registry.read(cx).themes();
this.width(px(520.))
.show_close(true)
.title("Select theme")
.child(v_flex().gap_2().w_full().children({
let mut items = vec![];
for (ix, (path, theme)) in themes.iter().enumerate() {
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_8()
.w_full()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_1p5()
.flex_1()
.text_sm()
.child(theme.name.clone())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_muted)
.child(theme.author.clone()),
),
)
.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("url-{ix}"))
.icon(IconName::Link)
.ghost()
.small()
.on_click({
let theme = theme.clone();
move |_ev, _window, cx| {
cx.open_url(&theme.url);
}
}),
)
.child(
Button::new(format!("set-{ix}"))
.icon(IconName::Check)
.primary()
.small()
.on_click({
let path = path.clone();
move |_ev, window, cx| {
let settings = AppSettings::global(cx);
let path = path.clone();
settings.update(cx, |this, cx| {
this.set_theme(path, window, cx);
})
}
}),
),
),
);
}
items
}))
});
}
}
impl Render for Workspace {
+7 -7
View File
@@ -1,12 +1,12 @@
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, px,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputState};
@@ -132,24 +132,24 @@ impl Render for BackupPanel {
.w_full()
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.flex_1()
.w_full()
.text_sm()
.text_style(TextStyle::Callout)
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
@@ -162,7 +162,7 @@ impl Render for BackupPanel {
.w_full()
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Secret Key:")),
+21 -22
View File
@@ -6,14 +6,14 @@ use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative,
size,
size, transparent_black,
};
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership};
use nostr_sdk::prelude::{RelayUrl, Timestamp};
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
@@ -268,7 +268,7 @@ impl BrowsePanel {
.min_w_0()
.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.line_height(relative(1.2))
.child("Public Groups"),
@@ -276,7 +276,7 @@ impl BrowsePanel {
.child(
div()
.truncate()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.map(|this| match &self.relay {
Some(relay) => {
@@ -294,13 +294,12 @@ impl BrowsePanel {
.w_full()
.max_w_64()
.px_1()
.rounded(cx.theme().radius)
.bg(cx.theme().surface_background)
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.child(
Input::new(&self.input)
.appearance(false)
.small()
.text_sm()
.flex_1(),
)
.child(
@@ -375,26 +374,26 @@ impl BrowsePanel {
.min_w_0()
.h_full()
.overflow_hidden()
.rounded(cx.theme().radius)
.rounded(px(panel_radius()))
.cursor_pointer()
.border_1()
.border_color(cx.theme().border_transparent)
.bg(cx.theme().surface_background)
.border_color(transparent_black())
.bg(cx.theme().surface)
.hover(|this| {
this.bg(cx.theme().elevated_surface_background)
.border_color(cx.theme().border_focused)
this.bg(cx.theme().surface_raised)
.border_color(cx.theme().border_strong)
})
.child(
v_flex()
.flex_1()
.min_h_0()
.p_2()
.gap_2()
.gap(px(theme::SPACE))
.child(
h_flex()
.w_full()
.items_center()
.gap_2()
.gap(px(theme::SPACE))
.when(!hide_avatar, |this| {
this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0())
})
@@ -404,7 +403,7 @@ impl BrowsePanel {
.min_w_0()
.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.truncate()
.font_semibold()
.line_height(relative(1.2))
@@ -413,8 +412,8 @@ impl BrowsePanel {
.child(
div()
.truncate()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(relay),
),
),
@@ -422,7 +421,7 @@ impl BrowsePanel {
.child(
div()
.min_w_0()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_ellipsis()
.line_clamp(2)
@@ -443,7 +442,7 @@ impl BrowsePanel {
.child(Icon::new(IconName::Book))
.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.child(SharedString::from(format!("{members} members"))),
),
),
@@ -463,11 +462,11 @@ impl Panel for BrowsePanel {
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().icon_muted),
.text_color(cx.theme().text_faint),
)
.child(TITLE)
.into_any_element()
@@ -543,7 +542,7 @@ fn notice(text: &'static str, cx: &App) -> AnyElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child(text)
.into_any_element()
+13 -13
View File
@@ -5,14 +5,14 @@ use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems, retain_all,
Task, TextAlign, Window, div, px, rems, retain_all,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
@@ -233,12 +233,12 @@ impl ContactListPanel {
.h_8()
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.rounded(px(button_radius()))
.hover(|this| this.bg(cx.theme().element_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
@@ -273,8 +273,8 @@ impl ContactListPanel {
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_sm()
.rounded(px(panel_radius()))
.text_style(TextStyle::Callout)
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
@@ -308,13 +308,13 @@ impl Render for ContactListPanel {
.overflow_y_scrollbar()
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.flex_1()
.w_full()
.text_sm()
.text_style(TextStyle::Callout)
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child("New contact:"),
@@ -342,8 +342,8 @@ impl Render for ContactListPanel {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error.clone()),
)
}),
+10 -24
View File
@@ -1,14 +1,13 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px, svg,
};
use state::NostrRegistry;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use crate::Command;
use crate::dialogs::profile;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
@@ -45,7 +44,7 @@ impl Panel for GreeterPanel {
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.child(
svg()
.path("brand/coop.svg")
@@ -85,13 +84,13 @@ impl Render for GreeterPanel {
.child(
h_flex()
.mb_4()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().icon_muted),
.text_color(cx.theme().text_faint),
)
.child(
v_flex()
@@ -103,7 +102,7 @@ impl Render for GreeterPanel {
)
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(DESCRIPTION)),
),
@@ -111,13 +110,13 @@ impl Render for GreeterPanel {
)
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.child(
h_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Get Started"))
@@ -125,7 +124,7 @@ impl Render for GreeterPanel {
)
.child(
v_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.justify_start()
.items_start()
@@ -138,19 +137,6 @@ impl Render for GreeterPanel {
.on_click(cx.listener(move |this, _, window, cx| {
this.open_profile(window, cx)
})),
)
.child(
Button::new("theme")
.icon(Icon::new(IconName::Moon))
.label("Change theme")
.ghost()
.small()
.on_click(cx.listener(move |_, _, window, cx| {
window.dispatch_action(
Box::new(Command::ToggleTheme),
cx,
);
})),
),
),
),
+3 -3
View File
@@ -2,7 +2,7 @@ use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
@@ -35,7 +35,7 @@ impl Panel for InboxPanel {
.child(
Icon::new(IconName::Inbox)
.small()
.text_color(cx.theme().icon_muted),
.text_color(cx.theme().text_faint),
)
.child(self.name.clone())
.into_any_element()
@@ -55,7 +55,7 @@ impl Render for InboxPanel {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
+10 -10
View File
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use state::NostrRegistry;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::avatar::Avatar;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::nav::Nav;
@@ -131,7 +131,7 @@ impl Sidebar {
.flex_1()
.min_h_0()
.w_full()
.gap_2()
.gap(px(theme::SPACE))
.child(
h_flex()
.px_2()
@@ -149,7 +149,7 @@ impl Sidebar {
.min_w_0()
.child(
div()
.text_xs()
.text_style(TextStyle::Caption)
.truncate()
.line_height(relative(1.2))
.font_semibold()
@@ -157,7 +157,7 @@ impl Sidebar {
)
.child(
div()
.text_size(px(10.))
.text_style(TextStyle::Caption2)
.text_color(cx.theme().text_muted)
.child(total_members),
),
@@ -230,10 +230,10 @@ impl Sidebar {
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint))
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
GroupSection::Admins => this.admins_open = !this.admins_open,
@@ -262,7 +262,7 @@ impl Sidebar {
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.text_style(TextStyle::Callout)
.font_medium()
.clickable(true)
.when(!hide_avatar, |this| {
@@ -277,8 +277,8 @@ impl Sidebar {
this.suffix(
div()
.truncate()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(SharedString::from(roles.join(", "))),
)
})
+2 -3
View File
@@ -33,7 +33,7 @@ impl Sidebar {
.h(TABBAR_HEIGHT)
.flex_shrink_0()
.items_center()
.gap_2()
.gap(px(theme::SPACE))
.px_2()
.when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
@@ -57,7 +57,6 @@ impl Sidebar {
)
.menu_with_icon("Relays", IconName::Relay, Box::new(Command::ShowRelayList))
.separator()
.menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -121,7 +120,7 @@ impl Sidebar {
let staged = updater.read(cx).staged();
h_flex()
.gap_2()
.gap(px(theme::SPACE))
.when(!up_to_date, |this| {
this.child(
Button::new("update")
+3 -3
View File
@@ -6,7 +6,7 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, PanelHandle};
use ui::notification::Notification;
@@ -192,7 +192,7 @@ impl Sidebar {
.px_4()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.bg(cx.theme().bg)
.child(
Button::new("create-chat")
.label(if self.selected_contacts.len() > 1 {
@@ -208,7 +208,7 @@ impl Sidebar {
.child(
h_flex()
.gap_1()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child("Selected")
.child(
+10 -14
View File
@@ -8,7 +8,7 @@ use gpui::{
};
use nip29::GroupsRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
@@ -61,7 +61,7 @@ impl Sidebar {
.flex_1()
.min_h_0()
.relative()
.gap_2()
.gap(px(theme::SPACE))
.child(
v_flex()
.px_2()
@@ -85,34 +85,30 @@ impl Sidebar {
.px_1()
.min_w_6()
.justify_center()
.text_size(px(10.))
.text_style(TextStyle::Caption2)
.text_color(cx.theme().text_muted)
.text_center()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.rounded(px(control_radius()))
.bg(cx.theme().surface_raised)
.child(SharedString::from(
request_count.to_string(),
)),
)
.when(self.new_requests && !requests_open, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
div().size_1().rounded_full().bg(cx.theme().accent),
)
}),
)
})
.when(requests_open, |this| {
this.bg(cx.theme().ghost_element_active)
})
.when(requests_open, |this| this.bg(cx.theme().element_active))
.on_click(cx.listener(|this, _event, _window, cx| {
this.toggle_requests(cx);
})),
)
.child(
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
.when(contacts_open, |this| {
this.bg(cx.theme().ghost_element_active)
})
.when(contacts_open, |this| this.bg(cx.theme().element_active))
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_contacts(window, cx);
})),
@@ -122,9 +118,9 @@ impl Sidebar {
this.child(
div()
.px_4()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.child(list_title),
)
})
+8 -8
View File
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px,
retain_all,
};
use instant::Duration;
@@ -15,7 +15,7 @@ use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
use ui::dock::{DockArea, Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState};
@@ -276,8 +276,8 @@ impl Render for Sidebar {
.image_cache(retain_all("sidebar"))
.size_full()
.relative()
.gap_2()
.bg(cx.theme().surface_background)
.gap(px(theme::SPACE))
.bg(cx.theme().surface)
.when_some(current_user.as_ref(), |this, current_user| {
this.child(title_bar_drag_handlers(
self.render_user(current_user, cx),
@@ -326,17 +326,17 @@ impl Render for Sidebar {
.px_8()
.child(
h_flex()
.gap_2()
.gap(px(theme::SPACE))
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.bg(cx.theme().bg.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded_full()
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child(Indicator::new().small().color(cx.theme().accent))
.child("Getting messages..."),
),
)
+21 -21
View File
@@ -5,13 +5,13 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity,
Window, div,
Window, div, px,
};
use nip29::Group;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::dialog::DialogButtonProps;
use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
@@ -89,10 +89,10 @@ impl Sidebar {
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint))
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
ListSection::Pins => this.pins_open = !this.pins_open,
@@ -112,9 +112,9 @@ impl Sidebar {
Nav::new(label)
.label(label)
.text_xs()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_faint)
.into_any_element()
}
SidebarRow::Empty(text) => v_flex()
@@ -130,9 +130,9 @@ impl Sidebar {
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().text_placeholder)
.rounded(px(button_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(*text),
)
.into_any_element(),
@@ -149,7 +149,7 @@ impl Sidebar {
let nav = Nav::new(index)
.label(name)
.text_sm()
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
@@ -161,8 +161,8 @@ impl Sidebar {
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
)
.on_click(move |_event, window, cx| {
@@ -204,7 +204,7 @@ impl Sidebar {
Nav::new(index)
.label(person.name())
.text_sm()
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
@@ -230,7 +230,7 @@ impl Sidebar {
Nav::new(index)
.label(person.name())
.text_sm()
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
@@ -265,7 +265,7 @@ impl Sidebar {
let nav = Nav::new(index)
.label(name)
.text_sm()
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
@@ -278,8 +278,8 @@ impl Sidebar {
this.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(suffix),
)
})
@@ -339,7 +339,7 @@ impl Sidebar {
Nav::new(index)
.label(content)
.text_sm()
.text_style(TextStyle::Callout)
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
@@ -352,8 +352,8 @@ impl Sidebar {
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(detail),
)
.on_click(move |_event, window, cx| {
+5 -10
View File
@@ -4,12 +4,12 @@ use std::rc::Rc;
use chat::{ChatRegistry, Room};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
Window, div, uniform_list,
Window, div, px, uniform_list,
};
use nip29::{Group, GroupsRegistry};
use nostr_sdk::prelude::*;
use state::{FIND_LIMIT, NostrRegistry};
use theme::ActiveTheme;
use theme::{ActiveTheme, button_radius};
use ui::button::{Button, ButtonVariants};
use ui::input::Input;
use ui::scroll::Scrollbar;
@@ -201,14 +201,9 @@ impl Sidebar {
.px_1p5()
.gap_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
Input::new(&self.find_input)
.appearance(false)
.small()
.text_xs(),
)
.rounded(px(button_radius()))
.bg(cx.theme().surface_raised)
.child(Input::new(&self.find_input).appearance(false).small())
.child(
Button::new("sidebar-global")
.icon(IconName::Compass)
+1 -1
View File
@@ -34,7 +34,7 @@ fn main() {
// Initialize components
ui::init(cx);
// Initialize theme registry
// Initialize theme
theme::init(cx);
// Load embedded fonts in assets/fonts
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn run() -> Result<(), JsValue> {
// Initialize components
ui::init(cx);
// Initialize theme registry
// Initialize theme
theme::init(cx);
// Initialize settings