diff --git a/.gitignore b/.gitignore index b16239ac..fa04bfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ target/ index.scip *.log node_modules/ +.ai_agents/ diff --git a/AGENTS.md b/AGENTS.md index df7a4af9..c1fde1cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,17 @@ bd sync # Sync with git - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds +## Animation Guardrails (GPUI) + +- Failure mode: open animation flashes (open -> collapse -> open). +- Root cause: using `bounce(...)` easing for reveal/size/opacity. `bounce` is forward-then-reverse; at `delta=1` it returns ~0, so the end frame collapses. +- Rule: only use monotonic easings (fast_invoke/point_to_point/soft_dismiss) for reveal/size/opacity. +- If you want “spring” feel: simulate with a snappier monotonic curve or staged animations; avoid `bounce` unless you want ping-pong. +- Required pattern for open/close motion: + 1. Keep `target_state` (source-of-truth open/closed). + 2. Keep `visual_state` (mounted/visible during exit animation). + 3. Compute `transition_active = target_changed || (visual_state != target_state)`. + 4. Run `with_animation(...)` **only** when `transition_active`. + 5. On close, delay `visual_state=false` until close duration elapses; guard timer with latest `target_state`. +- For dialogs/surfaces: do not unmount on close request if animation enabled; mark as closing, remove after timer. +- Reduced motion / `animate(false)`: bypass delay + animation, apply final state immediately. diff --git a/Cargo.lock b/Cargo.lock index a94bf3f3..9c421ab7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "app_assets" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", @@ -1677,7 +1677,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "dialog_overlay" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", @@ -2184,6 +2184,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "focus_trap" +version = "0.5.0" +dependencies = [ + "anyhow", + "gpui", + "gpui-component", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -2900,11 +2909,12 @@ dependencies = [ [[package]] name = "gpui-component" -version = "0.5.0" +version = "0.5.1" dependencies = [ "aho-corasick", "anyhow", "chrono", + "core-text", "enum-iterator", "fuzzy-matcher", "gpui", @@ -2952,6 +2962,7 @@ dependencies = [ "tree-sitter-json", "tree-sitter-make", "tree-sitter-md", + "tree-sitter-php", "tree-sitter-proto", "tree-sitter-python", "tree-sitter-ruby", @@ -2971,7 +2982,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", @@ -2980,7 +2991,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" -version = "0.5.0" +version = "0.5.1" dependencies = [ "proc-macro2", "quote", @@ -2989,7 +3000,7 @@ dependencies = [ [[package]] name = "gpui-component-story" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "autocorrect", @@ -3175,7 +3186,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello_world" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", @@ -3634,7 +3645,7 @@ dependencies = [ [[package]] name = "input" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", @@ -5982,7 +5993,7 @@ dependencies = [ [[package]] name = "reqwest_client" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "bytes", @@ -8022,6 +8033,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-proto" version = "0.4.0" @@ -8925,7 +8946,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window_title" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gpui", diff --git a/Cargo.toml b/Cargo.toml index 98eb680d..88e1783a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "examples/dialog_overlay", "examples/webview", "examples/system_monitor", + "examples/focus_trap", ] resolver = "2" @@ -21,9 +22,9 @@ publish = false edition = "2024" [workspace.dependencies] -gpui-component = { path = "crates/ui", version = "0.5.0" } -gpui-component-macros = { path = "crates/macros", version = "0.5.0" } -gpui-component-assets = { path = "crates/assets", version = "0.5.0" } +gpui-component = { path = "crates/ui", version = "0.5.1" } +gpui-component-macros = { path = "crates/macros", version = "0.5.1" } +gpui-component-assets = { path = "crates/assets", version = "0.5.1" } story = { path = "crates/story" } gpui = { git = "https://github.com/BumpyClock/zed", rev = "b0ca6e65c7ae3ed7fafd03ce26f2d095bc002e31" } @@ -93,3 +94,6 @@ ttf-parser = { opt-level = 3 } [workspace.metadata.typos] files.extend-exclude = ["**/fixtures/*"] + +[workspace.metadata.typos.default.extend-identifiers] +consts = "consts" \ No newline at end of file diff --git a/LEARNINGS.md b/LEARNINGS.md index fda21259..fc923abc 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -1,13 +1,80 @@ # Learnings ## 2026-01-22 -Context: restore glass styling for popup menus and selects. -What I tried: wrapped menu content with SurfacePreset::flyout and used blur_enabled from GlobalState. -Outcome: popover surfaces now handle opacity, border, elevation, and noise consistently. -Next time: prefer SurfacePreset::flyout for popover containers and keep content styling separate. +Context: popup/select glass surface styling and noise overlay consistency. +What worked: +- Use `SurfacePreset::flyout` on popover containers; keep content styling separate. +- Read noise texture via `ImageSource::Resource(Resource::Embedded(...))`, not `img("...")`. +Outcome: consistent opacity, border, elevation, and noise; no URI-loading failures. +Next time: default to flyout preset + embedded resource images for non-URI assets. -## 2026-01-22 -Context: noise asset failing to load for surface overlays. -What I tried: used `img("NoiseAsset_256.png")` with gpui assets. -Outcome: gpui treated the path as a URI; loading failed until using `ImageSource::Resource(Resource::Embedded(...))`. -Next time: use explicit embedded resource sources for non-URI image assets. +## 2026-02-10 +Context: command palette animation stability and quality. +What worked: +- Clamp/sanitize custom easing output to `[0, 1]` to avoid `delta should always be between 0 and 1`. +- Stage motion: shell instant (`animate(false)`), then list/content reveal. +- Keep container expand and child reveal aligned; avoid conflicting opacity/height phases on translucent surfaces. +Outcome: no animation assert panic and cleaner open sequence without white flash. +Next time: add regression test for overshoot cubic-bezier curves; keep dismiss curves simpler than entrance curves. + +## 2026-02-10 +Context: collapsible/accordion motion felt abrupt. +What worked: +- Keep content mounted during close (delayed commit), animate both open and close. +- Use spring-style easing on open (`bounce(ease_in_out)`) and simpler close easing. +Outcome: smoother sibling reflow and playful open without heavy dismiss. +Next time: avoid conditional unmount when exit/layout animation is required. + +## 2026-02-10 +Context: command palette selection crash (`cannot update ... while it is already being updated`). +What worked: in `cx.subscribe(...)` callbacks, update `this` directly; remove nested `view.update(...)` on the same entity. +Outcome: no re-entrant lease panic; selection updates render normally. +Next time: treat subscribe callbacks as the update scope; avoid nested entity updates. + +## 2026-02-10 +Context: command palette empty state clipping/blankness during expand. +What worked: +- Use dedicated `EMPTY_STATE_HEIGHT` instead of row `item_height`. +- Top-align empty-state content (`pt_6`) instead of vertical centering. +Outcome: icon/text render fully and appear earlier during expansion. +Next time: design empty-state layout independently from row metrics when height is animated. + +## 2026-02-10 +Context: command palette still showed a blank strip under search before results reveal. +What worked: +- Make header row explicitly match `HEADER_HEIGHT` (`h + flex + items_center`) instead of relying on padding-only sizing. +Outcome: collapsed shell height and header layout now match; removed mismatch strip. +Next time: when using fixed layout constants, size the corresponding section explicitly to that constant. + +## 2026-02-10 +Context: blank strip persisted even after palette view-level fixes. +What worked: override dialog default minimum height for command palette (`.min_h(px(0.))`), because `Dialog` enforces `.min_h_24()` by default. +Outcome: command palette collapsed height now respects its own shell height during pre-reveal. +Next time: when embedding compact overlays inside `Dialog`, explicitly set min-height if default dialog floor is too large. + +## 2026-02-10 +Context: sidebar and nested menu sections had snap-open/snap-close behavior. +What worked: +- animate sidebar width from a dedicated expanded width source (`Sidebar::width(...)`) instead of style-only width overrides. +- keep a delayed visual-collapsed state so compact paddings/icon-only layout applies after close width animation. +- keep submenu mounted during close and unmount after animation duration. +Outcome: sidebar collapse/expand and submenu section open/close now animate as continuous layout motion; reduced-motion path remains instant. +Next time: if caret icon rotation should animate, avoid coupling icon transform to `Button::icon(...)` and render a custom caret container with direct animation hook. + +## 2026-02-10 +Context: recurring motion regressions (snap-back, reopen-on-close, no-exit) across dialog/sidebar/accordion/popover. +What worked: +- Introduce shared keyed presence state machine in `animation::keyed_presence` (Entering/Entered/Exiting/Exited). +- Use generation-guarded timers to ignore stale async transitions. +- Drive render + animation from presence phase (`should_render`, `transition_active`, `progress`) instead of ad-hoc booleans. +- For dropdown menus, delay menu entity reset until popover exit completes. +Outcome: motion lifecycle is centralized; avoids per-component timer drift and repeated transition bugs. +Next time: default new animated components to keyed presence first; avoid custom target/visible timer code. + +## 2026-02-10 +Context: open animations flashed (open -> collapse -> open) on Accordion/Sidebar/Dialog. +What worked: +- Remove `bounce(...)` easing from reveal/size/opacity transitions. +- Use monotonic easings (fast_invoke/point_to_point) for open/close. +Outcome: no end-frame collapse; open state stays stable. +Next time: avoid `bounce` for reveal/size/opacity; it is forward-then-reverse. diff --git a/README.md b/README.md index d4006586..d2dfdf7b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ UI components for building fantastic desktop applications using [GPUI](https://g ```toml gpui = "0.2.2" -gpui-component = "0.5.0" +gpui-component = "0.5.1" ``` ### Basic Example diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 8cf437ce..b1eb7df1 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/longbridge/gpui-component" readme = "README.md" edition.workspace = true publish = true -version = "0.5.0" +version = "0.5.1" [lib] doctest = false diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 90d12cd1..3deae6dc 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gpui-component-macros" description = "Macros for GPUI Component." -version = "0.5.0" +version = "0.5.1" license = "Apache-2.0" publish = true edition.workspace = true @@ -18,4 +18,4 @@ quote = "1.0" syn = "2.0" [package.metadata.cargo-machete] -ignored = ["proc-macro2"] +ignored = ["proc-macro2", "core-text"] diff --git a/crates/reqwest_client/Cargo.toml b/crates/reqwest_client/Cargo.toml index 76db6932..8d536419 100644 --- a/crates/reqwest_client/Cargo.toml +++ b/crates/reqwest_client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reqwest_client" -version = "0.5.0" +version = "0.5.1" license = "Apache-2.0" publish = false edition.workspace = true diff --git a/crates/story/Cargo.toml b/crates/story/Cargo.toml index a3208c16..27cc9c33 100644 --- a/crates/story/Cargo.toml +++ b/crates/story/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui-component-story" -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index de88f941..a6ea4bca 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -74,6 +74,7 @@ pub struct Example { line_number: bool, indent_guides: bool, soft_wrap: bool, + show_whitespaces: bool, lsp_store: ExampleLspStore, _subscriptions: Vec, _lint_task: Task<()>, @@ -728,6 +729,7 @@ impl Example { line_number: true, indent_guides: true, soft_wrap: false, + show_whitespaces: false, lsp_store, _subscriptions, _lint_task: Task::ready(()), @@ -987,6 +989,25 @@ impl Example { })) } + fn render_show_whitespaces_button( + &self, + _: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + Button::new("show-whitespace") + .ghost() + .xsmall() + .when(self.show_whitespaces, |this| this.icon(IconName::Check)) + .label("Show Whitespaces") + .on_click(cx.listener(|this, _, window, cx| { + this.show_whitespaces = !this.show_whitespaces; + this.editor.update(cx, |state, cx| { + state.set_show_whitespaces(this.show_whitespaces, window, cx); + }); + cx.notify(); + })) + } + fn render_indent_guides_button( &self, _: &mut Window, @@ -1079,6 +1100,7 @@ impl Render for Example { .gap_3() .child(self.render_line_number_button(window, cx)) .child(self.render_soft_wrap_button(window, cx)) + .child(self.render_show_whitespaces_button(window, cx)) .child(self.render_indent_guides_button(window, cx)), ) .child(self.render_go_to_line_button(window, cx)), diff --git a/crates/story/examples/fixtures/test.php b/crates/story/examples/fixtures/test.php new file mode 100644 index 00000000..a072e0c8 --- /dev/null +++ b/crates/story/examples/fixtures/test.php @@ -0,0 +1,66 @@ + "Hello, {$n}!", $names); + } + + public function report(): string + { + return "HelloWorld({$this->name})"; + } +} + +function is_valid_email(string $email): bool +{ + return preg_match('/^[\w+\-.]+@[\w\-]+\.[a-z]{2,}$/i', $email) === 1; +} + +$name = $_GET['name'] ?? 'PHP'; +$email = $_POST['email'] ?? 'user@example.com'; + +$greeter = new HelloWorld((string) $name); +$lines = $greeter->greet('Alice', 'Bob', 'Charlie'); + +?> + + + + + <?php echo htmlspecialchars($greeter->report(), ENT_QUOTES, 'UTF-8'); ?> + + +

report(), ENT_QUOTES, 'UTF-8'); ?>

+ + + +
+ + +
+ + +

Status:

+ + + diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 7b912af5..723d4c73 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -478,6 +478,7 @@ impl StoryState { "SidebarStory" => story!(SidebarStory), "FormStory" => story!(FormStory), "NotificationStory" => story!(NotificationStory), + "ThemeColorsStory" => story!(ThemeColorsStory), _ => { unreachable!("Invalid story klass: {}", self.story_klass) } diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index 1569d8df..5df4348c 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -88,6 +88,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/color_picker_story.rs b/crates/story/src/stories/color_picker_story.rs index cf96b35a..e2b3b84d 100644 --- a/crates/story/src/stories/color_picker_story.rs +++ b/crates/story/src/stories/color_picker_story.rs @@ -1,10 +1,11 @@ use gpui::{ - prelude::FluentBuilder as _, App, AppContext, Context, Entity, Focusable, Hsla, IntoElement, - ParentElement as _, Render, Styled as _, Subscription, Window, + App, AppContext, Context, Entity, Focusable, Hsla, IntoElement, ParentElement as _, Render, + Styled as _, Subscription, Window, div, prelude::FluentBuilder as _, }; use gpui_component::{ + ActiveTheme as _, Colorize, Sizable, color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState}, - v_flex, ActiveTheme as _, Colorize, Sizable, + v_flex, }; use crate::section; @@ -41,7 +42,6 @@ impl ColorPickerStory { let _subscriptions = vec![cx.subscribe(&color, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { this.selected_color = *color; - println!("Color changed to: {:?}", color); } })]; @@ -66,7 +66,7 @@ impl Render for ColorPickerStory { .max_w_md() .child(ColorPicker::new(&self.color).small()) .when_some(self.selected_color, |this, color| { - this.child(color.to_hex()) + this.child(div().w_24().child(color.to_hex())) }), ) } diff --git a/crates/story/src/stories/command_palette_story.rs b/crates/story/src/stories/command_palette_story.rs index 593d4a04..36f37097 100644 --- a/crates/story/src/stories/command_palette_story.rs +++ b/crates/story/src/stories/command_palette_story.rs @@ -103,13 +103,10 @@ impl CommandPaletteStory { let provider = Arc::new(StaticProvider::new(items)); let handle = CommandPalette::open(window, cx, provider); - let view = cx.entity(); - cx.subscribe(&handle.state(), move |_, _state, event, cx| { + cx.subscribe(&handle.state(), move |this, _state, event, cx| { if let CommandPaletteEvent::Selected { item } = event { - view.update(cx, |view, cx| { - view.last_selected = Some(item.title.clone()); - cx.notify(); - }); + this.last_selected = Some(item.title.clone()); + cx.notify(); } }) .detach(); @@ -119,13 +116,10 @@ impl CommandPaletteStory { let provider = Arc::new(AsyncDemoProvider::new()); let handle = CommandPalette::open(window, cx, provider); - let view = cx.entity(); - cx.subscribe(&handle.state(), move |_, _state, event, cx| { + cx.subscribe(&handle.state(), move |this, _state, event, cx| { if let CommandPaletteEvent::Selected { item } = event { - view.update(cx, |view, cx| { - view.last_selected = Some(item.title.clone()); - cx.notify(); - }); + this.last_selected = Some(item.title.clone()); + cx.notify(); } }) .detach(); @@ -158,13 +152,10 @@ impl CommandPaletteStory { let handle = CommandPalette::open_with_config(window, cx, provider, custom_config); - let view = cx.entity(); - cx.subscribe(&handle.state(), move |_, _state, event, cx| { + cx.subscribe(&handle.state(), move |this, _state, event, cx| { if let CommandPaletteEvent::Selected { item } = event { - view.update(cx, |view, cx| { - view.last_selected = Some(item.title.clone()); - cx.notify(); - }); + this.last_selected = Some(item.title.clone()); + cx.notify(); } }) .detach(); diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 4547bf03..507a783b 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -98,6 +98,7 @@ impl InputStory { InputState::new(window, cx) .code_editor("json") .multi_line(false) + .show_whitespaces(true) .default_value(CODE_EXAMPLE) }); diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index 7888bcee..4912949e 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -302,6 +302,31 @@ impl Render for MenuStory { .menu("Item 1", Box::new(Info(1))) } }), + ) + .child( + div() + .id("other1") + .flex() + .w_full() + .p_4() + .items_center() + .justify_center() + .min_h_20() + .rounded_lg() + .border_2() + .border_dashed() + .border_color(cx.theme().border) + .child("ContextMenu area 1") + .context_menu({ + move |this, _, _| { + this.link( + "About", + "https://github.com/longbridge/gpui-component", + ) + .separator() + .menu("Item 1", Box::new(Info(1))) + } + }), ), ) .child( diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index c99b1c3e..f6709049 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -57,6 +57,7 @@ mod tooltip_story; mod tree_story; mod virtual_list_story; mod welcome_story; +mod theme_story; pub use accordion_story::AccordionStory; pub use alert_story::AlertStory; @@ -113,6 +114,7 @@ pub use toggle_story::ToggleStory; pub use tooltip_story::TooltipStory; pub use tree_story::TreeStory; pub use virtual_list_story::VirtualListStory; +pub use theme_story::ThemeColorsStory; pub use welcome_story::WelcomeStory; diff --git a/crates/story/src/stories/sidebar_story.rs b/crates/story/src/stories/sidebar_story.rs index 76abe1bd..bdf50453 100644 --- a/crates/story/src/stories/sidebar_story.rs +++ b/crates/story/src/stories/sidebar_story.rs @@ -290,7 +290,7 @@ impl Render for SidebarStory { Sidebar::new("sidebar-story") .side(self.side) .collapsed(self.collapsed) - .w(px(220.)) + .width(px(220.)) .gap_0() .header( SidebarHeader::new() diff --git a/crates/story/src/stories/table_story.rs b/crates/story/src/stories/table_story.rs index f1e5c03a..765d0cf1 100644 --- a/crates/story/src/stories/table_story.rs +++ b/crates/story/src/stories/table_story.rs @@ -210,7 +210,8 @@ impl StockTableDelegate { .fixed(ColumnFixed::Left) .resizable(true) .min_width(40.) - .max_width(100.), + .max_width(100.) + .text_center(), Column::new("market", "Market") .width(60.) .fixed(ColumnFixed::Left) @@ -373,6 +374,9 @@ impl TableDelegate for StockTableDelegate { .when(col_ix >= 3 && col_ix <= 10, |this| { this.table_cell_size(self.size) }) + .when(col.align == TextAlign::Center, |this| { + this.h_flex().w_full().justify_center() + }) .when(col.align == TextAlign::Right, |this| { this.h_flex().w_full().justify_end() }) @@ -434,7 +438,10 @@ impl TableDelegate for StockTableDelegate { let col = self.columns.get(col_ix).unwrap(); match col.key.as_ref() { - "id" => stock.id.to_string().into_any_element(), + "id" => div() + .child(stock.id.to_string()) + .when(col.align == TextAlign::Center, |this| this.text_center()) + .into_any_element(), "market" => div() .map(|this| { if stock.counter.market == "US" { diff --git a/crates/story/src/stories/theme_story/checkerboard.rs b/crates/story/src/stories/theme_story/checkerboard.rs new file mode 100644 index 00000000..d25b4b8d --- /dev/null +++ b/crates/story/src/stories/theme_story/checkerboard.rs @@ -0,0 +1,76 @@ +use gpui::*; + +#[derive(IntoElement)] +pub struct Checkerboard { + children: Vec, + is_dark: bool, +} + +impl Checkerboard { + pub fn new(is_dark: bool) -> Self { + Self { + children: Vec::new(), + is_dark, + } + } +} + +impl ParentElement for Checkerboard { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Checkerboard { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let square_size = px(12.); + // Use a subtle difference for the checkerboard + let (c1, c2) = if self.is_dark { + // Dark mode: dark grey and slightly lighter grey + (hsla(0., 0., 0.1, 1.), hsla(0., 0., 0.13, 1.)) + } else { + // Light mode: white and light grey + (hsla(0., 0., 1.0, 1.), hsla(0., 0., 0.95, 1.)) + }; + + div() + .bg(c1) + .rounded_lg() + .overflow_hidden() + .size_full() + .child( + gpui::canvas( + move |_, _, _| (), + move |bounds, _, window, _| { + let size = square_size; + let rows = (bounds.size.height / size).ceil() as i32; + let cols = (bounds.size.width / size).ceil() as i32; + + for row in 0..rows { + for col in 0..cols { + if (row + col) % 2 == 0 { + let origin = bounds.origin + + gpui::point(size * (col as f32), size * (row as f32)); + + window.paint_quad(gpui::PaintQuad { + bounds: gpui::Bounds { + origin, + size: gpui::size(size, size), + }, + corner_radii: gpui::Corners::default(), + background: c2.into(), + border_widths: gpui::Edges::default(), + border_color: gpui::transparent_black(), + border_style: gpui::BorderStyle::default(), + }); + } + } + } + }, + ) + .absolute() + .size_full(), + ) + .children(self.children) + } +} diff --git a/crates/story/src/stories/theme_story/color_theme_story.rs b/crates/story/src/stories/theme_story/color_theme_story.rs new file mode 100644 index 00000000..a1954d85 --- /dev/null +++ b/crates/story/src/stories/theme_story/color_theme_story.rs @@ -0,0 +1,790 @@ +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, IndexPath, StyledExt as _, ThemeColor, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + menu::PopupMenuItem, + scroll::ScrollableElement, + select::{Select, SelectEvent, SelectItem, SelectState}, + sidebar::{Sidebar, SidebarMenu, SidebarMenuItem}, + switch::Switch, + v_flex, +}; + +use crate::stories::theme_story::checkerboard::Checkerboard; + +use std::collections::BTreeMap; +use std::rc::Rc; + +#[derive(Clone)] +struct ColorEntry { + name: String, + color: Hsla, + hex: String, + is_explicit: bool, +} + +#[derive(Clone)] +struct ColorCategory { + name: String, + entries: Vec, +} + +#[derive(Clone, PartialEq)] +struct ThemeItem { + name: SharedString, + is_active: bool, +} + +impl ThemeItem { + fn new(name: impl Into, is_active: bool) -> Self { + Self { + name: name.into(), + is_active, + } + } +} + +impl SelectItem for ThemeItem { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.name.clone() + } + + fn value(&self) -> &Self::Value { + &self.name + } + + fn render(&self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .size(rems(1.0)) + .flex_shrink_0() + .when(self.is_active, |this| { + this.child( + Icon::new(IconName::Check) + .size(rems(1.0)) + .text_color(cx.theme().primary), + ) + }), + ) + .child(self.name.clone()) + } +} + +pub struct ThemeColorsStory { + select_state: Entity>>, + selected_theme_name: SharedString, + show_all_colors: bool, + sidebar_render_key: usize, + force_open_state: Option, + filter_by_value: Option, + filter_input: Entity, + all_categories: Vec, + categories: Vec, +} + +impl crate::stories::Story for ThemeColorsStory { + fn title() -> &'static str { + "Theme Colors" + } + + fn description() -> &'static str { + "A color theme viewer to explore colors organized by categories." + // Themes are loaded by applying user-defined color overrides to a default base theme, + // with inherited colors marked by an indicator dot. + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl ThemeColorsStory { + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + fn new(window: &mut Window, cx: &mut Context) -> Self { + use gpui_component::ThemeRegistry; + + let registry = ThemeRegistry::global(cx); + let mut themes = registry.sorted_themes(); + + themes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + let active_theme_name = cx.theme().theme_name().clone(); + let items: Vec = themes + .iter() + .map(|theme| ThemeItem::new(theme.name.clone(), theme.name == active_theme_name)) + .collect(); + + let current_theme = active_theme_name; + let selected_index = items.iter().position(|item| item.name == current_theme); + let selected_path = selected_index.map(|idx| IndexPath::default().row(idx)); + let select_state = cx.new(|cx| SelectState::new(items, selected_path, window, cx)); + + let mut this = Self { + select_state: select_state.clone(), + selected_theme_name: current_theme, + show_all_colors: false, + sidebar_render_key: 0, + force_open_state: None, + filter_by_value: None, + filter_input: cx.new(|cx| InputState::new(window, cx).placeholder("Search...")), + all_categories: Vec::new(), + categories: Vec::new(), + }; + + cx.subscribe( + &select_state, + |this, _, event: &SelectEvent>, cx| { + let SelectEvent::Confirm(theme_name) = event; + if let Some(theme_name) = theme_name { + this.selected_theme_name = theme_name.clone(); + this.filter_by_value = None; + this.all_categories.clear(); + this.compute_categories(cx); + cx.notify(); + } + }, + ) + .detach(); + + cx.subscribe(&this.filter_input, |this, _, event, cx| { + if let InputEvent::Change = event { + this.compute_categories(cx); + cx.notify(); + } + }) + .detach(); + + this.compute_categories(cx); + this + } + + fn get_theme_colors(&self, cx: &Context) -> ThemeColor { + use gpui_component::{Theme as UITheme, ThemeRegistry}; + + if let Some(theme_config) = ThemeRegistry::global(cx) + .themes() + .get(&self.selected_theme_name) + .cloned() + { + let mut temp_theme = if theme_config.mode.is_dark() { + UITheme::from(ThemeColor::dark().as_ref()) + } else { + UITheme::from(ThemeColor::light().as_ref()) + }; + + // Apply the config to get proper colors using the public API + temp_theme.apply_config(&theme_config); + temp_theme.colors + } else { + // Fallback to current theme if selected theme not found + **cx.theme() + } + } + + fn get_isolated_theme(&self, cx: &App) -> (ThemeColor, bool) { + use gpui_component::{Theme as UITheme, ThemeRegistry}; + + let registry = ThemeRegistry::global(cx); + + // Look up the selected theme configuration + let selected_theme_config = registry.themes().get(&self.selected_theme_name); + + let is_dark = if let Some(config) = selected_theme_config { + config.mode.is_dark() + } else { + // Fallback to system appearance if selected theme lookup fails + let appearance = cx.window_appearance(); + appearance == WindowAppearance::Dark || appearance == WindowAppearance::VibrantDark + }; + + let theme_config = if is_dark { + registry.default_dark_theme() + } else { + registry.default_light_theme() + }; + + let mut temp_theme = if theme_config.mode.is_dark() { + UITheme::from(ThemeColor::dark().as_ref()) + } else { + UITheme::from(ThemeColor::light().as_ref()) + }; + + temp_theme.apply_config(theme_config); + (temp_theme.colors, is_dark) + } + + fn compute_categories(&mut self, cx: &Context) { + use gpui_component::ThemeRegistry; + + if self.all_categories.is_empty() { + let theme = self.get_theme_colors(cx); + let registry = ThemeRegistry::global(cx); + let theme_config = registry.themes().get(&self.selected_theme_name).cloned(); + + self.all_categories = format_colors(&theme, theme_config.as_ref().map(|c| &c.colors)); + } + + let mut categories = self.all_categories.clone(); + + if let Some(filter_value) = self.filter_by_value { + categories = filter_categories(categories, |entry| { + colors_equal_u8(entry.color, filter_value) + }); + } else if !self.show_all_colors { + categories = filter_categories(categories, |entry| entry.is_explicit); + } + + let query = self.filter_input.read(cx).value().trim().to_lowercase(); + if !query.is_empty() { + let normalized_query = query.strip_prefix('#').unwrap_or(&query); + categories = categories + .into_iter() + .filter_map( + |ColorCategory { + name: category, + entries: colors, + }| { + let category_matches = category.to_lowercase().contains(&query); + let filtered_colors: Vec<_> = colors + .into_iter() + .filter(|entry| { + if category_matches || entry.name.to_lowercase().contains(&query) { + return true; + } + + // Hex matching + entry.hex.starts_with(normalized_query) + }) + .collect(); + + if filtered_colors.is_empty() { + None + } else { + Some(ColorCategory { + name: category, + entries: filtered_colors, + }) + } + }, + ) + .collect(); + } + + self.categories = categories; + } + + fn render_color_swatch( + name: String, + color: Hsla, + hex: String, + is_explicit: bool, + isolated_theme: &ThemeColor, + ) -> impl IntoElement { + use gpui_component::{WindowExt as _, clipboard::Clipboard}; + + let rgb_str = format!("#{}", hex); + let swatch_group = format!("swatch-{}", name); + + h_flex() + .group(swatch_group.clone()) + .gap_3() + .items_center() + .child( + div() + .size_16() + .rounded_md() + .bg(color) + .border_1() + .border_color(isolated_theme.border) + .flex_shrink_0(), + ) + .child( + v_flex() + .gap_1() + .flex_1() + .child( + h_flex() + .gap_2() + .items_center() + .when(!is_explicit, |this| { + this.child( + div() + .size_1p5() + .rounded_full() + .bg(isolated_theme.foreground) + .flex_shrink_0(), + ) + }) + .child( + div() + .text_sm() + .font_medium() + .when(!is_explicit, |this: Div| { + this.text_color(isolated_theme.muted_foreground) + }) + .when(is_explicit, |this| { + this.text_color(isolated_theme.foreground) + }) + .child(name.clone()), + ), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + div() + .text_sm() + .text_color(isolated_theme.muted_foreground) + .child(rgb_str.clone()), + ) + .child( + div() + .invisible() + .group_hover(swatch_group, |this| this.visible()) + .child( + Clipboard::new(format!("copy-{}", name)) + .value(rgb_str) + .on_copied(move |value, window, cx| { + window.push_notification( + format!("Copied {} to clipboard", value), + cx, + ) + }), + ), + ), + ), + ) + } + + fn render_left_panel(&self, _: &mut Window, cx: &mut Context) -> Sidebar { + let categories = &self.categories; + let is_filtering = self.filter_by_value.is_some(); + let entity_ref = cx.entity(); + + let expand_all = Rc::new(cx.listener( + |this: &mut Self, _: &ClickEvent, _: &mut Window, cx: &mut Context| { + this.sidebar_render_key += 1; + this.force_open_state = Some(true); + cx.notify(); + }, + )); + + let collapse_all = Rc::new(cx.listener( + |this: &mut Self, _: &ClickEvent, _: &mut Window, cx: &mut Context| { + this.sidebar_render_key += 1; + this.force_open_state = Some(false); + cx.notify(); + }, + )); + + Sidebar::new(format!("color-theme-sidebar-{}", self.sidebar_render_key)) + .w(px(300.)) + .border_0() + .header(Input::new(&self.filter_input).prefix(IconName::Search)) + .child( + SidebarMenu::new().children(categories.iter().enumerate().map( + |( + idx, + ColorCategory { + name: category_name, + entries: colors, + }, + )| { + let is_open = self.force_open_state.unwrap_or_else(|| idx == 0); + + SidebarMenuItem::new(category_name.clone()) + .default_open(is_open) + .click_to_open(true) + .context_menu({ + let expand_all = expand_all.clone(); + let collapse_all = collapse_all.clone(); + move |menu, _, _| { + menu.item(PopupMenuItem::new("Expand All").on_click({ + let expand_all = expand_all.clone(); + move |ev, window, cx| (expand_all)(ev, window, cx) + })) + .item( + PopupMenuItem::new("Collapse All").on_click({ + let collapse_all = collapse_all.clone(); + move |ev, window, cx| (collapse_all)(ev, window, cx) + }), + ) + } + }) + .children(colors.iter().map(|entry| { + let color_value = entry.color; + let is_explicit = entry.is_explicit; + let color_view = entity_ref.clone(); + SidebarMenuItem::new(entry.name.clone()) + .suffix(move |_, cx| { + h_flex() + .gap_2() + .items_center() + .when(!is_explicit, |this| { + this.child( + div() + .size_1p5() + .rounded_full() + .bg(cx.theme().foreground), + ) + }) + .child( + div() + .size_4() + .rounded_sm() + .bg(color_value) + .border_1() + .border_color(cx.theme().border) + .flex_shrink_0(), + ) + }) + .context_menu(move |menu, _, _| { + let menu_view = color_view.clone(); + if is_filtering { + menu.item( + PopupMenuItem::new("Show All Values").on_click( + move |_, _, cx| { + menu_view.update(cx, |this, cx| { + this.filter_by_value = None; + this.compute_categories(cx); + cx.notify(); + }) + }, + ), + ) + } else { + menu.item( + PopupMenuItem::new("Filter By Value").on_click( + move |_, _, cx| { + menu_view.update(cx, |this, cx| { + this.filter_by_value = + Some(color_value); + this.compute_categories(cx); + cx.notify(); + }) + }, + ), + ) + } + }) + })) + }, + )), + ) + } + + fn render_right_panel(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (isolated_theme, is_dark) = self.get_isolated_theme(cx); + + let categories = self.categories.clone(); + let categories_count = categories.len(); + let list_state = window + .use_keyed_state("color-theme-right-panel-list-state", cx, |_, _| { + ListState::new(19, ListAlignment::Top, px(1000.)) + }) + .read(cx) + .clone(); + if list_state.item_count() != categories_count { + list_state.reset(categories_count); + } + + div() + .border_1() + .border_color(isolated_theme.border) + .rounded_lg() + .size_full() + .overflow_hidden() + .child( + Checkerboard::new(is_dark).child( + v_flex() + .size_full() + .overflow_hidden() + .rounded_lg() + .px_4() + .child( + list(list_state.clone(), { + move |ix, _, _| { + let ColorCategory { + name: category_name, + entries: colors, + } = categories[ix].clone(); + let is_last = categories_count > 0 + && ix == categories_count.saturating_sub(1); + + v_flex() + .w_full() + .gap_3() + .pt_4() + .when(is_last, |this| this.pb_4()) + .child( + div() + .text_base() + .font_semibold() + .pb_2() + .border_b_1() + .border_color(isolated_theme.border) + .text_color(isolated_theme.foreground) + .child(category_name), + ) + .child(div().flex().flex_wrap().gap_4().children( + colors.iter().map(|entry| { + div().w(px(220.)).child(Self::render_color_swatch( + entry.name.to_string(), + entry.color, + entry.hex.clone(), + entry.is_explicit, + &isolated_theme, + )) + }), + )) + .into_any_element() + } + }) + .size_full(), + ) + .vertical_scrollbar(&list_state), + ), + ) + } +} + +fn format_colors( + theme: &ThemeColor, + config: Option<&gpui_component::theme::ThemeConfigColors>, +) -> Vec { + let json_theme = serde_json::to_value(theme).unwrap_or(serde_json::Value::Null); + let mut categories: BTreeMap> = BTreeMap::new(); + + // Create a set of keys present in the config (if available) + let config_keys: Option> = config.map(|c| { + let json_config = serde_json::to_value(c).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(map) = json_config { + map.into_iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, _)| k) + .collect() + } else { + std::collections::HashSet::new() + } + }); + + if let serde_json::Value::Object(map) = json_theme { + for (key, value) in map { + if let Ok(color) = serde_json::from_value::(value) { + let parsed = super::mapper::parse_theme_key(&key); + let category = parsed.category; + let name = parsed.name; + + // Check if this key is explicit in the user config + let is_explicit = config_keys + .as_ref() + .map_or(false, |k| k.contains(&parsed.canonical_key)); + + categories.entry(category).or_default().push(ColorEntry { + name, + color, + hex: hsla_to_hex(color), + is_explicit, + }); + } + } + } + + for colors in categories.values_mut() { + colors.sort_by(|a, b| a.name.cmp(&b.name)); + } + + let mut categories_vec: Vec<_> = categories + .into_iter() + .map(|(name, entries)| ColorCategory { name, entries }) + .collect(); + + // Custom sort: Global first, then Primary, then others + categories_vec.sort_by(|a, b| { + let priority_order = [ + "Global", + "Primary", + "Secondary", + "Accent", + "Base", + "Background", + "Foreground", + "Structure", + ]; + + let a_priority = priority_order.iter().position(|&x| x == a.name.as_str()); + let b_priority = priority_order.iter().position(|&x| x == b.name.as_str()); + + match (a_priority, b_priority) { + (Some(a_pos), Some(b_pos)) => a_pos.cmp(&b_pos), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.name.cmp(&b.name), + } + }); + + categories_vec +} + +fn hsla_to_hex(color: Hsla) -> String { + let rgb = color.to_rgb(); + if color.a < 1.0 { + format!( + "{:02x}{:02x}{:02x}{:02x}", + (rgb.r * 255.0) as u8, + (rgb.g * 255.0) as u8, + (rgb.b * 255.0) as u8, + (color.a * 255.0) as u8 + ) + } else { + format!( + "{:02x}{:02x}{:02x}", + (rgb.r * 255.0) as u8, + (rgb.g * 255.0) as u8, + (rgb.b * 255.0) as u8 + ) + } +} + +/// Compares two HSLA colors for equality at 8-bit precision. +fn colors_equal_u8(c1: Hsla, c2: Hsla) -> bool { + let rgb1 = c1.to_rgb(); + let rgb2 = c2.to_rgb(); + let eq = |a: f32, b: f32| (a * 255.0).round() as u8 == (b * 255.0).round() as u8; + eq(rgb1.r, rgb2.r) && eq(rgb1.g, rgb2.g) && eq(rgb1.b, rgb2.b) && eq(c1.a, c2.a) +} + +/// Filters categories by a predicate on color entries, removing empty categories. +fn filter_categories( + categories: Vec, + predicate: impl Fn(&ColorEntry) -> bool, +) -> Vec { + categories + .into_iter() + .filter_map( + |ColorCategory { + name: category, + entries: colors, + }| { + let filtered: Vec<_> = colors.into_iter().filter(|e| predicate(e)).collect(); + if filtered.is_empty() { + None + } else { + Some(ColorCategory { + name: category, + entries: filtered, + }) + } + }, + ) + .collect() +} + +impl Render for ThemeColorsStory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_4() + .size_full() + .overflow_hidden() + .child( + // Theme selector at the top + h_flex() + .gap_x_3() + .child(div().w(px(300.)).child(Select::new(&self.select_state))) + .child( + Button::new("set_theme") + .primary() + .label("Set Theme") + .on_click(cx.listener(|this, _, window, cx| { + use gpui_component::{Theme, ThemeRegistry}; + + let registry = ThemeRegistry::global(cx); + if let Some(theme_config) = + registry.themes().get(&this.selected_theme_name).cloned() + { + let mode = theme_config.mode; + let theme = Theme::global_mut(cx); + if mode.is_dark() { + theme.dark_theme = theme_config; + } else { + theme.light_theme = theme_config; + } + Theme::change(mode, None, cx); + cx.refresh_windows(); + + // Refresh the select items to update the active checkmark + let active_theme_name = cx.theme().theme_name().clone(); + let themes = ThemeRegistry::global(cx).sorted_themes(); + + // Re-create items with new active state + let mut items: Vec = themes + .iter() + .map(|theme| { + ThemeItem::new( + theme.name.clone(), + // Note: we need to handle case sensitivity if names differ, + // but usually accurate. + theme.name == active_theme_name, + ) + }) + .collect(); + + // Sort again to be safe/consistent + items.sort_by(|a, b| { + a.name.to_lowercase().cmp(&b.name.to_lowercase()) + }); + + // Update the select state + this.select_state.update(cx, |state, cx| { + state.set_items(items, window, cx); + }); + } + })), + ) + .child( + Switch::new("show_all_colors") + .checked(self.show_all_colors) + .label("Show Inherited Colors") + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.show_all_colors = *checked; + this.compute_categories(cx); + cx.notify(); + })), + ) + .child( + Switch::new("expand_collapse_switch") + .checked(self.force_open_state == Some(true)) + .label(if self.force_open_state == Some(true) { + "Collapse All" + } else { + "Expand All" + }) + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.sidebar_render_key += 1; + this.force_open_state = Some(*checked); + cx.notify(); + })), + ), + ) + .child( + h_flex() + .flex_1() + .items_start() + .gap_4() + .child(self.render_left_panel(window, cx)) + .child(self.render_right_panel(window, cx)), + ) + } +} diff --git a/crates/story/src/stories/theme_story/mapper.rs b/crates/story/src/stories/theme_story/mapper.rs new file mode 100644 index 00000000..1fce7a1a --- /dev/null +++ b/crates/story/src/stories/theme_story/mapper.rs @@ -0,0 +1,212 @@ +/// A compatibility bridge for mapping theme color keys. +/// +/// This module provides a way to translate between the legacy snake_case keys +/// used in `ThemeColor` and the logical categories/names expected by the +/// Color Theme Viewer. +/// +/// ### How to eliminate this mapper +/// +/// If the project decides to unify the theme schema project-wide (using dot-notation), +/// follow these steps to remove this "temporary bridge": +/// +/// 1. **Update `ThemeColor`**: +/// In `crates/ui/src/theme/theme_color.rs`, add `#[serde(rename = "...")]` attributes +/// to all fields of the `ThemeColor` struct to match their canonical dot-notation +/// names (e.g., `accent_foreground` -> `#[serde(rename = "accent.foreground")]`). +/// +/// 2. **Update JSON Themes**: +/// Ensure `crates/ui/src/theme/default-theme.json` and any files in `themes/` +/// strictly use the dot-notation keys. +/// +/// 3. **Refactor the Viewer**: +/// In `crates/story/src/stories/theme_story/color_theme_story.rs`, remove the call +/// to `super::mapper::parse_theme_key` and replace it with a simple split on the '.' character. +/// +/// 4. **Delete this file**: +/// Remove `mapper.rs` and its module declaration in `mod.rs`. +/// +/// Represents a parsed theme key with a category, a display name, and a canonical dot-notation key. +pub struct ParsedKey { + pub category: String, + pub name: String, + pub canonical_key: String, +} + +/// Parses a theme key (either snake_case or dot-notation) into a logical category and name. +pub fn parse_theme_key(key: &str) -> ParsedKey { + // 1. Check for dot-notation (e.g., "accent.background") + if key.contains('.') { + let parts: Vec<&str> = key.splitn(2, '.').collect(); + return ParsedKey { + category: to_title_case_full(parts[0]), + name: to_title_case_full(parts[1]), + canonical_key: key.to_string(), + }; + } + + // 2. Handle legacy snake_case remapping (e.g., "accent_foreground" -> "Accent" / "Foreground") + // This list attempts to reconstruct the hierarchy from the flat ThemeColor struct. + let (category, name, canonical) = match key { + // Accent + "accent" => ("Accent", "Background", "accent.background"), + "accent_foreground" => ("Accent", "Foreground", "accent.foreground"), + + // Primary + "primary" => ("Primary", "Background", "primary.background"), + "primary_active" => ("Primary", "Active Background", "primary.active.background"), + "primary_foreground" => ("Primary", "Foreground", "primary.foreground"), + "primary_hover" => ("Primary", "Hover Background", "primary.hover.background"), + + // Secondary + "secondary" => ("Secondary", "Background", "secondary.background"), + "secondary_active" => ( + "Secondary", + "Active Background", + "secondary.active.background", + ), + "secondary_foreground" => ("Secondary", "Foreground", "secondary.foreground"), + "secondary_hover" => ( + "Secondary", + "Hover Background", + "secondary.hover.background", + ), + + // Sidebar + "sidebar" => ("Sidebar", "Background", "sidebar.background"), + "sidebar_accent" => ("Sidebar", "Accent Background", "sidebar.accent.background"), + "sidebar_accent_foreground" => { + ("Sidebar", "Accent Foreground", "sidebar.accent.foreground") + } + "sidebar_border" => ("Sidebar", "Border", "sidebar.border"), + "sidebar_foreground" => ("Sidebar", "Foreground", "sidebar.foreground"), + "sidebar_primary" => ( + "Sidebar", + "Primary Background", + "sidebar.primary.background", + ), + "sidebar_primary_foreground" => ( + "Sidebar", + "Primary Foreground", + "sidebar.primary.foreground", + ), + + // List + "list" => ("List", "Background", "list.background"), + "list_active" => ("List", "Active Background", "list.active.background"), + "list_active_border" => ("List", "Active Border", "list.active.border"), + "list_even" => ("List", "Even Background", "list.even.background"), + "list_head" => ("List", "Head Background", "list.head.background"), + "list_hover" => ("List", "Hover Background", "list.hover.background"), + + // Table + "table" => ("Table", "Background", "table.background"), + "table_active" => ("Table", "Active Background", "table.active.background"), + "table_active_border" => ("Table", "Active Border", "table.active.border"), + "table_even" => ("Table", "Even Background", "table.even.background"), + "table_head" => ("Table", "Head Background", "table.head.background"), + "table_head_foreground" => ("Table", "Head Foreground", "table.head.foreground"), + "table_hover" => ("Table", "Hover Background", "table.hover.background"), + "table_row_border" => ("Table", "Row Border", "table.row.border"), + + // Tabs + "tab" => ("Tab", "Background", "tab.background"), + "tab_active" => ("Tab", "Active Background", "tab.active.background"), + "tab_active_foreground" => ("Tab", "Active Foreground", "tab.active.foreground"), + "tab_bar" => ("Tab Bar", "Background", "tab_bar.background"), + "tab_bar_segmented" => ( + "Tab Bar", + "Segmented Background", + "tab_bar.segmented.background", + ), + "tab_foreground" => ("Tab", "Foreground", "tab.foreground"), + + // Input + "input" => ("Input", "Border", "input.border"), + "caret" => ("Input", "Caret", "caret"), + "selection" => ("Input", "Selection", "selection.background"), + + // Slider / Switch + "slider_bar" => ("Slider", "Bar", "slider.background"), + "slider_thumb" => ("Slider", "Thumb", "slider.thumb.background"), + "switch" => ("Switch", "Background", "switch.background"), + "switch_thumb" => ("Switch", "Thumb", "switch.thumb.background"), + + // Muted / Skeleton + "muted" => ("Muted", "Background", "muted.background"), + "muted_foreground" => ("Muted", "Foreground", "muted.foreground"), + "skeleton" => ("Skeleton", "Background", "skeleton.background"), + + // Charts + "chart_1" => ("Chart", "Color 1", "chart.1"), + "chart_2" => ("Chart", "Color 2", "chart.2"), + "chart_3" => ("Chart", "Color 3", "chart.3"), + "chart_4" => ("Chart", "Color 4", "chart.4"), + "chart_5" => ("Chart", "Color 5", "chart.5"), + + // Danger / Success / Warning / Info + "danger" => ("Danger", "Background", "danger.background"), + "danger_active" => ("Danger", "Active", "danger.active.background"), + "danger_foreground" => ("Danger", "Foreground", "danger.foreground"), + "danger_hover" => ("Danger", "Hover", "danger.hover.background"), + + "success" => ("Success", "Background", "success.background"), + "success_active" => ("Success", "Active", "success.active.background"), + "success_foreground" => ("Success", "Foreground", "success.foreground"), + "success_hover" => ("Success", "Hover", "success.hover.background"), + + "warning" => ("Warning", "Background", "warning.background"), + "warning_active" => ("Warning", "Active", "warning.active.background"), + "warning_foreground" => ("Warning", "Foreground", "warning.foreground"), + "warning_hover" => ("Warning", "Hover", "warning.hover.background"), + + "info" => ("Info", "Background", "info.background"), + "info_active" => ("Info", "Active", "info.active.background"), + "info_foreground" => ("Info", "Foreground", "info.foreground"), + "info_hover" => ("Info", "Hover", "info.hover.background"), + + // Base Colors + "red" => ("Base", "Red", "base.red"), + "red_light" => ("Base", "Red Light", "base.red.light"), + "green" => ("Base", "Green", "base.green"), + "green_light" => ("Base", "Green Light", "base.green.light"), + "blue" => ("Base", "Blue", "base.blue"), + "blue_light" => ("Base", "Blue Light", "base.blue.light"), + "yellow" => ("Base", "Yellow", "base.yellow"), + "yellow_light" => ("Base", "Yellow Light", "base.yellow.light"), + "magenta" => ("Base", "Magenta", "base.magenta"), + "magenta_light" => ("Base", "Magenta Light", "base.magenta.light"), + "cyan" => ("Base", "Cyan", "base.cyan"), + "cyan_light" => ("Base", "Cyan Light", "base.cyan.light"), + + // Everything else remains in Global or attempts a split + _ => { + if key.contains('_') { + let parts: Vec<&str> = key.splitn(2, '_').collect(); + (parts[0], parts[1], key) + } else { + ("Global", key, key) + } + } + }; + + ParsedKey { + category: to_title_case_full(category), + name: to_title_case_full(name), + canonical_key: canonical.to_string(), + } +} + +fn to_title_case(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +fn to_title_case_full(s: &str) -> String { + s.split(|c| c == '_' || c == '.') + .map(to_title_case) + .collect::>() + .join(" ") +} diff --git a/crates/story/src/stories/theme_story/mod.rs b/crates/story/src/stories/theme_story/mod.rs new file mode 100644 index 00000000..dff820ef --- /dev/null +++ b/crates/story/src/stories/theme_story/mod.rs @@ -0,0 +1,5 @@ +mod checkerboard; +mod color_theme_story; +mod mapper; + +pub use color_theme_story::*; \ No newline at end of file diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index df17d731..fda593ac 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -7,7 +7,7 @@ documentation = "https://docs.rs/gpui-component" homepage = "https://longbridge.github.io/gpui-component" repository = "https://github.com/longbridge/gpui-component" readme = "../../README.md" -version = "0.5.0" +version = "0.5.1" publish = true edition.workspace = true @@ -37,6 +37,7 @@ tree-sitter-languages = [ "dep:tree-sitter-jsdoc", "dep:tree-sitter-make", "dep:tree-sitter-md", + "dep:tree-sitter-php", "dep:tree-sitter-proto", "dep:tree-sitter-python", "dep:tree-sitter-ruby", @@ -114,6 +115,7 @@ tree-sitter-jsdoc = { version = "0.25.0", optional = true } tree-sitter-json = "0.24.8" tree-sitter-make = { version = "1.1.1", optional = true } tree-sitter-md = { version = "0.5.1", optional = true } +tree-sitter-php = { version = "0.24.2", optional = true } tree-sitter-proto = { version = "0.4.0", optional = true } tree-sitter-python = { version = "0.25.0", optional = true } tree-sitter-ruby = { version = "0.23.1", optional = true } @@ -129,6 +131,9 @@ tree-sitter-zig = { version = "1.1.2", optional = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +core-text = "=21.0.0" + [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } indoc = "2" diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index e56d1b7c..ce2a9858 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -138,6 +138,37 @@ Dock: zh-CN: 展开 zh-HK: 展開 it: Espandi +ColorPicker: + Palette: + en: Palette + zh-CN: 调色板 + zh-HK: 調色板 + it: Tavolozza + HSLA: + en: HSLA + zh-CN: HSLA + zh-HK: HSLA + it: HSLA + Hue: + en: Hue + zh-CN: 色相 + zh-HK: 色相 + it: Tonalità + Saturation: + en: Saturation + zh-CN: 饱和度 + zh-HK: 飽和度 + it: Saturazione + Lightness: + en: Lightness + zh-CN: 亮度 + zh-HK: 亮度 + it: Luminosità + Alpha: + en: Alpha + zh-CN: 透明度 + zh-HK: 透明度 + it: Alfa Dialog: ok: en: OK diff --git a/crates/ui/src/accordion.rs b/crates/ui/src/accordion.rs index c84b58d1..cc56fa22 100644 --- a/crates/ui/src/accordion.rs +++ b/crates/ui/src/accordion.rs @@ -1,12 +1,25 @@ -use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc}; +use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc, time::Duration}; use gpui::{ - AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement as _, Styled, Window, div, - prelude::FluentBuilder as _, rems, + AnimationExt as _, AnyElement, App, ElementId, InteractiveElement as _, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, Window, div, + prelude::FluentBuilder as _, px, rems, }; -use crate::{ActiveTheme as _, Icon, IconName, Sizable, Size, h_flex, v_flex}; +use crate::{ + ActiveTheme as _, Icon, IconName, Sizable, Size, + animation::{PresenceOptions, PresencePhase, fast_invoke_animation, keyed_presence, point_to_point_animation}, + global_state::GlobalState, h_flex, v_flex, +}; + +/// Generous max for animated height reveal. Content fully visible +/// well before delta=1 due to decelerating easing. +const ACCORDION_CONTENT_MAX_H: f32 = 1500.0; + +/// Shape height progress so sibling reflow lasts longer when max-height cap is large. +fn accordion_height_progress(progress: f32) -> f32 { + progress.clamp(0.0, 1.0).powf(3.0) +} /// Accordion element. #[derive(IntoElement)] @@ -85,6 +98,7 @@ impl RenderOnce for Accordion { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let open_ixs = Rc::new(RefCell::new(HashSet::new())); let is_multiple = self.multiple; + let accordion_id_prefix = SharedString::from(format!("{}", self.id)); v_flex() .id(self.id) @@ -101,6 +115,10 @@ impl RenderOnce for Accordion { accordion .index(ix) + .key_prefix(SharedString::from(format!( + "{}-{}", + accordion_id_prefix, ix + ))) .with_size(self.size) .bordered(self.bordered) .disabled(self.disabled) @@ -138,6 +156,7 @@ impl RenderOnce for Accordion { #[derive(IntoElement)] pub struct AccordionItem { index: usize, + key_prefix: SharedString, icon: Option, title: AnyElement, children: Vec, @@ -153,6 +172,7 @@ impl AccordionItem { pub fn new() -> Self { Self { index: 0, + key_prefix: "accordion".into(), icon: None, title: SharedString::default().into_any_element(), children: Vec::new(), @@ -196,6 +216,11 @@ impl AccordionItem { self } + fn key_prefix(mut self, key_prefix: impl Into) -> Self { + self.key_prefix = key_prefix.into(); + self + } + fn on_toggle_click( mut self, on_toggle_click: impl Fn(&bool, &mut Window, &mut App) + 'static, @@ -219,7 +244,26 @@ impl Sizable for AccordionItem { } impl RenderOnce for AccordionItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let close_anim = point_to_point_animation(&motion, reduced_motion); + let open_anim = fast_invoke_animation(&motion, reduced_motion); + let presence_key = SharedString::from(format!("accordion-presence-{}", self.key_prefix)); + let open_duration = Duration::from_millis(u64::from(motion.fast_duration_ms)); + let close_duration = Duration::from_millis(u64::from(motion.fast_duration_ms)); + let presence = keyed_presence( + presence_key, + self.open, + !reduced_motion, + open_duration, + close_duration, + PresenceOptions::default(), + window, + cx, + ); + let expanded_visible = presence.should_render(); + let text_size = match self.size { Size::XSmall => rems(0.875), Size::Small => rems(0.875), @@ -248,7 +292,7 @@ impl RenderOnce for AccordionItem { Size::Large => this.py_1p5().px_4(), _ => this.py_1().px_3(), }) - .when(self.open, |this| { + .when(expanded_visible, |this| { this.when(self.bordered, |this| { this.text_color(cx.theme().foreground) .border_b_1() @@ -287,23 +331,62 @@ impl RenderOnce for AccordionItem { ) .when_some(self.on_toggle_click, |this, on_toggle_click| { this.on_click({ + let open = self.open; move |_, window, cx| { - on_toggle_click(&!self.open, window, cx); + on_toggle_click(&!open, window, cx); } }) }) }), ) - .when(self.open, |this| { + .when(expanded_visible, |this| { this.child( div() - .map(|this| match self.size { - Size::XSmall => this.p_1p5(), - Size::Small => this.p_2(), - Size::Large => this.p_4(), - _ => this.p_3(), - }) - .children(self.children), + .overflow_hidden() + .child( + div() + .map(|this| match self.size { + Size::XSmall => this.p_1p5(), + Size::Small => this.p_2(), + Size::Large => this.p_4(), + _ => this.p_3(), + }) + .children(self.children), + ) + .map(|el| { + let anim = if presence.transition_active() { + if matches!(presence.phase, PresencePhase::Entering) { + open_anim + } else { + close_anim + } + } else { + None + }; + if let Some(anim) = anim { + let animation_id = ElementId::NamedInteger( + SharedString::from(format!( + "accordion-expand-{}", + self.key_prefix + )), + (self.index as u64) << 1 + | u64::from(matches!( + presence.phase, + PresencePhase::Entering + )), + ); + + el.with_animation(animation_id, anim, move |el, delta| { + let progress = presence.progress(delta); + let height_progress = accordion_height_progress(progress); + el.max_h(px(ACCORDION_CONTENT_MAX_H * height_progress)) + .opacity(progress) + }) + .into_any_element() + } else { + el.into_any_element() + } + }), ) }), ) diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs index f9eadd7a..71145733 100644 --- a/crates/ui/src/actions.rs +++ b/crates/ui/src/actions.rs @@ -8,4 +8,19 @@ pub struct Confirm { pub secondary: bool, } -actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]); +actions!( + ui, + [ + Cancel, + SelectUp, + SelectDown, + SelectLeft, + SelectRight, + SelectFirst, + SelectLast, + SelectPrevColumn, + SelectNextColumn, + SelectPageUp, + SelectPageDown + ] +); diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index be3539ea..950f8bb4 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -1,3 +1,8 @@ +use gpui::{Animation, App, SharedString, Window}; +use std::time::Duration; + +use crate::ThemeMotion; + /// A cubic bezier function like CSS `cubic-bezier`. /// /// Builder: @@ -5,6 +10,10 @@ /// https://cubic-bezier.com pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 { move |t: f32| { + if !t.is_finite() { + return 0.0; + } + let t = t.clamp(0.0, 1.0); let one_t = 1.0 - t; let one_t2 = one_t * one_t; let t2 = t * t; @@ -14,6 +23,254 @@ pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 { let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3; let y = 3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3; - y + if y.is_finite() { + y.clamp(0.0, 1.0) + } else { + 0.0 + } + } +} + +/// Parse a CSS cubic-bezier string into (x1, y1, x2, y2). +pub fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { + let trimmed = value.trim(); + let body = trimmed + .strip_prefix("cubic-bezier(")? + .strip_suffix(')')? + .trim(); + let mut parts = body.split(',').map(str::trim); + let x1 = parts.next()?.parse::().ok()?; + let y1 = parts.next()?.parse::().ok()?; + let x2 = parts.next()?.parse::().ok()?; + let y2 = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + Some((x1, y1, x2, y2)) +} + +/// Apply a theme easing string to an Animation. +pub fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { + if easing.trim().eq_ignore_ascii_case("linear") { + return animation.with_easing(|delta: f32| delta); + } + if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { + return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); + } + animation +} + +/// Create a theme animation with the given duration and easing. Returns None if reduced_motion. +pub fn theme_animation(duration_ms: u16, easing: &str, reduced_motion: bool) -> Option { + if reduced_motion { + return None; + } + let anim = Animation::new(Duration::from_millis(duration_ms as u64)); + Some(animation_with_theme_easing(anim, easing)) +} + +/// Fast invoke animation (187ms, fast_invoke_easing). +pub fn fast_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { + theme_animation( + motion.fast_duration_ms, + &motion.fast_invoke_easing, + reduced_motion, + ) +} + +/// Soft dismiss animation (167ms, soft_dismiss_easing). +pub fn soft_dismiss_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { + theme_animation( + motion.soft_dismiss_duration_ms, + &motion.soft_dismiss_easing, + reduced_motion, + ) +} + +/// Point-to-point animation (187ms, point_to_point_easing). +pub fn point_to_point_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { + theme_animation( + motion.fast_duration_ms, + &motion.point_to_point_easing, + reduced_motion, + ) +} + +/// Fade animation (83ms, linear). +pub fn fade_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { + theme_animation(motion.fade_duration_ms, &motion.fade_easing, reduced_motion) +} + +/// Strong invoke animation (667ms, strong_invoke_easing with overshoot bounce). +pub fn strong_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { + theme_animation( + motion.strong_invoke_duration_ms, + &motion.strong_invoke_easing, + reduced_motion, + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PresencePhase { + Entering, + Entered, + Exiting, + Exited, +} + +#[derive(Clone, Copy, Debug)] +pub struct PresenceTransition { + pub phase: PresencePhase, +} + +impl PresenceTransition { + pub fn transition_active(self) -> bool { + matches!(self.phase, PresencePhase::Entering | PresencePhase::Exiting) + } + + pub fn should_render(self) -> bool { + self.phase != PresencePhase::Exited + } + + pub fn progress(self, delta: f32) -> f32 { + let delta = delta.clamp(0.0, 1.0); + match self.phase { + PresencePhase::Entering => delta, + PresencePhase::Exiting => 1.0 - delta, + PresencePhase::Entered => 1.0, + PresencePhase::Exited => 0.0, + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct PresenceOptions { + pub animate_on_mount: bool, +} + +/// Shared mount/open/close presence state machine keyed by element id. +/// +/// - `target_open=true` moves to Entering/Entered +/// - `target_open=false` moves to Exiting/Exited +/// - stale async timers are ignored via generation guard +pub fn keyed_presence( + key_base: SharedString, + target_open: bool, + animate: bool, + open_duration: Duration, + close_duration: Duration, + options: PresenceOptions, + window: &mut Window, + cx: &mut App, +) -> PresenceTransition { + let initial_open = if options.animate_on_mount && animate { + false + } else { + target_open + }; + let target_key = SharedString::from(format!("{}-presence-target", key_base)); + let phase_key = SharedString::from(format!("{}-presence-phase", key_base)); + let generation_key = SharedString::from(format!("{}-presence-generation", key_base)); + let target_state = window.use_keyed_state(target_key, cx, |_, _| initial_open); + let phase_state = window.use_keyed_state(phase_key, cx, |_, _| { + if initial_open { + PresencePhase::Entered + } else { + PresencePhase::Exited + } + }); + let generation_state = window.use_keyed_state(generation_key, cx, |_, _| 0_u64); + + let previous_target = *target_state.read(cx); + let target_changed = previous_target != target_open; + if target_changed { + target_state.update(cx, |state, _| *state = target_open); + let generation = generation_state.update(cx, |state, _| { + *state += 1; + *state + }); + + if !animate { + let next_phase = if target_open { + PresencePhase::Entered + } else { + PresencePhase::Exited + }; + phase_state.update(cx, |state, _| *state = next_phase); + } else if target_open { + phase_state.update(cx, |state, _| *state = PresencePhase::Entering); + cx.spawn({ + let target_state = target_state.clone(); + let phase_state = phase_state.clone(); + let generation_state = generation_state.clone(); + async move |cx| { + cx.background_executor().timer(open_duration).await; + let still_latest = generation_state.update(cx, |state, _| *state == generation); + if !still_latest { + return; + } + let still_open = target_state.update(cx, |state, _| *state); + if still_open { + _ = phase_state.update(cx, |state, cx| { + *state = PresencePhase::Entered; + cx.notify(); + }); + } + } + }) + .detach(); + } else { + phase_state.update(cx, |state, _| *state = PresencePhase::Exiting); + cx.spawn({ + let target_state = target_state.clone(); + let phase_state = phase_state.clone(); + let generation_state = generation_state.clone(); + async move |cx| { + cx.background_executor().timer(close_duration).await; + let still_latest = generation_state.update(cx, |state, _| *state == generation); + if !still_latest { + return; + } + let still_closed = target_state.update(cx, |state, _| !*state); + if still_closed { + _ = phase_state.update(cx, |state, cx| { + *state = PresencePhase::Exited; + cx.notify(); + }); + } + } + }) + .detach(); + } + } + + PresenceTransition { + phase: *phase_state.read(cx), + } +} + +#[cfg(test)] +mod tests { + use super::cubic_bezier; + + #[test] + fn strong_invoke_curve_is_bounded() { + let easing = cubic_bezier(0.13, 1.62, 0.0, 0.92); + for i in 0..=1_000 { + let t = i as f32 / 1_000.0; + let y = easing(t); + assert!( + (0.0..=1.0).contains(&y), + "expected output in [0, 1], got {y} at t={t}" + ); + } + } + + #[test] + fn cubic_bezier_non_finite_input_returns_zero() { + let easing = cubic_bezier(0.0, 0.0, 1.0, 1.0); + assert_eq!(easing(f32::NAN), 0.0); + assert_eq!(easing(f32::INFINITY), 0.0); + assert_eq!(easing(f32::NEG_INFINITY), 0.0); } } diff --git a/crates/ui/src/badge.rs b/crates/ui/src/badge.rs index d4b7ee86..6b9727f4 100644 --- a/crates/ui/src/badge.rs +++ b/crates/ui/src/badge.rs @@ -1,9 +1,13 @@ use gpui::{ - AnyElement, App, Hsla, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, - div, prelude::FluentBuilder, px, relative, + AnimationExt as _, AnyElement, App, ElementId, Hsla, InteractiveElement as _, IntoElement, + ParentElement, RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px, + relative, }; -use crate::{ActiveTheme, Icon, Sizable, Size, StyledExt, h_flex, white}; +use crate::{ + ActiveTheme, Icon, Sizable, Size, StyledExt, animation::strong_invoke_animation, + global_state::GlobalState, h_flex, white, +}; #[derive(Default, Clone)] enum BadgeVariant { @@ -29,6 +33,7 @@ impl BadgeVariant { /// A badge for displaying a count, dot, or icon on an element. #[derive(IntoElement)] pub struct Badge { + id: Option, style: StyleRefinement, count: usize, max: usize, @@ -42,6 +47,7 @@ impl Badge { /// Create a new badge. pub fn new() -> Self { Self { + id: None, style: StyleRefinement::default(), count: 0, max: 99, @@ -52,6 +58,12 @@ impl Badge { } } + /// Set an element ID to enable entry animation on the badge indicator. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + /// Set to use [`BadgeVariant::Dot`] to show a dot. pub fn dot(mut self) -> Self { self.variant = BadgeVariant::Dot; @@ -111,6 +123,12 @@ impl RenderOnce for Badge { Size::Small | Size::XSmall => (px(10.), px(8.)), }; + let animation = self.id.as_ref().and_then(|_| { + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + strong_invoke_animation(motion, reduced_motion) + }); + div() .relative() .refine_style(&self.style) @@ -158,6 +176,15 @@ impl RenderOnce for Badge { .border_1() .border_color(cx.theme().background) .child(*icon), + }) + .map(|this| match (self.id, animation) { + (Some(id), Some(anim)) => this + .id(id) + .with_animation("badge-pulse", anim, |this, delta| { + this.opacity(delta) + }) + .into_any_element(), + _ => this.into_any_element(), }), ) }) diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index 9483653f..f363be2a 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -2,7 +2,8 @@ use std::{rc::Rc, time::Duration}; use crate::{ ActiveTheme, Disableable, FocusableExt, IconName, Selectable, Sizable, Size, StyledExt as _, - animation::cubic_bezier, global_state::GlobalState, icon::IconNamed, text::Text, v_flex, + animation::animation_with_theme_easing, global_state::GlobalState, icon::IconNamed, text::Text, + v_flex, }; use gpui::{ Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, @@ -10,33 +11,6 @@ use gpui::{ prelude::FluentBuilder as _, px, relative, rems, svg, }; -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - /// A Checkbox element. #[derive(IntoElement)] pub struct Checkbox { @@ -199,8 +173,7 @@ pub(crate) fn checkbox_check_icon( } if value_changed { - let duration = - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + let duration = Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); let animation = animation_with_theme_easing( Animation::new(duration), cx.theme().motion.fade_easing.as_ref(), diff --git a/crates/ui/src/collapsible.rs b/crates/ui/src/collapsible.rs index 9ea2a24e..811005f6 100644 --- a/crates/ui/src/collapsible.rs +++ b/crates/ui/src/collapsible.rs @@ -1,20 +1,21 @@ use gpui::{ - AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, + AnimationExt as _, AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, + Styled, Window, prelude::FluentBuilder as _, }; -use crate::{StyledExt, v_flex}; +use crate::{ + ActiveTheme, StyledExt, animation::fast_invoke_animation, global_state::GlobalState, v_flex, +}; + +/// Generous max for animated height reveal. Content fully visible +/// well before delta=1 due to decelerating easing. +const COLLAPSIBLE_CONTENT_MAX_H: f32 = 1500.0; enum CollapsibleChild { Element(AnyElement), Content(AnyElement), } -impl CollapsibleChild { - fn is_content(&self) -> bool { - matches!(self, CollapsibleChild::Content(_)) - } -} - /// An interactive element which expands/collapses. #[derive(IntoElement)] pub struct Collapsible { @@ -63,18 +64,42 @@ impl ParentElement for Collapsible { } impl RenderOnce for Collapsible { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let anim = fast_invoke_animation(motion, reduced_motion); + + let mut non_content = Vec::new(); + let mut content_elements = Vec::new(); + + for child in self.children { + match child { + CollapsibleChild::Element(el) => non_content.push(el), + CollapsibleChild::Content(el) => { + if self.open { + content_elements.push(el); + } + } + } + } + v_flex() .refine_style(&self.style) - .children(self.children.into_iter().filter_map(|child| { - if child.is_content() && !self.open { - None + .children(non_content) + .when(self.open && !content_elements.is_empty(), |this| { + let content_wrapper = gpui::div() + .overflow_hidden() + .child(gpui::div().children(content_elements)); + this.child(if let Some(anim) = anim { + content_wrapper + .with_animation("collapsible-expand", anim, |el, delta| { + el.max_h(gpui::px(COLLAPSIBLE_CONTENT_MAX_H * delta)) + .opacity(delta) + }) + .into_any_element() } else { - match child { - CollapsibleChild::Element(el) => Some(el), - CollapsibleChild::Content(el) => Some(el), - } - } - })) + content_wrapper.into_any_element() + }) + }) } } diff --git a/crates/ui/src/color_picker.rs b/crates/ui/src/color_picker.rs index ae42d64b..41660a21 100644 --- a/crates/ui/src/color_picker.rs +++ b/crates/ui/src/color_picker.rs @@ -2,8 +2,9 @@ use gpui::{ App, AppContext, Context, Corner, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement as _, IntoElement, KeyBinding, ParentElement, Render, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, - Window, div, prelude::FluentBuilder as _, + TextAlign, Window, div, hsla, linear_color_stop, linear_gradient, prelude::FluentBuilder as _, }; +use rust_i18n::t; use crate::{ ActiveTheme as _, Colorize as _, Icon, Sizable, Size, StyleSized, @@ -13,6 +14,8 @@ use crate::{ h_flex, input::{Input, InputEvent, InputState}, popover::Popover, + slider::{Slider, SliderEvent, SliderState}, + tab::{Tab, TabBar}, tooltip::Tooltip, v_flex, }; @@ -60,12 +63,83 @@ fn color_palettes() -> Vec> { ] } +#[derive(Clone)] +struct HslaSliders { + hue: Entity, + saturation: Entity, + lightness: Entity, + alpha: Entity, +} + +impl HslaSliders { + fn new(cx: &mut App) -> Self { + Self { + hue: cx.new(|_| { + SliderState::new() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }), + saturation: cx.new(|_| { + SliderState::new() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }), + lightness: cx.new(|_| { + SliderState::new() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }), + alpha: cx.new(|_| { + SliderState::new() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }), + } + } + + fn read(&self, cx: &App) -> Hsla { + hsla( + self.hue.read(cx).value().start(), + self.saturation.read(cx).value().start(), + self.lightness.read(cx).value().start(), + self.alpha.read(cx).value().start(), + ) + } + + fn update(&self, new_color: Hsla, window: &mut Window, cx: &mut App) { + self.hue.update(cx, |slider, cx| { + slider.set_value(new_color.h, window, cx); + }); + self.saturation.update(cx, |slider, cx| { + slider.set_value(new_color.s, window, cx); + }); + self.lightness.update(cx, |slider, cx| { + slider.set_value(new_color.l, window, cx); + }); + self.alpha.update(cx, |slider, cx| { + slider.set_value(new_color.a, window, cx); + }); + } +} + /// State of the [`ColorPicker`]. pub struct ColorPickerState { focus_handle: FocusHandle, value: Option, hovered_color: Option, state: Entity, + hsla_sliders: HslaSliders, + needs_slider_sync: bool, + suppress_input_change: bool, + active_tab: usize, open: bool, _subscriptions: Vec, } @@ -76,33 +150,76 @@ impl ColorPickerState { let state = cx.new(|cx| { InputState::new(window, cx).pattern(regex::Regex::new(r"^#[0-9a-fA-F]{0,8}$").unwrap()) }); - - let _subscriptions = vec![cx.subscribe_in( - &state, - window, - |this, state, ev: &InputEvent, window, cx| match ev { - InputEvent::Change => { - let value = state.read(cx).value(); - if let Ok(color) = Hsla::parse_hex(value.as_str()) { - this.hovered_color = Some(color); + let hsla_sliders = HslaSliders::new(cx); + + let mut _subscriptions = vec![ + cx.subscribe_in( + &state, + window, + |this, state, ev: &InputEvent, window, cx| match ev { + InputEvent::Change => { + if this.suppress_input_change { + return; + } + let value = state.read(cx).value(); + if let Ok(color) = Hsla::parse_hex(value.as_str()) { + this.hovered_color = Some(color); + this.sync_sliders(Some(color), window, cx); + } } - } - InputEvent::PressEnter { .. } => { - let val = this.state.read(cx).value(); - if let Ok(color) = Hsla::parse_hex(&val) { - this.open = false; - this.update_value(Some(color), true, window, cx); + InputEvent::PressEnter { .. } => { + let val = this.state.read(cx).value(); + if let Ok(color) = Hsla::parse_hex(&val) { + this.open = false; + this.update_value(Some(color), true, window, cx); + } } - } - _ => {} - }, - )]; + _ => {} + }, + ), + cx.subscribe_in( + &hsla_sliders.hue, + window, + |this, _, _: &SliderEvent, window, cx| { + let color = this.hsla_sliders.read(cx); + this.update_value_from_slider(color, true, window, cx); + }, + ), + cx.subscribe_in( + &hsla_sliders.saturation, + window, + |this, _, _: &SliderEvent, window, cx| { + let color = this.hsla_sliders.read(cx); + this.update_value_from_slider(color, true, window, cx); + }, + ), + cx.subscribe_in( + &hsla_sliders.lightness, + window, + |this, _, _: &SliderEvent, window, cx| { + let color = this.hsla_sliders.read(cx); + this.update_value_from_slider(color, true, window, cx); + }, + ), + cx.subscribe_in( + &hsla_sliders.alpha, + window, + |this, _, _: &SliderEvent, window, cx| { + let color = this.hsla_sliders.read(cx); + this.update_value_from_slider(color, true, window, cx); + }, + ), + ]; Self { focus_handle: cx.focus_handle(), value: None, hovered_color: None, state, + hsla_sliders, + needs_slider_sync: false, + suppress_input_change: false, + active_tab: 0, open: false, _subscriptions, } @@ -110,7 +227,10 @@ impl ColorPickerState { /// Set default color value. pub fn default_value(mut self, value: impl Into) -> Self { - self.value = Some(value.into()); + let value = value.into(); + self.value = Some(value); + self.hovered_color = Some(value); + self.needs_slider_sync = true; self } @@ -141,6 +261,7 @@ impl ColorPickerState { window: &mut Window, cx: &mut Context, ) { + self.needs_slider_sync = false; self.value = value; self.hovered_color = value; self.state.update(cx, |view, cx| { @@ -155,6 +276,28 @@ impl ColorPickerState { } cx.notify(); } + + fn update_value_from_slider( + &mut self, + value: Hsla, + emit: bool, + _: &mut Window, + cx: &mut Context, + ) { + self.needs_slider_sync = false; + self.value = Some(value); + self.hovered_color = Some(value); + if emit { + cx.emit(ColorPickerEvent::Change(Some(value))); + } + cx.notify(); + } + + fn sync_sliders(&mut self, color: Option, window: &mut Window, cx: &mut Context) { + if let Some(color) = color { + self.hsla_sliders.update(color, window, cx); + } + } } impl EventEmitter for ColorPickerState {} @@ -274,6 +417,66 @@ impl ColorPicker { } fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.state.update(cx, |state, cx| { + if state.needs_slider_sync { + let value = state.value; + state.update_value(value, false, window, cx); + } + }); + + let active_tab = self.state.read(cx).active_tab; + + let (slider_color, hovered_color) = { + let state = self.state.read(cx); + let slider_color = state + .hovered_color + .or(state.value) + .unwrap_or_else(|| hsla(0., 0., 0., 1.)); + (slider_color, state.hovered_color) + }; + + v_flex() + .p_0p5() + .gap_3() + .child( + TabBar::new("mode") + .segmented() + .selected_index(active_tab) + .on_click( + window.listener_for(&self.state, |state, ix: &usize, _, cx| { + state.active_tab = *ix; + cx.notify(); + }), + ) + .child(Tab::new().flex_1().label(t!("ColorPicker.Palette"))) + .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))), + ) + .child(match active_tab { + 0 => self.render_palette_panel(window, cx).into_any_element(), + _ => self + .render_slider_tab_panel(slider_color, cx) + .into_any_element(), + }) + .when_some(hovered_color, |this, hovered_color| { + this.child(Divider::horizontal()).child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .bg(hovered_color) + .flex_shrink_0() + .border_1() + .border_color(hovered_color.darken(0.2)) + .size_5() + .rounded(cx.theme().radius), + ) + .child(Input::new(&self.state.read(cx).state).small()), + ) + }) + } + + fn render_palette_panel(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { let featured_colors = self.featured_colors.clone().unwrap_or(vec![ cx.theme().red, cx.theme().red_light, @@ -290,7 +493,6 @@ impl ColorPicker { ]); v_flex() - .p_0p5() .gap_3() .child( h_flex().gap_1().children( @@ -312,23 +514,200 @@ impl ColorPicker { ) })), ) - .when_some(self.state.read(cx).hovered_color, |this, hovered_color| { - this.child(Divider::horizontal()).child( - h_flex() - .gap_2() - .items_center() - .child( - div() - .bg(hovered_color) - .flex_shrink_0() - .border_1() - .border_color(hovered_color.darken(0.2)) - .size_5() - .rounded(cx.theme().radius), - ) - .child(Input::new(&self.state.read(cx).state).small()), - ) + } + + fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement { + let hsla_sliders = self.state.read(cx).hsla_sliders.clone(); + let steps = 96usize; + let hue_colors = (0..steps) + .map(|ix| { + let h = ix as f32 / (steps.saturating_sub(1)) as f32; + hsla(h, 1.0, 0.5, 1.0) }) + .collect::>(); + let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0); + let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0); + let lightness_colors = (0..steps) + .map(|ix| { + let l = ix as f32 / (steps.saturating_sub(1)) as f32; + hsla(slider_color.h, 1.0, l, 1.0) + }) + .collect::>(); + let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0); + let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0); + + let label_color = cx.theme().foreground.opacity(0.7); + + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(SharedString::from(t!("ColorPicker.Hue"))), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track(hue_colors, cx)) + .child( + Slider::new(&hsla_sliders.hue) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.h * 360.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(SharedString::from(t!("ColorPicker.Saturation"))), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track_gradient( + saturation_start, + saturation_end, + cx, + )) + .child( + Slider::new(&hsla_sliders.saturation) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.s * 100.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(SharedString::from(t!("ColorPicker.Lightness"))), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track(lightness_colors, cx)) + .child( + Slider::new(&hsla_sliders.lightness) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.l * 100.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(SharedString::from(t!("ColorPicker.Alpha"))), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx)) + .child( + Slider::new(&hsla_sliders.alpha) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.a * 100.)), + ), + ) + } + + fn render_slider_track(&self, colors: Vec, _: &App) -> impl IntoElement { + h_flex() + .absolute() + .left_0() + .right_0() + .h_2_5() + .overflow_hidden() + .children( + colors + .into_iter() + .map(|color| div().flex_1().h_full().bg(color)), + ) + } + + fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement { + div() + .absolute() + .left_0() + .right_0() + .h_2_5() + .overflow_hidden() + .bg(linear_gradient( + 90., + linear_color_stop(start, 0.), + linear_color_stop(end, 1.), + )) } } diff --git a/crates/ui/src/command_palette/mod.rs b/crates/ui/src/command_palette/mod.rs index bf005502..ca6b39c3 100644 --- a/crates/ui/src/command_palette/mod.rs +++ b/crates/ui/src/command_palette/mod.rs @@ -157,9 +157,11 @@ impl CommandPalette { window.open_dialog(cx, move |dialog, _window, _cx| { dialog .w(width) + .min_h(gpui::px(0.)) .overlay(true) .overlay_closable(true) .keyboard(true) + .animate(false) .close_button(false) .p_0() .child(view.clone()) diff --git a/crates/ui/src/command_palette/state.rs b/crates/ui/src/command_palette/state.rs index 7a2cdce5..036a74d6 100644 --- a/crates/ui/src/command_palette/state.rs +++ b/crates/ui/src/command_palette/state.rs @@ -1,8 +1,8 @@ //! State management for the Command Palette. +use super::REVEAL_QUERY_DELAY; use super::matcher::{FuzzyMatcherWrapper, NucleoMatcher}; use super::provider::CommandPaletteProvider; -use super::REVEAL_QUERY_DELAY; use super::types::{ CommandMatcher, CommandMatcherKind, CommandPaletteConfig, CommandPaletteItem, MatchedItem, }; diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 50b68ba0..39f53717 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -5,19 +5,18 @@ use super::state::{CommandPaletteEvent, CommandPaletteState}; use super::types::{CommandPaletteConfig, MatchedItem}; use super::{reveal_animation_duration, reveal_delay}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::animation::cubic_bezier; use crate::global_state::GlobalState; use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; use crate::{ - h_flex, v_flex, v_virtual_list, ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, - SurfacePreset, VirtualListScrollHandle, WindowExt as _, + ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, + VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, v_virtual_list, }; use gpui::{ - div, prelude::FluentBuilder, px, Animation, AnimationExt, App, AppContext as _, Context, - ElementId, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, - ParentElement, Pixels, Render, ScrollStrategy, SharedString, Size as GpuiSize, Styled, - Subscription, Task, Window, + Animation, AnimationExt, App, AppContext as _, Context, ElementId, Entity, FocusHandle, + Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, + ScrollStrategy, SharedString, Size as GpuiSize, Styled, Subscription, Task, Window, div, + prelude::FluentBuilder, px, }; use std::rc::Rc; use std::sync::Arc; @@ -28,32 +27,14 @@ const CONTEXT: &str = "CommandPalette"; const HEADER_HEIGHT: f32 = 52.0; const FOOTER_HEIGHT: f32 = 36.0; const SECTION_HEADER_HEIGHT: f32 = 28.0; - -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation +const EMPTY_STATE_HEIGHT: f32 = 120.0; + +/// Monotonic spring-like easing (critically damped) to avoid bounce oscillation. +fn gentle_spring(delta: f32) -> f32 { + let t = delta.clamp(0.0, 1.0); + let omega = 10.0; + let value = 1.0 - (1.0 + omega * t) * (-omega * t).exp(); + value.clamp(0.0, 1.0) } /// A render row for the command palette list. @@ -237,11 +218,13 @@ impl CommandPaletteView { let item_data = item.item.clone(); let match_info = item.match_info.clone(); let disabled = item_data.disabled; + let show_inline_category = show_category && !item_data.category.is_empty(); let shortcut_element = item_data .shortcut .as_ref() .and_then(|s| gpui::Keystroke::parse(s).ok().map(|k| Kbd::new(k))); + let has_shortcut = shortcut_element.is_some(); h_flex() .id(SharedString::from(format!("cmd-item-{}", item_index))) @@ -316,15 +299,20 @@ impl CommandPaletteView { ) }), ) - // Shortcut - .when_some(shortcut_element, |this, kbd| this.child(kbd)) - // Category (inline, like old palette) - .when(show_category && !item_data.category.is_empty(), |this| { + .when(show_inline_category || has_shortcut, |this| { this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(item_data.category.clone()), + h_flex() + .items_center() + .gap_2() + .when(show_inline_category, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(item_data.category.clone()), + ) + }) + .when_some(shortcut_element, |this, kbd| this.child(kbd)), ) }) } @@ -442,9 +430,8 @@ impl CommandPaletteView { fn render_empty(&self, cx: &App) -> impl IntoElement { v_flex() .size_full() - .justify_center() .items_center() - .py_8() + .pt_6() .gap_2() .text_color(cx.theme().muted_foreground) .child(Icon::new(IconName::Search).size_8().opacity(0.5)) @@ -541,12 +528,8 @@ impl Render for CommandPaletteView { let max_height = px(config.max_height); let reduced_motion = GlobalState::global(cx).reduced_motion(); let reveal_animation_duration = reveal_animation_duration(cx); - let reveal_animation = (!reduced_motion).then(|| { - animation_with_theme_easing( - Animation::new(reveal_animation_duration), - cx.theme().motion.fast_invoke_easing.as_ref(), - ) - }); + let expand_animation = + (!reduced_motion).then(|| Animation::new(reveal_animation_duration).with_easing(gentle_spring)); // Focus input once after opening to avoid render jitter if !self.did_focus { @@ -566,7 +549,7 @@ impl Render for CommandPaletteView { // Compute height for surface bounds let list_content_height = if row_count == 0 { - self.item_height + px(EMPTY_STATE_HEIGHT) } else { rows.iter().fold(px(0.0), |sum, row| { sum + match row { @@ -596,15 +579,21 @@ impl Render for CommandPaletteView { .on_action(cx.listener(Self::on_action_confirm)) .on_action(cx.listener(Self::on_action_select_up)) .on_action(cx.listener(Self::on_action_select_down)) - .h(expanded_height) + .h(if self.list_revealed { + expanded_height + } else { + collapsed_height + }) .w_full() .overflow_hidden() // Search input .child( div() .w_full() + .h(px(HEADER_HEIGHT)) + .flex() + .items_center() .px_3() - .py_2() .border_b_1() .border_color(cx.theme().border) .child( @@ -620,51 +609,53 @@ impl Render for CommandPaletteView { ), ) // Results list - .child( - div() - .w_full() - .h(list_height) - .overflow_hidden() - .when(row_count == 0, |this| this.child(self.render_empty(cx))) - .when(row_count > 0, |this| { - this.child( - v_virtual_list(cx.entity(), "command-palette-list", item_sizes, { - let matched_items = matched_items.clone(); - let rows = rows.clone(); - move |view, visible_range, window, cx| { - visible_range - .filter_map(|ix| { - let row = rows.get(ix)?; - match row { - CommandPaletteRow::Header(title) => Some( - view.render_section_header(title.clone(), cx) - .into_any_element(), - ), - CommandPaletteRow::Item(item_index) => { - matched_items.get(*item_index).map(|item| { - view.render_item( - item, - *item_index, - selected_index == Some(*item_index), - show_categories, - window, - cx, - ) - .into_any_element() - }) + .when(self.list_revealed, |this| { + this.child( + div() + .w_full() + .h(list_height) + .overflow_hidden() + .when(row_count == 0, |this| this.child(self.render_empty(cx))) + .when(row_count > 0, |this| { + this.child( + v_virtual_list(cx.entity(), "command-palette-list", item_sizes, { + let matched_items = matched_items.clone(); + let rows = rows.clone(); + move |view, visible_range, window, cx| { + visible_range + .filter_map(|ix| { + let row = rows.get(ix)?; + match row { + CommandPaletteRow::Header(title) => Some( + view.render_section_header(title.clone(), cx) + .into_any_element(), + ), + CommandPaletteRow::Item(item_index) => { + matched_items.get(*item_index).map(|item| { + view.render_item( + item, + *item_index, + selected_index == Some(*item_index), + show_categories, + window, + cx, + ) + .into_any_element() + }) + } } - } - }) - .collect() - } - }) - .track_scroll(&self.scroll_handle) - .py_1(), - ) - }), - ) + }) + .collect() + } + }) + .track_scroll(&self.scroll_handle) + .py_1(), + ) + }), + ) + }) // Footer - .when(show_footer, |this| { + .when(show_footer && self.list_revealed, |this| { this.child(self.render_footer(footer_status.clone(), cx)) }); @@ -686,7 +677,7 @@ impl Render for CommandPaletteView { .w(px(config.width)); if self.list_revealed { - if let Some(reveal_animation) = reveal_animation { + if let Some(reveal_animation) = expand_animation { surface .with_animation( ElementId::NamedInteger("command-palette-expand".into(), 1), diff --git a/crates/ui/src/dialog.rs b/crates/ui/src/dialog.rs index 4ecef832..b4f5ea66 100644 --- a/crates/ui/src/dialog.rs +++ b/crates/ui/src/dialog.rs @@ -1,17 +1,20 @@ use std::{rc::Rc, time::Duration}; use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Edges, - ElementId, FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, - WindowControlArea, anchored, div, hsla, point, prelude::FluentBuilder, px, relative, + Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Edges, ElementId, + FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, + Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, WindowControlArea, + anchored, div, hsla, point, prelude::FluentBuilder, px, relative, }; use rust_i18n::t; use crate::{ - ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _, + ActiveTheme as _, FocusTrapElement as _, IconName, Root, Sizable as _, StyledExt, + TITLE_BAR_HEIGHT, WindowExt as _, actions::{Cancel, Confirm}, - animation::cubic_bezier, + animation::{ + PresenceOptions, PresencePhase, animation_with_theme_easing, keyed_presence, + }, button::{Button, ButtonVariant, ButtonVariants as _}, global_state::GlobalState, h_flex, @@ -20,6 +23,8 @@ use crate::{ }; const CONTEXT: &str = "Dialog"; +const OPEN_Y_OFFSET: f32 = 10.0; +const CLOSE_Y_OFFSET: f32 = 8.0; pub(crate) fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), @@ -27,33 +32,6 @@ pub(crate) fn init(cx: &mut App) { ]); } -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - fn dialog_shadow(delta: f32) -> Vec { vec![ BoxShadow { @@ -71,6 +49,18 @@ fn dialog_shadow(delta: f32) -> Vec { ] } +pub(crate) fn close_animation_duration(cx: &App) -> Duration { + let motion = &cx.theme().motion; + Duration::from_millis( + u64::from( + motion + .fast_duration_ms + .max(motion.soft_dismiss_duration_ms) + .max(motion.fade_duration_ms), + ), + ) +} + type RenderButtonFn = Box AnyElement>; type FooterFn = Box Vec>; @@ -139,11 +129,14 @@ pub struct Dialog { overlay: bool, overlay_closable: bool, keyboard: bool, + animate: bool, /// This will be change when open the dialog, the focus handle is create when open the dialog. pub(crate) focus_handle: FocusHandle, + pub(crate) id: u64, pub(crate) layer_ix: usize, pub(crate) overlay_visible: bool, + pub(crate) closing: bool, } pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla { @@ -168,8 +161,11 @@ impl Dialog { max_width: None, overlay: true, keyboard: true, + animate: true, + id: 0, layer_ix: 0, overlay_visible: false, + closing: false, on_close: Rc::new(|_, _, _| {}), on_ok: None, on_cancel: Rc::new(|_, _, _| true), @@ -179,6 +175,10 @@ impl Dialog { } } + pub(crate) fn should_animate(&self, cx: &App) -> bool { + self.animate && !GlobalState::global(cx).reduced_motion() + } + /// Sets the title of the dialog. pub fn title(mut self, title: impl IntoElement) -> Self { self.title = Some(title.into_any_element()); @@ -316,9 +316,21 @@ impl Dialog { self } + /// Set whether to play enter animations, defaults to `true`. + pub fn animate(mut self, animate: bool) -> Self { + self.animate = animate; + self + } + pub(crate) fn has_overlay(&self) -> bool { self.overlay } + + fn defer_close_dialog(window: &mut Window, cx: &mut App) { + Root::update(window, cx, |root, window, cx| { + root.defer_close_dialog(window, cx); + }); + } } impl ParentElement for Dialog { @@ -336,10 +348,13 @@ impl Styled for Dialog { impl RenderOnce for Dialog { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { let layer_ix = self.layer_ix; + let dialog_id = self.id; let on_close = self.on_close.clone(); let on_ok = self.on_ok.clone(); let on_cancel = self.on_cancel.clone(); let has_title = self.title.is_some(); + let should_animate = self.should_animate(cx); + let target_open = !self.closing; let render_ok: RenderButtonFn = Box::new({ let on_ok = on_ok.clone(); @@ -435,14 +450,37 @@ impl RenderOnce for Dialog { paddings.top -= px(6.); } - let reduced_motion = GlobalState::global(cx).reduced_motion(); + let open_duration = Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + let close_duration = close_animation_duration(cx); + let presence = keyed_presence( + SharedString::from(format!("dialog-{}-presence", dialog_id)), + target_open, + should_animate, + open_duration, + close_duration, + PresenceOptions { + animate_on_mount: true, + }, + window, + cx, + ); + let transition_active = presence.transition_active(); + let motion = &cx.theme().motion; - let slide_animation = animation_with_theme_easing( - Animation::new(Duration::from_millis(u64::from(motion.normal_duration_ms))), - motion.strong_invoke_easing.as_ref(), + let open_panel_animation = animation_with_theme_easing( + Animation::new(Duration::from_millis(u64::from(motion.fast_duration_ms))), + motion.fast_invoke_easing.as_ref(), + ); + let close_panel_animation = animation_with_theme_easing( + Animation::new(Duration::from_millis(u64::from(motion.soft_dismiss_duration_ms))), + motion.soft_dismiss_easing.as_ref(), ); - let fade_animation = animation_with_theme_easing( - Animation::new(Duration::from_millis(u64::from(motion.normal_duration_ms))), + let fade_in_animation = animation_with_theme_easing( + Animation::new(Duration::from_millis(u64::from(motion.fade_duration_ms))), + motion.fade_easing.as_ref(), + ); + let fade_out_animation = animation_with_theme_easing( + Animation::new(Duration::from_millis(u64::from(motion.fade_duration_ms))), motion.fade_easing.as_ref(), ); @@ -485,6 +523,8 @@ impl RenderOnce for Dialog { .child( v_flex() .id(layer_ix) + .track_focus(&self.focus_handle) + .focus_trap(format!("dialog-{}", layer_ix), &self.focus_handle) .bg(cx.theme().popover) .border_1() .border_color(cx.theme().border) @@ -496,8 +536,6 @@ impl RenderOnce for Dialog { .refine_style(&self.style) .px_0() .key_context(CONTEXT) - .track_focus(&self.focus_handle) - .tab_group() .when(self.keyboard, |this| { this.on_action({ let on_cancel = on_cancel.clone(); @@ -519,11 +557,11 @@ impl RenderOnce for Dialog { move |_: &Confirm, window, cx| { if let Some(on_ok) = &on_ok { if on_ok(&ClickEvent::default(), window, cx) { - window.close_dialog(cx); + Self::defer_close_dialog(window, cx); on_close(&ClickEvent::default(), window, cx); } } else if has_footer { - window.close_dialog(cx); + Self::defer_close_dialog(window, cx); on_close(&ClickEvent::default(), window, cx); } } @@ -596,23 +634,69 @@ impl RenderOnce for Dialog { } }) .map(move |this| { - if reduced_motion { - this.shadow(dialog_shadow(1.)).into_any_element() + if !should_animate || !transition_active { + let progress = presence.progress(1.0); + this.shadow(dialog_shadow(progress)) + .opacity(progress) + .into_any_element() } else { - this.with_animation("slide-down", slide_animation, move |this, delta| { - this.top(y * delta).shadow(dialog_shadow(delta)) - }) + let panel_animation = if matches!( + presence.phase, + PresencePhase::Entering + ) { + open_panel_animation + } else { + close_panel_animation + }; + this.with_animation( + SharedString::from(format!( + "dialog-panel-motion-{}", + u8::from(matches!( + presence.phase, + PresencePhase::Entering + )) + )), + panel_animation, + move |this, delta| { + let progress = presence.progress(delta).clamp(0.0, 1.0); + let top = if matches!( + presence.phase, + PresencePhase::Entering + ) { + y + px(OPEN_Y_OFFSET * (progress - 1.0)) + } else { + y + px(CLOSE_Y_OFFSET * (1.0 - progress)) + }; + this.top(top) + .opacity(progress) + .shadow(dialog_shadow(progress)) + }, + ) .into_any_element() } }), ) .map(move |this| { - if reduced_motion { - this.into_any_element() + if !should_animate || !transition_active { + this.opacity(presence.progress(1.0)).into_any_element() } else { - this.with_animation("fade-in", fade_animation, move |this, delta| { - this.opacity(delta) - }) + let fade_animation = if matches!(presence.phase, PresencePhase::Entering) + { + fade_in_animation + } else { + fade_out_animation + }; + this.with_animation( + SharedString::from(format!( + "dialog-fade-motion-{}", + u8::from(matches!(presence.phase, PresencePhase::Entering)) + )), + fade_animation, + move |this, delta| { + let opacity = presence.progress(delta); + this.opacity(opacity.clamp(0.0, 1.0)) + }, + ) .into_any_element() } }), diff --git a/crates/ui/src/dock/dock.rs b/crates/ui/src/dock/dock.rs index 43d9519e..c54f2436 100644 --- a/crates/ui/src/dock/dock.rs +++ b/crates/ui/src/dock/dock.rs @@ -398,9 +398,7 @@ impl Render for Dock { DockItem::Tiles { .. } => this, }) .child(self.render_resize_handle(window, cx)) - .child(DockElement { - view: cx.entity(), - }) + .child(DockElement { view: cx.entity() }) } } diff --git a/crates/ui/src/focus_trap.rs b/crates/ui/src/focus_trap.rs new file mode 100644 index 00000000..aea95a07 --- /dev/null +++ b/crates/ui/src/focus_trap.rs @@ -0,0 +1,222 @@ +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, FocusHandle, Global, GlobalElementId, + InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, Pixels, + StatefulInteractiveElement, StyleRefinement, Styled, WeakFocusHandle, Window, +}; +use std::collections::HashMap; + +/// Initialize the focus trap manager as a global +pub(crate) fn init(cx: &mut App) { + cx.set_global(FocusTrapManager::new()); +} + +/// An extension trait to add `focus_trap` functionality to interactive elements. +pub trait FocusTrapElement: InteractiveElement + Sized { + /// Enable focus trap for this element. + /// + /// When enabled, focus will automatically cycle within this container + /// instead of escaping to parent elements. This is useful for modal dialogs, + /// sheets, and other overlay components. + /// + /// The focus trap works by: + /// 1. Registering this element as a focus trap container + /// 2. When Tab/Shift-Tab is pressed, Root intercepts the event + /// 3. If focus would leave the container, it cycles back to the beginning/end + /// + /// # Example + /// + /// ```ignore + /// v_flex() + /// .child(Button::new("btn1").label("Button 1")) + /// .child(Button::new("btn2").label("Button 2")) + /// .child(Button::new("btn3").label("Button 3")) + /// .focus_trap("trap1", &self.container_focus_handle) + /// // Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1 + /// // Focus will not escape to elements outside this container + /// ``` + /// + /// See also: + fn focus_trap( + self, + id: impl Into, + focus_handle: &FocusHandle, + ) -> FocusTrapContainer + where + Self: ParentElement + Styled + Element + 'static, + { + FocusTrapContainer::new(id, focus_handle.clone(), self) + } +} +impl FocusTrapElement for T {} + +/// Global state to manage all focus trap containers +pub(crate) struct FocusTrapManager { + /// Map from container element ID to its focus trap info + traps: HashMap, +} + +impl Global for FocusTrapManager {} + +impl FocusTrapManager { + /// Create a new focus trap manager + fn new() -> Self { + Self { + traps: HashMap::new(), + } + } + + pub(crate) fn global(cx: &App) -> &Self { + cx.global::() + } + + fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Register a focus trap container + fn register_trap(id: &GlobalElementId, container_handle: WeakFocusHandle, cx: &mut App) { + let this = Self::global_mut(cx); + this.traps.insert(id.clone(), container_handle); + this.cleanup(); + } + + /// Find which focus trap contains the currently focused element + pub(crate) fn find_active_trap(window: &Window, cx: &App) -> Option { + for (_id, container_handle) in Self::global(cx).traps.iter() { + let Some(container) = container_handle.upgrade() else { + continue; + }; + + if container.contains_focused(window, cx) { + return Some(container); + } + } + None + } + + /// Cleanup any traps with dropped handles + fn cleanup(&mut self) { + self.traps.retain(|_, handle| handle.upgrade().is_some()); + } +} + +impl Default for FocusTrapManager { + fn default() -> Self { + Self::new() + } +} + +/// A wrapper element that implements focus trap behavior. +/// +/// This element wraps another element and registers it as a focus trap container. +/// Focus will automatically cycle within the container when Tab/Shift-Tab is pressed. +pub struct FocusTrapContainer { + id: ElementId, + focus_handle: FocusHandle, + base: E, +} + +impl FocusTrapContainer { + pub(crate) fn new(id: impl Into, focus_handle: FocusHandle, child: E) -> Self { + Self { + id: id.into(), + base: child.track_focus(&focus_handle), + focus_handle, + } + } +} + +impl IntoElement + for FocusTrapContainer +{ + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} +impl ParentElement + for FocusTrapContainer +{ + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} +impl InteractiveElement + for FocusTrapContainer +{ + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} +impl StatefulInteractiveElement + for FocusTrapContainer +{ +} +impl Styled for FocusTrapContainer { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl Element + for FocusTrapContainer +{ + type RequestLayoutState = E::RequestLayoutState; + type PrepaintState = E::PrepaintState; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + // Register this focus trap with the manager + FocusTrapManager::register_trap(global_id.unwrap(), self.focus_handle.downgrade(), cx); + + self.base.request_layout(global_id, None, window, cx) + } + + fn prepaint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.base + .prepaint(global_id, inspector_id, bounds, request_layout, window, cx) + } + + fn paint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.base.paint( + global_id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ) + } +} diff --git a/crates/ui/src/global_state.rs b/crates/ui/src/global_state.rs index 53f1ba38..c07a9ed1 100644 --- a/crates/ui/src/global_state.rs +++ b/crates/ui/src/global_state.rs @@ -20,7 +20,7 @@ impl GlobalState { pub(crate) fn new() -> Self { Self { text_view_state_stack: Vec::new(), - blur_enabled_stack: vec![true], // Default to enabled + blur_enabled_stack: vec![true], // Default to enabled reduced_motion_stack: vec![false], // Default to not reduced } } diff --git a/crates/ui/src/highlighter/highlighter.rs b/crates/ui/src/highlighter/highlighter.rs index 9eed10da..077bf393 100644 --- a/crates/ui/src/highlighter/highlighter.rs +++ b/crates/ui/src/highlighter/highlighter.rs @@ -1,5 +1,4 @@ use crate::highlighter::{HighlightTheme, LanguageRegistry}; -use crate::input::RopeExt; use anyhow::{Context, Result, anyhow}; use gpui::{HighlightStyle, SharedString}; @@ -10,10 +9,7 @@ use std::{ ops::Range, usize, }; -use sum_tree::Bias; -use tree_sitter::{ - InputEdit, Node, Parser, Point, Query, QueryCursor, QueryMatch, StreamingIterator, Tree, -}; +use tree_sitter::{InputEdit, Parser, Point, Query, QueryCursor, StreamingIterator, Tree}; /// A syntax highlighter that supports incremental parsing, multiline text, /// and caching of highlight results. @@ -21,6 +17,8 @@ use tree_sitter::{ pub struct SyntaxHighlighter { language: SharedString, query: Option, + /// A separate query for injection patterns that have `#set! injection.combined`. + combined_injections_query: Option, injection_queries: HashMap, locals_pattern_index: usize, @@ -29,6 +27,7 @@ pub struct SyntaxHighlighter { non_local_variable_patterns: Vec, injection_content_capture_index: Option, injection_language_capture_index: Option, + combined_injection_content_capture_index: Option, local_scope_capture_index: Option, local_def_capture_index: Option, local_def_value_capture_index: Option, @@ -196,7 +195,7 @@ impl SyntaxHighlighter { // Construct a single query by concatenating the three query strings, but record the // range of pattern indices that belong to each individual string. - let query = Query::new(&config.language, &query_source).context("new query")?; + let mut query = Query::new(&config.language, &query_source).context("new query")?; let mut locals_pattern_index = 0; let mut highlights_pattern_index = 0; @@ -212,27 +211,37 @@ impl SyntaxHighlighter { } } - // let Some(mut combined_injections_query) = - // Query::new(&config.language, &config.injections).ok() - // else { - // return None; - // }; - - // let mut has_combined_queries = false; - // for pattern_index in 0..locals_pattern_index { - // let settings = query.property_settings(pattern_index); - // if settings.iter().any(|s| &*s.key == "injection.combined") { - // has_combined_queries = true; - // query.disable_pattern(pattern_index); - // } else { - // combined_injections_query.disable_pattern(pattern_index); - // } - // } - // let combined_injections_query = if has_combined_queries { - // Some(combined_injections_query) - // } else { - // None - // }; + // Separate combined injection patterns into their own query. + // Combined injections (e.g., PHP's HTML text nodes) collect all matching + // ranges and parse them as a single document, so that opening/closing + // tags across injection boundaries are correctly matched. + let combined_injections_query = if !config.injections.is_empty() { + if let Ok(mut ciq) = Query::new(&config.language, &config.injections) { + let mut has_combined_query = false; + for pattern_index in 0..locals_pattern_index { + let settings = query.property_settings(pattern_index); + if settings.iter().any(|s| &*s.key == "injection.combined") { + has_combined_query = true; + query.disable_pattern(pattern_index); + } else { + ciq.disable_pattern(pattern_index); + } + } + if has_combined_query { Some(ciq) } else { None } + } else { + None + } + } else { + None + }; + + let combined_injection_content_capture_index = + combined_injections_query.as_ref().and_then(|q| { + q.capture_names() + .iter() + .position(|name| *name == "injection.content") + .map(|i| i as u32) + }); // Find all of the highlighting patterns that are disabled for nodes that // have been identified as local variables. @@ -288,6 +297,7 @@ impl SyntaxHighlighter { Ok(Self { language: config.name.clone(), query: Some(query), + combined_injections_query, injection_queries, locals_pattern_index, @@ -295,6 +305,7 @@ impl SyntaxHighlighter { non_local_variable_patterns, injection_content_capture_index, injection_language_capture_index, + combined_injection_content_capture_index, local_scope_capture_index, local_def_capture_index, local_def_value_capture_index, @@ -365,26 +376,67 @@ impl SyntaxHighlighter { }; let root_node = tree.root_node(); - let source = &self.text; - let mut cursor = QueryCursor::new(); - cursor.set_byte_range(range); - let mut matches = cursor.matches(&query, root_node, TextProvider(&source)); - while let Some(query_match) = matches.next() { - // Ref: - // https://github.com/tree-sitter/tree-sitter/blob/460118b4c82318b083b4d527c9c750426730f9c0/highlight/src/lib.rs#L556 - if let (Some(language_name), Some(content_node), _) = - self.injection_for_match(None, query, query_match) - { - let styles = self.handle_injection(&language_name, content_node); - for (node_range, highlight_name) in styles { - highlights.push(HighlightItem::new(node_range.clone(), highlight_name)); + // Process combined injections first. + if let Some(combined_query) = &self.combined_injections_query { + let mut cursor = QueryCursor::new(); + // Do NOT restrict to visible range — we need all nodes for context + let mut matches = cursor.matches(combined_query, root_node, TextProvider(source)); + + // Group ranges by injection language + let mut combined_ranges: HashMap> = + HashMap::new(); + while let Some(query_match) = matches.next() { + // Extract language name from property settings + let mut language_name: Option = None; + + if let Some(prop) = combined_query + .property_settings(query_match.pattern_index) + .iter() + .find(|prop| prop.key.as_ref() == "injection.language") + { + language_name = prop + .value + .as_ref() + .map(|v| SharedString::from(v.to_string())); } - continue; + let Some(language_name) = language_name else { + continue; + }; + + // Collect content node ranges + for capture in query_match + .captures + .iter() + .filter(|cap| Some(cap.index) == self.combined_injection_content_capture_index) + { + let node = capture.node; + combined_ranges + .entry(language_name.clone()) + .or_default() + .push(node.range()); + } } + // Parse each combined language group + for (language_name, ranges) in combined_ranges { + if ranges.is_empty() { + continue; + } + let styles = self.handle_combined_injection(&language_name, &ranges, &range); + for (node_range, highlight_name) in styles { + highlights.push(HighlightItem::new(node_range, highlight_name)); + } + } + } + + let mut cursor = QueryCursor::new(); + cursor.set_byte_range(range); + let mut matches = cursor.matches(&query, root_node, TextProvider(&source)); + + while let Some(query_match) = matches.next() { for cap in query_match.captures { let node = cap.node; @@ -429,58 +481,68 @@ impl SyntaxHighlighter { highlights } - /// TODO: Use incremental parsing to handle the injection. - fn handle_injection( + /// Handle combined injections by parsing all ranges as a single document. + /// + /// Uses `Parser::set_included_ranges` so the injection language parser + /// only sees the combined text ranges but byte offsets in the resulting + /// tree correspond to positions in the original document. + /// + /// `visible_range` limits which highlights are returned (for performance), + /// but the parser sees all ranges for correctness. + fn handle_combined_injection( &self, injection_language: &str, - node: Node, + ranges: &[tree_sitter::Range], + visible_range: &Range, ) -> Vec<(Range, String)> { - // Ensure byte offsets are on char boundaries for UTF-8 safety - let start_offset = self.text.clip_offset(node.start_byte(), Bias::Left); - let end_offset = self.text.clip_offset(node.end_byte(), Bias::Right); - let mut cache = vec![]; - let Some(query) = &self.injection_queries.get(injection_language) else { - return cache; - }; - - let content = self.text.slice(start_offset..end_offset); - if content.len() == 0 { + let Some(query) = self.injection_queries.get(injection_language) else { return cache; }; - // FIXME: Avoid to_string. - let content = content.to_string(); - let Some(config) = LanguageRegistry::singleton().language(injection_language) else { return cache; }; + let mut parser = Parser::new(); if parser.set_language(&config.language).is_err() { return cache; } + if parser.set_included_ranges(ranges).is_err() { + return cache; + } - let source = content.as_bytes(); - let Some(tree) = parser.parse(source, None) else { + // Parse the full source text — the parser will only look at the + // included ranges but the resulting byte offsets match the original. + let Some(tree) = parser.parse_with_options( + &mut move |offset, _| { + if offset >= self.text.len() { + "" + } else { + let (chunk, chunk_byte_ix) = self.text.chunk(offset); + &chunk[offset - chunk_byte_ix..] + } + }, + None, + None, + ) else { return cache; }; let mut query_cursor = QueryCursor::new(); - let mut matches = query_cursor.matches(query, tree.root_node(), source); + // Only return highlights within the visible range + query_cursor.set_byte_range(visible_range.clone()); - let mut last_end = start_offset; + let mut matches = query_cursor.matches(query, tree.root_node(), TextProvider(&self.text)); + + let mut last_end = 0usize; while let Some(m) = matches.next() { for cap in m.captures { let cap_node = cap.node; - - let node_range: Range = - start_offset + cap_node.start_byte()..start_offset + cap_node.end_byte(); + let node_range = cap_node.start_byte()..cap_node.end_byte(); if node_range.start < last_end { continue; } - if node_range.end > end_offset { - break; - } if let Some(highlight_name) = query.capture_names().get(cap.index as usize) { last_end = node_range.end; @@ -492,79 +554,6 @@ impl SyntaxHighlighter { cache } - /// Ref: - /// https://github.com/tree-sitter/tree-sitter/blob/v0.25.5/highlight/src/lib.rs#L1229 - /// - /// Returns: - /// - `language_name`: The language name of the injection. - /// - `content_node`: The content node of the injection. - /// - `include_children`: Whether to include the children of the content node. - fn injection_for_match<'a>( - &self, - parent_name: Option, - query: &'a Query, - query_match: &QueryMatch<'a, 'a>, - ) -> (Option, Option>, bool) { - let content_capture_index = self.injection_content_capture_index; - // let language_capture_index = self.injection_language_capture_index; - - let mut language_name: Option = None; - let mut content_node = None; - - for capture in query_match.captures { - let index = Some(capture.index); - if index == content_capture_index { - content_node = Some(capture.node); - } - } - - let mut include_children = false; - for prop in query.property_settings(query_match.pattern_index) { - match prop.key.as_ref() { - // In addition to specifying the language name via the text of a - // captured node, it can also be hard-coded via a `#set!` predicate - // that sets the injection.language key. - "injection.language" => { - if language_name.is_none() { - language_name = prop - .value - .as_ref() - .map(std::convert::AsRef::as_ref) - .map(ToString::to_string) - .map(SharedString::from); - } - } - - // Setting the `injection.self` key can be used to specify that the - // language name should be the same as the language of the current - // layer. - "injection.self" => { - if language_name.is_none() { - language_name = Some(self.language.clone()); - } - } - - // Setting the `injection.parent` key can be used to specify that - // the language name should be the same as the language of the - // parent layer - "injection.parent" => { - if language_name.is_none() { - language_name = parent_name.clone(); - } - } - - // By default, injections do not include the *children* of an - // `injection.content` node - only the ranges that belong to the - // node itself. This can be changed using a `#set!` predicate that - // sets the `injection.include-children` key. - "injection.include-children" => include_children = true, - _ => {} - } - } - - (language_name, content_node, include_children) - } - /// Returns the syntax highlight styles for a range of text. /// /// The argument `range` is the range of bytes in the text to highlight. @@ -802,6 +791,55 @@ mod tests { } } + #[test] + #[cfg(feature = "tree-sitter-languages")] + fn test_php_combined_injection_closing_tags() { + let php_code = r#" + + +

+
    + +
  • + +
+ + +"#; + + let rope = Rope::from_str(php_code); + let mut highlighter = SyntaxHighlighter::new("php"); + highlighter.update(None, &rope); + + assert!( + highlighter.combined_injections_query.is_some(), + "PHP should have combined injections query" + ); + + let full_range = 0..php_code.len(); + let highlights = highlighter.match_styles(full_range); + + // Verify all closing HTML tags are highlighted + let closing_tags = ["", "", "", "", ""]; + for tag in closing_tags { + let pos = php_code.find(tag).unwrap(); + let tag_name_start = pos + 2; // after "" + + let has_highlight = highlights + .iter() + .any(|item| item.range.start <= tag_name_start && item.range.end >= tag_name_end); + + assert!( + has_highlight, + "closing tag {} at byte {} should be highlighted", + tag, pos + ); + } + } + #[test] fn test_unique_styles() { let red = color_style(gpui::red()); diff --git a/crates/ui/src/highlighter/languages.rs b/crates/ui/src/highlighter/languages.rs index 2bbfc2d8..0ad63c3c 100644 --- a/crates/ui/src/highlighter/languages.rs +++ b/crates/ui/src/highlighter/languages.rs @@ -32,6 +32,7 @@ pub enum Language { Make, Markdown, MarkdownInline, + Php, Proto, Python, Ruby, @@ -84,6 +85,7 @@ impl Language { Self::Make => "make", Self::Markdown => "markdown", Self::MarkdownInline => "markdown_inline", + Self::Php => "php", Self::Proto => "proto", Self::Python => "python", Self::Ruby => "ruby", @@ -126,6 +128,7 @@ impl Language { "make" | "makefile" => Self::Make, "markdown" | "md" | "mdx" => Self::Markdown, "markdown_inline" | "markdown-inline" => Self::MarkdownInline, + "php" | "php3" | "php4" | "php5" | "phtml" => Self::Php, "proto" | "protobuf" => Self::Proto, "python" | "py" => Self::Python, "ruby" | "rb" => Self::Ruby, @@ -165,6 +168,15 @@ impl Language { "yaml", "graphql", ], + Self::Php => vec![ + "php", + "html", + "css", + "javascript", + "json", + "jsdoc", + "graphql", + ], _ => vec![], } .into_iter() @@ -356,6 +368,12 @@ impl Language { tree_sitter_embedded_template::INJECTIONS_EJS_QUERY, "", ), + Self::Php => ( + tree_sitter_php::LANGUAGE_PHP, + tree_sitter_php::HIGHLIGHTS_QUERY, + include_str!("languages/php/injections.scm"), + "", + ), }; let language = tree_sitter::Language::new(language); diff --git a/crates/ui/src/highlighter/languages/php/injections.scm b/crates/ui/src/highlighter/languages/php/injections.scm new file mode 100644 index 00000000..9fcc462a --- /dev/null +++ b/crates/ui/src/highlighter/languages/php/injections.scm @@ -0,0 +1,20 @@ +; PHP injection rules +; Based on tree-sitter-php injections.scm with added HTML support for text nodes + +((comment) @injection.content + (#set! injection.language "phpdoc")) + +(heredoc + (heredoc_body) @injection.content + (heredoc_end) @injection.language) + +(nowdoc + (nowdoc_body) @injection.content + (heredoc_end) @injection.language) + +; HTML in text nodes (content outside tags) +; injection.combined tells the highlighter to merge all text nodes into a single +; HTML document before parsing, so opening/closing tags across PHP blocks match. +((text) @injection.content + (#set! injection.language "html") + (#set! injection.combined)) diff --git a/crates/ui/src/highlighter/registry.rs b/crates/ui/src/highlighter/registry.rs index 3de1b7aa..f17a2fe2 100644 --- a/crates/ui/src/highlighter/registry.rs +++ b/crates/ui/src/highlighter/registry.rs @@ -421,6 +421,8 @@ pub struct HighlightThemeStyle { pub editor_line_number: Option, #[serde(rename = "editor.active_line_number")] pub editor_active_line_number: Option, + #[serde(rename = "editor.invisible")] + pub editor_invisible: Option, #[serde(flatten)] pub status: StatusColors, #[serde(rename = "syntax")] diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 3e0070e3..65bbf14f 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -14,7 +14,7 @@ use crate::{ input::{RopeExt as _, blink_cursor::CURSOR_WIDTH, text_wrapper::LineLayout}, }; -use super::{InputState, LastLayout, mode::InputMode}; +use super::{InputState, LastLayout, WhitespaceIndicators, mode::InputMode}; const BOTTOM_MARGIN_ROWS: usize = 3; pub(super) const RIGHT_MARGIN: Pixels = px(10.); @@ -573,6 +573,63 @@ impl TextElement { (line_number_width, line_number_len) } + /// Layout shaped lines for whitespace indicators (space and tab). + /// + /// Returns `WhitespaceIndicators` with shaped lines for space and tab characters. + fn layout_whitespace_indicators( + state: &InputState, + text_size: Pixels, + style: &TextStyle, + window: &mut Window, + cx: &App, + ) -> Option { + if !state.show_whitespaces { + return None; + } + + let invisible_color = cx + .theme() + .highlight_theme + .style + .editor_invisible + .unwrap_or(cx.theme().muted_foreground); + + let space_font_size = text_size.half(); + let tab_font_size = text_size; + + let space_text = SharedString::new_static("•"); + let space = window.text_system().shape_line( + space_text.clone(), + space_font_size, + &[TextRun { + len: space_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + let tab_text = SharedString::new_static("→"); + let tab = window.text_system().shape_line( + tab_text.clone(), + tab_font_size, + &[TextRun { + len: tab_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + Some(WhitespaceIndicators { space, tab }) + } + /// Compute inline completion ghost lines for rendering. /// /// Returns (first_line, ghost_lines) where: @@ -658,6 +715,7 @@ impl TextElement { (first_line, ghost_lines) } + #[allow(clippy::too_many_arguments)] fn layout_lines( state: &InputState, display_text: &Rope, @@ -665,6 +723,7 @@ impl TextElement { font_size: Pixels, runs: &[TextRun], bg_segments: &[(Range, Hsla)], + whitespace_indicators: Option, window: &mut Window, ) -> Vec { let is_single_line = state.mode.is_single_line(); @@ -680,7 +739,10 @@ impl TextElement { None, ); - return vec![LineLayout::new().lines(smallvec::smallvec![shaped_line])]; + let line_layout = LineLayout::new() + .lines(smallvec::smallvec![shaped_line]) + .with_whitespaces(whitespace_indicators); + return vec![line_layout]; } // Empty to use placeholder, the placeholder is not in the text_wrapper map. @@ -695,7 +757,9 @@ impl TextElement { &runs, None, ); - LineLayout::new().lines(smallvec::smallvec![shaped_line]) + LineLayout::new() + .lines(smallvec::smallvec![shaped_line]) + .with_whitespaces(whitespace_indicators.clone()) }) .collect(); } @@ -714,7 +778,6 @@ impl TextElement { debug_assert_eq!(line_item.len(), line.len()); - let mut line_layout = LineLayout::new(); let mut wrapped_lines = SmallVec::with_capacity(1); for range in &line_item.wrapped_lines { @@ -737,7 +800,9 @@ impl TextElement { wrapped_lines.push(shaped_line); } - line_layout.set_wrapped_lines(wrapped_lines); + let line_layout = LineLayout::new() + .lines(wrapped_lines) + .with_whitespaces(whitespace_indicators.clone()); lines.push(line_layout); // +1 for the `\n` @@ -1078,6 +1143,11 @@ impl Element for TextElement { let document_colors = state .lsp .document_colors_for_range(&text, &last_layout.visible_range); + + // Create shaped lines for whitespace indicators before layout + let whitespace_indicators = + Self::layout_whitespace_indicators(&state, text_size, &text_style, window, cx); + let lines = Self::layout_lines( &state, &display_text, @@ -1085,6 +1155,7 @@ impl Element for TextElement { text_size, &runs, &document_colors, + whitespace_indicators, window, ); @@ -1399,11 +1470,15 @@ impl Element for TextElement { px(0.) }; + // Track the y-position of the cursor row for positioning the first line suffix + let mut cursor_row_y = None; + for (ix, line) in prepaint.last_layout.lines.iter().enumerate() { let row = visible_range.start + ix; + let line_y = origin.y + offset_y; let p = point( origin.x + prepaint.last_layout.line_number_width + (scroll_offset), - origin.y + offset_y, + line_y, ); // Paint the actual line @@ -1417,6 +1492,10 @@ impl Element for TextElement { ); offset_y += line.size(line_height).height; + if Some(row) == prepaint.current_row { + cursor_row_y = Some(line_y); + } + // After the cursor row, paint ghost lines (which shifts subsequent content down) if has_ghost_lines && Some(row) == prepaint.current_row { let ghost_x = origin.x + prepaint.last_layout.line_number_width; @@ -1495,7 +1574,7 @@ impl Element for TextElement { } // Add ghost line height after cursor row for line numbers alignment - if !prepaint.ghost_lines.is_empty() && prepaint.current_row.is_some() { + if !prepaint.ghost_lines.is_empty() && prepaint.current_row == Some(row) { offset_y += prepaint.ghost_lines_height; } } @@ -1521,9 +1600,11 @@ impl Element for TextElement { // Paint inline completion first line suffix (after cursor on same line) if focused { if let Some(first_line) = &prepaint.ghost_first_line { - if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { + if let (Some(cursor_bounds), Some(cursor_row_y)) = + (prepaint.cursor_bounds_with_scroll(), cursor_row_y) + { let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; - let p = point(first_line_x, cursor_bounds.origin.y); + let p = point(first_line_x, cursor_row_y); // Paint background to cover any existing text let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height)); @@ -1678,10 +1759,7 @@ mod tests { ..run.clone() }, // this-is-test - TextRun { - len: 12, - ..run - }, + TextRun { len: 12, ..run }, ]; #[track_caller] @@ -1722,10 +1800,7 @@ mod tests { len: 7, ..run.clone() }, - TextRun { - len: 24, - ..run - }, + TextRun { len: 24, ..run }, ]; let bg_segments = vec![(8..12, gpui::red()), (12..18, gpui::blue())]; diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index e06081c1..f6cc3c1a 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -271,8 +271,11 @@ impl RenderOnce for Input { let prefix = self.prefix; let suffix = self.suffix; - let show_clear_button = - self.cleanable && !state.loading && state.text.len() > 0 && state.mode.is_single_line(); + let show_clear_button = self.cleanable + && !state.disabled + && !state.loading + && state.text.len() > 0 + && state.mode.is_single_line(); let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; div() diff --git a/crates/ui/src/input/lsp/document_colors.rs b/crates/ui/src/input/lsp/document_colors.rs index 2b473b82..015ea002 100644 --- a/crates/ui/src/input/lsp/document_colors.rs +++ b/crates/ui/src/input/lsp/document_colors.rs @@ -1,4 +1,5 @@ use std::ops::Range; +use std::time::Duration; use anyhow::Result; use gpui::{App, Context, Hsla, Task, Window}; @@ -57,35 +58,46 @@ impl Lsp { return; }; - let task = provider.document_colors(text, window, cx); - self._hover_task = cx.spawn_in(window, async move |editor, cx| { - let colors = task.await?; + let provider = provider.clone(); + let text = text.clone(); + let input_state = cx.entity(); - editor.update(cx, |editor, cx| { - let mut document_colors: Vec<(lsp_types::Range, Hsla)> = colors - .iter() - .map(|info| { - let color = gpui::Rgba { - r: info.color.red, - g: info.color.green, - b: info.color.blue, - a: info.color.alpha, - } - .into(); + // debounce timer 100ms + self._document_color_task = cx.spawn_in(window, async move |_, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; - (info.range, color) - }) - .collect(); - document_colors.sort_by_key(|(range, _)| range.start); + let task_result = cx + .update(|window, cx| provider.document_colors(&text, window, cx)) + .ok(); - if document_colors == editor.lsp.document_colors { - return; - } - editor.lsp.document_colors = document_colors; - cx.notify(); - })?; + if let Some(task) = task_result { + if let Ok(colors) = task.await { + let _ = input_state.update(cx, |input_state, cx| { + let mut document_colors: Vec<(lsp_types::Range, Hsla)> = colors + .iter() + .map(|info| { + let color = gpui::Rgba { + r: info.color.red, + g: info.color.green, + b: info.color.blue, + a: info.color.alpha, + } + .into(); + + (info.range, color) + }) + .collect(); + document_colors.sort_by_key(|(range, _)| range.start); - Ok(()) + if document_colors != input_state.lsp.document_colors { + input_state.lsp.document_colors = document_colors; + cx.notify(); + } + }); + } + } }); } } diff --git a/crates/ui/src/input/lsp/mod.rs b/crates/ui/src/input/lsp/mod.rs index 94fe4cb9..b9de0cf3 100644 --- a/crates/ui/src/input/lsp/mod.rs +++ b/crates/ui/src/input/lsp/mod.rs @@ -34,7 +34,7 @@ pub struct Lsp { document_colors: Vec<(lsp_types::Range, Hsla)>, _hover_task: Task>, - _document_color_task: Task>, + _document_color_task: Task<()>, } impl Default for Lsp { @@ -47,7 +47,7 @@ impl Default for Lsp { document_color_provider: None, document_colors: vec![], _hover_task: Task::ready(Ok(())), - _document_color_task: Task::ready(Ok(())), + _document_color_task: Task::ready(()), } } } @@ -67,7 +67,7 @@ impl Lsp { pub(crate) fn reset(&mut self) { self.document_colors.clear(); self._hover_task = Task::ready(Ok(())); - self._document_color_task = Task::ready(Ok(())); + self._document_color_task = Task::ready(()); } } diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index b77c437f..24e00b3b 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -90,7 +90,8 @@ impl RenderOnce for CompletionMenuItem { .filter_text .as_ref() .map(|s| s.len()) - .unwrap_or(self.highlight_prefix.len()); + .unwrap_or(self.highlight_prefix.len()) + .min(item.label.len()); let highlights = vec![( 0..matched_len, diff --git a/crates/ui/src/input/selection.rs b/crates/ui/src/input/selection.rs index 78bf6550..358c75c6 100644 --- a/crates/ui/src/input/selection.rs +++ b/crates/ui/src/input/selection.rs @@ -18,11 +18,34 @@ enum CharType { Other, } +/// Implementation based on +fn is_word_char(c: char) -> bool { + matches!(c, '_' ) || + // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. + c.is_ascii_alphanumeric() || + // Latin script in Unicode for French, German, Spanish, etc. + // Latin-1 Supplement + // https://en.wikipedia.org/wiki/Latin-1_Supplement + matches!(c, '\u{00C0}'..='\u{00FF}') || + // Latin Extended-A + // https://en.wikipedia.org/wiki/Latin_Extended-A + matches!(c, '\u{0100}'..='\u{017F}') || + // Latin Extended-B + // https://en.wikipedia.org/wiki/Latin_Extended-B + matches!(c, '\u{0180}'..='\u{024F}') || + // Cyrillic for Russian, Ukrainian, etc. + // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode + matches!(c, '\u{0400}'..='\u{04FF}') || + + // Vietnamese (https://vietunicode.sourceforge.net/charset/) + matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional + matches!(c, '\u{0300}'..='\u{036F}') // Combining Diacritical Marks +} + impl From for CharType { fn from(c: char) -> Self { match c { - '_' => CharType::Word, - c if c.is_ascii_alphanumeric() => CharType::Word, + c if is_word_char(c) => CharType::Word, c if c == '\n' || c == '\r' => CharType::Newline, c if c.is_whitespace() => CharType::Whitespace, _ => CharType::Other, @@ -144,7 +167,13 @@ mod tests { assert_eq!(CharType::from('\n'), CharType::Newline); assert_eq!(CharType::from('\r'), CharType::Newline); assert_eq!(CharType::from('汉'), CharType::Other); - assert_eq!(CharType::from('é'), CharType::Other); + // European letters + assert_eq!(CharType::from('é'), CharType::Word); + assert_eq!(CharType::from('ä'), CharType::Word); + assert_eq!(CharType::from('ö'), CharType::Word); + assert_eq!(CharType::from('ü'), CharType::Word); + //Cyrillic letters + assert_eq!(CharType::from('д'), CharType::Word); } #[test] @@ -158,6 +187,8 @@ mod tests { hello[()] test_connector ____ Rope + rök + grande île "# }); @@ -179,6 +210,8 @@ mod tests { (3, 14, Some(" ")), (3, 16, Some("____")), (4, 0, Some("Rope")), + (5, 0, Some("rök")), + (6, 8, Some("île")), ]; for (line, column, expected) in tests { diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 0733ff38..b54b6ad8 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -7,8 +7,9 @@ use gpui::{ Action, App, AppContext, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, - Task, UTF16Selection, Window, actions, div, point, prelude::FluentBuilder as _, px, + Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Styled as _, + Subscription, Task, UTF16Selection, Window, actions, div, point, prelude::FluentBuilder as _, + px, }; use gpui::{Half, TextAlign}; use ropey::{Rope, RopeSlice}; @@ -224,6 +225,15 @@ pub(crate) fn init(cx: &mut App) { number_input::init(cx); } +/// Whitespace indicators for rendering spaces and tabs. +#[derive(Clone, Default)] +pub(crate) struct WhitespaceIndicators { + /// Shaped line for space character indicator (•) + pub(crate) space: ShapedLine, + /// Shaped line for tab character indicator (→) + pub(crate) tab: ShapedLine, +} + #[derive(Clone)] pub(super) struct LastLayout { /// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index. @@ -306,6 +316,7 @@ pub struct InputState { pub(super) masked: bool, pub(super) clean_on_escape: bool, pub(super) soft_wrap: bool, + pub(super) show_whitespaces: bool, pub(super) pattern: Option, pub(super) validate: Option) -> bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, @@ -400,6 +411,7 @@ impl InputState { masked: false, clean_on_escape: false, soft_wrap: true, + show_whitespaces: false, loading: false, pattern: None, validate: None, @@ -619,10 +631,7 @@ impl InputState { cx: &mut Context, ) { self.history.ignore = true; - let was_disabled = self.disabled; - self.disabled = false; self.replace_text(value, window, cx); - self.disabled = was_disabled; self.history.ignore = false; // Ensure cursor to start when set text @@ -652,10 +661,13 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor())); self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); + self.disabled = was_disabled; } /// Replace text at the current cursor position. @@ -667,9 +679,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); self.replace_text_in_range_silent(None, &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); + self.disabled = was_disabled; } fn replace_text( @@ -678,10 +693,13 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); self.replace_text_in_range_silent(Some(range), &text, window, cx); self.reset_highlighter(cx); + self.disabled = was_disabled; } /// Set with disabled mode. @@ -724,6 +742,12 @@ impl InputState { self } + /// Set whether to show whitespace characters. + pub fn show_whitespaces(mut self, show: bool) -> Self { + self.show_whitespaces = show; + self + } + /// Update the soft wrap mode for multi-line input, default is true. pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { debug_assert!(self.mode.is_multi_line()); @@ -747,6 +771,12 @@ impl InputState { cx.notify(); } + /// Update whether to show whitespace characters. + pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context) { + self.show_whitespaces = show; + cx.notify(); + } + /// Set the regular expression pattern of the input field. /// /// Only for [`InputMode::SingleLine`] mode. diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 8caecd2c..1bd0a451 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -1,12 +1,13 @@ use std::ops::Range; use gpui::{ - App, Font, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, size, + App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, + size, }; use ropey::Rope; use smallvec::SmallVec; -use crate::input::{LastLayout, RopeExt}; +use crate::input::{LastLayout, RopeExt, WhitespaceIndicators}; /// A line with soft wrapped lines info. #[derive(Debug, Clone)] @@ -344,6 +345,9 @@ pub(crate) struct LineLayout { /// The soft wrapped lines of this line (Include the first line). pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, pub(crate) longest_width: Pixels, + pub(crate) whitespace_indicators: Option, + /// Whitespace indicators: (line_index, x_position, is_tab) + pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>, } impl LineLayout { @@ -352,6 +356,8 @@ impl LineLayout { len: 0, longest_width: px(0.), wrapped_lines: SmallVec::new(), + whitespace_chars: Vec::new(), + whitespace_indicators: None, } } @@ -371,6 +377,34 @@ impl LineLayout { self.wrapped_lines = wrapped_lines; } + pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { + self.whitespace_indicators = indicators; + let Some(indicators) = self.whitespace_indicators.as_ref() else { + return self; + }; + + let space_indicator_offset = indicators.space.width.half(); + + for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() { + for (relative_offset, c) in wrapped_line.text.char_indices() { + if matches!(c, ' ' | '\t') { + let is_tab = c == '\t'; + let start_x = wrapped_line.x_for_index(relative_offset); + let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8()); + // Center the indicator in the actual character's space + let x_position = if c == ' ' { + (start_x + end_x).half() - space_indicator_offset + } else { + start_x + }; + + self.whitespace_chars.push((line_index, x_position, is_tab)); + } + } + } + self + } + #[inline] pub(super) fn len(&self) -> usize { self.len @@ -507,6 +541,24 @@ impl LineLayout { cx, ); } + + // Paint whitespace indicators + if let Some(indicators) = self.whitespace_indicators.as_ref() { + for (line_index, x_position, is_tab) in &self.whitespace_chars { + let invisible = if *is_tab { + indicators.tab.clone() + } else { + indicators.space.clone() + }; + + let origin = point( + pos.x + *x_position, + pos.y + *line_index as f32 * line_height, + ); + + _ = invisible.paint(origin, line_height, text_align, align_width, window, cx); + } + } } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f5120657..3ab0eb6c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -4,6 +4,7 @@ use std::ops::Deref; mod anchored; mod element_ext; mod event; +mod focus_trap; mod geometry; mod global_state; mod icon; @@ -25,7 +26,6 @@ pub(crate) mod actions; pub mod accordion; pub mod alert; -pub mod command_palette; pub mod animation; pub mod avatar; pub mod badge; @@ -36,6 +36,7 @@ pub mod checkbox; pub mod clipboard; pub mod collapsible; pub mod color_picker; +pub mod command_palette; pub mod description_list; pub mod dialog; pub mod divider; @@ -81,6 +82,7 @@ pub use crate::Disableable; pub(crate) use anchored::*; pub use element_ext::ElementExt; pub use event::InteractiveElementExt; +pub use focus_trap::FocusTrapElement; pub use geometry::*; pub use icon::*; pub use index_path::IndexPath; @@ -110,6 +112,7 @@ pub fn init(cx: &mut App) { #[cfg(any(feature = "inspector", debug_assertions))] inspector::init(cx); root::init(cx); + focus_trap::init(cx); color_picker::init(cx); date_picker::init(cx); dock::init(cx); diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index a7395bac..5ffa2099 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -1,13 +1,15 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, Focusable, - GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled, - Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px, + AnimationExt as _, AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, + Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, + IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, + Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px, }; -use crate::menu::PopupMenu; +use crate::{ + ActiveTheme, animation::fast_invoke_animation, global_state::GlobalState, menu::PopupMenu, +}; /// A extension trait for adding a context menu to an element. pub trait ContextMenuExt: ParentElement + Styled { @@ -18,8 +20,14 @@ pub trait ContextMenuExt: ParentElement + Styled { fn context_menu( self, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> ContextMenu { - ContextMenu::new("context-menu", self).menu(f) + ) -> ContextMenu + where + Self: Sized, + { + // Generate a unique ID based on the element's memory address to ensure + // each context menu has its own state and doesn't share with others + let id = format!("context-menu-{:p}", &self as *const _); + ContextMenu::new(id, self).menu(f) } } @@ -166,6 +174,10 @@ impl Element for ContextMenu< .unwrap_or(false); if has_menu_item { + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let anim = fast_invoke_animation(motion, reduced_motion); + menu_element = Some( deferred( anchored().child( @@ -191,7 +203,19 @@ impl Element for ContextMenu< this.child(menu) }), - ), + ) + .map(|el| { + if let Some(anim) = anim { + el.with_animation( + "context-menu-enter", + anim, + |el, delta| el.opacity(delta), + ) + .into_any_element() + } else { + el.into_any_element() + } + }), ), ) .with_priority(1) diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index 75cb4900..4ac14dda 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -5,7 +5,7 @@ use gpui::{ RenderOnce, SharedString, StyleRefinement, Styled, Window, }; -use crate::{Selectable, button::Button, menu::PopupMenu, popover::Popover}; +use crate::{ActiveTheme, Selectable, button::Button, menu::PopupMenu, popover::Popover}; /// A dropdown menu trait for buttons and other interactive elements pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { @@ -120,9 +120,19 @@ where move |_, _: &DismissEvent, window, cx| { popover_state.update(cx, |state, cx| { state.dismiss(window, cx); - }); - menu_state.update(cx, |state, _| { - state.menu = None; + let dismiss_duration = std::time::Duration::from_millis( + u64::from(cx.theme().motion.fade_duration_ms), + ); + cx.spawn({ + let menu_state = menu_state.clone(); + async move |_, cx| { + cx.background_executor().timer(dismiss_duration).await; + _ = menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); }); } }) diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 6d5c0240..c17627aa 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -3,13 +3,13 @@ use crate::actions::{SelectLeft, SelectRight}; use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; -use crate::{h_flex, kbd::Kbd, v_flex, Side, Size, SurfacePreset}; use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, SurfaceContext}; +use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ - anchored, div, prelude::FluentBuilder, px, rems, Action, AnyElement, App, AppContext, Bounds, - Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, ScrollHandle, - SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, + Action, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, Edges, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, + ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, + WeakEntity, Window, anchored, div, prelude::FluentBuilder, px, rems, }; use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Point, Subscription}; use std::rc::Rc; diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index ee9c82ec..7bc8577c 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -19,39 +19,12 @@ use smol::Timer; use crate::{ ActiveTheme as _, Anchor, Edges, Icon, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, - animation::cubic_bezier, + animation::animation_with_theme_easing, button::{Button, ButtonVariants as _}, global_state::GlobalState, h_flex, v_flex, }; -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - fn notification_element_id(id: &NotificationId) -> ElementId { let mut hasher = DefaultHasher::new(); id.hash(&mut hasher); diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 27794d86..241918fa 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,12 +1,17 @@ use gpui::{ - AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter, - FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled, - Subscription, Window, deferred, div, prelude::FluentBuilder as _, px, + AnimationExt as _, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, + EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Stateful, + StyleRefinement, Styled, Subscription, Window, deferred, div, prelude::FluentBuilder as _, px, }; use std::rc::Rc; -use crate::{Anchor, ElementExt, Selectable, StyledExt as _, actions::Cancel, anchored, v_flex}; +use crate::{ + ActiveTheme, Anchor, ElementExt, Selectable, StyledExt as _, actions::Cancel, anchored, + animation::{PresenceOptions, PresencePhase, fade_animation, fast_invoke_animation, keyed_presence}, + global_state::GlobalState, + v_flex, +}; const CONTEXT: &str = "Popover"; pub(crate) fn init(cx: &mut App) { @@ -353,9 +358,10 @@ impl RenderOnce for Popover { }; let parent_view_id = window.current_view(); + let popover_id = self.id.clone(); let el = div() - .id(self.id) + .id(popover_id.clone()) .child((trigger)(open, window, cx)) .on_mouse_down(self.mouse_button, { let state = state.clone(); @@ -379,10 +385,27 @@ impl RenderOnce for Popover { } }); - if !open { + let motion = cx.theme().motion.clone(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let presence = keyed_presence( + SharedString::from(format!("popover-presence-{}", popover_id)), + open, + !reduced_motion, + std::time::Duration::from_millis(u64::from(motion.fast_duration_ms)), + std::time::Duration::from_millis(u64::from(motion.fade_duration_ms)), + PresenceOptions { + animate_on_mount: true, + }, + window, + cx, + ); + if !presence.should_render() { return el; } + let open_anim = fast_invoke_animation(&motion, reduced_motion); + let close_anim = fade_animation(&motion, reduced_motion); + let popover_content = Self::render_popover_content(self.anchor, self.appearance, window, cx) .track_focus(&focus_handle) @@ -403,7 +426,31 @@ impl RenderOnce for Popover { } }) }) - .refine_style(&self.style); + .refine_style(&self.style) + .map(move |el| { + if !presence.transition_active() { + el.opacity(presence.progress(1.0)).into_any_element() + } else { + let anim = if matches!(presence.phase, PresencePhase::Entering) { + open_anim + } else { + close_anim + }; + if let Some(anim) = anim { + el.with_animation( + SharedString::from(format!( + "popover-motion-{}", + u8::from(matches!(presence.phase, PresencePhase::Entering)) + )), + anim, + move |el, delta| el.opacity(presence.progress(delta)), + ) + .into_any_element() + } else { + el.into_any_element() + } + } + }); el.child(Self::render_popover( self.anchor, diff --git a/crates/ui/src/progress/progress.rs b/crates/ui/src/progress/progress.rs index c39eb948..796ef8e9 100644 --- a/crates/ui/src/progress/progress.rs +++ b/crates/ui/src/progress/progress.rs @@ -1,5 +1,6 @@ use crate::{ - ActiveTheme, Sizable, Size, StyledExt, animation::cubic_bezier, global_state::GlobalState, + ActiveTheme, Sizable, Size, StyledExt, animation::animation_with_theme_easing, + global_state::GlobalState, }; use gpui::{ Animation, AnimationExt as _, App, ElementId, Hsla, InteractiveElement as _, IntoElement, @@ -10,33 +11,6 @@ use std::time::Duration; use super::ProgressState; -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - fn progress_fraction(value: f32) -> f32 { match value { v if v < 0. => 0., @@ -142,11 +116,14 @@ impl RenderOnce for Progress { if prev_value != value { if reduced_motion { state.update(cx, |state, _| state.value = value); - return this.w(relative(progress_fraction(value))).into_any_element(); + return this + .w(relative(progress_fraction(value))) + .into_any_element(); } - let duration = - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + let duration = Duration::from_millis(u64::from( + cx.theme().motion.fast_duration_ms, + )); let animation = animation_with_theme_easing( Animation::new(duration), cx.theme().motion.point_to_point_easing.as_ref(), @@ -170,7 +147,8 @@ impl RenderOnce for Progress { ) .into_any_element() } else { - this.w(relative(progress_fraction(value))).into_any_element() + this.w(relative(progress_fraction(value))) + .into_any_element() } }), ) diff --git a/crates/ui/src/progress/progress_circle.rs b/crates/ui/src/progress/progress_circle.rs index 50b1fffd..47e4b466 100644 --- a/crates/ui/src/progress/progress_circle.rs +++ b/crates/ui/src/progress/progress_circle.rs @@ -1,5 +1,5 @@ use crate::{ - ActiveTheme, PixelsExt, Sizable, Size, StyledExt, animation::cubic_bezier, + ActiveTheme, PixelsExt, Sizable, Size, StyledExt, animation::animation_with_theme_easing, global_state::GlobalState, }; use gpui::prelude::FluentBuilder as _; @@ -15,33 +15,6 @@ use std::time::Duration; use super::ProgressState; use crate::plot::shape::{Arc, ArcData}; -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - /// A circular progress indicator element. #[derive(IntoElement)] pub struct ProgressCircle { @@ -213,7 +186,9 @@ impl RenderOnce for ProgressCircle { if has_changed { if reduced_motion { state.update(cx, |state, _| state.value = value); - return this.child(Self::render_circle(value, color)).into_any_element(); + return this + .child(Self::render_circle(value, color)) + .into_any_element(); } let duration = diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index ad91dbc5..e593ba82 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -1,6 +1,7 @@ use crate::{ ActiveTheme, Anchor, ElementExt, Placement, StyledExt, - dialog::Dialog, + dialog::{Dialog, close_animation_duration}, + focus_trap::FocusTrapManager, input::InputState, notification::{Notification, NotificationList}, sheet::Sheet, @@ -29,6 +30,7 @@ pub(crate) fn init(cx: &mut App) { pub struct Root { pub(crate) active_sheet: Option, pub(crate) active_dialogs: Vec, + next_dialog_id: u64, pub(super) focused_input: Option>, pub notification: Entity, sheet_size: Option, @@ -47,6 +49,8 @@ pub(crate) struct ActiveSheet { #[derive(Clone)] pub(crate) struct ActiveDialog { + id: u64, + closing: bool, focus_handle: FocusHandle, /// The previous focused handle before opening the Dialog. previous_focused_handle: Option, @@ -55,11 +59,14 @@ pub(crate) struct ActiveDialog { impl ActiveDialog { pub(crate) fn new( + id: u64, focus_handle: FocusHandle, previous_focused_handle: Option, builder: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static, ) -> Self { Self { + id, + closing: false, focus_handle, previous_focused_handle, builder: Rc::new(builder), @@ -73,6 +80,7 @@ impl Root { Self { active_sheet: None, active_dialogs: Vec::new(), + next_dialog_id: 1, focused_input: None, notification: cx.new(|cx| NotificationList::new(window, cx)), sheet_size: None, @@ -205,7 +213,9 @@ impl Root { // So we keep the focus handle in the `active_dialog`, this is owned by the `Root`. dialog.focus_handle = active_dialog.focus_handle.clone(); + dialog.id = active_dialog.id; dialog.layer_ix = i; + dialog.closing = active_dialog.closing; // Find the dialog which one needs to show overlay. if dialog.has_overlay() { show_overlay_ix = Some(i); @@ -231,8 +241,11 @@ impl Root { let previous_focused_handle = window.focused(cx).map(|h| h.downgrade()); let focus_handle = cx.focus_handle(); focus_handle.focus(window, cx); + let dialog_id = self.next_dialog_id; + self.next_dialog_id += 1; self.active_dialogs.push(ActiveDialog::new( + dialog_id, focus_handle, previous_focused_handle, build, @@ -240,19 +253,69 @@ impl Root { cx.notify(); } + fn finalize_dialog_close( + &mut self, + dialog_id: u64, + restore_focus: Option, + window: &mut Window, + cx: &mut Context<'_, Root>, + ) { + if let Some(ix) = self.active_dialogs.iter().position(|d| d.id == dialog_id) { + self.focused_input = None; + let was_top = ix + 1 == self.active_dialogs.len(); + self.active_dialogs.remove(ix); + if was_top && let Some(handle) = restore_focus { + window.focus(&handle, cx); + } + cx.notify(); + } + } + pub fn close_dialog(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { - self.focused_input = None; - if let Some(handle) = self - .active_dialogs - .pop() - .and_then(|d| d.previous_focused_handle) - .and_then(|h| h.upgrade()) - { - window.focus(&handle, cx); + let Some(active_dialog) = self.active_dialogs.last_mut() else { + return; + }; + + if active_dialog.closing { + return; } + + let restore_focus = active_dialog + .previous_focused_handle + .as_ref() + .and_then(|h| h.upgrade()); + let dialog_id = active_dialog.id; + + let should_animate_close = { + let mut dialog = Dialog::new(window, cx); + dialog = (active_dialog.builder)(dialog, window, cx); + dialog.should_animate(cx) + }; + + if !should_animate_close { + self.finalize_dialog_close(dialog_id, restore_focus, window, cx); + return; + } + + active_dialog.closing = true; + let duration = close_animation_duration(cx); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(duration).await; + _ = cx.update(|window, cx| { + Root::update(window, cx, |root, window, cx| { + root.finalize_dialog_close(dialog_id, restore_focus.clone(), window, cx); + }); + }); + }) + .detach(); cx.notify(); } + pub(crate) fn defer_close_dialog(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + self.close_dialog(window, cx); + } + pub fn close_all_dialogs(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { self.focused_input = None; let previous_focused_handle = self @@ -337,10 +400,72 @@ impl Root { } fn on_action_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + // Check if we're inside a focus trap + if let Some(container_focus_handle) = FocusTrapManager::find_active_trap(window, cx) { + // We're in a focus trap - try to focus next, then check if we're still inside + let before_focus = window.focused(cx); + + // Try normal focus navigation + window.focus_next(cx); + + // Check if we're still in the trap + if !container_focus_handle.contains_focused(window, cx) { + // We jumped out of the trap - need to cycle back to the beginning + // Find the first focusable element in the trap by continuing to focus_next + let mut attempts = 0; + const MAX_ATTEMPTS: usize = 100; // Prevent infinite loop + + while !container_focus_handle.contains_focused(window, cx) + && attempts < MAX_ATTEMPTS + { + window.focus_next(cx); + attempts += 1; + + // If we cycled back to where we started, restore original focus + if window.focused(cx) == before_focus { + break; + } + } + } + return; + } + + // Normal tab navigation window.focus_next(cx); } fn on_action_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + // Check if we're inside a focus trap + if let Some(container_focus_handle) = FocusTrapManager::find_active_trap(window, cx) { + // We're in a focus trap - try to focus previous, then check if we're still inside + let before_focus = window.focused(cx); + + // Try normal focus navigation + window.focus_prev(cx); + + // Check if we're still in the trap + if !container_focus_handle.contains_focused(window, cx) { + // We jumped out of the trap - need to cycle back to the end + // Find the last focusable element in the trap by continuing to focus_prev + let mut attempts = 0; + const MAX_ATTEMPTS: usize = 100; // Prevent infinite loop + + while !container_focus_handle.contains_focused(window, cx) + && attempts < MAX_ATTEMPTS + { + window.focus_prev(cx); + attempts += 1; + + // If we cycled back to where we started, restore original focus + if window.focused(cx) == before_focus { + break; + } + } + } + return; + } + + // Normal tab navigation window.focus_prev(cx); } } diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index c69be416..4a7dc4d0 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -1,20 +1,22 @@ use gpui::{ - anchored, deferred, div, prelude::FluentBuilder, px, rems, AbsoluteLength, AnyElement, App, - AppContext, Bounds, ClickEvent, Context, DefiniteLength, DismissEvent, Edges, ElementId, - Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, - Length, ParentElement, Pixels, Render, RenderOnce, SharedString, StatefulInteractiveElement, - StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, + AbsoluteLength, AnimationExt as _, AnyElement, App, AppContext, Bounds, ClickEvent, Context, + DefiniteLength, DismissEvent, Edges, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, RenderOnce, + SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, + WeakEntity, Window, anchored, deferred, div, prelude::FluentBuilder, px, rems, }; use rust_i18n::t; use crate::{ + ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Selectable, Sizable, + Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, actions::{Cancel, Confirm, SelectDown, SelectUp}, + animation::fast_invoke_animation, global_state::GlobalState, h_flex, input::clear_button, list::{List, ListDelegate, ListState}, - v_flex, ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Selectable, - Sizable, Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, + v_flex, }; const CONTEXT: &str = "Select"; @@ -428,7 +430,7 @@ impl SelectDelegate for SearchableVec { self.matched_items = self .items .iter() - .filter(|item| item.title().to_lowercase().contains(&query.to_lowercase())) + .filter(|item| item.matches(query)) .cloned() .collect(); @@ -731,17 +733,12 @@ where /// Returns the title element for the select input. fn display_title(&mut self, _: &Window, cx: &mut Context) -> impl IntoElement { - let default_title = div() - .text_color(cx.theme().accent_foreground) - .child( - self.options - .placeholder - .clone() - .unwrap_or_else(|| t!("Select.placeholder").into()), - ) - .when(self.options.disabled, |this| { - this.text_color(cx.theme().muted_foreground) - }); + let default_title = div().text_color(cx.theme().muted_foreground).child( + self.options + .placeholder + .clone() + .unwrap_or_else(|| t!("Select.placeholder").into()), + ); let Some(selected_index) = &self.selected_index(cx) else { return default_title; @@ -880,6 +877,10 @@ where }), ) .when(self.open, |this| { + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let anim = fast_invoke_animation(motion, reduced_motion); + this.child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( @@ -912,7 +913,17 @@ where ) .on_mouse_down_out(cx.listener(|this, _, window, cx| { this.escape(&Cancel, window, cx); - })), + })) + .map(|el| { + if let Some(anim) = anim { + el.with_animation("select-enter", anim, |el, delta| { + el.opacity(delta) + }) + .into_any_element() + } else { + el.into_any_element() + } + }), ), ) .with_priority(1), diff --git a/crates/ui/src/setting/settings.rs b/crates/ui/src/setting/settings.rs index 3e54a37d..09df7255 100644 --- a/crates/ui/src/setting/settings.rs +++ b/crates/ui/src/setting/settings.rs @@ -32,6 +32,8 @@ pub struct Settings { size: Size, sidebar_width: Pixels, sidebar_style: StyleRefinement, + default_selected_index: SelectIndex, + header_style: StyleRefinement, } impl Settings { @@ -44,6 +46,8 @@ impl Settings { size: Size::default(), sidebar_width: px(250.0), sidebar_style: StyleRefinement::default(), + default_selected_index: SelectIndex::default(), + header_style: StyleRefinement::default(), } } @@ -79,6 +83,18 @@ impl Settings { self } + /// Set the default index of the page to be selected. + pub fn default_selected_index(mut self, index: SelectIndex) -> Self { + self.default_selected_index = index; + self + } + + /// Set the style refinement for the header. + pub fn header_style(mut self, style: &StyleRefinement) -> Self { + self.header_style = style.clone(); + self + } + fn filtered_pages(&self, query: &str, cx: &App) -> Vec { self.pages .iter() @@ -151,6 +167,7 @@ impl Settings { .header( div() .w_full() + .refine_style(&self.header_style) .child(Input::new(&search_input).prefix(IconName::Search)), ) .child( @@ -230,9 +247,9 @@ pub struct RenderOptions { } #[derive(Clone, Copy, Default)] -pub(super) struct SelectIndex { - page_ix: usize, - group_ix: Option, +pub struct SelectIndex { + pub page_ix: usize, + pub group_ix: Option, } impl RenderOnce for Settings { @@ -246,7 +263,7 @@ impl RenderOnce for Settings { SettingsState { search_input, - selected_index: SelectIndex::default(), + selected_index: self.default_selected_index, deferred_scroll_group_ix: None, } }); diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs index 7e104052..1a5ab27f 100644 --- a/crates/ui/src/sheet.rs +++ b/crates/ui/src/sheet.rs @@ -10,9 +10,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{ - ActiveTheme, IconName, Placement, Sizable, StyledExt as _, WindowExt as _, + ActiveTheme, FocusTrapElement as _, IconName, Placement, Sizable, StyledExt as _, + WindowExt as _, actions::Cancel, - animation::cubic_bezier, + animation::animation_with_theme_easing, button::{Button, ButtonVariants as _}, dialog::overlay_color, global_state::GlobalState, @@ -27,33 +28,6 @@ pub(crate) fn init(cx: &mut App) { cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) } -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - /// The settings for sheets. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SheetSettings { @@ -225,9 +199,10 @@ impl RenderOnce for Sheet { }) .child( v_flex() - .tab_group() + .id("sheet") .key_context(CONTEXT) .track_focus(&self.focus_handle) + .focus_trap("sheet", &self.focus_handle) .on_action({ let on_close = self.on_close.clone(); move |_: &Cancel, window, cx| { @@ -311,15 +286,19 @@ impl RenderOnce for Sheet { if reduced_motion { this.into_any_element() } else { - this.with_animation("slide", slide_animation, move |this, delta| { - let y = px(-100.) + delta * px(100.); - this.map(|this| match placement { - Placement::Top => this.top(top + y), - Placement::Right => this.right(y), - Placement::Bottom => this.bottom(y), - Placement::Left => this.left(y), - }) - }) + this.with_animation( + "slide", + slide_animation, + move |this, delta| { + let y = px(-100.) + delta * px(100.); + this.map(|this| match placement { + Placement::Top => this.top(top + y), + Placement::Right => this.right(y), + Placement::Bottom => this.bottom(y), + Placement::Left => this.left(y), + }) + }, + ) .into_any_element() } }), diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 32ba923b..2dfab880 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -1,17 +1,27 @@ use crate::{ ActiveTheme as _, Collapsible, Icon, IconName, Sizable as _, StyledExt, + animation::{PresenceOptions, PresencePhase, fast_invoke_animation, keyed_presence, point_to_point_animation}, button::{Button, ButtonVariants as _}, + global_state::GlobalState, h_flex, menu::{ContextMenuExt, PopupMenu}, sidebar::SidebarItem, v_flex, }; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, - ParentElement as _, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, - Window, div, percentage, prelude::FluentBuilder, + AnimationExt as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, + IntoElement, ParentElement as _, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, div, percentage, prelude::FluentBuilder, px, }; use std::rc::Rc; +use std::time::Duration; + +/// Generous max for animated submenu reveal. +const SUBMENU_CONTENT_MAX_H: f32 = 1200.0; + +fn submenu_height_progress(progress: f32) -> f32 { + progress.clamp(0.0, 1.0).powf(3.0) +} /// Menu for the [`super::Sidebar`] #[derive(Clone)] @@ -226,7 +236,8 @@ impl SidebarItem for SidebarMenuItem { let click_to_open = self.click_to_open; let default_open = self.default_open; let id = id.into(); - let open_state = window.use_keyed_state(id.clone(), cx, |_, _| default_open); + let state_key = SharedString::from(format!("sidebar-menu-state-{}", id)); + let open_state = window.use_keyed_state(state_key.clone(), cx, |_, _| default_open); let handler = self.handler.clone(); let is_collapsed = self.collapsed; let is_active = self.active; @@ -234,6 +245,21 @@ impl SidebarItem for SidebarMenuItem { let is_disabled = self.disabled; let is_submenu = self.is_submenu(); let is_open = is_submenu && !is_collapsed && *open_state.read(cx); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let submenu_presence = keyed_presence( + SharedString::from(format!("{}-submenu-presence", state_key)), + is_open, + !reduced_motion, + Duration::from_millis(u64::from(motion.fast_duration_ms)), + Duration::from_millis(u64::from(motion.fast_duration_ms)), + PresenceOptions::default(), + window, + cx, + ); + let submenu_visible = submenu_presence.should_render(); + let open_anim = fast_invoke_animation(&motion, reduced_motion); + let close_anim = point_to_point_animation(&motion, reduced_motion); div() .id(id.clone()) @@ -339,7 +365,7 @@ impl SidebarItem for SidebarMenuItem { } }), ) - .when(is_open, |this| { + .when(submenu_visible, |this| { this.child( v_flex() .id("submenu") @@ -352,7 +378,41 @@ impl SidebarItem for SidebarMenuItem { .children(self.children.into_iter().enumerate().map(|(ix, item)| { let id = format!("{}-{}", id, ix); item.render(id, window, cx).into_any_element() - })), + })) + .map(|el| { + let anim = if submenu_presence.transition_active() { + if matches!(submenu_presence.phase, PresencePhase::Entering) { + open_anim + } else { + close_anim + } + } else { + None + }; + if let Some(anim) = anim { + el.with_animation( + SharedString::from(format!( + "{}-submenu-expand-{}", + id, + u8::from(matches!( + submenu_presence.phase, + PresencePhase::Entering + )) + )), + anim, + move |el, delta| { + let progress = submenu_presence.progress(delta); + let clamped = progress.clamp(0.0, 1.0); + el.max_h(px(SUBMENU_CONTENT_MAX_H + * submenu_height_progress(clamped))) + .opacity(clamped) + }, + ) + .into_any_element() + } else { + el.into_any_element() + } + }), ) }) } diff --git a/crates/ui/src/sidebar/mod.rs b/crates/ui/src/sidebar/mod.rs index ee186e5c..839a8710 100644 --- a/crates/ui/src/sidebar/mod.rs +++ b/crates/ui/src/sidebar/mod.rs @@ -1,16 +1,23 @@ use crate::{ ActiveTheme, Collapsible, Icon, IconName, PixelsExt, Side, Sizable, StyledExt, + animation::{ + PresenceOptions, PresencePhase, keyed_presence, point_to_point_animation, + soft_dismiss_animation, + }, button::{Button, ButtonVariants}, + global_state::GlobalState, h_flex, scroll::ScrollableElement, v_flex, }; use gpui::{ - AnyElement, App, ClickEvent, EdgesRefinement, ElementId, InteractiveElement as _, IntoElement, - ListAlignment, ListState, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, - Styled, Window, div, list, prelude::FluentBuilder, px, + AnimationExt as _, AnyElement, App, ClickEvent, EdgesRefinement, ElementId, + InteractiveElement as _, IntoElement, ListAlignment, ListState, ParentElement, Pixels, + RenderOnce, SharedString, StyleRefinement, Styled, Window, div, list, prelude::FluentBuilder, + px, }; use std::rc::Rc; +use std::time::Duration; mod footer; mod group; @@ -47,6 +54,7 @@ pub struct Sidebar { side: Side, collapsible: bool, collapsed: bool, + width: Pixels, } impl Sidebar { @@ -61,6 +69,7 @@ impl Sidebar { side: Side::Left, collapsible: true, collapsed: false, + width: DEFAULT_WIDTH, } } @@ -84,6 +93,12 @@ impl Sidebar { self } + /// Set the expanded width of the sidebar. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + /// Set the header of the sidebar. pub fn header(mut self, header: impl IntoElement) -> Self { self.header = Some(header.into_any_element()); @@ -192,11 +207,28 @@ impl RenderOnce for Sidebar { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { self.style.padding = EdgesRefinement::default(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let target_collapsed = self.collapsed; + let sidebar_id = self.id.clone(); + let expanded_width = self.width; + let presence = keyed_presence( + SharedString::from(format!("{}-collapsed-presence", sidebar_id)), + !target_collapsed, + !reduced_motion, + Duration::from_millis(u64::from(motion.fast_duration_ms)), + Duration::from_millis(u64::from(motion.soft_dismiss_duration_ms)), + PresenceOptions::default(), + window, + cx, + ); + let visual_collapsed = matches!(presence.phase, PresencePhase::Exited); + let content_len = self.content.len(); let overdraw = px(window.viewport_size().height.as_f32() * 0.3); let list_state = window .use_keyed_state( - SharedString::from(format!("{}-list-state", self.id)), + SharedString::from(format!("{}-list-state", sidebar_id)), cx, |_, _| ListState::new(content_len, ListAlignment::Top, overdraw), ) @@ -206,9 +238,10 @@ impl RenderOnce for Sidebar { list_state.reset(content_len); } - v_flex() - .id(self.id) - .w(DEFAULT_WIDTH) + let item_id_prefix = sidebar_id.clone(); + let sidebar = v_flex() + .id(sidebar_id.clone()) + .w(expanded_width) .flex_shrink_0() .h_full() .overflow_hidden() @@ -221,7 +254,8 @@ impl RenderOnce for Sidebar { Side::Right => this.border_l_1(), }) .refine_style(&self.style) - .when(self.collapsed, |this| this.w(COLLAPSED_WIDTH).gap_2()) + .when(target_collapsed, |this| this.w(COLLAPSED_WIDTH)) + .when(visual_collapsed, |this| this.gap_2()) .when_some(self.header.take(), |this, header| { this.child( h_flex() @@ -229,7 +263,7 @@ impl RenderOnce for Sidebar { .pt_3() .px_3() .gap_2() - .when(self.collapsed, |this| this.pt_2().px_2()) + .when(visual_collapsed, |this| this.pt_2().px_2()) .child(header), ) }) @@ -240,7 +274,7 @@ impl RenderOnce for Sidebar { .size_full() .px_3() .gap_y_3() - .when(self.collapsed, |this| this.p_2()) + .when(visual_collapsed, |this| this.p_2()) .child( list(list_state.clone(), { move |ix, window, cx| { @@ -253,8 +287,15 @@ impl RenderOnce for Sidebar { .when_some(group, |this, group| { this.child( group - .collapsed(self.collapsed) - .render(ix, window, cx) + .collapsed(visual_collapsed) + .render( + SharedString::from(format!( + "{}-{}", + item_id_prefix, ix + )), + window, + cx, + ) .into_any_element(), ) }) @@ -275,9 +316,49 @@ impl RenderOnce for Sidebar { .pb_3() .px_3() .gap_2() - .when(self.collapsed, |this| this.pt_2().px_2()) + .when(visual_collapsed, |this| this.pt_2().px_2()) .child(footer), ) - }) + }); + + if reduced_motion || !presence.transition_active() { + sidebar.into_any_element() + } else { + let collapsed_width = COLLAPSED_WIDTH; + let from_width = if target_collapsed { + expanded_width + } else { + collapsed_width + }; + let to_width = if target_collapsed { + collapsed_width + } else { + expanded_width + }; + let anim = if matches!(presence.phase, PresencePhase::Entering) { + point_to_point_animation(&motion, reduced_motion) + } else { + soft_dismiss_animation(&motion, reduced_motion) + }; + + if let Some(anim) = anim { + sidebar + .with_animation( + SharedString::from(format!( + "{}-sidebar-width-{}", + sidebar_id, + u8::from(target_collapsed) + )), + anim, + move |this, delta| { + let progress = presence.progress(delta); + this.w(from_width + (to_width - from_width) * progress) + }, + ) + .into_any_element() + } else { + sidebar.into_any_element() + } + } } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 93a4b0da..d3b97bbf 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -183,6 +183,70 @@ pub trait StyledExt: Styled + Sized { .rounded(cx.theme().radius) } + /// Apply Fluent caption typography (12/16 Regular). + fn fluent_caption(self, cx: &App) -> Self { + let t = &cx.theme().typography.caption; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent body typography (14/20 Regular). + fn fluent_body(self, cx: &App) -> Self { + let t = &cx.theme().typography.body; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent body strong typography (14/20 Semibold). + fn fluent_body_strong(self, cx: &App) -> Self { + let t = &cx.theme().typography.body_strong; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent body large typography (18/24 Regular). + fn fluent_body_large(self, cx: &App) -> Self { + let t = &cx.theme().typography.body_large; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent subtitle typography (20/28 Semibold). + fn fluent_subtitle(self, cx: &App) -> Self { + let t = &cx.theme().typography.subtitle; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent title typography (28/36 Semibold). + fn fluent_title(self, cx: &App) -> Self { + let t = &cx.theme().typography.title; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent title large typography (40/52 Semibold). + fn fluent_title_large(self, cx: &App) -> Self { + let t = &cx.theme().typography.title_large; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + + /// Apply Fluent display typography (68/92 Semibold). + fn fluent_display(self, cx: &App) -> Self { + let t = &cx.theme().typography.display; + self.text_size(t.size) + .line_height(t.line_height) + .font_weight(t.weight) + } + /// Set corner radii for the element. fn corner_radii(self, radius: Corners) -> Self { self.rounded_tl(radius.top_left) diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index ee3a1493..79b7d743 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -1,6 +1,7 @@ use crate::{ - ActiveTheme, Disableable, Side, Sizable, Size, StyledExt, animation::cubic_bezier, - global_state::GlobalState, h_flex, text::Text, tooltip::Tooltip, + ActiveTheme, Disableable, Side, Sizable, Size, StyledExt, + animation::animation_with_theme_easing, global_state::GlobalState, h_flex, text::Text, + tooltip::Tooltip, }; use gpui::{ Animation, AnimationExt as _, App, ElementId, InteractiveElement, IntoElement, @@ -9,33 +10,6 @@ use gpui::{ }; use std::{rc::Rc, time::Duration}; -fn parse_cubic_bezier_easing(value: &str) -> Option<(f32, f32, f32, f32)> { - let trimmed = value.trim(); - let body = trimmed - .strip_prefix("cubic-bezier(")? - .strip_suffix(')')? - .trim(); - let mut parts = body.split(',').map(str::trim); - let x1 = parts.next()?.parse::().ok()?; - let y1 = parts.next()?.parse::().ok()?; - let x2 = parts.next()?.parse::().ok()?; - let y2 = parts.next()?.parse::().ok()?; - if parts.next().is_some() { - return None; - } - Some((x1, y1, x2, y2)) -} - -fn animation_with_theme_easing(animation: Animation, easing: &str) -> Animation { - if easing.trim().eq_ignore_ascii_case("linear") { - return animation.with_easing(|delta: f32| delta); - } - if let Some((x1, y1, x2, y2)) = parse_cubic_bezier_easing(easing) { - return animation.with_easing(cubic_bezier(x1, y1, x2, y2)); - } - animation -} - /// A Switch element that can be toggled on or off. #[derive(IntoElement)] pub struct Switch { @@ -183,7 +157,8 @@ impl RenderOnce for Switch { .size(bar_width) .map(|this| { let previous_checked = *toggle_state.read(cx); - let value_changed = !self.disabled && previous_checked != checked; + let value_changed = + !self.disabled && previous_checked != checked; if value_changed && reduced_motion { toggle_state.update(cx, |state, _| *state = checked); } diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index 91a89583..ffa1fc04 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -1,11 +1,15 @@ use std::rc::Rc; +use std::time::Duration; -use crate::{ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex}; +use crate::{ + ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, + animation::animation_with_theme_easing, global_state::GlobalState, h_flex, +}; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, - div, px, relative, + Animation, AnimationExt as _, AnyElement, App, ClickEvent, Div, Edges, ElementId, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, relative, }; /// Tab variants. @@ -583,7 +587,7 @@ impl Sizable for Tab { } impl RenderOnce for Tab { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let mut tab_style = if self.selected { self.variant.selected(cx) } else { @@ -608,6 +612,48 @@ impl RenderOnce for Tab { let inner_height = self.variant.inner_height(self.size); let height = self.variant.height(self.size); + let selected = self.selected; + let disabled = self.disabled; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + + // Track selected state transitions for animation + let tab_select_state = window.use_keyed_state( + ElementId::NamedInteger("tab-select".into(), self.ix as u64), + cx, + |_, _| selected, + ); + let became_selected = { + let prev = *tab_select_state.read(cx); + let changed = !disabled && selected && !prev; + if prev != selected { + if reduced_motion { + tab_select_state.update(cx, |state, _| *state = selected); + } else if changed { + let duration = + Duration::from_millis(u64::from(cx.theme().motion.fade_duration_ms)); + cx.spawn({ + let tab_select_state = tab_select_state.clone(); + async move |cx| { + cx.background_executor().timer(duration).await; + _ = tab_select_state.update(cx, |this, _| *this = selected); + } + }) + .detach(); + } else { + tab_select_state.update(cx, |state, _| *state = selected); + } + } + changed && !reduced_motion + }; + + let fade_animation = became_selected.then(|| { + let motion = &cx.theme().motion; + animation_with_theme_easing( + Animation::new(Duration::from_millis(u64::from(motion.fade_duration_ms))), + &motion.fade_easing, + ) + }); + self.base .id(self.ix) .flex() @@ -631,7 +677,7 @@ impl RenderOnce for Tab { .border_b(tab_style.borders.bottom) .border_color(tab_style.border_color) .rounded(radius) - .when(!self.selected && !self.disabled, |this| { + .when(!selected && !disabled, |this| { this.hover(|this| { this.text_color(hover_style.fg) .bg(hover_style.bg) @@ -683,10 +729,20 @@ impl RenderOnce for Tab { // https://github.com/longbridge/gpui-component/issues/1836 cx.stop_propagation(); }) - .when(!self.disabled, |this| { + .when(!disabled, |this| { this.when_some(self.on_click.clone(), |this, on_click| { this.on_click(move |event, window, cx| on_click(event, window, cx)) }) }) + .map(|this| match fade_animation { + Some(anim) => this + .with_animation( + ElementId::NamedInteger("tab-fade".into(), self.ix as u64), + anim, + |this, delta| this.opacity(delta), + ) + .into_any_element(), + None => this.into_any_element(), + }) } } diff --git a/crates/ui/src/table/column.rs b/crates/ui/src/table/column.rs index d2eda362..b40dbfd4 100644 --- a/crates/ui/src/table/column.rs +++ b/crates/ui/src/table/column.rs @@ -99,6 +99,12 @@ impl Column { self } + /// Set the text alignment of the column to center. + pub fn text_center(mut self) -> Self { + self.align = TextAlign::Center; + self + } + /// Set the alignment of the column text, default is left. /// /// Only `text_left`, `text_right` is supported. diff --git a/crates/ui/src/table/delegate.rs b/crates/ui/src/table/delegate.rs index d0b2df20..6ca98a8c 100644 --- a/crates/ui/src/table/delegate.rs +++ b/crates/ui/src/table/delegate.rs @@ -51,9 +51,7 @@ pub trait TableDelegate: Sized + 'static { window: &mut Window, cx: &mut Context>, ) -> impl IntoElement { - div() - .size_full() - .child(self.column(col_ix, cx).name) + div().size_full().child(self.column(col_ix, cx).name) } /// Render the row at the given row and column. diff --git a/crates/ui/src/table/mod.rs b/crates/ui/src/table/mod.rs index 2eff8a87..47664aa6 100644 --- a/crates/ui/src/table/mod.rs +++ b/crates/ui/src/table/mod.rs @@ -1,10 +1,13 @@ use crate::{ ActiveTheme, Sizable, Size, - actions::{Cancel, SelectDown, SelectUp}, + actions::{ + Cancel, SelectDown, SelectFirst, SelectLast, SelectNextColumn, SelectPageDown, + SelectPageUp, SelectPrevColumn, SelectUp, + }, }; use gpui::{ App, Edges, Entity, Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement, - RenderOnce, Styled, Window, actions, div, prelude::FluentBuilder, + RenderOnce, Styled, Window, div, prelude::FluentBuilder, }; mod column; @@ -16,8 +19,6 @@ pub use column::*; pub use delegate::*; pub use state::*; -actions!(table, [SelectPrevColumn, SelectNextColumn]); - const CONTEXT: &'static str = "Table"; pub(crate) fn init(cx: &mut App) { cx.bind_keys([ @@ -26,6 +27,12 @@ pub(crate) fn init(cx: &mut App) { KeyBinding::new("down", SelectDown, Some(CONTEXT)), KeyBinding::new("left", SelectPrevColumn, Some(CONTEXT)), KeyBinding::new("right", SelectNextColumn, Some(CONTEXT)), + KeyBinding::new("home", SelectFirst, Some(CONTEXT)), + KeyBinding::new("end", SelectLast, Some(CONTEXT)), + KeyBinding::new("pageup", SelectPageUp, Some(CONTEXT)), + KeyBinding::new("pagedown", SelectPageDown, Some(CONTEXT)), + KeyBinding::new("tab", SelectNextColumn, Some(CONTEXT)), + KeyBinding::new("shift-tab", SelectPrevColumn, Some(CONTEXT)), ]); } @@ -123,6 +130,10 @@ where .on_action(window.listener_for(&self.state, TableState::action_select_prev)) .on_action(window.listener_for(&self.state, TableState::action_select_next_col)) .on_action(window.listener_for(&self.state, TableState::action_select_prev_col)) + .on_action(window.listener_for(&self.state, TableState::action_select_first_column)) + .on_action(window.listener_for(&self.state, TableState::action_select_last_column)) + .on_action(window.listener_for(&self.state, TableState::action_select_page_up)) + .on_action(window.listener_for(&self.state, TableState::action_select_page_down)) .bg(cx.theme().table) .when(bordered, |this| { this.rounded(cx.theme().radius) diff --git a/crates/ui/src/table/state.rs b/crates/ui/src/table/state.rs index dae311d5..a243964d 100644 --- a/crates/ui/src/table/state.rs +++ b/crates/ui/src/table/state.rs @@ -2,7 +2,10 @@ use std::{ops::Range, rc::Rc, time::Duration}; use crate::{ ActiveTheme, ElementExt, Icon, IconName, StyleSized as _, StyledExt, VirtualListScrollHandle, - actions::{Cancel, SelectDown, SelectUp}, + actions::{ + Cancel, SelectDown, SelectFirst, SelectLast, SelectNextColumn, SelectPageDown, + SelectPageUp, SelectPrevColumn, SelectUp, + }, h_flex, menu::{ContextMenuExt, PopupMenu}, scroll::{ScrollableMask, Scrollbar}, @@ -121,7 +124,7 @@ where /// Create a new TableState with the given delegate. pub fn new(delegate: D, _: &mut Window, cx: &mut Context) -> Self { let mut this = Self { - focus_handle: cx.focus_handle(), + focus_handle: cx.focus_handle().tab_stop(true), options: TableOptions::default(), delegate, col_groups: Vec::new(), @@ -334,6 +337,13 @@ where .count() } + fn page_item_count(&self) -> usize { + let row_height = self.options.size.table_row_height(); + let height = self.bounds.size.height; + let count = (height / row_height).floor() as usize; + count.saturating_sub(1).max(1) + } + fn on_row_right_click( &mut self, _: &MouseDownEvent, @@ -440,6 +450,55 @@ where self.set_selected_row(selected_row, cx); } + pub(super) fn action_select_first_column( + &mut self, + _: &SelectFirst, + _: &mut Window, + cx: &mut Context, + ) { + self.set_selected_col(0, cx); + } + + pub(super) fn action_select_last_column( + &mut self, + _: &SelectLast, + _: &mut Window, + cx: &mut Context, + ) { + let columns_count = self.delegate.columns_count(cx); + self.set_selected_col(columns_count.saturating_sub(1), cx); + } + + pub(super) fn action_select_page_up( + &mut self, + _: &SelectPageUp, + _: &mut Window, + cx: &mut Context, + ) { + let step = self.page_item_count(); + let current = self.selected_row.unwrap_or(0); + let target = current.saturating_sub(step); + self.set_selected_row(target, cx); + } + + pub(super) fn action_select_page_down( + &mut self, + _: &SelectPageDown, + _: &mut Window, + cx: &mut Context, + ) { + let rows_count = self.delegate.rows_count(cx); + if rows_count == 0 { + return; + } + + let step = self.page_item_count(); + let current = self.selected_row.unwrap_or(0); + let max_row = rows_count.saturating_sub(1); + let target = (current + step).min(max_row); + self.set_selected_row(target, cx); + } + pub(super) fn action_select_prev_col( &mut self, _: &SelectPrevColumn, diff --git a/crates/ui/src/text/state.rs b/crates/ui/src/text/state.rs index 18de348c..21638f18 100644 --- a/crates/ui/src/text/state.rs +++ b/crates/ui/src/text/state.rs @@ -260,6 +260,7 @@ impl Render for TextViewState { }; node_cx.code_block_actions = self.code_block_actions.clone(); + node_cx.style = self.text_view_style.clone(); v_flex() .size_full() diff --git a/crates/ui/src/theme/default-theme.json b/crates/ui/src/theme/default-theme.json index 595e3054..ec6dbb52 100644 --- a/crates/ui/src/theme/default-theme.json +++ b/crates/ui/src/theme/default-theme.json @@ -16,7 +16,7 @@ "soft_dismiss_duration_ms": 167, "fade_duration_ms": 83, "fast_invoke_easing": "cubic-bezier(0, 0, 0, 1)", - "strong_invoke_easing": "cubic-bezier(0.13, 1, 0, 0.92)", + "strong_invoke_easing": "cubic-bezier(0.13, 1.62, 0, 0.92)", "fast_dismiss_easing": "cubic-bezier(0, 0, 0, 1)", "soft_dismiss_easing": "cubic-bezier(1, 0, 1, 1)", "point_to_point_easing": "cubic-bezier(0.55, 0.55, 0, 1)", @@ -156,6 +156,11 @@ "warning.foreground": "#f9fafb", "overlay": "#0000000d", "window.border": "#e5e5e5", + "disabled.foreground": "#0000005C", + "control.stroke": "#0000000F", + "card.background": "#FFFFFFB3", + "card.foreground": "#0a0a0a", + "solid.background": "#F3F3F3", "base.red": "#ef4444", "base.red.light": "#fecaca", "base.green": "#22c55e", @@ -175,6 +180,7 @@ "editor.active_line.background": "#F5F5F5", "editor.line_number": "#929292", "editor.active_line_number": "#000000", + "editor.invisible": "#73737366", "conflict": "#C5060B", "created": "#1642FF", "hidden": "#6D6D6D", @@ -272,7 +278,7 @@ "soft_dismiss_duration_ms": 167, "fade_duration_ms": 83, "fast_invoke_easing": "cubic-bezier(0, 0, 0, 1)", - "strong_invoke_easing": "cubic-bezier(0.13, 1, 0, 0.92)", + "strong_invoke_easing": "cubic-bezier(0.13, 1.62, 0, 0.92)", "fast_dismiss_easing": "cubic-bezier(0, 0, 0, 1)", "soft_dismiss_easing": "cubic-bezier(1, 0, 1, 1)", "point_to_point_easing": "cubic-bezier(0.55, 0.55, 0, 1)", @@ -406,6 +412,11 @@ "warning.hover.background": "#7b4414", "overlay": "#ffffff08", "window.border": "#262626", + "disabled.foreground": "#FFFFFF5D", + "control.stroke": "#FFFFFF12", + "card.background": "#FFFFFF0D", + "card.foreground": "#fafafa", + "solid.background": "#202020", "base.red": "#ef4444", "base.red.light": "#fecaca", "base.green": "#22c55e", @@ -425,6 +436,7 @@ "editor.active_line.background": "#171717", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#73737366", "conflict": "#D2602D", "created": "#3f72e2", "created.background": "#0C4619", diff --git a/crates/ui/src/theme/elevation.rs b/crates/ui/src/theme/elevation.rs new file mode 100644 index 00000000..9c86cd25 --- /dev/null +++ b/crates/ui/src/theme/elevation.rs @@ -0,0 +1,64 @@ +use gpui::{BoxShadow, hsla, point, px}; +use smallvec::SmallVec; + +use crate::ThemeElevation; + +impl ThemeElevation { + /// Compute Fluent-style box shadows for a given elevation level. + /// + /// Returns up to 2 shadows (directional + ambient) based on the Fluent elevation equations: + /// - Level 0-2: no shadow (stroke only) + /// - Level 3-32: directional only (blur=0.5n, y=0.25n) + /// - Level >=33: directional + ambient (ambient: blur=0.167n, y=2) + /// - Level 128 (active window): special opacities + pub fn computed_shadow(&self, level: usize, is_dark: bool) -> SmallVec<[BoxShadow; 2]> { + let mut shadows = SmallVec::new(); + + if level <= 2 { + return shadows; + } + + let n = level as f32; + + // Directional shadow + let dir_blur = 0.5 * n; + let dir_y = 0.25 * n; + let dir_opacity = if level == 128 { + if is_dark { 0.56 } else { 0.28 } + } else if level >= 33 { + if is_dark { 0.37 } else { 0.19 } + } else if is_dark { + 0.26 + } else { + ((n + 6.0) / 100.0).min(0.14) + }; + + shadows.push(BoxShadow { + offset: point(px(0.), px(dir_y)), + blur_radius: px(dir_blur), + spread_radius: px(0.), + color: hsla(0., 0., 0., dir_opacity), + }); + + // Ambient shadow (high elevations only) + if level >= 33 { + let amb_blur = 0.167 * n; + let amb_opacity = if level == 128 { + if is_dark { 0.55 } else { 0.22 } + } else if is_dark { + 0.37 + } else { + 0.15 + }; + + shadows.push(BoxShadow { + offset: point(px(0.), px(2.)), + blur_radius: px(amb_blur), + spread_radius: px(0.), + color: hsla(0., 0., 0., amb_opacity), + }); + } + + shadows + } +} diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index 61348d8b..8de3f105 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -12,7 +12,7 @@ pub(crate) fn theme_motion_defaults() -> ThemeMotion { soft_dismiss_duration_ms: 167, fade_duration_ms: 83, fast_invoke_easing: "cubic-bezier(0, 0, 0, 1)".into(), - strong_invoke_easing: "cubic-bezier(0.13, 1, 0, 0.92)".into(), + strong_invoke_easing: "cubic-bezier(0.13, 1.62, 0, 0.92)".into(), fast_dismiss_easing: "cubic-bezier(0, 0, 0, 1)".into(), soft_dismiss_easing: "cubic-bezier(1, 0, 1, 1)".into(), point_to_point_easing: "cubic-bezier(0.55, 0.55, 0, 1)".into(), diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 7edb8461..c53356a7 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -12,15 +12,18 @@ use std::{ }; mod color; +mod elevation; mod fluent_tokens; mod registry; mod schema; mod theme_color; +mod typography; pub use color::*; pub use registry::*; pub use schema::*; pub use theme_color::*; +pub use typography::*; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -136,6 +139,7 @@ pub struct Theme { pub motion: ThemeMotion, pub elevation: ThemeElevation, pub material: ThemeMaterial, + pub typography: ThemeTypography, pub highlight_theme: Arc, pub light_theme: Rc, pub dark_theme: Rc, @@ -310,6 +314,7 @@ impl From<&ThemeColor> for Theme { motion: ThemeMotion::default(), elevation: ThemeElevation::default(), material: ThemeMaterial::default(), + typography: ThemeTypography::default(), light_theme: Rc::new(ThemeConfig::default()), dark_theme: Rc::new(ThemeConfig::default()), highlight_theme: HighlightTheme::default_light(), diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 7230c58e..4ecb00d8 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Colorize, Theme, ThemeColor, ThemeElevation, ThemeMaterial, ThemeMode, ThemeMotion, - ThemeShadowToken, + ThemeShadowToken, ThemeTypographyConfig, highlighter::{HighlightTheme, HighlightThemeStyle}, try_parse_color, }; @@ -67,6 +67,8 @@ pub struct ThemeConfig { pub elevation: Option, /// Material and layering token overrides sourced from Fluent material tokens. pub material: Option, + /// Typography ramp overrides sourced from Fluent type ramp tokens. + pub typography: Option, /// The colors of the theme. pub colors: ThemeConfigColors, @@ -658,6 +660,22 @@ pub struct ThemeConfigColors { #[serde(rename = "window.border")] pub window_border: Option, + /// Fluent disabled text color. + #[serde(rename = "disabled.foreground")] + pub disabled_foreground: Option, + /// Fluent control stroke color. + #[serde(rename = "control.stroke")] + pub control_stroke: Option, + /// Card background color. + #[serde(rename = "card.background")] + pub card: Option, + /// Card text color. + #[serde(rename = "card.foreground")] + pub card_foreground: Option, + /// Solid background color. + #[serde(rename = "solid.background")] + pub solid_background: Option, + /// Base blue color. #[serde(rename = "base.blue")] blue: Option, @@ -919,6 +937,12 @@ impl ThemeColor { apply_color!(overlay); apply_color!(window_border, fallback = self.border); + apply_color!(disabled_foreground, fallback = self.muted_foreground); + apply_color!(control_stroke, fallback = self.border); + apply_color!(card, fallback = self.background); + apply_color!(card_foreground, fallback = self.foreground); + apply_color!(solid_background, fallback = self.background); + // TODO: Apply default fallback colors to highlight. // Ensure opacity for list_active, table_active @@ -992,6 +1016,7 @@ impl Theme { .apply_config(config.elevation.as_ref(), &default_theme.elevation); self.material .apply_config(config.material.as_ref(), &default_theme.material); + self.typography.apply_config(config.typography.as_ref()); self.colors.apply_config(&config, &default_theme.colors); self.mode = config.mode; diff --git a/crates/ui/src/theme/theme_color.rs b/crates/ui/src/theme/theme_color.rs index 8e374b5d..163eb6af 100644 --- a/crates/ui/src/theme/theme_color.rs +++ b/crates/ui/src/theme/theme_color.rs @@ -206,6 +206,17 @@ pub struct ThemeColor { /// This is only works on Linux, other platforms we can't change the window border color. pub window_border: Hsla, + /// Fluent TextFillColorDisabled — disabled text color. + pub disabled_foreground: Hsla, + /// Fluent ControlStrokeColorDefault — subtle control border. + pub control_stroke: Hsla, + /// Fluent CardBackgroundFillColorDefault — card surface background. + pub card: Hsla, + /// Card text color. + pub card_foreground: Hsla, + /// Fluent SolidBackgroundFillColorBase — solid opaque background. + pub solid_background: Hsla, + /// The base red color. pub red: Hsla, /// The base red light color. diff --git a/crates/ui/src/theme/typography.rs b/crates/ui/src/theme/typography.rs new file mode 100644 index 00000000..19d59661 --- /dev/null +++ b/crates/ui/src/theme/typography.rs @@ -0,0 +1,133 @@ +use gpui::{FontWeight, Pixels, px}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A single step in the type ramp. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +pub struct TypeRampToken { + pub size: Pixels, + pub line_height: Pixels, + pub weight: FontWeight, +} + +/// Fluent 9-step type ramp. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeTypography { + pub caption: TypeRampToken, + pub body: TypeRampToken, + pub body_strong: TypeRampToken, + pub body_large: TypeRampToken, + pub body_large_strong: TypeRampToken, + pub subtitle: TypeRampToken, + pub title: TypeRampToken, + pub title_large: TypeRampToken, + pub display: TypeRampToken, +} + +impl Default for ThemeTypography { + fn default() -> Self { + Self { + caption: TypeRampToken { + size: px(12.), + line_height: px(16.), + weight: FontWeight::NORMAL, + }, + body: TypeRampToken { + size: px(14.), + line_height: px(20.), + weight: FontWeight::NORMAL, + }, + body_strong: TypeRampToken { + size: px(14.), + line_height: px(20.), + weight: FontWeight::SEMIBOLD, + }, + body_large: TypeRampToken { + size: px(18.), + line_height: px(24.), + weight: FontWeight::NORMAL, + }, + body_large_strong: TypeRampToken { + size: px(18.), + line_height: px(24.), + weight: FontWeight::SEMIBOLD, + }, + subtitle: TypeRampToken { + size: px(20.), + line_height: px(28.), + weight: FontWeight::SEMIBOLD, + }, + title: TypeRampToken { + size: px(28.), + line_height: px(36.), + weight: FontWeight::SEMIBOLD, + }, + title_large: TypeRampToken { + size: px(40.), + line_height: px(52.), + weight: FontWeight::SEMIBOLD, + }, + display: TypeRampToken { + size: px(68.), + line_height: px(92.), + weight: FontWeight::SEMIBOLD, + }, + } + } +} + +/// Optional overrides for ThemeTypography in JSON config. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ThemeTypographyConfig { + pub caption: Option, + pub body: Option, + pub body_strong: Option, + pub body_large: Option, + pub body_large_strong: Option, + pub subtitle: Option, + pub title: Option, + pub title_large: Option, + pub display: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct TypeRampTokenConfig { + pub size: Option, + pub line_height: Option, + pub weight: Option, +} + +impl ThemeTypography { + pub fn apply_config(&mut self, config: Option<&ThemeTypographyConfig>) { + let defaults = ThemeTypography::default(); + if let Some(config) = config { + macro_rules! apply_ramp { + ($field:ident) => { + if let Some(ref cfg) = config.$field { + self.$field.size = px(cfg.size.unwrap_or(f32::from(defaults.$field.size))); + self.$field.line_height = px(cfg + .line_height + .unwrap_or(f32::from(defaults.$field.line_height))); + self.$field.weight = + FontWeight(cfg.weight.unwrap_or(defaults.$field.weight.0)); + } else { + self.$field = defaults.$field; + } + }; + } + apply_ramp!(caption); + apply_ramp!(body); + apply_ramp!(body_strong); + apply_ramp!(body_large); + apply_ramp!(body_large_strong); + apply_ramp!(subtitle); + apply_ramp!(title); + apply_ramp!(title_large); + apply_ramp!(display); + } else { + *self = defaults; + } + } +} diff --git a/crates/ui/src/time/date_picker.rs b/crates/ui/src/time/date_picker.rs index 74c496da..80509de7 100644 --- a/crates/ui/src/time/date_picker.rs +++ b/crates/ui/src/time/date_picker.rs @@ -2,17 +2,20 @@ use std::rc::Rc; use chrono::NaiveDate; use gpui::{ - App, AppContext, ClickEvent, Context, ElementId, Empty, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _, - Render, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, - Subscription, Window, anchored, deferred, div, prelude::FluentBuilder as _, px, + AnimationExt as _, App, AppContext, ClickEvent, Context, ElementId, Empty, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement as _, Render, RenderOnce, SharedString, + StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, anchored, + deferred, div, prelude::FluentBuilder as _, px, }; use rust_i18n::t; use crate::{ ActiveTheme, Disableable, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt as _, actions::{Cancel, Confirm}, + animation::fast_invoke_animation, button::{Button, ButtonVariants as _}, + global_state::GlobalState, h_flex, input::{Delete, clear_button}, v_flex, @@ -363,10 +366,7 @@ impl RenderOnce for DatePicker { .placeholder .clone() .unwrap_or_else(|| t!("DatePicker.placeholder").into()); - let display_title = state - .date - .format(&state.date_format) - .unwrap_or(placeholder); + let display_title = state.date.format(&state.date_format).unwrap_or(placeholder); div() .id(self.id.clone()) @@ -441,6 +441,10 @@ impl RenderOnce for DatePicker { ), ) .when(state.open, |this| { + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let anim = fast_invoke_animation(motion, reduced_motion); + this.child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( @@ -496,7 +500,17 @@ impl RenderOnce for DatePicker { .p_0() .with_size(self.size), ), - ), + ) + .map(|el| { + if let Some(anim) = anim { + el.with_animation("datepicker-enter", anim, |el, delta| { + el.opacity(delta) + }) + .into_any_element() + } else { + el.into_any_element() + } + }), ), ) .with_priority(2), diff --git a/crates/ui/src/tooltip.rs b/crates/ui/src/tooltip.rs index c682e875..f4a5d6de 100644 --- a/crates/ui/src/tooltip.rs +++ b/crates/ui/src/tooltip.rs @@ -1,9 +1,13 @@ use gpui::{ - Action, AnyElement, AnyView, App, AppContext, Context, IntoElement, ParentElement, Render, - SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px, + Action, AnimationExt as _, AnyElement, AnyView, App, AppContext, Context, IntoElement, + ParentElement, Render, SharedString, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder, px, }; -use crate::{ActiveTheme, StyledExt, h_flex, kbd::Kbd, text::Text}; +use crate::{ + ActiveTheme, StyledExt, animation::fade_animation, global_state::GlobalState, h_flex, kbd::Kbd, + text::Text, +}; enum TooltipContext { Text(Text), @@ -86,39 +90,52 @@ impl Render for Tooltip { } }; - div().child( - // Wrap in a child, to ensure the left margin is applied to the tooltip - h_flex() - .font_family(cx.theme().font_family.clone()) - .m_3() - .bg(cx.theme().popover) - .text_color(cx.theme().popover_foreground) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_md() - .rounded(px(6.)) - .justify_between() - .py_0p5() - .px_2() - .text_sm() - .gap_3() - .refine_style(&self.style) - .map(|this| { - this.child(div().map(|this| match self.content { - TooltipContext::Text(ref text) => this.child(text.clone()), - TooltipContext::Element(ref builder) => this.child(builder(window, cx)), - })) - }) - .when_some(key_binding, |this, kbd| { - this.child( - div() - .text_xs() - .flex_shrink_0() - .text_color(cx.theme().muted_foreground) - .child(kbd.appearance(false)), - ) - }), - ) + let motion = &cx.theme().motion; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let anim = fade_animation(motion, reduced_motion); + + div() + .child( + // Wrap in a child, to ensure the left margin is applied to the tooltip + h_flex() + .font_family(cx.theme().font_family.clone()) + .m_3() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .rounded(px(6.)) + .justify_between() + .py_0p5() + .px_2() + .text_sm() + .gap_3() + .refine_style(&self.style) + .map(|this| { + this.child(div().map(|this| match self.content { + TooltipContext::Text(ref text) => this.child(text.clone()), + TooltipContext::Element(ref builder) => this.child(builder(window, cx)), + })) + }) + .when_some(key_binding, |this, kbd| { + this.child( + div() + .text_xs() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child(kbd.appearance(false)), + ) + }), + ) + .map(|el| { + if let Some(anim) = anim { + el.with_animation("tooltip-fade", anim, |el, delta| el.opacity(delta)) + .into_any_element() + } else { + el.into_any_element() + } + }) } } diff --git a/docs/docs/components/accordion.md b/docs/docs/components/accordion.md index 339b6958..5dfe55c8 100644 --- a/docs/docs/components/accordion.md +++ b/docs/docs/components/accordion.md @@ -1,11 +1,19 @@ --- title: Accordion -description: The accordion uses collapse internally to make it collapsible. +description: Collapsible content panels with animated expand/collapse transitions. --- # Accordion -An accordion component that allows users to show and hide sections of content. It uses collapse functionality internally to create collapsible panels. +An accordion component that allows users to show and hide sections of content. + +## Motion + +- Expanding and collapsing are both animated. +- Content stays mounted during collapse so height animates to zero smoothly. +- Adjacent accordion items reflow during the same animation. +- Expand uses a subtle spring easing; collapse stays snappy. +- Motion tokens come from `theme.motion` (Fluent-aligned defaults). ## Import diff --git a/docs/docs/components/dialog.md b/docs/docs/components/dialog.md index 9b6ea992..2be95e07 100644 --- a/docs/docs/components/dialog.md +++ b/docs/docs/components/dialog.md @@ -182,6 +182,7 @@ window.open_dialog(cx, |dialog, _, _| { .overlay(true) // Show overlay (default: true) .overlay_closable(true) // Click overlay to close (default: true) .keyboard(true) // ESC to close (default: true) + .animate(true) // Enter animation (default: true) .close_button(false) // Show close button (default: true) .child("Dialog content") }) diff --git a/docs/docs/components/editor.md b/docs/docs/components/editor.md index d26420fc..3286c046 100644 --- a/docs/docs/components/editor.md +++ b/docs/docs/components/editor.md @@ -66,6 +66,7 @@ let state = cx.new(|cx| .code_editor("rust") // Language for syntax highlighting .line_number(true) // Show line numbers .searchable(true) // Enable search functionality + .show_whitespaces(true) // Show whitespace characters .default_value("fn main() {\n println!(\"Hello, world!\");\n}") ); diff --git a/docs/docs/components/focus-trap.md b/docs/docs/components/focus-trap.md new file mode 100644 index 00000000..556bf92a --- /dev/null +++ b/docs/docs/components/focus-trap.md @@ -0,0 +1,256 @@ +--- +title: Focus Trap +description: A utility element that traps keyboard focus within a container, preventing Tab navigation from escaping. +--- + +# Focus Trap + +Focus trap utility for constraining keyboard focus within a specific container. Essential for modal dialogs, sheets, and overlay components to provide proper keyboard navigation accessibility. + +**Note:** [Dialog](/docs/components/dialog) and [Sheet](/docs/components/sheet) components have focus trap built-in. You only need to manually use `focus_trap()` for custom modal-like components. + +## Import + +```rust +use gpui_component::FocusTrapElement; +``` + +## Usage + +### Basic Focus Trap + +```rust +let container_handle = cx.focus_handle(); + +v_flex() + .child(Button::new("btn1").label("Button 1")) + .child(Button::new("btn2").label("Button 2")) + .child(Button::new("btn3").label("Button 3")) + .focus_trap("trap1", &container_handle) +// Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1 +// Focus will not escape to elements outside this container +``` + +### Multiple Focus Traps + +You can have multiple independent focus trap areas in your application. Each trap operates independently: + +```rust +let trap1_handle = cx.focus_handle(); +let trap2_handle = cx.focus_handle(); + +v_flex() + .gap_4() + // First focus trap area + .child( + h_flex() + .gap_2() + .child(Button::new("trap1-1").label("Area 1 - Button 1")) + .child(Button::new("trap1-2").label("Area 1 - Button 2")) + .child(Button::new("trap1-3").label("Area 1 - Button 3")) + .focus_trap("trap1", &trap1_handle) + ) + // Second focus trap area + .child( + h_flex() + .gap_2() + .child(Button::new("trap2-1").label("Area 2 - Button 1")) + .child(Button::new("trap2-2").label("Area 2 - Button 2")) + .focus_trap("trap2", &trap2_handle) + ) +``` + +### Focus Trap with Dialog + +[Dialog] components have focus trap built-in automatically. You don't need to manually add `focus_trap()`: + +```rust +window.open_dialog(cx, |dialog, _, _| { + dialog + .title("Settings") + .child( + v_flex() + .gap_3() + .child(Button::new("save").label("Save")) + .child(Button::new("cancel").label("Cancel")) + .child(Button::new("reset").label("Reset")) + ) + // Dialog internally uses focus_trap() + // Tab navigation automatically cycles: save -> cancel -> reset -> save +}) +``` + +### Focus Trap with Sheet + +[Sheet] components also have focus trap built-in automatically: + +```rust +window.open_sheet(cx, |sheet, _, _| { + sheet + .title("Filter Options") + .child( + v_flex() + .gap_2() + .child(Checkbox::new("option1").label("Option 1")) + .child(Checkbox::new("option2").label("Option 2")) + .child(Button::new("apply").label("Apply Filters")) + ) + // Sheet internally uses focus_trap() + // Focus automatically cycles within the sheet panel +}) +``` + +## How It Works + +The focus trap system consists of three key components: + +1. **FocusTrapContainer**: Wraps any container element and registers it as a focus trap area +2. **FocusTrapManager**: Global state manager that tracks all active focus traps +3. **Root Integration**: The [Root] view intercepts Tab/Shift-Tab events and enforces focus cycling + +When Tab or Shift-Tab is pressed: + +1. [Root] detects if the currently focused element is inside a focus trap +2. If yes, it calculates the next focusable element within the same trap +3. If focus would escape the trap, it cycles back to the beginning (Tab) or end (Shift-Tab) +4. This prevents focus from leaving the trapped container + +### Built-in Focus Trap Components + +The following components have focus trap functionality built-in and don't require manual `focus_trap()` calls: + +- **[Dialog]** - Modal dialogs automatically trap focus (see `dialog.rs:437`) +- **[Sheet]** - Side panels automatically trap focus (see `sheet.rs:197`) + +## API Reference + +- [FocusTrapElement](https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html) +- [FocusTrapContainer](https://docs.rs/gpui-component/latest/gpui_component/struct.FocusTrapContainer.html) + +## Examples + +### Custom Modal with Focus Trap + +```rust +struct CustomModal { + container_handle: FocusHandle, +} + +impl CustomModal { + fn new(cx: &mut App) -> Self { + Self { + container_handle: cx.focus_handle(), + } + } +} + +impl Render for CustomModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .gap_4() + .p_6() + .bg(cx.theme().background) + .rounded_lg() + .shadow_lg() + .border_1() + .border_color(cx.theme().border) + .child("This is a modal dialog") + .child( + h_flex() + .gap_2() + .child(Button::new("ok").primary().label("OK")) + .child(Button::new("cancel").label("Cancel")) + ) + .focus_trap("modal", &self.container_handle) + ) + } +} +``` + +### Nested Focus Traps + +Focus traps support nesting. When multiple traps are active, the innermost trap takes precedence: + +```rust +let outer_handle = cx.focus_handle(); +let inner_handle = cx.focus_handle(); + +div() + .child( + v_flex() + .gap_4() + .p_4() + .border_1() + .border_color(cx.theme().border) + .child(Button::new("outer-1").label("Outer Button 1")) + .child( + // Inner trap takes precedence when focused + h_flex() + .gap_2() + .p_4() + .bg(cx.theme().accent.opacity(0.1)) + .child(Button::new("inner-1").label("Inner Button 1")) + .child(Button::new("inner-2").label("Inner Button 2")) + .focus_trap("inner", &inner_handle) + ) + .child(Button::new("outer-2").label("Outer Button 2")) + .focus_trap("outer", &outer_handle) + ) +``` + +### Conditional Focus Trap + +You can conditionally apply focus trapping based on application state: + +```rust +struct ModalView { + is_modal: bool, + container_handle: FocusHandle, +} + +impl Render for ModalView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = v_flex() + .gap_2() + .child(Button::new("btn1").label("Button 1")) + .child(Button::new("btn2").label("Button 2")) + .child(Button::new("btn3").label("Button 3")); + + if self.is_modal { + // Apply focus trap when in modal mode + content.focus_trap("conditional", &self.container_handle) + .into_any_element() + } else { + // Normal behavior without focus trap + content.into_any_element() + } + } +} +``` + +## Accessibility Notes + +- Focus trapping is essential for modal dialogs and overlays to meet WCAG accessibility guidelines +- Always provide a way to close or dismiss trapped focus areas (ESC key, close button) +- The first focusable element in the trap should receive focus when the trap is activated +- Use focus traps sparingly - only for truly modal interactions +- Ensure keyboard navigation order is logical within the trapped area + +## See Also + +- [Root View System](/docs/root) - Manages focus trap behavior at the window level +- [Dialog](/docs/components/dialog) - Uses focus trap automatically +- [Sheet](/docs/components/sheet) - Uses focus trap automatically +- [focus-trap-react](https://github.com/focus-trap/focus-trap-react) - Similar concept for React applications + +[Root]: https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html +[FocusTrapElement]: https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html +[Dialog]: /docs/components/dialog +[Sheet]: /docs/components/sheet diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md index 7ee0eae7..bcc39a22 100644 --- a/docs/docs/components/sidebar.md +++ b/docs/docs/components/sidebar.md @@ -321,6 +321,23 @@ cx.theme().sidebar_primary // Primary elements cx.theme().sidebar_primary_foreground // Primary text ``` +## Motion + +Sidebar motion follows theme motion tokens and respects reduced-motion mode: + +- Sidebar collapse/expand animates panel width. +- Submenu sections animate both open and close (not open-only). +- Reduced motion falls back to immediate state changes. + +Default sidebar motion profile: + +- Sidebar expand: `point_to_point_easing` (`fast_duration_ms`) +- Sidebar collapse: `soft_dismiss_easing` (`soft_dismiss_duration_ms`) +- Submenu expand: subtle spring (`bounce(ease_in_out)`, `fast_duration_ms`) +- Submenu collapse: `point_to_point_easing` (`fast_duration_ms`) + +Use `Sidebar::width(...)` to define the expanded width used by width interpolation. + ## Examples ### File Explorer Sidebar diff --git a/docs/learned/accordion-animation.md b/docs/learned/accordion-animation.md new file mode 100644 index 00000000..b5e862c9 --- /dev/null +++ b/docs/learned/accordion-animation.md @@ -0,0 +1,32 @@ +# Accordion Animation Notes + +Date: 2026-02-10 + +## Problem + +- Content reveal animated. +- Container/sibling reflow not perceived as animated. +- Collapse path removed content immediately (`when(self.open, ...)`), so no exit/layout transition. + +## Fix Pattern + +- Use `animation::keyed_presence` to manage Entering/Entered/Exiting/Exited. +- Keep content mounted during close via `PresenceTransition::should_render`. +- Gate `with_animation` on `presence.transition_active()`. +- Progress: `presence.progress(delta)` for open/close. + +## Token Alignment + +- Source: `theme.motion` defaults (Fluent-aligned). +-- Applied curve: `fast_invoke_easing` (open), `point_to_point_easing` (close). +- Duration used: `fast_duration_ms`. + +## Notes + +- Avoid `bounce(...)` for reveal/size/opacity; it reverses at the end and causes a collapse flash. +- Keep height shaping (`powf(3.0)`) so sibling reflow does not finish too early with large max-height caps. + +## Why It Works + +- Height shrinks/expands over time, so parent box and following accordion items reflow continuously. +- Exit animation now visible before unmount. diff --git a/docs/learned/command-palette-animation.md b/docs/learned/command-palette-animation.md new file mode 100644 index 00000000..9c6e0282 --- /dev/null +++ b/docs/learned/command-palette-animation.md @@ -0,0 +1,29 @@ +# Command Palette Animation Notes + +Date: 2026-02-10 + +## Goal + +- No simultaneous shell-enter + list-expand motion. +- Shell visible immediately. +- Then results/children reveal. +- Keep shortcut keycap pinned at row right edge. + +## Changes + +- Added per-dialog animation toggle (`Dialog::animate(bool)`). +- Command palette opens dialog with `animate(false)` so shell appears instantly. +- Kept delayed reveal + expand animation for list area. +- Added list children fade reveal animation. +- Row layout: category text then shortcut in trailing right group. + +## Result + +- Open sequence feels staged: shell first, content second. +- Shortcut keycaps align to right edge consistently. +- Removed list opacity fade during expand to prevent temporary white block from flyout background. + +## Crash Note + +- Story crash (`cannot update ... while it is already being updated`) was caused by nested `view.update(...)` inside `cx.subscribe(...)`. +- Fix: update `this` directly in subscription callback; no nested lease of same entity. diff --git a/docs/learned/sidebar-animation.md b/docs/learned/sidebar-animation.md new file mode 100644 index 00000000..6514e7eb --- /dev/null +++ b/docs/learned/sidebar-animation.md @@ -0,0 +1,28 @@ +# Sidebar Animation Notes + +Date: 2026-02-10 + +## Goals + +- Sidebar container should animate width on collapse/expand. +- Submenu sections should animate both open and close. +- Respect reduced motion. + +## Implementation + +- Sidebar width: + - Added `Sidebar::width(Pixels)` as the expanded width source. + - Animated width with theme tokens: + - expand: `point_to_point_easing` + - collapse: `soft_dismiss_easing` + - Added delayed `visual_collapsed` state so internal collapsed layout (tight paddings/icon-only presentation) applies after close animation instead of snapping at frame 0. + +- Submenu sections: + - Added delayed visibility state to keep submenu mounted while closing. + - Open animation: `fast_invoke_easing` using fast duration. + - Close animation: `point_to_point_easing`. + - Height + opacity animate together with shaped progress (`powf(3.0)`) to keep reflow readable. + +## Caveats + +- Caret icon still uses immediate 0deg/90deg toggle (no smooth rotate), because `Button` does not expose a simple transform animation hook at the wrapper level. diff --git a/examples/app_assets/Cargo.toml b/examples/app_assets/Cargo.toml index 02f3e2d2..211d7129 100644 --- a/examples/app_assets/Cargo.toml +++ b/examples/app_assets/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "app_assets" description = "Example to load icons or images from assets folder." -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/examples/dialog_overlay/Cargo.toml b/examples/dialog_overlay/Cargo.toml index 6679e4fb..fb24764a 100644 --- a/examples/dialog_overlay/Cargo.toml +++ b/examples/dialog_overlay/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dialog_overlay" description = "An example of using gpui-component to create a Dialog with overlay." -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/examples/focus_trap/Cargo.toml b/examples/focus_trap/Cargo.toml new file mode 100644 index 00000000..d9c786eb --- /dev/null +++ b/examples/focus_trap/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "focus_trap" +description = "Example demonstrating focus trap functionality." +version = "0.5.0" +publish = false +edition.workspace = true + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +gpui-component = { workspace = true } + +[lints] +workspace = true diff --git a/examples/focus_trap/src/main.rs b/examples/focus_trap/src/main.rs new file mode 100644 index 00000000..e7f87be5 --- /dev/null +++ b/examples/focus_trap/src/main.rs @@ -0,0 +1,156 @@ +use gpui::*; +use gpui_component::{button::*, h_flex, v_flex, *}; + +pub struct Example { + trap1_handle: FocusHandle, + trap2_handle: FocusHandle, +} +impl Example { + fn new(cx: &mut App) -> Self { + Self { + trap1_handle: cx.focus_handle(), + trap2_handle: cx.focus_handle(), + } + } +} + +impl Render for Example { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .gap_6() + .p_8() + .child(div().text_xl().font_bold().child("Focus Trap Example")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Press Tab to navigate between buttons. Notice how focus cycles within different areas."), + ) + // Outside buttons - not in focus trap + .child( + v_flex() + .gap_3() + .child( + div() + .text_base() + .font_semibold() + .child("Outside Area (No Focus Trap)"), + ) + .child( + h_flex() + .gap_2() + .child(Button::new("outside-1").label("Outside Button 1")) + .child(Button::new("outside-2").label("Outside Button 2")) + .child(Button::new("outside-3").label("Outside Button 3")), + ), + ) + // Focus trap area 1 + .child( + v_flex() + .gap_3() + .child(div().text_base().font_semibold().child("Focus Trap Area 1")) + .child( + h_flex() + .gap_2() + .p_4() + .bg(cx.theme().secondary) + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .child( + Button::new("trap1-1") + .label("Trap 1 - Button 1") + .on_click(|_, _, _| println!("Trap 1 - Button 1 clicked")), + ) + .child( + Button::new("trap1-2") + .label("Trap 1 - Button 2") + .on_click(|_, _, _| println!("Trap 1 - Button 2 clicked")), + ) + .child( + Button::new("trap1-3") + .label("Trap 1 - Button 3") + .on_click(|_, _, _| println!("Trap 1 - Button 3 clicked")), + ) + .focus_trap("trap1", &self.trap1_handle), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("→ Press Tab in this area, focus cycles through 3 buttons without escaping"), + ), + ) + // Middle outside buttons + .child( + v_flex() + .gap_3() + .child( + div() + .text_base() + .font_semibold() + .child("Outside Area (No Focus Trap)"), + ) + .child( + h_flex() + .gap_2() + .child(Button::new("outside-4").label("Outside Button 4")) + .child(Button::new("outside-5").label("Outside Button 5")), + ), + ) + // Focus trap area 2 + .child( + v_flex() + .gap_3() + .child(div().text_base().font_semibold().child("Focus Trap Area 2")) + .child( + v_flex() + .focus_trap("trap2", &self.trap2_handle) + .gap_2() + .p_4() + .grid() + .grid_cols(4) + .bg(cx.theme().accent.opacity(0.1)) + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().accent) + .child(Button::new("trap2-1").label("Trap 2 - Button 1")) + .child(Button::new("trap2-2").label("Trap 2 - Button 2")) + .child( + Button::new("trap2-3").label("Trap 2 - Button 3"), + ) + .child(Button::new("trap2-4").label("Trap 2 - Button 4")) + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("→ Press Tab in this area, focus cycles through 4 buttons without escaping"), + ), + ) + } +} + +fn main() { + let app = Application::new(); + + app.run(move |cx| { + gpui_component::init(cx); + + let window_options = WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(800.), px(600.)), cx)), + ..Default::default() + }; + + cx.spawn(async move |cx| { + cx.open_window(window_options, |window, cx| { + let view = cx.new(|cx| Example::new(cx)); + cx.new(|cx| Root::new(view, window, cx).bg(cx.theme().background)) + })?; + + Ok::<_, anyhow::Error>(()) + }) + .detach(); + }); +} diff --git a/examples/hello_world/Cargo.toml b/examples/hello_world/Cargo.toml index 4532012e..9312e3f4 100644 --- a/examples/hello_world/Cargo.toml +++ b/examples/hello_world/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hello_world" description = "A minimal example of application development with GPUI Component." -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/examples/input/Cargo.toml b/examples/input/Cargo.toml index 043e7c26..3312cbad 100644 --- a/examples/input/Cargo.toml +++ b/examples/input/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "input" -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/examples/window_title/Cargo.toml b/examples/window_title/Cargo.toml index 300b36f3..2a084bf7 100644 --- a/examples/window_title/Cargo.toml +++ b/examples/window_title/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "window_title" description = "An example of using gpui-component to create a window with a custom title bar." -version = "0.5.0" +version = "0.5.1" publish = false edition.workspace = true diff --git a/themes/adventure.json b/themes/adventure.json index 69f35f35..4f34925e 100644 --- a/themes/adventure.json +++ b/themes/adventure.json @@ -65,6 +65,7 @@ "editor.active_line.background": "#0e0e0e", "editor.line_number": "#5d6165", "editor.active_line_number": "#feffff", + "editor.invisible": "#5d616566", "conflict": "#d84a33", "created": "#5da602", "deleted": "#d84a33", @@ -218,6 +219,7 @@ "editor.active_line.background": "#36345f", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#5d616566", "conflict": "#D2602D", "created": "#3f72e2", "hidden": "#9E9E9E", diff --git a/themes/alduin.json b/themes/alduin.json index 0affa14f..a4fc0f7e 100644 --- a/themes/alduin.json +++ b/themes/alduin.json @@ -59,6 +59,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#9E9E9E66", "conflict": "#8b5f61", "created": "#87afaf", "error": "#8b5f61", diff --git a/themes/ayu.json b/themes/ayu.json index 6f93be08..6e23f425 100644 --- a/themes/ayu.json +++ b/themes/ayu.json @@ -53,6 +53,7 @@ "editor.active_line.background": "#ECECED", "editor.line_number": "#ABB0B6", "editor.active_line_number": "#5C6773", + "editor.invisible": "#73777b66", "conflict": "#f1ad49ff", "conflict.background": "#ffeedaff", "conflict.border": "#ffe1beff", @@ -276,6 +277,7 @@ "editor.active_line.background": "#1f2127bf", "editor.line_number": "#4b4c4e", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#73777b66", "conflict": "#feb454ff", "conflict.background": "#572815ff", "conflict.border": "#754221ff", diff --git a/themes/catppuccin.json b/themes/catppuccin.json index 8c5b82b8..e6b35ce3 100644 --- a/themes/catppuccin.json +++ b/themes/catppuccin.json @@ -55,6 +55,7 @@ "editor.active_line.background": "#dce0e8", "editor.line_number": "#9a9db2", "editor.active_line_number": "#4c4f69", + "editor.invisible": "#7c7f9366", "conflict": "#d20f39", "created": "#85dc78", "deleted": "#dc8a78", @@ -444,6 +445,7 @@ "editor.active_line.background": "#41455977", "editor.line_number": "#979db5", "editor.active_line_number": "#c6d0f5", + "editor.invisible": "#7c7f9366", "conflict": "#e78284", "created": "#a6d189", "deleted": "#3b2e2e", @@ -581,6 +583,7 @@ "editor.active_line.background": "#363a4f", "editor.line_number": "#b8c0e0", "editor.active_line_number": "#cad3f5", + "editor.invisible": "#7c7f9366", "conflict": "#ed8796", "created": "#a6da95", "deleted": "#ed8796", @@ -729,6 +732,7 @@ "editor.active_line.background": "#222230AA", "editor.line_number": "#6c7086", "editor.active_line_number": "#cdd6f4", + "editor.invisible": "#7c7f9366", "conflict": "#f38ba8", "created": "#a6e3a1", "deleted": "#f38ba8", diff --git a/themes/everforest.json b/themes/everforest.json index 6a221a39..56229dc1 100644 --- a/themes/everforest.json +++ b/themes/everforest.json @@ -54,6 +54,7 @@ "editor.active_line.background": "#E7E5D4", "editor.line_number": "#959a9d", "editor.active_line_number": "#5F6D75", + "editor.invisible": "#959a9d66", "conflict": "#e67e80", "created": "#a7c080", "deleted": "#e67e80", @@ -197,6 +198,7 @@ "editor.active_line.background": "#3c4448", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#959a9d66", "conflict": "#D2602D", "created": "#3f72e2", "hidden": "#9E9E9E", diff --git a/themes/fahrenheit.json b/themes/fahrenheit.json index 53c8aca7..abca0b86 100644 --- a/themes/fahrenheit.json +++ b/themes/fahrenheit.json @@ -55,6 +55,7 @@ "editor.background": "#000000", "editor.active_line.background": "#1e1e1e", "editor.line_number": "#828282", + "editor.invisible": "#82828266", "warning.border": "#720202", "syntax": { "attribute": { diff --git a/themes/flexoki.json b/themes/flexoki.json index 5749b9a2..7e3ece91 100644 --- a/themes/flexoki.json +++ b/themes/flexoki.json @@ -52,6 +52,7 @@ "editor.active_line.background": "#F2F0E5", "editor.line_number": "#B7B5AC", "editor.active_line_number": "#24837B", + "editor.invisible": "#B7B5AC66", "conflict": "#AD8301", "conflict.background": "#FAEEC6", "conflict.border": "#AD8301", @@ -221,6 +222,7 @@ "editor.active_line.background": "#1C1B1A", "editor.line_number": "#575653", "editor.active_line_number": "#3AA99F", + "editor.invisible": "#B7B5AC66", "conflict": "#D0A215", "created": "#879A39", "hidden": "#575653", diff --git a/themes/gruvbox.json b/themes/gruvbox.json index 8acc17db..db446fc9 100644 --- a/themes/gruvbox.json +++ b/themes/gruvbox.json @@ -61,6 +61,7 @@ "editor.active_line.background": "#ebdbb2", "editor.line_number": "#928374", "editor.active_line_number": "#3c3836", + "editor.invisible": "#92837466", "conflict": "#cc241d", "created": "#79740e", "deleted": "#9d0006", @@ -206,6 +207,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#92837466", "conflict": "#f06555", "created": "#3f72e2", "deleted": "#9d0006", diff --git a/themes/harper.json b/themes/harper.json index ae42f4e0..aa9469b3 100644 --- a/themes/harper.json +++ b/themes/harper.json @@ -53,6 +53,7 @@ "editor.active_line.background": "#18151B", "editor.line_number": "#726E69", "editor.active_line_number": "#a8a49d", + "editor.invisible": "#726E6966", "conflict": "#ff5874", "created": "#489e48", "deleted": "#ff5874", diff --git a/themes/hybrid.json b/themes/hybrid.json index ea58a38e..dd261f8d 100644 --- a/themes/hybrid.json +++ b/themes/hybrid.json @@ -55,6 +55,7 @@ "editor.active_line.background": "#D3D3D3", "editor.line_number": "#5F5F5F", "editor.active_line_number": "#1C1C1C", + "editor.invisible": "#5F5F5F66", "conflict": "#D2602D", "created": "#3F72E2", "deleted": "#FF5F5F", @@ -202,6 +203,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#5F5F5F66", "conflict": "#D2602D", "created": "#3f72e2", "created.background": "#0C4619", diff --git a/themes/jellybeans.json b/themes/jellybeans.json index fb5a9c4b..bf2bdbd4 100644 --- a/themes/jellybeans.json +++ b/themes/jellybeans.json @@ -60,6 +60,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#9E9E9E66", "conflict": "#D2602D", "created": "#3f72e2", "hidden": "#9E9E9E", diff --git a/themes/kibble.json b/themes/kibble.json index e3780d21..a5c655d4 100644 --- a/themes/kibble.json +++ b/themes/kibble.json @@ -60,6 +60,7 @@ "editor.active_line.background": "#242223", "editor.line_number": "#726E69", "editor.active_line_number": "#f7f7f7", + "editor.invisible": "#726E6966", "conflict": "#c70031", "created": "#2BCF13", "deleted": "#c70031", diff --git a/themes/macos-classic.json b/themes/macos-classic.json index 691eb0be..70c2ac87 100644 --- a/themes/macos-classic.json +++ b/themes/macos-classic.json @@ -51,6 +51,7 @@ "editor.active_line.background": "#F5F5F5", "editor.line_number": "#929292", "editor.active_line_number": "#000000", + "editor.invisible": "#007fff66", "conflict": "#d21f07", "created": "#0060de", "hidden": "#6D6D6D", @@ -183,6 +184,7 @@ "editor.active_line.background": "#35343666", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#007fff66", "conflict": "#D2602D", "created": "#3f72e2", "created.background": "#0C4619", diff --git a/themes/matrix.json b/themes/matrix.json index 66ee784c..8a592ad7 100644 --- a/themes/matrix.json +++ b/themes/matrix.json @@ -61,6 +61,7 @@ "editor.active_line.background": "#001900", "editor.line_number": "#007700", "editor.active_line_number": "#00FF00", + "editor.invisible": "#00770066", "conflict": "#FF0000", "created": "#82d967", "deleted": "#FF0000", diff --git a/themes/mellifluous.json b/themes/mellifluous.json index 1d44d051..fac64574 100644 --- a/themes/mellifluous.json +++ b/themes/mellifluous.json @@ -59,6 +59,7 @@ "editor.active_line.background": "#d5d5d5", "editor.line_number": "#727272", "editor.active_line_number": "#383a42", + "editor.invisible": "#A0A0A066", "conflict": "#e06c75", "created": "#828040", "deleted": "#be5046", @@ -203,6 +204,7 @@ "editor.active_line.background": "#29292977", "editor.line_number": "#828997", "editor.active_line_number": "#abb2bf", + "editor.invisible": "#A0A0A066", "conflict": "#e06c75", "conflict.background": "#1A1A1A", "conflict.border": "#be5046", diff --git a/themes/molokai.json b/themes/molokai.json index 62a63386..5d0c2cbf 100644 --- a/themes/molokai.json +++ b/themes/molokai.json @@ -48,6 +48,7 @@ "editor.active_line.background": "#E4DEDA", "editor.line_number": "#767676", "editor.active_line_number": "#0a0a0a", + "editor.invisible": "#76767666", "conflict": "#e14775", "created": "#269D69", "deleted": "#e14775", @@ -183,6 +184,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#76767666", "conflict": "#D2602D", "created": "#3f72e2", "created.background": "#0C4619", @@ -278,4 +280,4 @@ } } ] -} +} \ No newline at end of file diff --git a/themes/solarized.json b/themes/solarized.json index 7c46e009..9d971b95 100644 --- a/themes/solarized.json +++ b/themes/solarized.json @@ -52,6 +52,7 @@ "editor.active_line.background": "#EEE8D5", "editor.line_number": "#93A1A1", "editor.active_line_number": "#073642", + "editor.invisible": "#93A1A166", "conflict": "#DC322F", "created": "#859900", "deleted": "#DC322F", @@ -191,6 +192,7 @@ "editor.active_line.background": "#073642", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#93A1A166", "conflict": "#D2602D", "created": "#3f72e2", "created.background": "#0C4619", diff --git a/themes/spaceduck.json b/themes/spaceduck.json index 1e348886..d147c8e6 100644 --- a/themes/spaceduck.json +++ b/themes/spaceduck.json @@ -51,6 +51,7 @@ "editor.active_line.background": "#1c1d39", "editor.line_number": "#4b6479", "editor.active_line_number": "#ecf0c1", + "editor.invisible": "#4b647966", "conflict": "#e33400", "created": "#5ccc96", "deleted": "#e33400", diff --git a/themes/tokyonight.json b/themes/tokyonight.json index fe361d3c..d0147300 100644 --- a/themes/tokyonight.json +++ b/themes/tokyonight.json @@ -50,6 +50,7 @@ "editor.active_line.background": "#292e42", "editor.line_number": "#565f89", "editor.active_line_number": "#c0caf5", + "editor.invisible": "#565f8966", "conflict": "#f7768e", "created": "#9ece6a", "deleted": "#f7768e", @@ -188,6 +189,7 @@ "editor.active_line.background": "#292E42", "editor.line_number": "#363C58", "editor.active_line_number": "#B0B9E2", + "editor.invisible": "#565f8966", "conflict": "#D2602D", "created": "#3f72e2", "deleted": "#f7768e", @@ -322,6 +324,7 @@ "editor.active_line.background": "#2d3149", "editor.line_number": "#6e738d", "editor.active_line_number": "#c0caf5", + "editor.invisible": "#565f8966", "conflict": "#ed8796", "created": "#c3e88d", "deleted": "#ed8796", diff --git a/themes/twilight.json b/themes/twilight.json index 07951d79..5c7b92bd 100644 --- a/themes/twilight.json +++ b/themes/twilight.json @@ -54,6 +54,7 @@ "editor.active_line.background": "#131313", "editor.line_number": "#8F8F8F", "editor.active_line_number": "#DDDDDD", + "editor.invisible": "#9E9E9E66", "conflict": "#D2602D", "created": "#3f72e2", "hidden": "#9E9E9E",