From 6e0e8dacc4a98d894fee644b96a8fbedf6adfb62 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 00:41:50 -0700 Subject: [PATCH 01/20] refactor(popover): refine surface and motion Use opaque themed elevation, compact spacing, and interruption-safe point-to-point transitions so the popover reads as a precise desktop overlay. --- crates/story/src/stories/popover_story.rs | 73 +++++++++++++++++------ crates/ui/src/popover.rs | 31 ++++++++-- docs/docs/components/popover.md | 14 +++-- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/crates/story/src/stories/popover_story.rs b/crates/story/src/stories/popover_story.rs index 6bd31379..4516c086 100644 --- a/crates/story/src/stories/popover_story.rs +++ b/crates/story/src/stories/popover_story.rs @@ -236,16 +236,44 @@ impl Render for PopoverStory { .child( section("Basic Popover").child( Popover::new("popover-0") - .max_w(px(600.)) - .trigger(Button::new("btn").outline().label("Popover")) - .gap_2() + .trigger(Button::new("btn").outline().label("View build details")) + .w(px(320.)) .text_sm() - .w(px(400.)) - .child("Hello, this is a Popover.") + .child( + v_flex() + .gap_1() + .child(div().font_semibold().child("Build completed")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Finished 2 minutes ago"), + ), + ) .child(Divider::horizontal()) .child( - "You can put any content here, including text,\ - buttons, forms, and more.", + v_flex() + .gap_2() + .child( + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Branch"), + ) + .child("main"), + ) + .child( + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Duration"), + ) + .child("1m 47s"), + ), ), ), ) @@ -314,27 +342,38 @@ impl Render for PopoverStory { Popover::new("popover-1") .trigger(Button::new("btn").outline().label("Style Popover")) .appearance(false) - .py_1() - .px_2() - .bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .max_w(px(600.)) - .rounded_sm() + .p_2() + .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) .text_sm() - .shadow_2xl() - .child("A styled Popover with custom background and text color."), + .shadow_sm() + .child("Custom surfaces retain the same radius and restrained depth."), ), ) .child( section("Default Open").child( Popover::new("default-open-popover") .default_open(true) + .w(px(280.)) .trigger( Button::new("default-open-btn") - .label("Default Open") + .label("Replay open motion") .outline(), ) - .child("This popover is open by default when first rendered."), + .child( + v_flex() + .gap_1() + .child(div().font_semibold().child("Motion baseline")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Click the trigger twice to close and replay."), + ), + ), ), ) .child( diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 0fdd2804..5194c39c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -7,7 +7,7 @@ use gpui::{ use std::rc::Rc; use crate::{ - ActiveTheme, Anchor, ElementExt, Selectable, StyledExt as _, + ActiveTheme, Anchor, ElementExt, ElevationToken, Selectable, StyledExt as _, ThemeShadowToken, actions::Cancel, anchored, animation::{ @@ -327,7 +327,26 @@ impl Popover { .id("content") .occlude() .tab_group() - .when(appearance, |this| this.popover_style(cx).p_3()) + .when(appearance, |this| { + let elevation = match cx.theme().elevation.surface_flyout_shadow { + ThemeShadowToken::None => ElevationToken::None, + ThemeShadowToken::Xs => ElevationToken::Xs, + ThemeShadowToken::Sm => ElevationToken::Sm, + ThemeShadowToken::Md => ElevationToken::Md, + ThemeShadowToken::Lg => ElevationToken::Lg, + ThemeShadowToken::Xl => ElevationToken::Xl, + }; + + elevation.apply( + this.bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .p_2(), + cx, + ) + }) .map(|this| match anchor { Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), @@ -395,7 +414,7 @@ impl RenderOnce for Popover { let open_duration_ms = if reduced_motion { motion.fast_duration_ms } else { - spring_preset_duration_ms(&motion, SpringPreset::Medium).max(motion.fast_duration_ms) + spring_preset_duration_ms(&motion, SpringPreset::Mild).max(motion.fast_duration_ms) }; let presence = keyed_presence( SharedString::from(format!("popover-presence-{}", popover_id)), @@ -415,7 +434,7 @@ impl RenderOnce for Popover { let open_fade_anim = point_to_point_animation(&motion, reduced_motion); let open_transform_anim = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Medium); + spring_preset_animation(&motion, reduced_motion, SpringPreset::Mild); let close_anim = point_to_point_animation(&motion, reduced_motion); let vertical_direction = if matches!( self.anchor, @@ -460,7 +479,7 @@ impl RenderOnce for Popover { SharedString::from("popover-open-transform"), anim, move |el, delta| { - el.translate_y(px(6.0 * (1.0 - delta) * vertical_direction)) + el.translate_y(px(4.0 * (1.0 - delta) * vertical_direction)) }, ) .into_any_element() @@ -495,7 +514,7 @@ impl RenderOnce for Popover { anim, move |el, delta| { let progress = presence.progress(delta).clamp(0.0, 1.0); - let offset = px(6.0 * (1.0 - progress) * vertical_direction); + let offset = px(4.0 * (1.0 - progress) * vertical_direction); el.opacity(progress).translate_y(offset) }, ) diff --git a/docs/docs/components/popover.md b/docs/docs/components/popover.md index b457e751..d9976727 100644 --- a/docs/docs/components/popover.md +++ b/docs/docs/components/popover.md @@ -186,11 +186,13 @@ And the `Popover` has implemented the [Styled] trait, so you can use all the sty Popover::new("custom-popover") .appearance(false) .trigger(Button::new("custom").label("Custom Style")) - .bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - .p_6() - .rounded_xl() - .shadow_2xl() + .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .border_1() + .border_color(cx.theme().border) + .p_2() + .rounded(cx.theme().radius) + .shadow_sm() .child("Fully custom styled popover") ``` @@ -224,7 +226,7 @@ Popover::new("controlled-popover") ## Motion -- Enter: popover content uses theme fast-invoke timing with opacity + small vertical translation. +- Enter: popover content uses the theme mild spring for a restrained 4px vertical translation, while opacity remains monotonic. - Exit: popover content uses point-to-point timing for a monotonic dismiss (no bounce overshoot). - Reduced motion: transitions are disabled and state changes render immediately. - Anchor-aware offset: top anchors drift downward on enter; bottom anchors drift upward. From 5f3bd378e9a09af9b5d9936b8cb066682e5c3473 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 00:42:05 -0700 Subject: [PATCH 02/20] refactor(menu): sharpen flyout hierarchy and motion Tighten row rhythm, align semantic interaction states, and unify context and submenu transitions for clearer keyboard-driven navigation. --- crates/story/src/stories/menu_story.rs | 376 ++++++++++++------------- crates/ui/src/menu/context_menu.rs | 139 ++++++--- crates/ui/src/menu/menu_item.rs | 59 +++- crates/ui/src/menu/popup_menu.rs | 153 +++++++--- docs/docs/components/menu.md | 15 +- 5 files changed, 455 insertions(+), 287 deletions(-) diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index a4fd67c4..d0d3402c 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -69,32 +69,32 @@ impl MenuStory { fn new(_: &mut Window, _: &mut Context) -> Self { Self { check_side: None, - message: "".to_string(), + message: "Open a menu and choose an action.".to_string(), } } fn on_copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked copy".to_string(); + self.message = "Copied selection".to_string(); cx.notify() } fn on_cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked cut".to_string(); + self.message = "Cut selection".to_string(); cx.notify() } fn on_paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked paste".to_string(); + self.message = "Pasted from clipboard".to_string(); cx.notify() } fn on_search_all(&mut self, _: &SearchAll, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked search all".to_string(); + self.message = "Opened workspace search".to_string(); cx.notify() } fn on_action_info(&mut self, info: &Info, _: &mut Window, cx: &mut Context) { - self.message = format!("You have clicked info: {}", info.0); + self.message = format!("Opened recent project {}", info.0 + 1); cx.notify() } @@ -107,7 +107,14 @@ impl MenuStory { Some(Side::Left) }; - self.message = format!("You have used check at side: {:?}", self.check_side); + self.message = format!( + "Check alignment: {}", + match self.check_side { + Some(Side::Left) => "left", + Some(Side::Right) => "right", + _ => "hidden", + } + ); cx.notify() } } @@ -129,246 +136,211 @@ impl Render for MenuStory { .min_h(px(400.)) .gap_6() .child( - section("Popup Menu") + section("Menu states") + .v_flex() + .gap_4() .child( - Button::new("popup-menu-1") - .outline() - .label("Edit") - .dropdown_menu(move |this, window, cx| { - this.link("About", "https://github.com/longbridge/gpui-component") - .check_side(check_side.unwrap_or(Side::Left)) - .separator() - .item(PopupMenuItem::new("Handle Click").on_click( - window.listener_for(&view, |this, _, _, cx| { - this.message = - "You have clicked Handle Click".to_string(); - cx.notify(); - }), - )) - .separator() - .menu("Copy", Box::new(Copy)) - .menu("Cut", Box::new(Cut)) - .menu("Paste", Box::new(Paste)) - .separator() - .menu_with_check( - format!("Check Side {:?}", check_side), - check_side.is_some(), - Box::new(ToggleCheck), - ) - .separator() - .menu_with_icon("Search", IconName::Search, Box::new(SearchAll)) - .separator() - .item( - PopupMenuItem::element(|_, cx| { - v_flex().child("Custom Element").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("This is sub-title"), + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("menu-states") + .outline() + .label("Open menu") + .dropdown_menu(move |this, window, cx| { + this.check_side(check_side.unwrap_or(Side::Left)) + .label("Edit") + .menu_with_icon( + "Find in workspace", + IconName::Search, + Box::new(SearchAll), ) - }) - .on_click( - window.listener_for(&view, |this, _, _, cx| { - this.message = "You have clicked on custom element" - .to_string(); - cx.notify(); - }), - ), - ) - .menu_element_with_check( - check_side.is_some(), - Box::new(ToggleCheck), - |_, cx| { - h_flex().gap_1().child("Custom Element").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("checked"), + .menu("Copy", Box::new(Copy)) + .menu("Cut", Box::new(Cut)) + .menu_with_disabled("Paste", Box::new(Paste), true) + .separator() + .label("View") + .menu_with_check( + "Cycle check alignment", + check_side.is_some(), + Box::new(ToggleCheck), ) - }, - ) - .menu_element_with_icon( - IconName::Info, - Box::new(Info(0)), - |_, cx| { - h_flex().gap_1().child("Custom").child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("element"), + .menu_with_icon_and_disabled( + "Reveal minimap", + IconName::Eye, + Box::new(Info(0)), + true, ) - }, - ) - .separator() - .menu_with_disabled("Disabled Item", Box::new(Info(0)), true) - .separator() - .submenu("Links", window, cx, |menu, _, _| { - menu.link_with_icon( - "GPUI Component", - IconName::GitHub, - "https://github.com/longbridge/gpui-component", - ) - .separator() - .link("GPUI", "https://gpui.rs") - .link("Zed", "https://zed.dev") - }) - .separator() - .submenu("Other Links", window, cx, |menu, _, _| { - menu.link("Crates", "https://crates.io") - .link("Rust Docs", "https://docs.rs") - }) - }), + .separator() + .item( + PopupMenuItem::element(|_, cx| { + v_flex().child("Inspect selection").child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Open details panel"), + ) + }) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.message = + "Opened selection details".to_string(); + cx.notify(); + }, + )), + ) + .separator() + .submenu_with_icon( + Some(IconName::FolderOpen.into()), + "Open recent", + window, + cx, + |menu, _, _| { + menu.menu("gpui-component", Box::new(Info(0))) + .menu("Atlas editor", Box::new(Info(1))) + .menu( + "Window shell demo", + Box::new(Info(2)), + ) + }, + ) + .separator() + .link_with_icon( + "Component docs", + IconName::ExternalLink, + "https://bumpyclock.github.io/gpui-component/", + ) + }), + ) + .child( + Button::new("menu-right-checks") + .outline() + .label("Right-side checks") + .dropdown_menu(|this, _, _| { + this.check_side(Side::Right) + .label("Panels") + .menu_with_check( + "Project navigator", + true, + Box::new(Info(0)), + ) + .menu_with_check( + "Debug console", + false, + Box::new(Info(1)), + ) + .menu_with_disabled("Timeline", Box::new(Info(2)), true) + }), + ), ) - .child(self.message.clone()), - ) - .child( - section("Context Menu") - .v_flex() - .gap_4() .child( - v_flex() - .w_full() - .p_4() + h_flex() + .gap_2() .items_center() - .justify_center() - .min_h_20() - .rounded_lg() - .border_2() - .border_dashed() + .p_3() + .rounded_md() + .border_1() .border_color(cx.theme().border) - .child("Right click to open ContextMenu") - .context_menu({ - move |this, window, cx| { - this.check_side(check_side.unwrap_or(Side::Left)) - .external_link_icon(false) - .link( - "About", - "https://github.com/longbridge/gpui-component", - ) - .separator() - .menu("Cut", Box::new(Cut)) - .menu("Copy", Box::new(Copy)) - .menu("Paste", Box::new(Paste)) - .separator() - .label("This is a label") - .menu_with_check( - format!("Check Side {:?}", check_side), - check_side.is_some(), - Box::new(ToggleCheck), - ) - .separator() - .submenu("Settings", window, cx, move |menu, _, _| { - menu.menu("Info 0", Box::new(Info(0))) - .separator() - .menu("Item 1", Box::new(Info(1))) - .menu("Item 2", Box::new(Info(2))) - }) - .separator() - .menu("Search All", Box::new(SearchAll)) - .separator() - } - }) .child( div() - .text_sm() + .text_xs() + .font_medium() .text_color(cx.theme().muted_foreground) - .child( - "You can right click anywhere in \ - this area to open the context menu.", - ), - ), - ) - .child( - div() - .id("other") - .flex() - .w_full() - .p_4() - .items_center() - .justify_center() - .min_h_20() - .rounded_lg() - .border_2() - .border_dashed() - .border_color(cx.theme().border) - .child("Here is another area with context menu.") - .context_menu({ - move |this, _, _| { - this.link( - "About", - "https://github.com/longbridge/gpui-component", - ) + .child("Last action"), + ) + .child(self.message.clone()), + ), + ) + .child( + section("Context menu").v_flex().gap_4().child( + v_flex() + .w_full() + .p_4() + .items_center() + .justify_center() + .min_h_20() + .rounded_md() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .child("Right-click to open") + .context_menu({ + move |this, window, cx| { + this.check_side(check_side.unwrap_or(Side::Left)) + .label("Selection") + .menu("Cut", Box::new(Cut)) + .menu("Copy", Box::new(Copy)) + .menu("Paste", Box::new(Paste)) .separator() - .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", - ) + .submenu("Refactor", window, cx, move |menu, _, _| { + menu.menu("Extract method", Box::new(Info(0))) + .menu("Rename symbol", Box::new(Info(1))) + .menu_with_disabled( + "Move to module", + Box::new(Info(2)), + true, + ) + }) .separator() - .menu("Item 1", Box::new(Info(1))) - } - }), - ), + .menu_with_icon( + "Find references", + IconName::Search, + Box::new(SearchAll), + ) + } + }) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Reopen repeatedly to compare enter and exit motion."), + ), + ), ) .child( - section("Menu with scrollbar") + section("Scrollable menu") .child( Button::new("dropdown-menu-scrollable-1") .outline() - .label("Scrollable Menu (100 items)") + .label("100 items") .dropdown_menu_with_anchor(Corner::TopRight, move |this, _, _| { let mut this = this .scrollable(true) .max_h(px(300.)) - .label(format!("Total {} items", 100)); + .label("100 workspace files"); for i in 0..100 { if i > 0 && i % 5 == 0 { this = this.separator(); } this = this.menu( - SharedString::from(format!("Item {}", i)), + SharedString::from(format!( + "workspace-file-{:02}.rs", + i + 1 + )), Box::new(Info(i)), ) } - this.min_w(px(100.)) + this.min_w(px(200.)) }), ) .child( Button::new("dropdown-menu-scrollable-2") .outline() - .label("Scrollable Menu (5 items)") + .label("5 items") .dropdown_menu_with_anchor(Corner::TopRight, move |this, _, _| { let mut this = this .scrollable(true) .max_h(px(300.)) - .label(format!("Total {} items", 100)); + .label("5 recent files"); for i in 0..5 { this = this.menu( - SharedString::from(format!("Item {}", i)), + SharedString::from(format!("recent-file-{}.rs", i + 1)), Box::new(Info(i)), ) } - this.min_w(px(100.)) + this.min_w(px(180.)) }), ), ) diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 5ffa2099..bb3f5814 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -1,14 +1,21 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::Rc, time::Duration}; use gpui::{ 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, + IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, SharedString, + StyleRefinement, Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, + px, }; use crate::{ - ActiveTheme, animation::fast_invoke_animation, global_state::GlobalState, menu::PopupMenu, + ActiveTheme, + animation::{ + PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, + spring_preset_animation, spring_preset_duration_ms, + }, + global_state::GlobalState, + menu::PopupMenu, }; /// A extension trait for adding a context menu to an element. @@ -167,16 +174,102 @@ impl Element for ContextMenu< }; let menu_view = state.shared_state.borrow().menu_view.clone(); let mut menu_element = None; - if open { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let open_duration_ms = if reduced_motion { + motion.fast_duration_ms + } else { + spring_preset_duration_ms(&motion, SpringPreset::Mild) + .max(motion.fast_duration_ms) + }; + let presence = keyed_presence( + SharedString::from(format!("context-menu-presence-{:?}", this.id)), + open, + !reduced_motion, + Duration::from_millis(u64::from(open_duration_ms)), + Duration::from_millis(u64::from(motion.fast_duration_ms)), + PresenceOptions::default(), + window, + cx, + ); + + if presence.should_render() { let has_menu_item = menu_view .as_ref() .map(|menu| !menu.read(cx).is_empty()) .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); + let open_opacity_animation = + point_to_point_animation(&motion, reduced_motion); + let open_transform_animation = + spring_preset_animation(&motion, reduced_motion, SpringPreset::Mild); + let close_animation = point_to_point_animation(&motion, reduced_motion); + let positioned_menu = anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .when_some(menu_view, |this, menu| { + if open && !menu.focus_handle(cx).contains_focused(window, cx) { + menu.focus_handle(cx).focus(window, cx); + } + + this.child(menu) + }); + let animated_menu = if presence.transition_active() { + if matches!(presence.phase, PresencePhase::Entering) { + let transformed = if let Some(animation) = open_transform_animation + { + div() + .child(positioned_menu) + .with_animation( + "context-menu-enter-transform", + animation, + |menu, delta| menu.translate_y(px(4.0 * (1.0 - delta))), + ) + .into_any_element() + } else { + div().child(positioned_menu).into_any_element() + }; + + if let Some(animation) = open_opacity_animation { + div() + .child(transformed) + .with_animation( + "context-menu-enter-opacity", + animation, + move |menu, delta| { + menu.opacity( + presence.progress(delta).clamp(0.0, 1.0), + ) + }, + ) + .into_any_element() + } else { + div() + .child(transformed) + .opacity(presence.progress(1.0)) + .into_any_element() + } + } else if let Some(animation) = close_animation { + div() + .child(positioned_menu) + .with_animation( + "context-menu-exit", + animation, + move |menu, delta| { + let progress = presence.progress(delta).clamp(0.0, 1.0); + menu.opacity(progress) + .translate_y(px(2.0 * (1.0 - progress))) + }, + ) + .into_any_element() + } else { + div().child(positioned_menu).into_any_element() + } + } else { + div().child(positioned_menu).into_any_element() + }; menu_element = Some( deferred( @@ -187,35 +280,7 @@ impl Element for ContextMenu< .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) - .child( - anchored() - .position(position) - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. - if !menu - .focus_handle(cx) - .contains_focused(window, cx) - { - menu.focus_handle(cx).focus(window, cx); - } - - 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() - } - }), + .child(animated_menu), ), ) .with_priority(1) diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index c680f385..67a82a5b 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -1,8 +1,13 @@ -use crate::{ActiveTheme, Disableable, StyledExt, h_flex}; +use crate::{ + ActiveTheme, Disableable, StyledExt, + animation::{SpringPreset, spring_preset_animation}, + global_state::GlobalState, + h_flex, +}; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, - ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, - Styled, Window, prelude::FluentBuilder as _, + AnimationExt as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px, }; use smallvec::SmallVec; @@ -83,7 +88,29 @@ impl ParentElement for MenuItemElement { } impl RenderOnce for MenuItemElement { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let selected_state = window.use_keyed_state( + ElementId::Name(SharedString::from(format!("{}:selected", self.group_name))), + cx, + |_, _| self.selected, + ); + let became_selected = { + let was_selected = *selected_state.read(cx); + if was_selected != self.selected { + selected_state.update(cx, |selected, _| *selected = self.selected); + } + !self.disabled && self.selected && !was_selected + }; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let selection_animation = became_selected + .then(|| { + spring_preset_animation(&cx.theme().motion, reduced_motion, SpringPreset::Mild) + }) + .flatten(); + let selected = self.selected; + let selection_animation_id = + SharedString::from(format!("{}:selection-feedback", self.group_name)); + h_flex() .id(self.id) .group(&self.group_name) @@ -101,12 +128,16 @@ impl RenderOnce for MenuItemElement { }) .when(!self.disabled, |this| { this.group_hover(self.group_name, |this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) + if selected { + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + } else { + this.bg(cx.theme().list_hover) + } }) .when(self.selected, |this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) }) .when_some(self.on_click, |this, on_click| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -119,5 +150,15 @@ impl RenderOnce for MenuItemElement { this.text_color(cx.theme().muted_foreground) }) .children(self.children) + .map(|this| { + if let Some(animation) = selection_animation { + this.with_animation(selection_animation_id, animation, |this, delta| { + this.translate_x(px(1.5 * (delta - 1.0))) + }) + .into_any_element() + } else { + this.into_any_element() + } + }) } } diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 42c1ea46..cc9af93c 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -7,7 +7,10 @@ use crate::animation::{ use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; -use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, SurfaceContext}; +use crate::{ + ActiveTheme, ElementExt, Icon, IconName, NoiseIntensity, Sizable as _, StyledExt, + SurfaceContext, +}; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ Action, AnimationExt as _, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, @@ -252,6 +255,16 @@ impl PopupMenuItem { matches!(self, PopupMenuItem::Separator) } + #[inline] + fn is_disabled(&self) -> bool { + matches!( + self, + PopupMenuItem::Item { disabled: true, .. } + | PopupMenuItem::ElementItem { disabled: true, .. } + | PopupMenuItem::Submenu { disabled: true, .. } + ) + } + fn has_left_icon(&self, check_side: Side) -> bool { match self { PopupMenuItem::Item { icon, checked, .. } => { @@ -980,10 +993,17 @@ impl PopupMenu { fn render_key_binding( &self, action: Option<&dyn Action>, + selected: bool, + disabled: bool, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) -> Option { let action = action?; + let color = if selected && !disabled { + cx.theme().primary_foreground.opacity(0.8) + } else { + cx.theme().muted_foreground + }; match self .action_context @@ -998,6 +1018,7 @@ impl PopupMenu { this.p_0() .flex_nowrap() .border_0() + .text_color(color) .bg(gpui::transparent_white()) }) } @@ -1006,8 +1027,10 @@ impl PopupMenu { has_icon: bool, checked: bool, icon: Option, + selected: bool, + disabled: bool, _: &mut Window, - _: &mut Context, + cx: &mut Context, ) -> Option { if !has_icon { return None; @@ -1021,7 +1044,20 @@ impl PopupMenu { Icon::empty() }; - Some(icon.xsmall()) + let color = if selected && !disabled { + cx.theme().primary_foreground + } else if checked && !disabled { + cx.theme().primary + } else { + cx.theme().muted_foreground + }; + + Some( + h_flex() + .w_4() + .justify_center() + .child(icon.xsmall().text_color(color)), + ) } #[inline] @@ -1057,13 +1093,24 @@ impl PopupMenu { ) -> MenuItemElement { let has_left_icon = options.has_left_icon; let is_left_check = options.check_side.is_left() && item.is_checked(); + let selected = self.selected_index == Some(ix); + let disabled = item.is_disabled(); let right_check_icon = if options.check_side.is_right() && item.is_checked() { - Some(Icon::new(IconName::Check).xsmall()) + Some( + Icon::new(IconName::Check) + .xsmall() + .text_color(if selected && !disabled { + cx.theme().primary_foreground + } else if disabled { + cx.theme().muted_foreground + } else { + cx.theme().primary + }), + ) } else { None }; - let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(4.); const INNER_PADDING: Pixels = px(8.); @@ -1071,8 +1118,8 @@ impl PopupMenu { let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); let (item_height, radius) = match self.size { - Size::Small => (px(20.), options.radius.half()), - _ => (px(26.), options.radius), + Size::Small => (px(24.), options.radius.half()), + _ => (px(28.), options.radius), }; let this = MenuItemElement::new(ix, &group_name) @@ -1098,19 +1145,33 @@ impl PopupMenu { PopupMenuItem::Separator => this .h_auto() .p_0() - .my_0p5() - .mx_neg_1() - .border_b(px(2.)) + .my_1() + .mx_1() + .border_b(px(1.)) .border_color(cx.theme().border) .disabled(true), - PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( - h_flex() - .cursor_default() - .items_center() - .gap_x_1() - .children(Self::render_icon(has_left_icon, false, None, window, cx)) - .child(div().flex_1().child(label.clone())), - ), + PopupMenuItem::Label(label) => this + .h(px(20.)) + .text_xs() + .font_medium() + .disabled(true) + .cursor_default() + .child( + h_flex() + .cursor_default() + .items_center() + .gap_x_1() + .children(Self::render_icon( + has_left_icon, + false, + None, + false, + true, + window, + cx, + )) + .child(div().flex_1().child(label.clone())), + ), PopupMenuItem::ElementItem { render, icon, @@ -1133,6 +1194,8 @@ impl PopupMenu { has_left_icon, is_left_check, icon.clone(), + selected, + *disabled, window, cx, )) @@ -1148,7 +1211,13 @@ impl PopupMenu { .. } => { let show_link_icon = *is_link && self.external_link_icon; - let key = self.render_key_binding(action.as_deref(), window, cx); + let key = + self.render_key_binding(action.as_deref(), selected, *disabled, window, cx); + let accessory_color = if selected && !disabled { + cx.theme().primary_foreground.opacity(0.8) + } else { + cx.theme().muted_foreground + }; this.when(!disabled, |this| { this.on_click( @@ -1162,6 +1231,8 @@ impl PopupMenu { has_left_icon, is_left_check, icon.clone(), + selected, + *disabled, window, cx, )) @@ -1183,7 +1254,7 @@ impl PopupMenu { .child( Icon::new(IconName::ExternalLink) .xsmall() - .text_color(cx.theme().muted_foreground), + .text_color(accessory_color), ), ) }) @@ -1236,6 +1307,8 @@ impl PopupMenu { has_left_icon, false, icon.clone(), + selected, + *disabled, window, cx, )) @@ -1246,11 +1319,13 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) - .child( - Icon::new(IconName::ChevronRight) - .xsmall() - .text_color(cx.theme().muted_foreground), - ), + .child(Icon::new(IconName::ChevronRight).xsmall().text_color( + if selected && !disabled { + cx.theme().primary_foreground.opacity(0.8) + } else { + cx.theme().muted_foreground + }, + )), ), ) .when(submenu_presence.should_render(), |this| { @@ -1393,7 +1468,7 @@ impl Render for PopupMenu { let options = RenderOptions { has_left_icon, check_side: self.check_side, - radius: cx.theme().radius.min(px(8.)), + radius: px(4.), }; let surface_ctx = SurfaceContext { @@ -1452,16 +1527,20 @@ impl Render for PopupMenu { this.vertical_scrollbar(&self.scroll_handle) }); - SurfacePreset::flyout() - .with_radius(cx.theme().radius) - .wrap_with_bounds( - content, - surface_width, - surface_height, - window, - cx, - surface_ctx, - ) + let mut surface = SurfacePreset::flyout() + .with_blur_radius(None) + .with_noise(NoiseIntensity::None) + .with_radius(px(6.)); + surface.background.light_opacity = 1.0; + surface.background.dark_opacity = 1.0; + surface.wrap_with_bounds( + content, + surface_width, + surface_height, + window, + cx, + surface_ctx, + ) } } diff --git a/docs/docs/components/menu.md b/docs/docs/components/menu.md index 2bd8cef6..3d0281c0 100644 --- a/docs/docs/components/menu.md +++ b/docs/docs/components/menu.md @@ -8,6 +8,15 @@ summary: "Context menus and popup menus with support for icons, shortcuts, subme The Menu component provides both context menus (right-click menus) and popup menus with comprehensive features including icons, keyboard shortcuts, submenus, separators, checkable items, and custom elements. Built with accessibility and keyboard navigation in mind. +## Visual treatment + +- Popup surfaces use an opaque popover color, a 1 px theme border, a restrained flyout shadow, and a 6 px radius. +- Default rows are 28 px high with 8 px horizontal padding. Compact application-menu rows remain 24 px high. +- Icons, labels, checks, shortcuts, and submenu chevrons share fixed columns so mixed item types stay aligned. +- Hover uses the neutral list-hover token. Keyboard and pointer selection use the theme primary color with its paired foreground color. +- Section labels use smaller medium-weight text; separators use a single 1 px rule with 4 px vertical spacing. +- Disabled items keep the muted foreground token while retaining row alignment. + ## Import ```rust @@ -450,8 +459,10 @@ Button::new("settings") ## Motion - Root popup menu surface is static to avoid open-time flicker on app-menu and large scrollable menus. -- Nested submenu: open/close uses keyed presence state and side-aware horizontal slide + opacity. -- Reduced motion: popup and submenu transitions are disabled and visibility updates are immediate. +- New row selection gets a 1.5 px spring translation while its selected color appears immediately. +- Nested submenu open uses a side-aware spring translation plus monotonic opacity. Close uses a short point-to-point translation and monotonic opacity. +- Context menu open uses a 4 px spring translation plus monotonic opacity; close uses a 2 px point-to-point translation plus monotonic opacity. +- Reduced motion disables selection, context-menu, and submenu transitions; visibility updates are immediate. - Dropdown menu cache reset: menu entity disposal is delayed to match dismiss timing (or immediate under reduced motion) to avoid flicker. ## Keyboard Shortcuts From 6b290f0da6da2cd916d6e5e13542f64c7e4cafea Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 00:42:24 -0700 Subject: [PATCH 03/20] refactor(sidebar): synchronize floating panel motion Drive shell width and canvas spacing from interruption-safe visual state so collapse reversals preserve geometry, opacity, and reduced-motion policy. --- .../src/stories/floating_sidebar_story.rs | 369 ++++++++++++++++-- crates/ui/src/animation.rs | 7 +- crates/ui/src/floating_sidebar/mod.rs | 167 +++++++- crates/ui/src/sidebar_shell/mod.rs | 84 +++- docs/docs/components/sidebar.md | 5 +- 5 files changed, 564 insertions(+), 68 deletions(-) diff --git a/crates/story/src/stories/floating_sidebar_story.rs b/crates/story/src/stories/floating_sidebar_story.rs index efcd90e1..8baa4db0 100644 --- a/crates/story/src/stories/floating_sidebar_story.rs +++ b/crates/story/src/stories/floating_sidebar_story.rs @@ -1,10 +1,18 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + use gpui::{ - App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, - Styled, Window, div, prelude::FluentBuilder as _, px, + AnimationExt as _, App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder as _, px, }; use gpui_component::{ - ActiveTheme, ElevationToken, FloatingSidebar, Icon, IconName, Side, h_flex, + ActiveTheme, ElevationToken, FloatingSidebar, Icon, IconName, Side, Sizable, StyledExt, + animation::{ + PresenceOptions, SpringPreset, keyed_presence, reduced_motion, spring_preset_duration_ms, + theme_animation, + }, + button::Button, + h_flex, radio::RadioGroup, sidebar::{SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, switch::Switch, @@ -13,9 +21,18 @@ use gpui_component::{ use crate::section; +const STORY_SIDEBAR_WIDTH: gpui::Pixels = px(240.0); +const STORY_COLLAPSED_WIDTH: gpui::Pixels = px(48.0); +const STORY_DEFAULT_INSET: gpui::Pixels = px(8.0); + pub struct FloatingSidebarStory { focus_handle: FocusHandle, collapsed: bool, + content_visual_offset: Rc>, + content_transition_from: gpui::Pixels, + content_transition_duration_ms: u16, + content_transition_generation: u64, + content_last_collapsed: bool, side: Side, wide_inset: bool, elevation: ElevationToken, @@ -26,6 +43,13 @@ impl FloatingSidebarStory { Self { focus_handle: cx.focus_handle(), collapsed: false, + content_visual_offset: Rc::new(Cell::new( + STORY_SIDEBAR_WIDTH + STORY_DEFAULT_INSET * 2.0, + )), + content_transition_from: STORY_SIDEBAR_WIDTH + STORY_DEFAULT_INSET * 2.0, + content_transition_duration_ms: 0, + content_transition_generation: 0, + content_last_collapsed: false, side: Side::Left, wide_inset: false, elevation: ElevationToken::Sm, @@ -45,79 +69,342 @@ impl FloatingSidebarStory { } fn render_example( - &self, + &mut self, id: &'static str, inset: gpui::Pixels, _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let sidebar_width = px(240.0); - let collapsed_width = px(48.0); - let content_offset = if self.collapsed { - collapsed_width + let side = self.side; + let reduced_motion = reduced_motion(cx); + let motion = cx.theme().motion.clone(); + let content_target_offset = if self.collapsed { + STORY_COLLAPSED_WIDTH } else { - sidebar_width + STORY_SIDEBAR_WIDTH } + inset * 2.0; + if self.content_last_collapsed != self.collapsed { + self.content_last_collapsed = self.collapsed; + self.content_transition_generation += 1; + + self.content_transition_from = self.content_visual_offset.get(); + let full_distance = (STORY_SIDEBAR_WIDTH - STORY_COLLAPSED_WIDTH).as_f32(); + let remaining_distance = (content_target_offset - self.content_transition_from) + .as_f32() + .abs() + .min(full_distance); + let spring_duration_ms = spring_preset_duration_ms(&motion, SpringPreset::Medium); + let base_duration_ms = if self.collapsed { + spring_duration_ms.max(motion.soft_dismiss_duration_ms) + } else { + spring_duration_ms.max(motion.fast_duration_ms) + }; + self.content_transition_duration_ms = + ((f32::from(base_duration_ms) * remaining_distance / full_distance).round() as u16) + .max(1); + } + let content_presence = keyed_presence( + SharedString::from(format!("{id}-content-offset")), + !self.collapsed, + !reduced_motion, + Duration::from_millis(u64::from(self.content_transition_duration_ms.max(1))), + Duration::from_millis(u64::from(self.content_transition_duration_ms.max(1))), + PresenceOptions::default(), + _window, + cx, + ); + let content_transition = if content_presence.transition_active() { + theme_animation( + self.content_transition_duration_ms, + &motion.point_to_point_easing, + reduced_motion, + ) + .map(|animation| { + ( + self.content_transition_from, + self.content_transition_generation, + animation, + ) + }) + } else { + self.content_visual_offset.set(content_target_offset); + None + }; let sidebar = FloatingSidebar::new(id) .side(self.side) .collapsed(self.collapsed) - .width(sidebar_width) + .width(STORY_SIDEBAR_WIDTH) .inset(inset) + .blur_enabled(false) .elevation(self.elevation) - .header_with(|collapsed, _, _cx| { + .header_with(move |collapsed, _, cx| { SidebarHeader::new() - .child(Icon::new(IconName::PanelLeft).size_4()) - .when(!collapsed, |this| this.child("Workspace")) + .child( + h_flex() + .min_w_0() + .gap_2() + .child( + div() + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_primary) + .text_color(cx.theme().sidebar_primary_foreground) + .child( + Icon::new(if side.is_left() { + IconName::PanelLeft + } else { + IconName::PanelRight + }) + .size_4(), + ), + ) + .when(!collapsed, |this| { + this.child( + v_flex() + .min_w_0() + .line_height(px(16.0)) + .child(div().text_sm().font_medium().child("Granite")) + .child( + div() + .text_xs() + .text_color( + cx.theme().sidebar_foreground.opacity(0.64), + ) + .child("Product workspace"), + ), + ) + }), + ) .when(!collapsed, |this| { this.child(Icon::new(IconName::ChevronsUpDown).size_4()) }) }) .child( - SidebarGroup::new("Navigation").child(SidebarMenu::new().children([ - SidebarMenuItem::new("Overview").icon(IconName::LayoutDashboard), - SidebarMenuItem::new("Projects").icon(IconName::Folder), - SidebarMenuItem::new("Settings").icon(IconName::Settings2), - ])), + SidebarGroup::new("Navigation").child( + SidebarMenu::new().children([ + SidebarMenuItem::new("Overview") + .icon(IconName::LayoutDashboard) + .active(true), + SidebarMenuItem::new("Projects") + .icon(IconName::Folder) + .suffix(|_, cx| { + div() + .h_5() + .min_w_5() + .px_1() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_accent) + .text_xs() + .text_color(cx.theme().sidebar_accent_foreground) + .child("7") + }), + SidebarMenuItem::new("Inbox") + .icon(IconName::Inbox) + .suffix(|_, cx| { + div() + .h_5() + .min_w_5() + .px_1() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_accent) + .text_xs() + .text_color(cx.theme().sidebar_accent_foreground) + .child("3") + }), + SidebarMenuItem::new("Settings").icon(IconName::Settings2), + ]), + ), ) - .footer_with(|collapsed, _, _| { + .footer_with(|collapsed, _, cx| { SidebarFooter::new() .child( h_flex() + .min_w_0() .gap_2() - .child(Icon::new(IconName::CircleUser)) - .when(!collapsed, |this| this.child("Avery")), + .child( + div() + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(px(999.0)) + .bg(cx.theme().secondary) + .text_xs() + .font_medium() + .child("MC"), + ) + .when(!collapsed, |this| { + this.child( + v_flex() + .min_w_0() + .line_height(px(16.0)) + .child(div().text_sm().font_medium().child("Mira Chen")) + .child( + div() + .text_xs() + .text_color( + cx.theme().sidebar_foreground.opacity(0.64), + ) + .child("mira@granite.tools"), + ), + ) + }), ) .when(!collapsed, |this| { this.child(Icon::new(IconName::ChevronsUpDown).size_4()) }) }); + let content = div() + .size_full() + .when(self.side.is_left(), |this| this.pl(content_target_offset)) + .when(self.side.is_right(), |this| this.pr(content_target_offset)) + .child( + v_flex() + .size_full() + .gap_4() + .p_4() + .child( + h_flex() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_lg().font_semibold().child("Overview")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Current workspace activity"), + ), + ) + .child( + Button::new("floating-sidebar-canvas-toggle") + .small() + .outline() + .icon(if self.collapsed { + if self.side.is_left() { + IconName::PanelLeftOpen + } else { + IconName::PanelRightOpen + } + } else if self.side.is_left() { + IconName::PanelLeftClose + } else { + IconName::PanelRightClose + }) + .label(if self.collapsed { "Expand" } else { "Collapse" }) + .on_click(cx.listener(|this, _, _, cx| { + this.collapsed = !this.collapsed; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .gap_3() + .child( + v_flex() + .flex_1() + .gap_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Open projects"), + ) + .child(div().text_xl().font_semibold().child("7")), + ) + .child( + v_flex() + .flex_1() + .gap_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Ready for review"), + ) + .child(div().text_xl().font_semibold().child("3")), + ), + ) + .child( + v_flex() + .gap_3() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child(div().text_sm().font_medium().child("Recent activity")) + .child( + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Folder).size_4()) + .child("Mira updated desktop navigation"), + ) + .child( + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Settings2).size_4()) + .child("Workspace permissions changed"), + ), + ), + ) + .map(move |content| { + let Some((from, generation, animation)) = content_transition else { + return content.into_any_element(); + }; + let visual_offset = self.content_visual_offset.clone(); + content + .with_animation( + format!("floating-sidebar-content-offset-{generation}"), + animation, + move |content, delta| { + let offset = from + (content_target_offset - from) * delta; + visual_offset.set(offset); + if side.is_left() { + content.pl(offset) + } else { + content.pr(offset) + } + }, + ) + .into_any_element() + }); + div() .relative() - .w(px(640.0)) - .h(px(360.0)) + .w(px(720.0)) + .h(px(420.0)) .overflow_hidden() .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) + .bg(cx.theme().background) + .child(content) .child(sidebar) - .child( - div() - .size_full() - .pt(px(16.0)) - .when(self.side.is_left(), |this| this.pl(content_offset)) - .when(self.side.is_right(), |this| this.pr(content_offset)) - .child( - div() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .p_4() - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child("Main content area"), - ), - ) } } @@ -151,7 +438,9 @@ impl Render for FloatingSidebarStory { .gap_4() .child( h_flex() - .gap_3() + .flex_wrap() + .gap_x_4() + .gap_y_3() .child( Switch::new("floating-sidebar-collapsed") .label("Collapsed") diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index c656087d..f06d76a3 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -1,7 +1,12 @@ use gpui::{Animation, App, SharedString, Window, spring}; use std::time::Duration; -use crate::ThemeMotion; +use crate::{ThemeMotion, global_state::GlobalState}; + +/// Returns whether motion should be reduced for the current component context. +pub fn reduced_motion(cx: &App) -> bool { + GlobalState::global(cx).reduced_motion() +} /// A cubic bezier function like CSS `cubic-bezier`. /// diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs index 1ccb57a6..fcf7657f 100644 --- a/crates/ui/src/floating_sidebar/mod.rs +++ b/crates/ui/src/floating_sidebar/mod.rs @@ -1,6 +1,8 @@ //! FloatingSidebar combines SidebarShell and Sidebar with internal resize handling. +use std::cell::Cell; use std::rc::Rc; +use std::time::Duration; use gpui::{ App, Element, ElementId, Hsla, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement, @@ -8,7 +10,11 @@ use gpui::{ }; use crate::{ - ElevationToken, Side, StyledExt, + ActiveTheme, ElevationToken, Side, StyledExt, + animation::{ + PresenceOptions, SpringPreset, keyed_presence, spring_preset_duration_ms, theme_animation, + }, + global_state::GlobalState, sidebar::{COLLAPSED_WIDTH, DEFAULT_WIDTH, Sidebar, SidebarItem}, sidebar_shell::SidebarShell, }; @@ -16,32 +22,83 @@ use crate::{ /// Default values for floating sidebar configuration. const DEFAULT_MIN_WIDTH: Pixels = px(200.0); const DEFAULT_MAX_WIDTH: Pixels = px(400.0); -const DEFAULT_RESIZER_WIDTH: Pixels = px(6.0); +const DEFAULT_RESIZER_WIDTH: Pixels = px(8.0); const DEFAULT_INSET: Pixels = px(8.0); #[derive(Clone)] struct FloatingSidebarState { expanded_width: Pixels, + visual_width: Rc>, + transition_from: Pixels, + transition_duration_ms: u16, + transition_generation: u64, + last_collapsed: bool, resizing: bool, drag_origin_x: Pixels, drag_origin_width: Pixels, } impl FloatingSidebarState { - fn new(width: Pixels) -> Self { + fn new(width: Pixels, collapsed: bool) -> Self { + let visual_width = if collapsed { COLLAPSED_WIDTH } else { width }; Self { expanded_width: width, + visual_width: Rc::new(Cell::new(visual_width)), + transition_from: visual_width, + transition_duration_ms: 0, + transition_generation: 0, + last_collapsed: collapsed, resizing: false, drag_origin_x: px(0.0), drag_origin_width: width, } } + + fn begin_width_transition( + &mut self, + collapsed: bool, + target_width: Pixels, + expanded_width: Pixels, + base_duration_ms: u16, + ) { + if self.last_collapsed == collapsed { + return; + } + + self.last_collapsed = collapsed; + self.transition_from = self.visual_width.get(); + self.transition_duration_ms = scaled_width_transition_duration_ms( + self.transition_from, + target_width, + expanded_width, + base_duration_ms, + ); + self.transition_generation += 1; + } +} + +fn scaled_width_transition_duration_ms( + from: Pixels, + target: Pixels, + expanded_width: Pixels, + base_duration_ms: u16, +) -> u16 { + let full_distance = (expanded_width - COLLAPSED_WIDTH).as_f32().abs(); + if full_distance <= f32::EPSILON { + return 1; + } + + let remaining_distance = (target - from).as_f32().abs().min(full_distance); + ((f32::from(base_duration_ms) * remaining_distance / full_distance).round() as u16).max(1) } /// A floating sidebar that composes SidebarShell and Sidebar with internal resize handling. /// /// By default, the sidebar uses the theme's panel elevation. Explicit elevation overrides may /// require a larger inset near window edges to avoid native-window clipping. +/// +/// Collapse and expand transitions use the theme's point-to-point motion and become immediate +/// when reduced motion is enabled. #[derive(IntoElement)] pub struct FloatingSidebar { id: ElementId, @@ -78,7 +135,7 @@ impl FloatingSidebar { resizer_hover_bg: None, inset: Some(DEFAULT_INSET), top_inset: px(0.0), - blur_enabled: None, + blur_enabled: Some(false), elevation: None, on_resize_end: None, } @@ -158,7 +215,9 @@ impl FloatingSidebar { self } - /// Explicitly set whether blur effects are enabled for the glass surface. + /// Set whether blur effects are enabled for the panel surface. + /// + /// Default is `false`. pub fn blur_enabled(mut self, enabled: bool) -> Self { self.blur_enabled = Some(enabled); self @@ -256,7 +315,7 @@ impl RenderOnce for FloatingSidebar { let initial_width = width.max(min_width).min(max_width); let state_key = SharedString::from(format!("{}-floating-sidebar-state", id)); let state = window.use_keyed_state(state_key, cx, |_, _| { - FloatingSidebarState::new(initial_width) + FloatingSidebarState::new(initial_width, collapsed) }); let expanded_width = { @@ -265,18 +324,62 @@ impl RenderOnce for FloatingSidebar { if clamped_width != current_width { state.update(cx, |state, cx| { state.expanded_width = clamped_width; + if !state.last_collapsed { + state.visual_width.set(clamped_width); + } cx.notify(); }); } clamped_width }; - let shell_width = if collapsed { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let width_spring_duration_ms = spring_preset_duration_ms(&motion, SpringPreset::Medium); + let target_width = if collapsed { COLLAPSED_WIDTH } else { expanded_width }; - let resizer_width = if collapsed { px(0.0) } else { resizer_width }; + let base_duration_ms = if collapsed { + width_spring_duration_ms.max(motion.soft_dismiss_duration_ms) + } else { + width_spring_duration_ms.max(motion.fast_duration_ms) + }; + state.update(cx, |state, _| { + state.begin_width_transition(collapsed, target_width, expanded_width, base_duration_ms); + }); + let (from_width, width_duration_ms, transition_generation, visual_width) = { + let state = state.read(cx); + ( + state.transition_from, + state.transition_duration_ms, + state.transition_generation, + state.visual_width.clone(), + ) + }; + let width_presence = keyed_presence( + SharedString::from(format!("{}-floating-sidebar-width", id)), + !collapsed, + !reduced_motion, + Duration::from_millis(u64::from(width_duration_ms.max(1))), + Duration::from_millis(u64::from(width_duration_ms.max(1))), + PresenceOptions::default(), + window, + cx, + ); + let transition_active = !reduced_motion && width_presence.transition_active(); + if !transition_active { + visual_width.set(target_width); + } + let visual_collapsed = collapsed && !transition_active; + let resizer_width = if collapsed || transition_active { + px(0.0) + } else { + resizer_width + }; + let resizer_hover_bg = + resizer_hover_bg.unwrap_or_else(|| cx.theme().sidebar_primary.opacity(0.18)); let end_resize = { let state = state.clone(); @@ -306,6 +409,7 @@ impl RenderOnce for FloatingSidebar { let width = width.max(min_width).min(max_width); state.update(cx, |state, cx| { state.expanded_width = width; + state.visual_width.set(width); state.drag_origin_width = width; state.drag_origin_x = x; state.resizing = true; @@ -324,9 +428,9 @@ impl RenderOnce for FloatingSidebar { }; let mut shell = if side.is_left() { - SidebarShell::left(shell_width) + SidebarShell::left(target_width) } else { - SidebarShell::right(shell_width) + SidebarShell::right(target_width) }; shell = shell @@ -339,12 +443,27 @@ impl RenderOnce for FloatingSidebar { end_resize(window, cx); }); + if transition_active { + if let Some(animation) = theme_animation( + width_duration_ms, + &motion.point_to_point_easing, + reduced_motion, + ) { + shell = shell.animate_width_from( + from_width, + SharedString::from(format!( + "{}-floating-sidebar-width-{}", + id, transition_generation + )), + animation, + visual_width, + ); + } + } if let Some(elevation) = elevation { shell = shell.elevation(elevation); } - if let Some(color) = resizer_hover_bg { - shell = shell.resizer_hover_bg(color); - } + shell = shell.resizer_hover_bg(resizer_hover_bg); if let Some(inset) = inset { shell = shell.inset(inset); } @@ -356,10 +475,9 @@ impl RenderOnce for FloatingSidebar { .child( sidebar .side(side) - .collapsed(collapsed) + .collapsed(visual_collapsed) .width(expanded_width) .animate_width(false) - .bg(gpui::transparent_black()) .refine_style(&style), ) .child(resize_tracker) @@ -460,6 +578,7 @@ impl Element for FloatingSidebarResizeTracker { } state.update(cx, |state, cx| { state.expanded_width = next_width; + state.visual_width.set(next_width); cx.notify(); }); } @@ -493,6 +612,7 @@ mod tests { assert_eq!(default_sidebar.width, DEFAULT_WIDTH); assert_eq!(default_sidebar.inset, Some(DEFAULT_INSET)); assert_eq!(default_sidebar.resizer_width, DEFAULT_RESIZER_WIDTH); + assert_eq!(default_sidebar.blur_enabled, Some(false)); let sidebar = FloatingSidebar::::new("floating-sidebar") .side(Side::Right) @@ -548,4 +668,21 @@ mod tests { .width(px(100.0)); assert_eq!(clamped_width.width, px(220.0)); } + + #[test] + fn test_width_transition_reversal_starts_from_current_visual_width() { + let mut state = FloatingSidebarState::new(px(260.0), false); + + state.visual_width.set(px(172.0)); + state.begin_width_transition(true, COLLAPSED_WIDTH, px(260.0), 300); + assert_eq!(state.transition_from, px(172.0)); + assert_eq!(state.transition_generation, 1); + assert!(state.transition_duration_ms < 300); + + state.visual_width.set(px(116.0)); + state.begin_width_transition(false, px(260.0), px(260.0), 300); + assert_eq!(state.transition_from, px(116.0)); + assert_eq!(state.transition_generation, 2); + assert!(state.transition_duration_ms < 300); + } } diff --git a/crates/ui/src/sidebar_shell/mod.rs b/crates/ui/src/sidebar_shell/mod.rs index 2045da0f..725140c4 100644 --- a/crates/ui/src/sidebar_shell/mod.rs +++ b/crates/ui/src/sidebar_shell/mod.rs @@ -38,11 +38,12 @@ //! .child(sidebar_content) //! ``` -use std::rc::Rc; +use std::{cell::Cell, rc::Rc}; use gpui::{ - AnyElement, App, BoxShadow, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - RenderOnce, StyleRefinement, Styled, Window, div, hsla, point, prelude::FluentBuilder, px, + Animation, AnimationExt as _, AnyElement, App, BoxShadow, Hsla, InteractiveElement, + IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, hsla, point, prelude::FluentBuilder, px, }; use smallvec::SmallVec; @@ -56,6 +57,13 @@ const DEFAULT_MIN_WIDTH: f32 = 200.0; const DEFAULT_MAX_WIDTH: f32 = 400.0; const DEFAULT_RESIZER_WIDTH: f32 = 6.0; +struct SidebarShellWidthTransition { + from: Pixels, + animation_id: SharedString, + animation: Animation, + current_width: Rc>, +} + /// Creates a 3-layer shadow effect for elevated sidebar panels. /// /// This shadow configuration provides a natural depth effect with: @@ -151,6 +159,8 @@ pub struct SidebarShell { /// If `None`, the value is inherited from the parent context (e.g., WindowShell). /// If `Some(value)`, the explicit value is used. blur_enabled: Option, + /// Optional width transition applied to the complete shell. + width_transition: Option, /// Child elements rendered inside the surface. children: SmallVec<[AnyElement; 1]>, /// Style refinement for the outer container. @@ -202,6 +212,7 @@ impl SidebarShell { inset: None, top_inset: px(0.0), blur_enabled: None, // Inherit from context by default + width_transition: None, children: SmallVec::new(), style: StyleRefinement::default(), } @@ -341,6 +352,22 @@ impl SidebarShell { self } + pub(crate) fn animate_width_from( + mut self, + from: Pixels, + animation_id: impl Into, + animation: Animation, + current_width: Rc>, + ) -> Self { + self.width_transition = Some(SidebarShellWidthTransition { + from, + animation_id: animation_id.into(), + animation, + current_width, + }); + self + } + fn surface_preset(&self) -> SurfacePreset { let mut surface_preset = SurfacePreset::panel(); if let Some(elevation) = self.elevation { @@ -377,6 +404,12 @@ impl RenderOnce for SidebarShell { let bottom = inset; let sidebar_height = (window_height - (top + bottom)).max(px(0.0)); let sidebar_width = self.width; + let surface_render_width = self + .width_transition + .as_ref() + .map_or(sidebar_width, |transition| { + sidebar_width.max(transition.from) + }); // Use explicit value if set, otherwise inherit from context let blur_enabled = self @@ -387,7 +420,7 @@ impl RenderOnce for SidebarShell { .surface_preset() .wrap_with_bounds( div(), - sidebar_width, + surface_render_width, sidebar_height, window, cx, @@ -398,11 +431,6 @@ impl RenderOnce for SidebarShell { .size_full(); let resizer_half = self.resizer_width / 2.0; - let resizer_left = if self.side.is_left() { - self.width - resizer_half - } else { - -resizer_half - }; let is_left = self.side.is_left(); let on_resize_start = self.on_resize_start.clone(); @@ -428,7 +456,8 @@ impl RenderOnce for SidebarShell { .absolute() .top_0() .bottom_0() - .left(resizer_left) + .when(is_left, |this| this.right(-resizer_half)) + .when(!is_left, |this| this.left(-resizer_half)) .w(self.resizer_width) .rounded(px(999.0)) .bg(gpui::transparent_black()) @@ -455,7 +484,23 @@ impl RenderOnce for SidebarShell { ) .refine_style(&self.style); - outer + if let Some(transition) = self.width_transition { + let SidebarShellWidthTransition { + from, + animation_id, + animation, + current_width, + } = transition; + outer + .with_animation(animation_id, animation, move |this, delta| { + let width = from + (sidebar_width - from) * delta; + current_width.set(width); + this.w(width) + }) + .into_any_element() + } else { + outer.into_any_element() + } } } @@ -472,6 +517,7 @@ mod tests { assert_eq!(default_shell.resizer_width, px(DEFAULT_RESIZER_WIDTH)); assert_eq!(default_shell.inset, None); assert_eq!(default_shell.side, Side::Left); + assert!(default_shell.width_transition.is_none()); let no_shadow = SidebarShell::left(px(260.0)).elevation(ElevationToken::None); assert_eq!(no_shadow.elevation, Some(ElevationToken::None)); @@ -484,6 +530,22 @@ mod tests { assert_eq!(large_shadow.inset, Some(px(12.0))); assert_eq!(large_shadow.resizer_width, px(8.0)); assert_eq!(large_shadow.side, Side::Right); + + let animated = SidebarShell::left(px(48.0)).animate_width_from( + px(260.0), + "sidebar-shell-width", + Animation::new(std::time::Duration::from_millis(187)), + Rc::new(Cell::new(px(260.0))), + ); + let transition = animated + .width_transition + .as_ref() + .expect("width transition should be configured"); + assert_eq!(transition.from, px(260.0)); + assert_eq!( + transition.animation.duration, + std::time::Duration::from_millis(187) + ); } #[test] diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md index 100400e1..f9d977e8 100644 --- a/docs/docs/components/sidebar.md +++ b/docs/docs/components/sidebar.md @@ -89,7 +89,10 @@ SidebarToggleButton::new() ### Floating Sidebar (SidebarShell + Sidebar) `FloatingSidebar` composes `SidebarShell` with `Sidebar`, handling resize internally. -It defaults to an 8px inset, uses the theme's panel elevation, and can be resized when expanded. +It defaults to an 8px inset, a non-blurred surface, the theme's panel elevation, and can be resized +when expanded. Call `.blur_enabled(true)` to opt into the inherited panel material. +Collapse and expand animate the complete panel and its content together using theme motion tokens; +reduced-motion mode applies the final width immediately. Call `.elevation(...)` to override the complete panel shadow. Larger elevations may need a larger inset near window edges to avoid native-window clipping; the inset is never adjusted automatically. From 41b5d2c9da2025e56a526f7716f49540f1dbee56 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 00:42:43 -0700 Subject: [PATCH 04/20] refactor(command-palette): refine search hierarchy and reveal Stabilize virtualized row geometry, strengthen selection semantics, and stage content reveal without delaying keyboard or action handling. --- .../src/stories/command_palette_story.rs | 98 +++-- crates/ui/src/command_palette/mod.rs | 16 +- crates/ui/src/command_palette/state.rs | 4 +- crates/ui/src/command_palette/view.rs | 358 ++++++++++-------- 4 files changed, 285 insertions(+), 191 deletions(-) diff --git a/crates/story/src/stories/command_palette_story.rs b/crates/story/src/stories/command_palette_story.rs index 3be81e3b..a81309eb 100644 --- a/crates/story/src/stories/command_palette_story.rs +++ b/crates/story/src/stories/command_palette_story.rs @@ -49,58 +49,67 @@ impl CommandPaletteStory { } } - fn show_static_palette(&mut self, window: &mut Window, cx: &mut Context) { - let items = vec![ + fn static_items() -> Vec { + #[cfg(target_os = "macos")] + const PRIMARY_MODIFIER: &str = "cmd"; + #[cfg(not(target_os = "macos"))] + const PRIMARY_MODIFIER: &str = "ctrl"; + + let shortcut = |key: &str| format!("{PRIMARY_MODIFIER}-{key}"); + + vec![ CommandPaletteItem::new("file.new", "New File") .category("File") .icon(IconName::Plus) - .shortcut("cmd-n") + .shortcut(shortcut("n")) .keyword("create"), CommandPaletteItem::new("file.open", "Open File") .category("File") .icon(IconName::FolderOpen) - .shortcut("cmd-o") + .shortcut(shortcut("o")) .keyword("browse"), CommandPaletteItem::new("file.save", "Save File") .category("File") .icon(IconName::File) - .shortcut("cmd-s"), + .shortcut(shortcut("s")), CommandPaletteItem::new("file.save-all", "Save All") .category("File") .icon(IconName::File) - .shortcut("cmd-shift-s"), + .shortcut(shortcut("shift-s")), CommandPaletteItem::new("edit.undo", "Undo") .category("Edit") .icon(IconName::Undo) - .shortcut("cmd-z"), + .shortcut(shortcut("z")), CommandPaletteItem::new("edit.redo", "Redo") .category("Edit") .icon(IconName::Redo) - .shortcut("cmd-shift-z"), + .shortcut(shortcut("shift-z")), CommandPaletteItem::new("edit.copy", "Copy") .category("Edit") .icon(IconName::Copy) - .shortcut("cmd-c"), + .shortcut(shortcut("c")), CommandPaletteItem::new("search.find", "Find") .category("Search") .icon(IconName::Search) - .shortcut("cmd-f") + .shortcut(shortcut("f")) .keyword("locate"), CommandPaletteItem::new("search.replace", "Replace") .category("Search") .icon(IconName::Replace) - .shortcut("cmd-r"), + .shortcut(shortcut("r")), CommandPaletteItem::new("view.terminal", "Toggle Terminal") .category("View") .icon(IconName::SquareTerminal) - .shortcut("cmd-`"), + .shortcut(shortcut("`")), CommandPaletteItem::new("view.sidebar", "Toggle Sidebar") .category("View") .icon(IconName::PanelLeft) - .shortcut("cmd-b"), - ]; + .shortcut(shortcut("b")), + ] + } - let provider = Arc::new(StaticProvider::new(items)); + fn show_static_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Self::static_items())); let handle = CommandPalette::open(window, cx, provider); cx.subscribe(&handle.state(), move |this, _state, event, cx| { @@ -112,6 +121,20 @@ impl CommandPaletteStory { .detach(); } + fn show_loading_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Self::static_items())); + let config = CommandPaletteConfig { + status_provider: Some(Arc::new(|_| Some("Indexing workspace…".into()))), + ..Default::default() + }; + CommandPalette::open_with_config(window, cx, provider, config); + } + + fn show_empty_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Vec::new())); + CommandPalette::open(window, cx, provider); + } + fn show_async_palette(&mut self, window: &mut Window, cx: &mut Context) { let provider = Arc::new(AsyncDemoProvider::new()); let handle = CommandPalette::open(window, cx, provider); @@ -178,7 +201,11 @@ impl AsyncDemoProvider { CommandPaletteItem::new("static.settings", "Settings") .category("Static") .icon(IconName::Settings) - .shortcut("cmd-,"), + .shortcut(if cfg!(target_os = "macos") { + "cmd-," + } else { + "ctrl-," + }), CommandPaletteItem::new("static.about", "About") .category("Static") .icon(IconName::Info), @@ -262,15 +289,38 @@ impl Render for CommandPaletteStory { v_flex() .gap_6() .child( - section("Basic Usage") - .child("Open a command palette with static items and fuzzy search.") + section("Visual Review") .child( - Button::new("show-static") - .outline() - .label("Open Static Palette") - .on_click(cx.listener(|this, _, window, cx| { - this.show_static_palette(window, cx) - })), + "Replay deterministic states for screenshots and motion recording.", + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("show-static") + .outline() + .label("Replay Open Motion") + .on_click(cx.listener(|this, _, window, cx| { + this.show_static_palette(window, cx) + })), + ) + .child( + Button::new("show-loading") + .outline() + .label("Review Loading State") + .on_click(cx.listener(|this, _, window, cx| { + this.show_loading_palette(window, cx) + })), + ) + .child( + Button::new("show-empty") + .outline() + .label("Review Empty State") + .on_click(cx.listener(|this, _, window, cx| { + this.show_empty_palette(window, cx) + })), + ), ), ) .child( diff --git a/crates/ui/src/command_palette/mod.rs b/crates/ui/src/command_palette/mod.rs index e94d4ec8..cfb51ddd 100644 --- a/crates/ui/src/command_palette/mod.rs +++ b/crates/ui/src/command_palette/mod.rs @@ -36,8 +36,6 @@ mod state; mod types; mod view; -use std::time::Duration; - pub use matcher::{FuzzyMatcherWrapper, NucleoMatcher}; pub use provider::{CommandPaletteProvider, StaticProvider}; pub use state::{CommandPaletteEvent, CommandPaletteState}; @@ -46,17 +44,13 @@ pub use types::{ CommandPaletteMatch, MatchedItem, }; -const REVEAL_DELAY_MS: u64 = 100; -const REVEAL_ANIMATION_DURATION_MS: u64 = 180; -pub(crate) const REVEAL_QUERY_DELAY: Duration = - Duration::from_millis(REVEAL_DELAY_MS + REVEAL_ANIMATION_DURATION_MS); - -pub(crate) fn reveal_delay(cx: &App) -> Duration { - Duration::from_millis(u64::from(cx.theme().motion.fade_duration_ms)) +pub(crate) fn reveal_delay(cx: &App) -> std::time::Duration { + std::time::Duration::from_millis(u64::from(cx.theme().motion.fade_duration_ms)) } -pub(crate) fn reveal_animation_duration(cx: &App) -> Duration { - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)) +pub(crate) fn reveal_query_delay(cx: &App) -> std::time::Duration { + reveal_delay(cx) + + std::time::Duration::from_millis(u64::from(cx.theme().motion.spring_mild_duration_ms)) } use gpui::{App, AppContext as _, Entity, KeyBinding, ParentElement as _, Styled, Window, actions}; diff --git a/crates/ui/src/command_palette/state.rs b/crates/ui/src/command_palette/state.rs index 036a74d6..cc945750 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, }; @@ -80,7 +80,7 @@ impl CommandPaletteState { let reveal_deadline = if GlobalState::global(cx).reduced_motion() { None } else { - Some(Instant::now() + REVEAL_QUERY_DELAY) + Some(Instant::now() + reveal_query_delay(cx)) }; let mut state = Self { diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 61927204..2b7b38b6 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -1,22 +1,23 @@ //! View component for the Command Palette. use super::provider::CommandPaletteProvider; +use super::reveal_delay; 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::spring_invoke_animation; +use crate::animation::{fade_animation, spring_invoke_animation}; use crate::global_state::GlobalState; use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; +use crate::spinner::Spinner; use crate::{ - ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, + ActiveTheme, Icon, IconName, NoiseIntensity, Sizable, Size, SurfaceContext, SurfacePreset, VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, v_virtual_list, }; use gpui::{ - 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, + 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; @@ -28,15 +29,8 @@ const CONTEXT: &str = "CommandPalette"; const HEADER_HEIGHT: f32 = 52.0; const FOOTER_HEIGHT: f32 = 36.0; const SECTION_HEADER_HEIGHT: f32 = 28.0; -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) -} +const EMPTY_STATE_HEIGHT: f32 = 112.0; +const LIST_PADDING: f32 = 8.0; /// A render row for the command palette list. #[derive(Clone)] @@ -103,7 +97,7 @@ impl CommandPaletteView { input_state, focus_handle, scroll_handle: VirtualListScrollHandle::new(), - item_height: px(48.), + item_height: px(44.), did_focus: false, list_revealed: false, _reveal_task: None, @@ -173,18 +167,21 @@ impl CommandPaletteView { } fn on_action_cancel(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.dismiss(cx); }); } fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.confirm(cx); }); } fn on_action_select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.select_prev(cx); }); @@ -192,6 +189,7 @@ impl CommandPaletteView { } fn on_action_select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.select_next(cx); }); @@ -224,23 +222,33 @@ impl CommandPaletteView { let shortcut_element = item_data .shortcut .as_ref() - .and_then(|s| gpui::Keystroke::parse(s).ok().map(|k| Kbd::new(k))); + .and_then(|s| gpui::Keystroke::parse(s).ok()) + .map(|keystroke| { + Kbd::new(keystroke) + .outline() + .when(selected && !disabled, |this| { + this.bg(cx.theme().list_active) + .border_color(cx.theme().list_active_border) + .text_color(cx.theme().foreground) + }) + }); let has_shortcut = shortcut_element.is_some(); h_flex() .id(SharedString::from(format!("cmd-item-{}", item_index))) .w_full() .h(self.item_height) - .px_3() + .px_2() .gap_3() .items_center() .rounded(cx.theme().radius) .cursor_pointer() - .my_1() .when(disabled, |this| this.opacity(0.5).cursor_not_allowed()) .when(selected && !disabled, |this| { this.bg(cx.theme().list_active) - .text_color(cx.theme().accent_foreground) + .border_1() + .border_color(cx.theme().list_active_border) + .text_color(cx.theme().foreground) }) .when(!selected && !disabled, |this| { this.hover(|this| this.bg(cx.theme().list_hover)) @@ -262,7 +270,11 @@ impl CommandPaletteView { this.child( Icon::new(icon) .size_4() - .text_color(cx.theme().muted_foreground), + .text_color(if selected && !disabled { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }), ) }) // Title and subtitle @@ -274,11 +286,7 @@ impl CommandPaletteView { div() .text_sm() .font_weight(gpui::FontWeight::MEDIUM) - .text_color(if selected { - cx.theme().accent_foreground - } else { - cx.theme().foreground - }) + .text_color(cx.theme().foreground) .truncate() .child(self.render_highlighted_text( &item_data.title, @@ -290,11 +298,7 @@ impl CommandPaletteView { this.child( div() .text_xs() - .text_color(if selected { - cx.theme().accent_foreground.opacity(0.8) - } else { - cx.theme().muted_foreground - }) + .text_color(cx.theme().muted_foreground) .truncate() .child(subtitle), ) @@ -303,13 +307,19 @@ impl CommandPaletteView { .when(show_inline_category || has_shortcut, |this| { this.child( h_flex() + .flex_shrink_0() .items_center() .gap_2() .when(show_inline_category, |this| { this.child( div() .text_xs() - .text_color(cx.theme().muted_foreground) + .text_color(if selected && !disabled { + cx.theme().foreground.opacity(0.72) + } else { + cx.theme().muted_foreground + }) + .truncate() .child(item_data.category.clone()), ) }) @@ -321,8 +331,10 @@ impl CommandPaletteView { fn render_section_header(&self, title: SharedString, cx: &App) -> impl IntoElement { div() .w_full() + .h(px(SECTION_HEADER_HEIGHT)) + .flex() + .items_center() .px_3() - .py_1() .text_xs() .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) @@ -356,7 +368,7 @@ impl CommandPaletteView { if start < end { elements.push( div() - .text_color(cx.theme().accent) + .text_color(cx.theme().list_active_border) .font_weight(gpui::FontWeight::SEMIBOLD) .child(text[start..end].to_string()) .into_any_element(), @@ -374,10 +386,13 @@ impl CommandPaletteView { } fn render_footer(&self, status_text: Option, cx: &App) -> impl IntoElement { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let has_status = status_text.is_some(); + h_flex() .w_full() + .h(px(FOOTER_HEIGHT)) .px_3() - .py_2() .border_t_1() .border_color(cx.theme().border) .justify_between() @@ -385,7 +400,7 @@ impl CommandPaletteView { .text_color(cx.theme().muted_foreground) .child( h_flex() - .gap_3() + .gap_4() .child( h_flex() .gap_1() @@ -395,7 +410,7 @@ impl CommandPaletteView { .child( Kbd::new(gpui::Keystroke::parse("down").unwrap()).appearance(false), ) - .child("to navigate"), + .child("Navigate"), ) .child( h_flex() @@ -404,16 +419,7 @@ impl CommandPaletteView { Kbd::new(gpui::Keystroke::parse("enter").unwrap()) .appearance(false), ) - .child("to select"), - ) - .child( - h_flex() - .gap_1() - .child( - Kbd::new(gpui::Keystroke::parse("escape").unwrap()) - .appearance(false), - ) - .child("to close"), + .child("Run"), ), ) .when_some(status_text, |this, status| { @@ -422,21 +428,55 @@ impl CommandPaletteView { .gap_2() .items_center() .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::LoaderCircle).size_4()) + .when(reduced_motion, |this| { + this.child(Icon::new(IconName::LoaderCircle).size_4()) + }) + .when(!reduced_motion, |this| { + this.child( + Spinner::new() + .icon(IconName::LoaderCircle) + .with_size(Size::Small) + .color(cx.theme().muted_foreground), + ) + }) .child(status), ) }) + .when(!has_status, |this| { + this.child( + h_flex() + .gap_1() + .child( + Kbd::new(gpui::Keystroke::parse("escape").unwrap()).appearance(false), + ) + .child("Close"), + ) + }) } - fn render_empty(&self, cx: &App) -> impl IntoElement { + fn render_empty(&self, query_empty: bool, cx: &App) -> impl IntoElement { v_flex() .size_full() .items_center() - .pt_6() - .gap_2() + .justify_center() + .gap_1() .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::Search).size_8().opacity(0.5)) - .child("No results found") + .child(Icon::new(IconName::Search).size_6().opacity(0.62)) + .child( + div() + .mt_1() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(if query_empty { + "No commands available" + } else { + "No commands found" + }), + ) + .when(!query_empty, |this| { + this.child(div().text_xs().child("Try a different search.")) + }) } fn build_rows( @@ -522,6 +562,7 @@ impl Render for CommandPaletteView { let show_categories = config.show_categories_inline; let show_footer = config.show_footer; + let query_empty = state.query.is_empty(); let footer_status = config .status_provider .as_ref() @@ -529,10 +570,8 @@ impl Render for CommandPaletteView { let max_height = px(config.max_height); let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let reveal_animation_duration = reveal_animation_duration(cx); - let expand_animation = (!reduced_motion) - .then(|| Animation::new(reveal_animation_duration).with_easing(gentle_spring)); - let list_reveal_animation = spring_invoke_animation(&motion, reduced_motion); + let reveal_opacity_animation = fade_animation(&motion, reduced_motion); + let reveal_transform_animation = spring_invoke_animation(&motion, reduced_motion); // Focus input once after opening to avoid render jitter if !self.did_focus { @@ -554,12 +593,13 @@ impl Render for CommandPaletteView { let list_content_height = if row_count == 0 { px(EMPTY_STATE_HEIGHT) } else { - rows.iter().fold(px(0.0), |sum, row| { - sum + match row { - CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), - CommandPaletteRow::Item(_) => self.item_height, - } - }) + px(LIST_PADDING) + + rows.iter().fold(px(0.0), |sum, row| { + sum + match row { + CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), + CommandPaletteRow::Item(_) => self.item_height, + } + }) }; let list_height = max_height.min(list_content_height); let expanded_height = px(HEADER_HEIGHT) @@ -569,7 +609,6 @@ impl Render for CommandPaletteView { } else { px(0.0) }; - let collapsed_height = px(HEADER_HEIGHT); let surface_ctx = SurfaceContext { blur_enabled: GlobalState::global(cx).blur_enabled(), @@ -582,11 +621,7 @@ 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(if self.list_revealed { - expanded_height - } else { - collapsed_height - }) + .h(expanded_height) .w_full() .overflow_hidden() // Search input @@ -614,75 +649,114 @@ impl Render for CommandPaletteView { // Results list .when(self.list_revealed, |this| { this.child({ - let list = div() + let results = v_flex() .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() + .child( + div() + .w_full() + .h(list_height) + .overflow_hidden() + .when(row_count == 0, |this| { + this.child(self.render_empty(query_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() } - }) - .collect() - } - }) - .track_scroll(&self.scroll_handle) - .py_1(), - ) + }, + ) + .track_scroll(&self.scroll_handle) + .p_1(), + ) + }), + ) + .when(show_footer, |this| { + this.child(self.render_footer(footer_status.clone(), cx)) }); - if let Some(anim) = list_reveal_animation { - list.with_animation( - ElementId::NamedInteger("command-palette-list-reveal".into(), 1), - anim, - move |el, delta| { - let opacity = delta.clamp(0.0, 1.0); - let transform_progress = delta.max(0.0); - el.opacity(opacity) - .translate_y(px(6.0 * (1.0 - transform_progress))) - }, - ) - .into_any_element() + let transformed = if let Some(anim) = reveal_transform_animation { + div() + .child(results) + .with_animation( + ElementId::NamedInteger( + "command-palette-results-transform".into(), + 1, + ), + anim, + move |el, delta| el.translate_y(px(4.0 * (1.0 - delta.max(0.0)))), + ) + .into_any_element() + } else { + div().child(results).into_any_element() + }; + + if let Some(anim) = reveal_opacity_animation { + div() + .child(transformed) + .with_animation( + ElementId::NamedInteger( + "command-palette-results-opacity".into(), + 1, + ), + anim, + move |el, delta| el.opacity(delta.clamp(0.0, 1.0)), + ) + .into_any_element() } else { - list.into_any_element() + transformed } }) - }) - // Footer - .when(show_footer && self.list_revealed, |this| { - this.child(self.render_footer(footer_status.clone(), cx)) }); - // Wrap in glassmorphic surface - let surface = SurfacePreset::flyout() + let mut surface = SurfacePreset::flyout() + .with_blur_radius(None) + .with_noise(NoiseIntensity::None) + .with_radius(cx.theme().radius_lg); + surface.background.light_opacity = 1.0; + surface.background.dark_opacity = 1.0; + + surface .wrap_with_bounds( content, px(config.width), @@ -691,31 +765,7 @@ impl Render for CommandPaletteView { cx, surface_ctx, ) - .h(if self.list_revealed { - expanded_height - } else { - collapsed_height - }) - .w(px(config.width)); - - if self.list_revealed { - if let Some(reveal_animation) = expand_animation { - surface - .with_animation( - ElementId::NamedInteger("command-palette-expand".into(), 1), - reveal_animation, - move |this, delta| { - let height = - collapsed_height + (expanded_height - collapsed_height) * delta; - this.h(height) - }, - ) - .into_any_element() - } else { - surface.into_any_element() - } - } else { - surface.into_any_element() - } + .h(expanded_height) + .w(px(config.width)) } } From a89b8654621369fe6de52d574a082fa120b036cd Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 00:43:00 -0700 Subject: [PATCH 05/20] refactor(dropdown-button): stabilize split control geometry Keep primary and trigger segments aligned across states, expose keyboard focus on the trigger, and route disabled and loading behavior through the full group. --- .../src/stories/dropdown_button_story.rs | 231 ++++++++++++++++-- crates/ui/src/button/dropdown_button.rs | 35 ++- docs/docs/components/dropdown_button.md | 15 ++ 3 files changed, 254 insertions(+), 27 deletions(-) diff --git a/crates/story/src/stories/dropdown_button_story.rs b/crates/story/src/stories/dropdown_button_story.rs index 3a21f18b..1b9d9d2b 100644 --- a/crates/story/src/stories/dropdown_button_story.rs +++ b/crates/story/src/stories/dropdown_button_story.rs @@ -128,29 +128,218 @@ impl Render for DropdownButtonStory { ), ) .child( - section("Dropdown Button").child( - DropdownButton::new("btn0") - .primary() - .button(Button::new("primary-action").label("Primary Dropdown")) - .when(self.compact, |this| this.compact()) - .loading(self.loading) - .disabled(self.disabled) - .selected(selected) - .dropdown_menu_with_anchor(Corner::BottomRight, move |this, _, _| { - this.menu_with_check( - "Disabled", - disabled, - Box::new(ButtonAction::Disabled), + section("Interactive split") + .sub_title("Tab moves from the primary action to the menu trigger") + .child( + DropdownButton::new("btn0") + .primary() + .button(Button::new("primary-action").label("Run task")) + .when(self.compact, |this| this.compact()) + .loading(self.loading) + .disabled(self.disabled) + .selected(selected) + .dropdown_menu_with_anchor(Corner::BottomRight, move |this, _, _| { + this.menu_with_check( + "Disabled", + disabled, + Box::new(ButtonAction::Disabled), + ) + .menu_with_check( + "Loading", + loading, + Box::new(ButtonAction::Loading), + ) + .menu_with_check( + "Selected", + selected, + Box::new(ButtonAction::Selected), + ) + .menu_with_check( + "Compact", + compact, + Box::new(ButtonAction::Compact), + ) + }), + ), + ) + .child( + section("State review") + .sub_title("Stable states for screenshot comparison") + .child( + h_flex() + .w_full() + .flex_wrap() + .justify_center() + .gap_6() + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Default"), + ) + .child( + DropdownButton::new("state-default") + .button( + Button::new("state-default-action").label("Run"), + ) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), ) - .menu_with_check("Loading", loading, Box::new(ButtonAction::Loading)) - .menu_with_check("Selected", selected, Box::new(ButtonAction::Selected)) - .menu_with_check( - "Compact", - compact, - Box::new(ButtonAction::Compact), + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Selected"), + ) + .child( + DropdownButton::new("state-selected") + .button( + Button::new("state-selected-action").label("Run"), + ) + .selected(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), ) - }), - ), + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Loading"), + ) + .child( + DropdownButton::new("state-loading") + .button( + Button::new("state-loading-action") + .label("Running"), + ) + .loading(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Disabled"), + ) + .child( + DropdownButton::new("state-disabled") + .button( + Button::new("state-disabled-action").label("Run"), + ) + .disabled(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ), + ), + ) + .child( + section("Scale and density") + .sub_title( + "Caret stays optically quiet while the hit area tracks control height", + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Extra small"), + ) + .child( + DropdownButton::new("size-xs") + .xsmall() + .button(Button::new("size-xs-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Small"), + ) + .child( + DropdownButton::new("size-sm") + .small() + .button(Button::new("size-sm-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Compact"), + ) + .child( + DropdownButton::new("size-compact") + .compact() + .button(Button::new("size-compact-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ), ) .child( section("Borderless Modes") diff --git a/crates/ui/src/button/dropdown_button.rs b/crates/ui/src/button/dropdown_button.rs index 469c2943..14b830a2 100644 --- a/crates/ui/src/button/dropdown_button.rs +++ b/crates/ui/src/button/dropdown_button.rs @@ -7,7 +7,7 @@ use gpui::{ }; use crate::{ - Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt as _, + ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt as _, menu::{DropdownMenu, PopupMenu}, }; @@ -179,9 +179,9 @@ impl Selectable for DropdownButton { } impl RenderOnce for DropdownButton { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let grouped = self.button.is_some() && self.menu.is_some(); - let joined = grouped && self.bordered && !(self.variant.is_ghost() && !self.selected); + let joined = grouped && self.bordered && !self.variant.is_ghost(); let button_corners = if joined { Corners { top_left: true, @@ -214,6 +214,18 @@ impl RenderOnce for DropdownButton { Edges::all(self.bordered) }; let trigger_disabled = self.disabled || (grouped && self.loading); + let trigger_focus_background = cx.theme().selection; + let trigger_focus_foreground = cx.theme().foreground; + let trigger_size = if grouped { + match self.size { + Size::XSmall => Size::XSmall, + Size::Small => Size::Small, + Size::Medium | Size::Large => Size::Medium, + Size::Size(size) => Size::Size(size), + } + } else { + self.size + }; let icon = self .icon .unwrap_or_else(|| Icon::new(IconName::ChevronDown)); @@ -221,7 +233,7 @@ impl RenderOnce for DropdownButton { div() .id(self.id) .h_flex() - .when(grouped && !self.bordered, |this| this.gap_1()) + .when(grouped && !joined, |this| this.gap_1()) .refine_style(&self.style) .when_some(self.button, |this, button| { this.child( @@ -231,7 +243,7 @@ impl RenderOnce for DropdownButton { .border_edges(button_edges) .loading(self.loading) .selected(self.selected) - .disabled(self.disabled) + .disabled(self.disabled || self.loading) .when(self.compact, |this| this.compact()) .when(self.outline, |this| this.outline()) .with_size(self.size) @@ -251,8 +263,19 @@ impl RenderOnce for DropdownButton { .when(self.compact, |this| this.compact()) .when(self.outline, |this| this.outline()) .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) - .with_size(self.size) + .with_size(trigger_size) + .when(grouped, |this| match self.size { + Size::XSmall => this.w_5().h_5(), + Size::Small => this.w_5().h_6(), + Size::Medium | Size::Large => this.w_6().h_8(), + Size::Size(size) => this.size(size), + }) .with_variant(self.variant) + .focus_visible(move |this| { + this.bg(trigger_focus_background) + .border_color(trigger_focus_background) + .text_color(trigger_focus_foreground) + }) .dropdown_menu_with_anchor(self.anchor, menu), ) }) diff --git a/docs/docs/components/dropdown_button.md b/docs/docs/components/dropdown_button.md index 4897d535..749e291a 100644 --- a/docs/docs/components/dropdown_button.md +++ b/docs/docs/components/dropdown_button.md @@ -10,6 +10,10 @@ A [DropdownButton] combines an optional action button with a menu trigger. In sp The component supports [Button] variants, [Sizable] sizes, borderless styling, custom trigger icons, loading, disabled, and selected states. +The split control uses one outer silhouette and one 1 px seam. Its menu trigger stays +narrower than the primary action while retaining the action's height. Ghost and +explicitly borderless modes use a stable 4 px gap so selection never shifts layout. + ## Import ```rust @@ -112,12 +116,23 @@ DropdownButton::new("dropdown") ## Accessibility - Split mode exposes two focus stops: primary action, then menu trigger. +- Each split segment gets its own visible focus ring so keyboard users can tell which action will run. +- Loading and disabled split controls remove both segments from interaction; loading remains visible on the primary action. - Icon-only triggers should always include `tooltip(...)`. +## Motion + +DropdownButton keeps its geometry stable across default, selected, loading, and +disabled states. Menu enter and exit motion belongs to the shared [Popover] and +[PopupMenu] layers rather than the trigger, so split controls do not stack a second +animation on top. The shared layers honor the application's reduced-motion setting. + ## Platform support Bordered, borderless, split, and icon-only modes use shared GPUI primitives and behave consistently on macOS, Windows, and Linux. [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [DropdownButton]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.DropdownButton.html +[Popover]: https://docs.rs/gpui-component/latest/gpui_component/popover/struct.Popover.html +[PopupMenu]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenu.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html From 771669856fb0525c9e06986604a1d4e60e94a8ec Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 06:41:47 -0700 Subject: [PATCH 06/20] refactor(surface): restore translucent glass materials Route flyouts and floating panels through theme-driven Acrylic and Mica surfaces. Preserve opaque blur-off fallbacks while giving menus, palettes, and popovers more breathing room. --- .../src/stories/floating_sidebar_story.rs | 1 - crates/ui/src/command_palette/view.rs | 44 +++--- crates/ui/src/floating_sidebar/mod.rs | 11 +- crates/ui/src/menu/popup_menu.rs | 43 +++--- crates/ui/src/popover.rs | 25 +--- crates/ui/src/surface/mod.rs | 127 ++++++++++++------ crates/ui/src/theme/default-theme.json | 40 +++--- crates/ui/src/theme/fluent_tokens.rs | 18 ++- docs/docs/components/menu.md | 4 +- docs/docs/components/popover.md | 2 + docs/docs/components/sidebar.md | 5 +- 11 files changed, 171 insertions(+), 149 deletions(-) diff --git a/crates/story/src/stories/floating_sidebar_story.rs b/crates/story/src/stories/floating_sidebar_story.rs index 8baa4db0..80e8ebdc 100644 --- a/crates/story/src/stories/floating_sidebar_story.rs +++ b/crates/story/src/stories/floating_sidebar_story.rs @@ -136,7 +136,6 @@ impl FloatingSidebarStory { .collapsed(self.collapsed) .width(STORY_SIDEBAR_WIDTH) .inset(inset) - .blur_enabled(false) .elevation(self.elevation) .header_with(move |collapsed, _, cx| { SidebarHeader::new() diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 2b7b38b6..701acb85 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -11,7 +11,7 @@ use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; use crate::spinner::Spinner; use crate::{ - ActiveTheme, Icon, IconName, NoiseIntensity, Sizable, Size, SurfaceContext, SurfacePreset, + ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, v_virtual_list, }; use gpui::{ @@ -26,11 +26,11 @@ use std::sync::Arc; const CONTEXT: &str = "CommandPalette"; // Height constants for layout calculations -const HEADER_HEIGHT: f32 = 52.0; -const FOOTER_HEIGHT: f32 = 36.0; -const SECTION_HEADER_HEIGHT: f32 = 28.0; -const EMPTY_STATE_HEIGHT: f32 = 112.0; -const LIST_PADDING: f32 = 8.0; +const HEADER_HEIGHT: f32 = 56.0; +const FOOTER_HEIGHT: f32 = 40.0; +const SECTION_HEADER_HEIGHT: f32 = 30.0; +const EMPTY_STATE_HEIGHT: f32 = 120.0; +const LIST_PADDING: f32 = 12.0; /// A render row for the command palette list. #[derive(Clone)] @@ -97,7 +97,7 @@ impl CommandPaletteView { input_state, focus_handle, scroll_handle: VirtualListScrollHandle::new(), - item_height: px(44.), + item_height: px(46.), did_focus: false, list_revealed: false, _reveal_task: None, @@ -218,6 +218,7 @@ impl CommandPaletteView { let match_info = item.match_info.clone(); let disabled = item_data.disabled; let show_inline_category = show_category && !item_data.category.is_empty(); + let secondary_foreground = cx.theme().foreground.opacity(0.68); let shortcut_element = item_data .shortcut @@ -226,6 +227,9 @@ impl CommandPaletteView { .map(|keystroke| { Kbd::new(keystroke) .outline() + .when(!selected && !disabled, |this| { + this.text_color(secondary_foreground) + }) .when(selected && !disabled, |this| { this.bg(cx.theme().list_active) .border_color(cx.theme().list_active_border) @@ -273,7 +277,7 @@ impl CommandPaletteView { .text_color(if selected && !disabled { cx.theme().foreground } else { - cx.theme().muted_foreground + secondary_foreground }), ) }) @@ -317,7 +321,7 @@ impl CommandPaletteView { .text_color(if selected && !disabled { cx.theme().foreground.opacity(0.72) } else { - cx.theme().muted_foreground + secondary_foreground }) .truncate() .child(item_data.category.clone()), @@ -337,7 +341,7 @@ impl CommandPaletteView { .px_3() .text_xs() .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().muted_foreground) + .text_color(cx.theme().foreground.opacity(0.64)) .child(title) } @@ -397,7 +401,7 @@ impl CommandPaletteView { .border_color(cx.theme().border) .justify_between() .text_xs() - .text_color(cx.theme().muted_foreground) + .text_color(cx.theme().foreground.opacity(0.64)) .child( h_flex() .gap_4() @@ -427,7 +431,7 @@ impl CommandPaletteView { h_flex() .gap_2() .items_center() - .text_color(cx.theme().muted_foreground) + .text_color(cx.theme().foreground.opacity(0.64)) .when(reduced_motion, |this| { this.child(Icon::new(IconName::LoaderCircle).size_4()) }) @@ -436,7 +440,7 @@ impl CommandPaletteView { Spinner::new() .icon(IconName::LoaderCircle) .with_size(Size::Small) - .color(cx.theme().muted_foreground), + .color(cx.theme().foreground.opacity(0.64)), ) }) .child(status), @@ -610,9 +614,7 @@ impl Render for CommandPaletteView { px(0.0) }; - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let surface_ctx = SurfaceContext::new(cx); let content = v_flex() .key_context(CONTEXT) @@ -749,14 +751,8 @@ impl Render for CommandPaletteView { }) }); - let mut surface = SurfacePreset::flyout() - .with_blur_radius(None) - .with_noise(NoiseIntensity::None) - .with_radius(cx.theme().radius_lg); - surface.background.light_opacity = 1.0; - surface.background.dark_opacity = 1.0; - - surface + SurfacePreset::flyout() + .with_radius(cx.theme().radius_lg) .wrap_with_bounds( content, px(config.width), diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs index fcf7657f..2ec8b972 100644 --- a/crates/ui/src/floating_sidebar/mod.rs +++ b/crates/ui/src/floating_sidebar/mod.rs @@ -135,7 +135,7 @@ impl FloatingSidebar { resizer_hover_bg: None, inset: Some(DEFAULT_INSET), top_inset: px(0.0), - blur_enabled: Some(false), + blur_enabled: None, elevation: None, on_resize_end: None, } @@ -215,9 +215,7 @@ impl FloatingSidebar { self } - /// Set whether blur effects are enabled for the panel surface. - /// - /// Default is `false`. + /// Explicitly set whether blur effects are enabled for the glass surface. pub fn blur_enabled(mut self, enabled: bool) -> Self { self.blur_enabled = Some(enabled); self @@ -478,7 +476,8 @@ impl RenderOnce for FloatingSidebar { .collapsed(visual_collapsed) .width(expanded_width) .animate_width(false) - .refine_style(&style), + .refine_style(&style) + .bg(gpui::transparent_black()), ) .child(resize_tracker) } @@ -612,7 +611,7 @@ mod tests { assert_eq!(default_sidebar.width, DEFAULT_WIDTH); assert_eq!(default_sidebar.inset, Some(DEFAULT_INSET)); assert_eq!(default_sidebar.resizer_width, DEFAULT_RESIZER_WIDTH); - assert_eq!(default_sidebar.blur_enabled, Some(false)); + assert_eq!(default_sidebar.blur_enabled, None); let sidebar = FloatingSidebar::::new("floating-sidebar") .side(Side::Right) diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index cc9af93c..cd324cba 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -7,10 +7,7 @@ use crate::animation::{ use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; -use crate::{ - ActiveTheme, ElementExt, Icon, IconName, NoiseIntensity, Sizable as _, StyledExt, - SurfaceContext, -}; +use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, StyledExt, SurfaceContext}; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ Action, AnimationExt as _, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, @@ -1118,8 +1115,8 @@ impl PopupMenu { let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); let (item_height, radius) = match self.size { - Size::Small => (px(24.), options.radius.half()), - _ => (px(28.), options.radius), + Size::Small => (px(26.), options.radius.half()), + _ => (px(30.), options.radius), }; let this = MenuItemElement::new(ix, &group_name) @@ -1151,7 +1148,7 @@ impl PopupMenu { .border_color(cx.theme().border) .disabled(true), PopupMenuItem::Label(label) => this - .h(px(20.)) + .h(px(22.)) .text_xs() .font_medium() .disabled(true) @@ -1471,9 +1468,7 @@ impl Render for PopupMenu { radius: px(4.), }; - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let surface_ctx = SurfaceContext::new(cx); let surface_width = if self.bounds.size.width > px(0.) { self.bounds.size.width } else { @@ -1502,8 +1497,8 @@ impl Render for PopupMenu { .child( v_flex() .id("items") - .p_1() - .gap_y_0p5() + .p_1p5() + .gap_y_1() .min_w(rems(8.)) .when_some(self.min_width, |this, min_width| this.min_w(min_width)) .max_w(max_width) @@ -1527,20 +1522,16 @@ impl Render for PopupMenu { this.vertical_scrollbar(&self.scroll_handle) }); - let mut surface = SurfacePreset::flyout() - .with_blur_radius(None) - .with_noise(NoiseIntensity::None) - .with_radius(px(6.)); - surface.background.light_opacity = 1.0; - surface.background.dark_opacity = 1.0; - surface.wrap_with_bounds( - content, - surface_width, - surface_height, - window, - cx, - surface_ctx, - ) + SurfacePreset::flyout() + .with_radius(px(6.)) + .wrap_with_bounds( + content, + surface_width, + surface_height, + window, + cx, + surface_ctx, + ) } } diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 5194c39c..e8abc646 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -7,7 +7,7 @@ use gpui::{ use std::rc::Rc; use crate::{ - ActiveTheme, Anchor, ElementExt, ElevationToken, Selectable, StyledExt as _, ThemeShadowToken, + ActiveTheme, Anchor, ElementExt, Selectable, StyledExt as _, SurfaceContext, SurfacePreset, actions::Cancel, anchored, animation::{ @@ -328,24 +328,11 @@ impl Popover { .occlude() .tab_group() .when(appearance, |this| { - let elevation = match cx.theme().elevation.surface_flyout_shadow { - ThemeShadowToken::None => ElevationToken::None, - ThemeShadowToken::Xs => ElevationToken::Xs, - ThemeShadowToken::Sm => ElevationToken::Sm, - ThemeShadowToken::Md => ElevationToken::Md, - ThemeShadowToken::Lg => ElevationToken::Lg, - ThemeShadowToken::Xl => ElevationToken::Xl, - }; - - elevation.apply( - this.bg(cx.theme().popover) - .text_color(cx.theme().popover_foreground) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .p_2(), - cx, - ) + SurfacePreset::flyout() + .with_radius(cx.theme().radius) + .apply_material(this, cx, SurfaceContext::new(cx)) + .text_color(cx.theme().popover_foreground) + .p_3() }) .map(|this| match anchor { Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), diff --git a/crates/ui/src/surface/mod.rs b/crates/ui/src/surface/mod.rs index 47fbfcc0..c14ed406 100644 --- a/crates/ui/src/surface/mod.rs +++ b/crates/ui/src/surface/mod.rs @@ -18,7 +18,7 @@ use gpui::{ StyledImage, Window, div, img, px, }; -use crate::{ActiveTheme, StyledExt, ThemeShadowToken}; +use crate::{ActiveTheme, StyledExt, ThemeShadowToken, global_state::GlobalState}; const GLASS_NOISE_ASSET_PATH: &str = "surface/NoiseAsset_256.png"; const GLASS_NOISE_TILE_SIZE_BASE: f32 = 128.0; @@ -30,6 +30,15 @@ pub struct SurfaceContext { pub blur_enabled: bool, } +impl SurfaceContext { + /// Builds a context from the app's current blur setting. + pub(crate) fn new(cx: &App) -> Self { + Self { + blur_enabled: GlobalState::global(cx).blur_enabled(), + } + } +} + /// Semantic categorization of surface types. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SurfaceKind { @@ -91,6 +100,8 @@ impl ElevationToken { pub enum SurfaceColorSource { #[default] Popover, + Acrylic, + Mica, White, Sidebar, Background, @@ -119,6 +130,20 @@ impl SurfaceBackground { pub fn resolve(&self, cx: &App) -> Hsla { let base = match self.color_source { SurfaceColorSource::Popover => cx.theme().popover, + SurfaceColorSource::Acrylic => { + if cx.theme().mode.is_dark() { + cx.theme().material.acrylic_default_dark + } else { + cx.theme().material.acrylic_default_light + } + } + SurfaceColorSource::Mica => { + if cx.theme().mode.is_dark() { + cx.theme().material.mica_base_dark + } else { + cx.theme().material.mica_base_light + } + } SurfaceColorSource::White => gpui::white(), SurfaceColorSource::Sidebar => cx.theme().sidebar, SurfaceColorSource::Background => cx.theme().background, @@ -174,7 +199,7 @@ impl StrokeSpec { cx.theme().material.subtle_stroke_light_opacity }; match self.color { - StrokeColor::Subtle => cx.theme().border.opacity(subtle_stroke_opacity), + StrokeColor::Subtle => cx.theme().foreground.opacity(subtle_stroke_opacity), StrokeColor::Default => cx.theme().border, StrokeColor::Strong => cx.theme().border, StrokeColor::SubtleWithOpacity(opacity) => cx.theme().border.opacity(opacity), @@ -229,22 +254,22 @@ impl SurfacePreset { /// Creates a flyout surface preset for menus and dropdowns. /// - /// - 60px blur radius + /// - 24px blur radius /// - Subtle noise - /// - Popover background at 0.60/0.70 opacity - /// - Small elevation with subtle stroke + /// - Acrylic background at 0.86/0.88 opacity + /// - Medium elevation with subtle stroke /// - 12px border radius pub fn flyout() -> Self { Self { kind: SurfaceKind::Flyout, - blur_radius: Some(px(60.0)), + blur_radius: Some(px(24.0)), noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Popover, - light_opacity: 0.60, - dark_opacity: 0.70, + color_source: SurfaceColorSource::Acrylic, + light_opacity: 0.86, + dark_opacity: 0.88, }, - elevation: ElevationToken::Sm, + elevation: ElevationToken::Md, stroke: Some(StrokeSpec::subtle()), transparency_factor: 1.0, radius: Some(px(12.0)), @@ -255,19 +280,19 @@ impl SurfacePreset { /// Creates a panel surface preset for sidebars and navigation. /// - /// - 120px blur radius - /// - Heavy noise - /// - Sidebar background at 0.85/0.90 opacity + /// - 48px blur radius + /// - Subtle noise + /// - Mica background at 0.88/0.90 opacity /// - Large elevation with subtle stroke /// - 16px border radius pub fn panel() -> Self { Self { kind: SurfaceKind::Panel, - blur_radius: Some(px(120.0)), - noise_intensity: NoiseIntensity::Heavy, + blur_radius: Some(px(48.0)), + noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Sidebar, - light_opacity: 0.85, + color_source: SurfaceColorSource::Mica, + light_opacity: 0.88, dark_opacity: 0.90, }, elevation: ElevationToken::Lg, @@ -344,33 +369,34 @@ impl SurfacePreset { self } - /// Wraps content in a surface container with all configured effects. + /// Applies the surface material (radius, background, backdrop blur, stroke, elevation) + /// directly to an element. /// - /// This method creates a complete surface element with: - /// - One rounded clip shared by the background, backdrop blur, noise, and content - /// - Border/stroke styling using the same rounded geometry - /// - Elevation shadows painted outside the rounded clip - /// - Noise overlay (if blur is enabled) - pub fn wrap_with_bounds( - &self, - content: impl IntoElement, - width: Pixels, - height: Pixels, - window: &Window, - cx: &App, - ctx: SurfaceContext, - ) -> Div { + /// Use this when the element already owns its layout and only needs the material, e.g. + /// a popover body. Use [`Self::wrap_with_bounds`] instead when the surface should also + /// render the noise overlay, which requires known bounds. + pub(crate) fn apply_material(&self, mut surface: E, cx: &App, ctx: SurfaceContext) -> E + where + E: Styled + StyledExt, + { let radius = self.radius.unwrap_or(cx.theme().radius); - let scale_factor = window.scale_factor(); let blur_radius = self.resolve_blur_radius(cx); - let background = self.resolve_background(cx); + let mut background = self.resolve_background(cx); let elevation = self.resolve_elevation(cx); + let uses_opaque_fallback = !ctx.blur_enabled && blur_radius.is_some(); - let bg_color = background.resolve(cx).opacity(self.transparency_factor); - let noise_opacity = self.noise_intensity.opacity(); - let should_render_noise = ctx.blur_enabled && noise_opacity > 0.0; + if uses_opaque_fallback { + background.light_opacity = 1.0; + background.dark_opacity = 1.0; + } - let mut surface = div().relative().rounded(radius).overflow_hidden(); + let transparency_factor = if uses_opaque_fallback { + 1.0 + } else { + self.transparency_factor + }; + let bg_color = background.resolve(cx).opacity(transparency_factor); + surface = surface.rounded(radius); if bg_color.a > 0.0 { surface = surface.bg(bg_color); @@ -388,7 +414,30 @@ impl SurfacePreset { .border_color(stroke.resolve_color(cx)); } - surface = elevation.apply(surface, cx); + elevation.apply(surface, cx) + } + + /// Wraps content in a surface container with all configured effects. + /// + /// This method creates a complete surface element with: + /// - One rounded clip shared by the background, backdrop blur, noise, and content + /// - Border/stroke styling using the same rounded geometry + /// - Elevation shadows painted outside the rounded clip + /// - Noise overlay (if blur is enabled) + pub fn wrap_with_bounds( + &self, + content: impl IntoElement, + width: Pixels, + height: Pixels, + window: &Window, + cx: &App, + ctx: SurfaceContext, + ) -> Div { + let scale_factor = window.scale_factor(); + let noise_opacity = self.noise_intensity.opacity(); + let should_render_noise = ctx.blur_enabled && noise_opacity > 0.0; + + let mut surface = self.apply_material(div().relative().overflow_hidden(), cx, ctx); if should_render_noise { surface = surface.child(render_noise_overlay_unclipped( diff --git a/crates/ui/src/theme/default-theme.json b/crates/ui/src/theme/default-theme.json index 9e0f4733..a4a8afd4 100644 --- a/crates/ui/src/theme/default-theme.json +++ b/crates/ui/src/theme/default-theme.json @@ -31,21 +31,21 @@ "shell_level": 36, "inactive_window_level": 64, "active_window_level": 128, - "surface_flyout_shadow": "sm", + "surface_flyout_shadow": "md", "surface_panel_shadow": "lg", "surface_card_shadow": "sm" }, "material": { - "flyout_blur_radius": 60.0, - "panel_blur_radius": 120.0, - "flyout_light_opacity": 0.6, - "flyout_dark_opacity": 0.7, - "panel_light_opacity": 0.85, - "panel_dark_opacity": 0.9, + "flyout_blur_radius": 24.0, + "panel_blur_radius": 48.0, + "flyout_light_opacity": 0.86, + "flyout_dark_opacity": 0.88, + "panel_light_opacity": 0.88, + "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, - "subtle_stroke_light_opacity": 0.5, - "subtle_stroke_dark_opacity": 0.5, + "subtle_stroke_light_opacity": 0.10, + "subtle_stroke_dark_opacity": 0.14, "smoke_light": "#0000004D", "smoke_dark": "#0000004D", "layer_light": "#FFFFFF80", @@ -154,7 +154,7 @@ "warning.active.background": "#CA8A04", "warning.hover.background": "#eab308e6", "warning.foreground": "#000000", - "overlay": "#0000000d", + "overlay": "#0000002E", "window.border": "#e5e5e5", "disabled.foreground": "#0000005C", "control.stroke": "#0000000F", @@ -293,21 +293,21 @@ "shell_level": 36, "inactive_window_level": 64, "active_window_level": 128, - "surface_flyout_shadow": "sm", + "surface_flyout_shadow": "md", "surface_panel_shadow": "lg", "surface_card_shadow": "sm" }, "material": { - "flyout_blur_radius": 60.0, - "panel_blur_radius": 120.0, - "flyout_light_opacity": 0.6, - "flyout_dark_opacity": 0.7, - "panel_light_opacity": 0.85, - "panel_dark_opacity": 0.9, + "flyout_blur_radius": 24.0, + "panel_blur_radius": 48.0, + "flyout_light_opacity": 0.86, + "flyout_dark_opacity": 0.88, + "panel_light_opacity": 0.88, + "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, - "subtle_stroke_light_opacity": 0.5, - "subtle_stroke_dark_opacity": 0.5, + "subtle_stroke_light_opacity": 0.10, + "subtle_stroke_dark_opacity": 0.14, "smoke_light": "#0000004D", "smoke_dark": "#0000004D", "layer_light": "#FFFFFF80", @@ -410,7 +410,7 @@ "warning.active.background": "#5b320f", "warning.foreground": "#fefce8", "warning.hover.background": "#7b4414", - "overlay": "#ffffff08", + "overlay": "#00000038", "window.border": "#262626", "disabled.foreground": "#FFFFFF5D", "control.stroke": "#FFFFFF12", diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index b4c8e1cb..129550ea 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -38,8 +38,7 @@ pub(crate) fn theme_elevation_defaults() -> ThemeElevation { shell_level: 36, inactive_window_level: 64, active_window_level: 128, - // Preserve current gpui-component surface shadow behavior - surface_flyout_shadow: ThemeShadowToken::Sm, + surface_flyout_shadow: ThemeShadowToken::Md, surface_panel_shadow: ThemeShadowToken::Lg, surface_card_shadow: ThemeShadowToken::Sm, } @@ -47,17 +46,16 @@ pub(crate) fn theme_elevation_defaults() -> ThemeElevation { pub(crate) fn theme_material_defaults() -> ThemeMaterial { ThemeMaterial { - // Preserve existing surface behavior when no config is supplied - flyout_blur_radius: px(60.0), - panel_blur_radius: px(120.0), - flyout_light_opacity: 0.60, - flyout_dark_opacity: 0.70, - panel_light_opacity: 0.85, + flyout_blur_radius: px(24.0), + panel_blur_radius: px(48.0), + flyout_light_opacity: 0.86, + flyout_dark_opacity: 0.88, + panel_light_opacity: 0.88, panel_dark_opacity: 0.90, card_light_opacity: 0.70, card_dark_opacity: 0.05, - subtle_stroke_light_opacity: 0.5, - subtle_stroke_dark_opacity: 0.5, + subtle_stroke_light_opacity: 0.10, + subtle_stroke_dark_opacity: 0.14, // Fluent layering + material palette tokens smoke_light: fluent_color("#0000004D"), diff --git a/docs/docs/components/menu.md b/docs/docs/components/menu.md index 3d0281c0..11545724 100644 --- a/docs/docs/components/menu.md +++ b/docs/docs/components/menu.md @@ -10,8 +10,8 @@ The Menu component provides both context menus (right-click menus) and popup men ## Visual treatment -- Popup surfaces use an opaque popover color, a 1 px theme border, a restrained flyout shadow, and a 6 px radius. -- Default rows are 28 px high with 8 px horizontal padding. Compact application-menu rows remain 24 px high. +- Popup surfaces use the theme's translucent Acrylic flyout material: backdrop blur, subtle noise, a 1 px stroke, restrained elevation, and a 6 px radius. When backdrop blur is unavailable or disabled, the material falls back to an opaque background. +- Default rows are 30 px high with 8 px horizontal padding. Compact application-menu rows remain 26 px high. The shell uses 6 px padding and 4 px row spacing for clearer grouping. - Icons, labels, checks, shortcuts, and submenu chevrons share fixed columns so mixed item types stay aligned. - Hover uses the neutral list-hover token. Keyboard and pointer selection use the theme primary color with its paired foreground color. - Section labels use smaller medium-weight text; separators use a single 1 px rule with 4 px vertical spacing. diff --git a/docs/docs/components/popover.md b/docs/docs/components/popover.md index d9976727..f9f5b0ce 100644 --- a/docs/docs/components/popover.md +++ b/docs/docs/components/popover.md @@ -8,6 +8,8 @@ summary: "A floating overlay that displays rich content relative to a trigger el Popover component for displaying floating content that appears when interacting with a trigger element. Supports multiple positioning options, custom content, different trigger methods, and automatic dismissal behaviors. Perfect for tooltips, menus, forms, and other contextual information. +Default popovers use the theme's translucent Acrylic flyout material: backdrop blur, theme opacity, a subtle stroke, and flyout elevation. When backdrop blur is unavailable or disabled, the material falls back to an opaque background. Content receives 12 px padding by default. + ## Import ```rust diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md index f9d977e8..7c26f286 100644 --- a/docs/docs/components/sidebar.md +++ b/docs/docs/components/sidebar.md @@ -89,8 +89,9 @@ SidebarToggleButton::new() ### Floating Sidebar (SidebarShell + Sidebar) `FloatingSidebar` composes `SidebarShell` with `Sidebar`, handling resize internally. -It defaults to an 8px inset, a non-blurred surface, the theme's panel elevation, and can be resized -when expanded. Call `.blur_enabled(true)` to opt into the inherited panel material. +It defaults to an 8px inset, the theme's translucent Mica panel material, panel elevation, and can be resized +when expanded. Backdrop blur follows the global surface policy; call `.blur_enabled(false)` to opt out. +When blur is unavailable or disabled, the material falls back to an opaque background. Collapse and expand animate the complete panel and its content together using theme motion tokens; reduced-motion mode applies the final width immediately. Call `.elevation(...)` to override the complete panel shadow. Larger elevations may need a larger From 36e810d4ad9fe233d0881c3391a68b3af979e094 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 06:55:40 -0700 Subject: [PATCH 07/20] fix(surface): preserve theme customization Keep existing subtle-stroke token semantics while sourcing glass edges from the dedicated control-stroke color. Let explicit FloatingSidebar background refinements override the transparent inner default. --- crates/ui/src/floating_sidebar/mod.rs | 4 ++-- crates/ui/src/surface/mod.rs | 19 +++++++++++++++---- crates/ui/src/theme/default-theme.json | 12 ++++++------ crates/ui/src/theme/fluent_tokens.rs | 4 ++-- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs index 2ec8b972..a5337099 100644 --- a/crates/ui/src/floating_sidebar/mod.rs +++ b/crates/ui/src/floating_sidebar/mod.rs @@ -476,8 +476,8 @@ impl RenderOnce for FloatingSidebar { .collapsed(visual_collapsed) .width(expanded_width) .animate_width(false) - .refine_style(&style) - .bg(gpui::transparent_black()), + .bg(gpui::transparent_black()) + .refine_style(&style), ) .child(resize_tracker) } diff --git a/crates/ui/src/surface/mod.rs b/crates/ui/src/surface/mod.rs index c14ed406..e9dc7af5 100644 --- a/crates/ui/src/surface/mod.rs +++ b/crates/ui/src/surface/mod.rs @@ -13,6 +13,10 @@ //! .wrap_with_bounds(content, width, height, window, cx, ctx); //! ``` +mod flyout; + +pub use flyout::*; + use gpui::{ App, Div, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, Pixels, Resource, Styled, StyledImage, Window, div, img, px, @@ -199,7 +203,7 @@ impl StrokeSpec { cx.theme().material.subtle_stroke_light_opacity }; match self.color { - StrokeColor::Subtle => cx.theme().foreground.opacity(subtle_stroke_opacity), + StrokeColor::Subtle => cx.theme().border.opacity(subtle_stroke_opacity), StrokeColor::Default => cx.theme().border, StrokeColor::Strong => cx.theme().border, StrokeColor::SubtleWithOpacity(opacity) => cx.theme().border.opacity(opacity), @@ -409,9 +413,16 @@ impl SurfacePreset { } if let Some(ref stroke) = self.stroke { - surface = surface - .border(stroke.width) - .border_color(stroke.resolve_color(cx)); + let stroke_color = if matches!( + self.background.color_source, + SurfaceColorSource::Acrylic | SurfaceColorSource::Mica + ) && matches!(stroke.color, StrokeColor::Subtle) + { + cx.theme().control_stroke + } else { + stroke.resolve_color(cx) + }; + surface = surface.border(stroke.width).border_color(stroke_color); } elevation.apply(surface, cx) diff --git a/crates/ui/src/theme/default-theme.json b/crates/ui/src/theme/default-theme.json index a4a8afd4..f09d51da 100644 --- a/crates/ui/src/theme/default-theme.json +++ b/crates/ui/src/theme/default-theme.json @@ -44,8 +44,8 @@ "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, - "subtle_stroke_light_opacity": 0.10, - "subtle_stroke_dark_opacity": 0.14, + "subtle_stroke_light_opacity": 0.5, + "subtle_stroke_dark_opacity": 0.5, "smoke_light": "#0000004D", "smoke_dark": "#0000004D", "layer_light": "#FFFFFF80", @@ -157,7 +157,7 @@ "overlay": "#0000002E", "window.border": "#e5e5e5", "disabled.foreground": "#0000005C", - "control.stroke": "#0000000F", + "control.stroke": "#0000001A", "card.background": "#FFFFFFB3", "card.foreground": "#0a0a0a", "solid.background": "#F3F3F3", @@ -306,8 +306,8 @@ "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, - "subtle_stroke_light_opacity": 0.10, - "subtle_stroke_dark_opacity": 0.14, + "subtle_stroke_light_opacity": 0.5, + "subtle_stroke_dark_opacity": 0.5, "smoke_light": "#0000004D", "smoke_dark": "#0000004D", "layer_light": "#FFFFFF80", @@ -413,7 +413,7 @@ "overlay": "#00000038", "window.border": "#262626", "disabled.foreground": "#FFFFFF5D", - "control.stroke": "#FFFFFF12", + "control.stroke": "#FFFFFF24", "card.background": "#FFFFFF0D", "card.foreground": "#fafafa", "solid.background": "#202020", diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index 129550ea..730f0462 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -54,8 +54,8 @@ pub(crate) fn theme_material_defaults() -> ThemeMaterial { panel_dark_opacity: 0.90, card_light_opacity: 0.70, card_dark_opacity: 0.05, - subtle_stroke_light_opacity: 0.10, - subtle_stroke_dark_opacity: 0.14, + subtle_stroke_light_opacity: 0.5, + subtle_stroke_dark_opacity: 0.5, // Fluent layering + material palette tokens smoke_light: fluent_color("#0000004D"), From 256e9b1bcaf87070b075cf1efe32dfa50e821b2b Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 07:42:59 -0700 Subject: [PATCH 08/20] feat(surface): centralize flyout layout tokens Flyout surfaces each carried their own geometry: menus hardcoded a 6px radius, popover and the editor popovers used theme.radius, select clamped to 8px, and the palette used radius_lg, with rows at a flat 4px unrelated to any container. Left insets, type sizes, and intra-row gaps drifted the same way. Add FlyoutTokens to derive all of it from theme tokens: container radius from radius_lg, row radius as radius minus inset so corners stay concentric, one 12px left rail, two type steps from theme.typography, and two intra-row gaps. Apply across popover, hover card, popup and context menu, select popup, command palette, editor popovers, and the collapsed sidebar submenu. Drop StyledExt::popover_style, which hand-rolled the flyout material in parallel with SurfacePreset and had drifted: it missed the noise overlay and the opaque fallback used when backdrop blur is off. SurfacePreset ::apply_material is now public as its replacement. Also fixes a 4px command palette height error and a duplicated magnifier glyph in its search row. --- crates/ui/src/command_palette/view.rs | 158 ++++++++------- .../ui/src/input/popovers/code_action_menu.rs | 12 +- .../ui/src/input/popovers/completion_menu.rs | 15 +- crates/ui/src/input/popovers/hover_popover.rs | 28 ++- crates/ui/src/input/popovers/mod.rs | 28 ++- crates/ui/src/menu/popup_menu.rs | 96 ++++----- crates/ui/src/popover.rs | 12 +- crates/ui/src/select.rs | 14 +- crates/ui/src/sidebar/menu.rs | 16 +- crates/ui/src/styled.rs | 26 +-- crates/ui/src/surface/flyout.rs | 184 ++++++++++++++++++ crates/ui/src/theme/schema.rs | 2 +- crates/ui/src/theme/tests.rs | 59 +++++- docs/docs/theme.md | 29 +++ 14 files changed, 491 insertions(+), 188 deletions(-) create mode 100644 crates/ui/src/surface/flyout.rs diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 701acb85..bf2b5fbd 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -11,8 +11,9 @@ use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; use crate::spinner::Spinner; use crate::{ - ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, - VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, v_virtual_list, + ActiveTheme, FlyoutTokens, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, + VirtualListScrollHandle, WindowExt as _, flyout_secondary_foreground, h_flex, v_flex, + v_virtual_list, }; use gpui::{ AnimationExt, App, AppContext as _, Context, ElementId, Entity, FocusHandle, Focusable, @@ -25,12 +26,12 @@ use std::sync::Arc; const CONTEXT: &str = "CommandPalette"; -// Height constants for layout calculations +// Height constants for layout calculations. Row geometry (section header height, +// list padding, row radius) comes from `FlyoutTokens` so the palette stays in the +// same geometry family as menus and select popups. const HEADER_HEIGHT: f32 = 56.0; const FOOTER_HEIGHT: f32 = 40.0; -const SECTION_HEADER_HEIGHT: f32 = 30.0; const EMPTY_STATE_HEIGHT: f32 = 120.0; -const LIST_PADDING: f32 = 12.0; /// A render row for the command palette list. #[derive(Clone)] @@ -214,11 +215,12 @@ impl CommandPaletteView { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); 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 secondary_foreground = cx.theme().foreground.opacity(0.68); + let secondary_foreground = flyout_secondary_foreground(cx); let shortcut_element = item_data .shortcut @@ -242,10 +244,10 @@ impl CommandPaletteView { .id(SharedString::from(format!("cmd-item-{}", item_index))) .w_full() .h(self.item_height) - .px_2() - .gap_3() + .px(tokens.item_padding_x) + .gap(tokens.accessory_gap) .items_center() - .rounded(cx.theme().radius) + .rounded(tokens.item_radius) .cursor_pointer() .when(disabled, |this| this.opacity(0.5).cursor_not_allowed()) .when(selected && !disabled, |this| { @@ -269,57 +271,61 @@ impl CommandPaletteView { }), ) }) - // Icon - .when_some(item_data.icon, |this, icon| { - this.child( - Icon::new(icon) - .size_4() - .text_color(if selected && !disabled { - cx.theme().foreground - } else { - secondary_foreground - }), - ) - }) - // Title and subtitle + // Icon + title/subtitle share the tight icon gutter, matching a menu row. .child( - v_flex() + h_flex() .flex_1() + .items_center() .overflow_hidden() + .gap(tokens.icon_gap) + .when_some(item_data.icon, |this, icon| { + this.child(Icon::new(icon).size_4().flex_shrink_0().text_color( + if selected && !disabled { + cx.theme().foreground + } else { + secondary_foreground + }, + )) + }) .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(cx.theme().foreground) - .truncate() - .child(self.render_highlighted_text( - &item_data.title, - &match_info.title_ranges, - cx, - )), - ) - .when_some(item_data.subtitle.clone(), |this, subtitle| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(subtitle), - ) - }), + v_flex() + .flex_1() + .overflow_hidden() + .child( + div() + .text_size(tokens.label_size) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .truncate() + .child(self.render_highlighted_text( + &item_data.title, + &match_info.title_ranges, + cx, + )), + ) + .when_some(item_data.subtitle.clone(), |this, subtitle| { + this.child( + div() + .text_size(tokens.meta_size) + .text_color(secondary_foreground) + .truncate() + .child(subtitle), + ) + }), + ), ) .when(show_inline_category || has_shortcut, |this| { this.child( h_flex() .flex_shrink_0() .items_center() - .gap_2() + .gap(tokens.icon_gap) .when(show_inline_category, |this| { this.child( div() - .text_xs() + .text_size(tokens.meta_size) .text_color(if selected && !disabled { - cx.theme().foreground.opacity(0.72) + cx.theme().foreground } else { secondary_foreground }) @@ -333,15 +339,19 @@ impl CommandPaletteView { } fn render_section_header(&self, title: SharedString, cx: &App) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); + div() .w_full() - .h(px(SECTION_HEADER_HEIGHT)) + .h(tokens.section_header_height) .flex() .items_center() - .px_3() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().foreground.opacity(0.64)) + // Same left edge as a row's label, so headers and rows share one + // alignment line instead of stepping in and out by 4px. + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) + .font_weight(tokens.section_weight) + .text_color(flyout_secondary_foreground(cx)) .child(title) } @@ -392,16 +402,18 @@ impl CommandPaletteView { fn render_footer(&self, status_text: Option, cx: &App) -> impl IntoElement { let reduced_motion = GlobalState::global(cx).reduced_motion(); let has_status = status_text.is_some(); + let tokens = FlyoutTokens::new(cx); h_flex() .w_full() .h(px(FOOTER_HEIGHT)) - .px_3() + // Align with row labels: list inset + row padding. + .px(tokens.inset + tokens.item_padding_x) .border_t_1() .border_color(cx.theme().border) .justify_between() - .text_xs() - .text_color(cx.theme().foreground.opacity(0.64)) + .text_size(tokens.meta_size) + .text_color(flyout_secondary_foreground(cx)) .child( h_flex() .gap_4() @@ -431,7 +443,7 @@ impl CommandPaletteView { h_flex() .gap_2() .items_center() - .text_color(cx.theme().foreground.opacity(0.64)) + .text_color(flyout_secondary_foreground(cx)) .when(reduced_motion, |this| { this.child(Icon::new(IconName::LoaderCircle).size_4()) }) @@ -440,7 +452,7 @@ impl CommandPaletteView { Spinner::new() .icon(IconName::LoaderCircle) .with_size(Size::Small) - .color(cx.theme().foreground.opacity(0.64)), + .color(flyout_secondary_foreground(cx)), ) }) .child(status), @@ -459,17 +471,19 @@ impl CommandPaletteView { } fn render_empty(&self, query_empty: bool, cx: &App) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); + v_flex() .size_full() .items_center() .justify_center() .gap_1() - .text_color(cx.theme().muted_foreground) + .text_color(flyout_secondary_foreground(cx)) .child(Icon::new(IconName::Search).size_6().opacity(0.62)) .child( div() .mt_1() - .text_sm() + .text_size(tokens.label_size) .font_weight(gpui::FontWeight::MEDIUM) .text_color(cx.theme().foreground) .child(if query_empty { @@ -479,7 +493,11 @@ impl CommandPaletteView { }), ) .when(!query_empty, |this| { - this.child(div().text_xs().child("Try a different search.")) + this.child( + div() + .text_size(tokens.meta_size) + .child("Try a different search."), + ) }) } @@ -544,6 +562,7 @@ impl Focusable for CommandPaletteView { impl Render for CommandPaletteView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); let state = self.state.read(cx); let config = state.config.clone(); let matched_items = state.matched_items.clone(); @@ -557,7 +576,7 @@ impl Render for CommandPaletteView { .map(|row| GpuiSize { width: px(0.), height: match row { - CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), + CommandPaletteRow::Header(_) => tokens.section_header_height, CommandPaletteRow::Item(_) => self.item_height, }, }) @@ -597,10 +616,12 @@ impl Render for CommandPaletteView { let list_content_height = if row_count == 0 { px(EMPTY_STATE_HEIGHT) } else { - px(LIST_PADDING) + // Must match the padding actually applied to the list below, or the + // surface ends up taller than its content. + tokens.inset * 2. + rows.iter().fold(px(0.0), |sum, row| { sum + match row { - CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), + CommandPaletteRow::Header(_) => tokens.section_header_height, CommandPaletteRow::Item(_) => self.item_height, } }) @@ -633,7 +654,6 @@ impl Render for CommandPaletteView { .h(px(HEADER_HEIGHT)) .flex() .items_center() - .px_3() .border_b_1() .border_color(cx.theme().border) .child( @@ -642,10 +662,14 @@ impl Render for CommandPaletteView { .prefix( Icon::new(IconName::Search) .size_4() - .text_color(cx.theme().muted_foreground), + .text_color(flyout_secondary_foreground(cx)), ) .appearance(false) - .cleanable(true), + .cleanable(true) + // The input carries its own size-derived padding. Override it + // onto the row rail so the search glyph sits on the same + // vertical line as the result-row icons directly below it. + .px(tokens.inset + tokens.item_padding_x), ), ) // Results list @@ -709,7 +733,7 @@ impl Render for CommandPaletteView { }, ) .track_scroll(&self.scroll_handle) - .p_1(), + .p(tokens.inset), ) }), ) @@ -752,7 +776,7 @@ impl Render for CommandPaletteView { }); SurfacePreset::flyout() - .with_radius(cx.theme().radius_lg) + .with_radius(tokens.radius) .wrap_with_bounds( content, px(config.width), diff --git a/crates/ui/src/input/popovers/code_action_menu.rs b/crates/ui/src/input/popovers/code_action_menu.rs index ca494ebc..9699dc32 100644 --- a/crates/ui/src/input/popovers/code_action_menu.rs +++ b/crates/ui/src/input/popovers/code_action_menu.rs @@ -12,7 +12,7 @@ const MAX_MENU_WIDTH: Pixels = px(320.); const MAX_MENU_HEIGHT: Pixels = px(480.); use crate::{ - ActiveTheme, IndexPath, Selectable, actions, h_flex, + ActiveTheme, FlyoutTokens, IndexPath, Selectable, actions, h_flex, input::{self, InputState, popovers::editor_popover}, list::{List, ListDelegate, ListEvent, ListState}, }; @@ -78,16 +78,18 @@ impl ParentElement for MenuItem { impl RenderOnce for MenuItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let item = self.item; + let tokens = FlyoutTokens::new(cx); let highlights = vec![]; h_flex() .id(self.ix) - .gap_2() - .p_1() - .text_xs() + .gap(tokens.icon_gap) + .py_1() + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) .line_height(relative(1.)) - .rounded_sm() + .rounded(tokens.item_radius) .hover(|this| this.bg(cx.theme().accent.opacity(0.8))) .when(self.selected, |this| { this.bg(cx.theme().accent) diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index 24e00b3b..61ba9fd3 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -13,7 +13,7 @@ const MAX_MENU_HEIGHT: Pixels = px(240.); const POPOVER_GAP: Pixels = px(4.); use crate::{ - ActiveTheme, IndexPath, Selectable, actions, h_flex, + ActiveTheme, FlyoutTokens, IndexPath, Selectable, actions, h_flex, input::{ self, InputState, RopeExt, popovers::{editor_popover, render_markdown}, @@ -101,13 +101,16 @@ impl RenderOnce for CompletionMenuItem { }, )]; + let tokens = FlyoutTokens::new(cx); + h_flex() .id(self.ix) - .gap_2() - .p_1() - .text_xs() + .gap(tokens.icon_gap) + .py_1() + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) .line_height(relative(1.)) - .rounded_sm() + .rounded(tokens.item_radius) .when(item.deprecated.unwrap_or(false), |this| this.line_through()) .hover(|this| this.bg(cx.theme().accent.opacity(0.8))) .when(self.selected, |this| { @@ -443,7 +446,7 @@ impl Render for CompletionMenu { div().child( editor_popover("completion-menu", cx) .w(MAX_MENU_WIDTH) - .px_2() + .p(FlyoutTokens::new(cx).item_padding_x) .child(render_markdown("doc", doc, window, cx)), ), ) diff --git a/crates/ui/src/input/popovers/hover_popover.rs b/crates/ui/src/input/popovers/hover_popover.rs index 453942b5..7fd03dc3 100644 --- a/crates/ui/src/input/popovers/hover_popover.rs +++ b/crates/ui/src/input/popovers/hover_popover.rs @@ -8,7 +8,7 @@ use gpui::{ }; use crate::{ - StyledExt, + FlyoutTokens, StyledExt, SurfaceContext, SurfacePreset, flyout_primary_foreground, input::{InputState, popovers::render_markdown}, }; @@ -189,16 +189,24 @@ impl Element for Popover { let is_open = *open_state.read(cx); + let tokens = FlyoutTokens::new(cx); let mut popover = deferred( - div() - .id("hover-popover-content") - .when(!is_open, |s| s.invisible()) - .flex_none() - .occlude() - .p_1() - .text_xs() - .popover_style(cx) - .shadow_md() + SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material( + div() + .id("hover-popover-content") + .when(!is_open, |s| s.invisible()) + .flex_none() + .occlude(), + cx, + SurfaceContext::new(cx), + ) + .text_color(flyout_primary_foreground(cx)) + // Prose, not rows: pad to the shared row rhythm so hover docs line up + // with the completion menu labels they sit beside. + .p(tokens.item_padding_x) + .text_size(tokens.meta_size) .max_w(max_width) .max_h(max_height) .overflow_y_scroll() diff --git a/crates/ui/src/input/popovers/mod.rs b/crates/ui/src/input/popovers/mod.rs index 4abeef76..0c96b228 100644 --- a/crates/ui/src/input/popovers/mod.rs +++ b/crates/ui/src/input/popovers/mod.rs @@ -16,7 +16,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, StyledExt as _, + ActiveTheme, FlyoutTokens, SurfaceContext, SurfacePreset, flyout_primary_foreground, text::{TextView, TextViewStyle}, }; @@ -69,13 +69,23 @@ pub(super) fn render_markdown( .selectable(true) } +/// Container for the editor's transient surfaces (completion, code actions, mouse +/// context menu, documentation). +/// +/// Shares the flyout material and geometry with the rest of the library, but stays +/// on the `meta` type step: these surfaces overlay live code, so density is +/// functional rather than decorative. pub(super) fn editor_popover(id: impl Into, cx: &App) -> Stateful
{ - div() - .id(id) - .flex_none() - .occlude() - .popover_style(cx) - .shadow_md() - .text_xs() - .p_1() + let tokens = FlyoutTokens::new(cx); + + SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material( + div().id(id).flex_none().occlude(), + cx, + SurfaceContext::new(cx), + ) + .text_color(flyout_primary_foreground(cx)) + .text_size(tokens.meta_size) + .p(tokens.inset) } diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index cd324cba..3e74a787 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -7,16 +7,19 @@ use crate::animation::{ use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; -use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, StyledExt, SurfaceContext}; +use crate::{ + ActiveTheme, ElementExt, FlyoutTokens, Icon, IconName, Sizable as _, SurfaceContext, + flyout_primary_foreground, flyout_secondary_foreground, flyout_selected_secondary_foreground, +}; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ Action, AnimationExt as _, 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, + px, }; -use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Point, Subscription}; +use gpui::{ClickEvent, MouseDownEvent, OwnedMenuItem, Point, Subscription}; use std::{rc::Rc, time::Duration}; const CONTEXT: &str = "PopupMenu"; @@ -997,9 +1000,9 @@ impl PopupMenu { ) -> Option { let action = action?; let color = if selected && !disabled { - cx.theme().primary_foreground.opacity(0.8) + flyout_selected_secondary_foreground(cx) } else { - cx.theme().muted_foreground + flyout_secondary_foreground(cx) }; match self @@ -1046,7 +1049,7 @@ impl PopupMenu { } else if checked && !disabled { cx.theme().primary } else { - cx.theme().muted_foreground + flyout_secondary_foreground(cx) }; Some( @@ -1058,14 +1061,15 @@ impl PopupMenu { } #[inline] - fn max_width(&self) -> Pixels { - self.max_width.unwrap_or(px(500.)) + fn max_width(&self, cx: &App) -> Pixels { + self.max_width + .unwrap_or_else(|| FlyoutTokens::sized(self.size, cx).max_width) } /// Calculate the anchor corner and left offset for child submenu - fn update_submenu_menu_anchor(&mut self, window: &Window) { + fn update_submenu_menu_anchor(&mut self, window: &Window, cx: &App) { let bounds = self.bounds; - let max_width = self.max_width(); + let max_width = self.max_width(cx); let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width { (Corner::TopRight, -px(16.)) } else { @@ -1099,7 +1103,7 @@ impl PopupMenu { .text_color(if selected && !disabled { cx.theme().primary_foreground } else if disabled { - cx.theme().muted_foreground + flyout_secondary_foreground(cx) } else { cx.theme().primary }), @@ -1108,23 +1112,20 @@ impl PopupMenu { None }; + /// Margin kept between a submenu and the window edge when it snaps. const EDGE_PADDING: Pixels = px(4.); - const INNER_PADDING: Pixels = px(8.); + let tokens = options.tokens; let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); - - let (item_height, radius) = match self.size { - Size::Small => (px(26.), options.radius.half()), - _ => (px(30.), options.radius), - }; + let item_height = tokens.item_height; let this = MenuItemElement::new(ix, &group_name) .relative() - .text_sm() + .text_size(tokens.label_size) .py_0() - .px(INNER_PADDING) - .rounded(radius) + .px(tokens.item_padding_x) + .rounded(tokens.item_radius) .items_center() .selected(selected) .on_hover(cx.listener(move |this, hovered, _, cx| { @@ -1139,25 +1140,27 @@ impl PopupMenu { })); match item { + // The rule spans the full row box so it shares one alignment line with + // the row hover/selection background instead of introducing a second. PopupMenuItem::Separator => this .h_auto() .p_0() - .my_1() - .mx_1() + .my(tokens.separator_margin) + .mx_0() .border_b(px(1.)) .border_color(cx.theme().border) .disabled(true), PopupMenuItem::Label(label) => this - .h(px(22.)) - .text_xs() - .font_medium() + .h(tokens.section_header_height) + .text_size(tokens.meta_size) + .font_weight(tokens.section_weight) .disabled(true) .cursor_default() .child( h_flex() .cursor_default() .items_center() - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, false, @@ -1186,7 +1189,7 @@ impl PopupMenu { .flex_1() .min_h(item_height) .items_center() - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, is_left_check, @@ -1197,7 +1200,7 @@ impl PopupMenu { cx, )) .child((render)(window, cx)) - .children(right_check_icon.map(|icon| icon.ml_3())), + .children(right_check_icon.map(|icon| icon.ml(tokens.accessory_gap))), ), PopupMenuItem::Item { icon, @@ -1211,9 +1214,9 @@ impl PopupMenu { let key = self.render_key_binding(action.as_deref(), selected, *disabled, window, cx); let accessory_color = if selected && !disabled { - cx.theme().primary_foreground.opacity(0.8) + flyout_selected_secondary_foreground(cx) } else { - cx.theme().muted_foreground + flyout_secondary_foreground(cx) }; this.when(!disabled, |this| { @@ -1223,7 +1226,7 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, is_left_check, @@ -1236,7 +1239,7 @@ impl PopupMenu { .child( h_flex() .w_full() - .gap_3() + .gap(tokens.accessory_gap) .items_center() .justify_between() .when(!show_link_icon, |this| this.child(label.clone())) @@ -1246,7 +1249,7 @@ impl PopupMenu { h_flex() .w_full() .justify_between() - .gap_1p5() + .gap(tokens.icon_gap) .child(label.clone()) .child( Icon::new(IconName::ExternalLink) @@ -1299,7 +1302,7 @@ impl PopupMenu { .min_h(item_height) .size_full() .items_center() - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, false, @@ -1312,15 +1315,15 @@ impl PopupMenu { .child( h_flex() .flex_1() - .gap_2() + .gap(tokens.accessory_gap) .items_center() .justify_between() .child(label.clone()) .child(Icon::new(IconName::ChevronRight).xsmall().text_color( if selected && !disabled { - cx.theme().primary_foreground.opacity(0.8) + flyout_selected_secondary_foreground(cx) } else { - cx.theme().muted_foreground + flyout_secondary_foreground(cx) }, )), ), @@ -1441,13 +1444,14 @@ impl Focusable for PopupMenu { struct RenderOptions { has_left_icon: bool, check_side: Side, - radius: Pixels, + tokens: FlyoutTokens, } impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.update_submenu_menu_anchor(window); + self.update_submenu_menu_anchor(window, cx); + let tokens = FlyoutTokens::sized(self.size, cx); let view = cx.entity(); let items_count = self.menu_items.len(); @@ -1461,11 +1465,11 @@ impl Render for PopupMenu { .iter() .any(|item| item.has_left_icon(self.check_side)); - let max_width = self.max_width(); + let max_width = self.max_width(cx); let options = RenderOptions { has_left_icon, check_side: self.check_side, - radius: px(4.), + tokens, }; let surface_ctx = SurfaceContext::new(cx); @@ -1491,15 +1495,15 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(Self::on_mouse_down_out)) - .text_color(cx.theme().popover_foreground) + .text_color(flyout_primary_foreground(cx)) .relative() .occlude() .child( v_flex() .id("items") - .p_1p5() - .gap_y_1() - .min_w(rems(8.)) + .p(tokens.inset) + .gap(tokens.item_gap) + .min_w(tokens.min_width) .when_some(self.min_width, |this, min_width| this.min_w(min_width)) .max_w(max_width) .when(self.scrollable, |this| { @@ -1523,7 +1527,7 @@ impl Render for PopupMenu { }); SurfacePreset::flyout() - .with_radius(px(6.)) + .with_radius(tokens.radius) .wrap_with_bounds( content, surface_width, diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index e8abc646..d1a7737c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -7,13 +7,15 @@ use gpui::{ use std::rc::Rc; use crate::{ - ActiveTheme, Anchor, ElementExt, Selectable, StyledExt as _, SurfaceContext, SurfacePreset, + ActiveTheme, Anchor, ElementExt, FlyoutTokens, Selectable, StyledExt as _, SurfaceContext, + SurfacePreset, actions::Cancel, anchored, animation::{ PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, spring_preset_animation, spring_preset_duration_ms, }, + flyout_primary_foreground, global_state::GlobalState, v_flex, }; @@ -323,16 +325,18 @@ impl Popover { _: &mut Window, cx: &mut App, ) -> Stateful
{ + let tokens = FlyoutTokens::new(cx); + v_flex() .id("content") .occlude() .tab_group() .when(appearance, |this| { SurfacePreset::flyout() - .with_radius(cx.theme().radius) + .with_radius(tokens.radius) .apply_material(this, cx, SurfaceContext::new(cx)) - .text_color(cx.theme().popover_foreground) - .p_3() + .text_color(flyout_primary_foreground(cx)) + .p(tokens.content_padding) }) .map(|this| match anchor { Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index 56cd8ef9..bec86613 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -8,8 +8,8 @@ use gpui::{ use rust_i18n::t; use crate::{ - ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Selectable, Sizable, - Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, + ActiveTheme, Disableable, ElementExt as _, FlyoutTokens, Icon, IconName, IndexPath, Selectable, + Sizable, Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, actions::{Cancel, Confirm, SelectDown, SelectUp}, animation::fast_invoke_animation, global_state::GlobalState, @@ -819,10 +819,8 @@ where let bounds = self.bounds; let allow_open = !(self.open || self.options.disabled); let outline_visible = self.open || is_focused && !self.options.disabled; - let popup_radius = cx.theme().radius.min(px(8.)); - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let tokens = FlyoutTokens::sized(self.options.size, cx); + let surface_ctx = SurfaceContext::new(cx); let base_width = bounds.size.width.into(); let base_height = bounds.size.height.into(); let rem_size = window.rem_size(); @@ -936,7 +934,7 @@ where .w(menu_width) .child( SurfacePreset::flyout() - .with_radius(popup_radius) + .with_radius(tokens.radius) .wrap_with_bounds( v_flex().occlude().child( List::new(&self.list) @@ -948,7 +946,7 @@ where ) .with_size(self.options.size) .max_h(rems(20.)) - .paddings(Edges::all(px(4.))), + .paddings(Edges::all(tokens.inset)), ), menu_width, menu_height, diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 9c9b5507..5ae2a20d 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -1,11 +1,13 @@ use crate::{ - ActiveTheme as _, Anchor, Collapsible, Icon, IconName, Selectable, Sizable as _, StyledExt, + ActiveTheme as _, Anchor, Collapsible, FlyoutTokens, Icon, IconName, Selectable, Sizable as _, + StyledExt, SurfaceContext, SurfacePreset, animation::{ PresenceOptions, PresencePhase, PresenceTransition, SpringPreset, expand_collapse_durations, expand_collapse_layout_animation, keyed_presence, subtle_reveal_transform_animation, theme_animation, }, button::{Button, ButtonVariants as _}, + flyout_primary_foreground, global_state::GlobalState, h_flex, menu::{ContextMenuExt, PopupMenu, PopupMenuItem}, @@ -638,11 +640,13 @@ impl SidebarItem for SidebarMenuItem { if has_suffix_controls { let list_id = SharedString::from(format!("{}-collapsed-submenu", collapsed_content_id)); - return v_flex() - .id(list_id.clone()) - .popover_style(cx) - .p_1() - .gap_1() + let tokens = FlyoutTokens::new(cx); + return SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material(v_flex().id(list_id.clone()), cx, SurfaceContext::new(cx)) + .text_color(flyout_primary_foreground(cx)) + .p(tokens.inset) + .gap(tokens.item_gap) .w(px(220.)) .children(children.clone().into_iter().enumerate().map(|(ix, item)| { let item_id = SharedString::from(format!("{}-{}", list_id, ix)); diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index ae7617eb..fca94354 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,4 +1,4 @@ -use crate::{ActiveTheme, global_state::GlobalState}; +use crate::ActiveTheme; use gpui::{ App, BoxShadow, Corners, DefiniteLength, Div, Edges, FocusHandle, Hsla, ParentElement, Pixels, Refineable, StyleRefinement, Styled, Window, div, point, px, @@ -173,30 +173,6 @@ pub trait StyledExt: Styled + Sized { font_weight!(font_extrabold, EXTRA_BOLD); font_weight!(font_black, BLACK); - /// Set as Popover style - #[inline] - fn popover_style(self, cx: &App) -> Self { - let material = &cx.theme().material; - let opacity = if cx.theme().mode.is_dark() { - material.flyout_dark_opacity - } else { - material.flyout_light_opacity - }; - let mut popover = self - .bg(cx.theme().popover.opacity(opacity)) - .text_color(cx.theme().popover_foreground) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded(cx.theme().radius); - - if GlobalState::global(cx).blur_enabled() { - popover = popover.backdrop_blur(material.flyout_blur_radius); - } - - popover - } - /// Apply Fluent caption typography (12/16 Regular). fn fluent_caption(self, cx: &App) -> Self { let t = &cx.theme().typography.caption; diff --git a/crates/ui/src/surface/flyout.rs b/crates/ui/src/surface/flyout.rs new file mode 100644 index 00000000..79234858 --- /dev/null +++ b/crates/ui/src/surface/flyout.rs @@ -0,0 +1,184 @@ +//! Centralized geometry, typography and color tokens for flyout surfaces. +//! +//! Every transient surface in the library — popover, hover card, popup menu, +//! context menu, select popup, command palette, editor popovers and the collapsed +//! sidebar submenu — shares one geometry language so they read as a family: +//! +//! - one container radius (`radius`, from [`Theme::radius_lg`]), +//! - one edge inset between the container and its rows (`inset`), +//! - a row radius that is *concentric* with the container (`item_radius = radius - inset`), +//! - one horizontal rhythm for rows (`item_padding_x`, `icon_gap`, `accessory_gap`), +//! - two type steps: `label` (Fluent body) for row labels, `meta` (Fluent caption) +//! for shortcuts, subtitles, categories and section headers. +//! +//! [`SurfacePreset::flyout`](super::SurfacePreset::flyout) owns the *material* +//! (blur, noise, elevation, stroke); this module owns the *layout* on top of it. +//! Both derive from theme tokens, so a theme override moves every flyout together. + +use gpui::{App, FontWeight, Hsla, Pixels, px}; + +use crate::{ActiveTheme, Size}; + +/// Padding between the flyout edge and its rows. +const INSET: Pixels = px(4.); +/// Horizontal padding inside a row. +const ITEM_PADDING_X: Pixels = px(8.); +/// Vertical gap between adjacent rows. +const ITEM_GAP: Pixels = px(2.); +/// Gap between a row's leading icon and its label. +const ICON_GAP: Pixels = px(8.); +/// Gap between a row's label and its trailing accessories (shortcut, chevron, check). +const ACCESSORY_GAP: Pixels = px(12.); +/// Height of a group label or section header row. +const SECTION_HEADER_HEIGHT: Pixels = px(24.); +/// Vertical margin around a separator rule. +const SEPARATOR_MARGIN: Pixels = px(4.); +/// Padding for prose flyouts (popover body, hover card, documentation). +const CONTENT_PADDING: Pixels = px(12.); +/// Minimum width of a menu-style flyout. +const MIN_WIDTH: Pixels = px(128.); +/// Maximum width of a menu-style flyout. +const MAX_WIDTH: Pixels = px(500.); +/// Smallest row radius that still reads as rounded. +const MIN_ITEM_RADIUS: Pixels = px(2.); + +/// Layout and text tokens shared by every flyout surface. +/// +/// Build with [`FlyoutTokens::new`] for the default (medium) density, or +/// [`FlyoutTokens::sized`] to match a control's [`Size`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FlyoutTokens { + // -- Container geometry -- + /// Corner radius of the flyout container. + pub radius: Pixels, + /// Padding between the container edge and its rows. + pub inset: Pixels, + /// Padding for prose content (popover body, hover card, documentation). + pub content_padding: Pixels, + /// Minimum width of a menu-style flyout. + pub min_width: Pixels, + /// Maximum width of a menu-style flyout. + pub max_width: Pixels, + + // -- Row geometry -- + /// Corner radius of a row, concentric with [`Self::radius`]. + pub item_radius: Pixels, + /// Height of a single-line row. + pub item_height: Pixels, + /// Horizontal padding inside a row. + pub item_padding_x: Pixels, + /// Vertical gap between adjacent rows. + pub item_gap: Pixels, + /// Gap between a leading icon and its label. + pub icon_gap: Pixels, + /// Gap between a label and its trailing accessories. + pub accessory_gap: Pixels, + /// Height of a group label or section header row. + pub section_header_height: Pixels, + /// Vertical margin around a separator rule. + pub separator_margin: Pixels, + + // -- Typography -- + /// Font size for row labels. + pub label_size: Pixels, + /// Font size for shortcuts, subtitles, categories and section headers. + pub meta_size: Pixels, + /// Font weight for group labels and section headers. + pub section_weight: FontWeight, +} + +impl FlyoutTokens { + /// Tokens at the default (medium) row density. + pub fn new(cx: &App) -> Self { + Self::sized(Size::Medium, cx) + } + + /// Tokens matching a control [`Size`]. + pub fn sized(size: Size, cx: &App) -> Self { + let theme = cx.theme(); + let radius = theme.radius_lg; + + Self { + radius, + inset: INSET, + content_padding: CONTENT_PADDING, + min_width: MIN_WIDTH, + max_width: MAX_WIDTH, + + item_radius: (radius - INSET).max(MIN_ITEM_RADIUS), + item_height: Self::item_height_for(size), + item_padding_x: ITEM_PADDING_X, + item_gap: ITEM_GAP, + icon_gap: ICON_GAP, + accessory_gap: ACCESSORY_GAP, + section_header_height: SECTION_HEADER_HEIGHT, + separator_margin: SEPARATOR_MARGIN, + + label_size: theme.typography.body.size, + meta_size: theme.typography.caption.size, + section_weight: FontWeight::MEDIUM, + } + } + + fn item_height_for(size: Size) -> Pixels { + match size { + Size::Size(height) => height, + Size::XSmall => px(22.), + Size::Small => px(26.), + Size::Medium => px(30.), + Size::Large => px(34.), + } + } +} + +/// Secondary text color inside a flyout: shortcuts, subtitles, categories, +/// section headers and inactive accessory icons. +/// +/// Flyout materials sit *above* the window background, so this reads against the +/// flyout surface rather than [`Theme::background`]. Always pair it with +/// [`flyout_primary_foreground`] for the label so the two-step hierarchy is +/// consistent across every flyout. +#[inline] +pub fn flyout_secondary_foreground(cx: &App) -> Hsla { + cx.theme().muted_foreground +} + +/// Primary text color inside a flyout: row labels and prose body. +#[inline] +pub fn flyout_primary_foreground(cx: &App) -> Hsla { + cx.theme().popover_foreground +} + +/// Secondary text color on a *selected* row, where the row paints +/// [`Theme::primary`] behind its content. +#[inline] +pub fn flyout_selected_secondary_foreground(cx: &App) -> Hsla { + cx.theme().primary_foreground.opacity(0.8) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_height_follows_size() { + assert_eq!(FlyoutTokens::item_height_for(Size::XSmall), px(22.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Small), px(26.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Medium), px(30.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Large), px(34.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Size(px(41.))), px(41.)); + } + + #[test] + fn row_radius_stays_concentric_with_the_container() { + // The rounded row must be inset from the rounded container by exactly the + // edge padding, otherwise the two curves visibly disagree at the corners. + for radius in [px(4.), px(6.), px(8.), px(12.)] { + let item_radius = (radius - INSET).max(MIN_ITEM_RADIUS); + assert!(item_radius >= MIN_ITEM_RADIUS); + if radius - INSET >= MIN_ITEM_RADIUS { + assert_eq!(item_radius + INSET, radius); + } + } + } +} diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 918b3589..8903a2c3 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -260,7 +260,7 @@ impl ThemeElevation { } impl ThemeMaterial { - fn apply_config( + pub(crate) fn apply_config( &mut self, config: Option<&ThemeMaterialConfig>, default_theme: &ThemeMaterial, diff --git a/crates/ui/src/theme/tests.rs b/crates/ui/src/theme/tests.rs index 8a78f345..23bd0265 100644 --- a/crates/ui/src/theme/tests.rs +++ b/crates/ui/src/theme/tests.rs @@ -6,7 +6,7 @@ use std::{ use gpui::Hsla; -use super::{Colorize as _, ThemeColor, ThemeConfig, ThemeSet, try_parse_color}; +use super::{Colorize as _, ThemeColor, ThemeConfig, ThemeMaterial, ThemeSet, try_parse_color}; const MIN_TEXT_CONTRAST: f32 = 4.5; const MIN_CHART_CONTRAST: f32 = 3.; @@ -221,6 +221,63 @@ fn bundled_themes_meet_text_contrast_floor() { ); } +/// Flyout materials (popover, menu, select popup, command palette, editor popovers) +/// sit *above* the window background and are lighter than it in dark mode, so text +/// on a flyout has less contrast than the same text on `background`. This checks the +/// two text roles used inside flyouts against the flyout material itself rather than +/// against `background`, which is what [`bundled_themes_meet_text_contrast_floor`] +/// covers. +/// +/// Currently failing, deliberately: `popover.foreground` passes everywhere, but +/// `muted.foreground` lands between 3.27:1 and 4.48:1 on the flyout material in +/// *every* bundled dark theme (Default Dark: 3.74:1) against a 4.5:1 floor. That is +/// a theme-layer problem — the same token is also used on cards and other raised +/// surfaces — so fixing it means either lightening `muted.foreground` across the 22 +/// dark themes or introducing a distinct raised-surface secondary role. Both are +/// larger than a flyout layout pass, so the check is recorded here rather than +/// silently dropped. Run with `cargo test -- --ignored` to see the current numbers. +#[test] +#[ignore = "known failure: muted.foreground is sub-AA on flyout materials in all bundled dark themes"] +fn bundled_themes_meet_flyout_text_contrast_floor() { + let mut failures = Vec::new(); + + for (path, config) in bundled_theme_configs() { + let colors = resolve_colors(&config); + let mut material = ThemeMaterial::default(); + material.apply_config(config.material.as_ref(), &ThemeMaterial::default()); + + let (acrylic, opacity) = if config.mode.is_dark() { + (material.acrylic_default_dark, material.flyout_dark_opacity) + } else { + ( + material.acrylic_default_light, + material.flyout_light_opacity, + ) + }; + let flyout = acrylic.opacity(opacity); + + for (name, foreground) in [ + ("flyout label", colors.popover_foreground), + ("flyout secondary", colors.muted_foreground), + ] { + let ratio = contrast_ratio(foreground, flyout, colors.background); + if !ratio.is_finite() || ratio < MIN_TEXT_CONTRAST { + failures.push(format!( + "{} / {} / {name}: {ratio:.2}:1", + path.display(), + config.name + )); + } + } + } + + assert!( + failures.is_empty(), + "bundled theme flyout contrast failures:\n{}", + failures.join("\n") + ); +} + #[test] fn default_theme_semantic_active_states_remain_distinct() { let (_, config) = bundled_theme_configs() diff --git a/docs/docs/theme.md b/docs/docs/theme.md index 8c4e6f1d..44ae700e 100644 --- a/docs/docs/theme.md +++ b/docs/docs/theme.md @@ -19,6 +19,35 @@ cx.theme().foreground So if you want use the colors from the current theme, you should keep your component or view have [App] context. +## Flyout tokens + +Transient surfaces — Popover, HoverCard, PopupMenu, ContextMenu, Select popup, +CommandPalette, the editor popovers and the collapsed sidebar submenu — share one +geometry language via `FlyoutTokens`. `SurfacePreset::flyout()` supplies the +material (blur, noise, elevation, stroke); `FlyoutTokens` supplies the layout on +top of it, so all flyouts stay in the same family when a theme changes. + +```rs +use gpui_component::{FlyoutTokens, flyout_primary_foreground, flyout_secondary_foreground}; + +let tokens = FlyoutTokens::new(cx); // medium density +let tokens = FlyoutTokens::sized(size, cx); // match a control's `Size` +``` + +Key relationships: + +- `radius` comes from `theme.radius_lg`; rows use `item_radius = radius - inset`, + so a row's corner stays concentric with the container's. +- `inset` is the padding between the container edge and its rows; a row's label + therefore sits at `inset + item_padding_x` from the container edge. Headers, + footers and search fields inside a flyout should use that same value so every + surface has a single left rail. +- Two type steps only: `label_size` (Fluent body) for row labels, `meta_size` + (Fluent caption) for shortcuts, subtitles, categories and section headers. +- Text roles come from `flyout_primary_foreground` / `flyout_secondary_foreground` + (and `flyout_selected_secondary_foreground` on a selected row) rather than + ad-hoc `foreground.opacity(..)` values. + ## AppShell integration AppShell can initialize the registry, persist mode/name in its From f0c154e203e683747b05449a873aa0735620a339 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 07:43:14 -0700 Subject: [PATCH 09/20] refactor(popover-story): group build details with spacing The basic popover separated its header from its metadata with a rule, while the gap between the two groups sat within 10% of the gap between the metadata rows themselves. Nothing read as grouped, and the rule was doing work that spacing should do. Drop the divider and set three distinct steps: the title and its timestamp pair at 18.5px, metadata rows at 26.5px, and the group break at 34.5px. Vertical insets come out symmetric at 18px as a result. --- crates/story/src/stories/popover_story.rs | 53 ++++++++++++----------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/crates/story/src/stories/popover_story.rs b/crates/story/src/stories/popover_story.rs index 4516c086..d09cd866 100644 --- a/crates/story/src/stories/popover_story.rs +++ b/crates/story/src/stories/popover_story.rs @@ -1,7 +1,7 @@ use gpui::{ Action, App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement as _, Render, - Styled as _, Window, actions, div, px, + Styled as _, Window, actions, div, px, relative, }; use gpui_component::{ ActiveTheme, Anchor, StyledExt, WindowExt, @@ -241,38 +241,41 @@ impl Render for PopoverStory { .text_sm() .child( v_flex() - .gap_1() - .child(div().font_semibold().child("Build completed")) + .gap_4() .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Finished 2 minutes ago"), - ), - ) - .child(Divider::horizontal()) - .child( - v_flex() - .gap_2() - .child( - h_flex() - .justify_between() + v_flex() + .child(div().font_semibold().child("Build completed")) .child( div() + .text_xs() + .line_height(relative(1.2)) .text_color(cx.theme().muted_foreground) - .child("Branch"), - ) - .child("main"), + .child("Finished 2 minutes ago"), + ), ) .child( - h_flex() - .justify_between() + v_flex() + .gap_1() .child( - div() - .text_color(cx.theme().muted_foreground) - .child("Duration"), + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Branch"), + ) + .child("main"), ) - .child("1m 47s"), + .child( + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Duration"), + ) + .child("1m 47s"), + ), ), ), ), From d4a0fb4a3a71b0a717a24f68a19ea570ce5276d6 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 08:03:56 -0700 Subject: [PATCH 10/20] fix(theme): correct flyout secondary text for AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit muted.foreground is tuned against the window background, but flyouts sit above it — noticeably lighter in dark mode — so secondary text inside a flyout lost roughly a stop. All 22 bundled dark themes measured sub-AA there, Default Dark at 3.74:1 against a 4.5:1 floor. Add theme::contrast with WCAG math that composites through translucent surfaces, and derive flyout_secondary_foreground from it: keep the theme's token wherever it already passes, otherwise bisect lightness away from the material for the smallest correction that clears AA. Hue, saturation and alpha are preserved, so a theme's character survives, and the correction is a pure function of the theme, so custom themes get it too. Scoped to flyouts deliberately: cards and other raised surfaces share muted.foreground and are left alone. This promotes bundled_themes_meet_flyout_text_contrast_floor from an ignored known-failure to a passing test. --- crates/ui/src/surface/flyout.rs | 40 +++++-- crates/ui/src/surface/mod.rs | 2 +- crates/ui/src/theme/contrast.rs | 187 ++++++++++++++++++++++++++++++++ crates/ui/src/theme/mod.rs | 1 + crates/ui/src/theme/tests.rs | 132 +++++++++++++++------- 5 files changed, 315 insertions(+), 47 deletions(-) create mode 100644 crates/ui/src/theme/contrast.rs diff --git a/crates/ui/src/surface/flyout.rs b/crates/ui/src/surface/flyout.rs index 79234858..8c720534 100644 --- a/crates/ui/src/surface/flyout.rs +++ b/crates/ui/src/surface/flyout.rs @@ -17,7 +17,11 @@ use gpui::{App, FontWeight, Hsla, Pixels, px}; -use crate::{ActiveTheme, Size}; +use super::SurfacePreset; +use crate::{ + ActiveTheme, Size, + theme::contrast::{MIN_TEXT_CONTRAST, contrast_adjusted}, +}; /// Padding between the flyout edge and its rows. const INSET: Pixels = px(4.); @@ -131,16 +135,36 @@ impl FlyoutTokens { } } +/// The composited color of the flyout material, over [`Theme::background`]. +/// +/// Text inside a flyout reads against *this*, not against the window background, +/// which is what makes [`flyout_secondary_foreground`] a distinct role rather +/// than an alias for [`Theme::muted_foreground`]. +pub fn flyout_material_color(cx: &App) -> Hsla { + SurfacePreset::flyout().resolve_background(cx).resolve(cx) +} + /// Secondary text color inside a flyout: shortcuts, subtitles, categories, -/// section headers and inactive accessory icons. +/// section headers, disabled rows and inactive accessory icons. /// -/// Flyout materials sit *above* the window background, so this reads against the -/// flyout surface rather than [`Theme::background`]. Always pair it with -/// [`flyout_primary_foreground`] for the label so the two-step hierarchy is -/// consistent across every flyout. -#[inline] +/// Flyout materials sit *above* the window background — in dark mode they are +/// noticeably lighter than it — so [`Theme::muted_foreground`], which themes tune +/// against the window background, loses roughly a stop of contrast here. This +/// role keeps that token wherever it is already readable and lightens (or, on a +/// light material, darkens) it by the minimum needed to hold WCAG AA otherwise. +/// The correction is a pure function of the theme, so custom themes get it too, +/// and it is scoped to flyouts: cards and other raised surfaces that share +/// `muted_foreground` are untouched. +/// +/// Always pair it with [`flyout_primary_foreground`] for the label so the +/// two-step hierarchy is consistent across every flyout. pub fn flyout_secondary_foreground(cx: &App) -> Hsla { - cx.theme().muted_foreground + contrast_adjusted( + cx.theme().muted_foreground, + flyout_material_color(cx), + cx.theme().background, + MIN_TEXT_CONTRAST, + ) } /// Primary text color inside a flyout: row labels and prose body. diff --git a/crates/ui/src/surface/mod.rs b/crates/ui/src/surface/mod.rs index e9dc7af5..93206d61 100644 --- a/crates/ui/src/surface/mod.rs +++ b/crates/ui/src/surface/mod.rs @@ -474,7 +474,7 @@ impl SurfacePreset { } } - fn resolve_background(&self, cx: &App) -> SurfaceBackground { + pub(crate) fn resolve_background(&self, cx: &App) -> SurfaceBackground { if !self.use_theme_material_defaults { return self.background; } diff --git a/crates/ui/src/theme/contrast.rs b/crates/ui/src/theme/contrast.rs new file mode 100644 index 00000000..d4f84d66 --- /dev/null +++ b/crates/ui/src/theme/contrast.rs @@ -0,0 +1,187 @@ +//! WCAG contrast math over theme colors. +//! +//! Theme colors are [`Hsla`] and frequently translucent, so a foreground's real +//! contrast depends on what it is composited over. Every helper here therefore +//! takes three colors: the text, the *surface* it sits on, and the opaque +//! `backdrop` behind that surface (normally [`crate::Theme::background`]). + +use gpui::{Hsla, Rgba}; + +/// WCAG AA floor for body text. +pub(crate) const MIN_TEXT_CONTRAST: f32 = 4.5; + +/// Steps used to search for the smallest lightness change that clears a floor. +/// 10 halvings resolve lightness to under 0.1%, below one 8-bit step. +const CONTRAST_SEARCH_STEPS: usize = 10; + +fn composite(foreground: Rgba, background: Rgba) -> Rgba { + let a = foreground.a + background.a * (1. - foreground.a); + if a == 0. { + return Rgba { + r: 0., + g: 0., + b: 0., + a: 0., + }; + } + let channel = + |fg: f32, bg: f32| (fg * foreground.a + bg * background.a * (1. - foreground.a)) / a; + Rgba { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + a, + } +} + +fn linear_srgb(channel: f32) -> f32 { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } +} + +fn relative_luminance(color: Rgba) -> f32 { + 0.2126 * linear_srgb(color.r) + 0.7152 * linear_srgb(color.g) + 0.0722 * linear_srgb(color.b) +} + +/// Contrast ratio of `foreground` against `surface`, with both composited over +/// the opaque `backdrop`. +pub(crate) fn contrast_ratio(foreground: Hsla, surface: Hsla, backdrop: Hsla) -> f32 { + let backdrop = Rgba::from(backdrop); + let surface = composite(Rgba::from(surface), backdrop); + let foreground = composite(Rgba::from(foreground), surface); + let foreground = relative_luminance(foreground); + let surface = relative_luminance(surface); + (foreground.max(surface) + 0.05) / (foreground.min(surface) + 0.05) +} + +/// Returns `foreground` if it already clears `min_ratio` against `surface`, +/// otherwise the nearest variant that does. +/// +/// Only lightness moves — hue, saturation and alpha are preserved, so a theme's +/// character survives the correction. Contrast is monotonic in lightness once a +/// direction is chosen (away from the surface), so a bisection finds the +/// *smallest* correction rather than clamping to black or white. When even the +/// extreme cannot clear the floor (a mid-luminance surface), the extreme is +/// returned, which is still the most readable option available. +pub(crate) fn contrast_adjusted( + foreground: Hsla, + surface: Hsla, + backdrop: Hsla, + min_ratio: f32, +) -> Hsla { + if contrast_ratio(foreground, surface, backdrop) >= min_ratio { + return foreground; + } + + let composited_surface = composite(Rgba::from(surface), Rgba::from(backdrop)); + let composited_foreground = composite(Rgba::from(foreground), composited_surface); + let limit = + if relative_luminance(composited_foreground) < relative_luminance(composited_surface) { + 0. + } else { + 1. + }; + + let with_lightness = |l: f32| Hsla { l, ..foreground }; + if contrast_ratio(with_lightness(limit), surface, backdrop) < min_ratio { + return with_lightness(limit); + } + + let (mut near, mut far) = (foreground.l, limit); + let mut best = with_lightness(limit); + for _ in 0..CONTRAST_SEARCH_STEPS { + let mid = (near + far) / 2.; + if contrast_ratio(with_lightness(mid), surface, backdrop) >= min_ratio { + best = with_lightness(mid); + far = mid; + } else { + near = mid; + } + } + + best +} + +#[cfg(test)] +mod tests { + use gpui::hsla; + + use super::*; + + const BLACK: Hsla = Hsla { + h: 0., + s: 0., + l: 0., + a: 1., + }; + const WHITE: Hsla = Hsla { + h: 0., + s: 0., + l: 1., + a: 1., + }; + + #[test] + fn contrast_ratio_matches_wcag_extremes() { + assert!((contrast_ratio(WHITE, BLACK, BLACK) - 21.).abs() < 0.01); + assert!((contrast_ratio(BLACK, BLACK, BLACK) - 1.).abs() < 0.01); + } + + #[test] + fn contrast_ratio_accounts_for_a_translucent_surface() { + // A dark surface at 50% over white composites to mid grey, so white text on + // it loses most of the contrast it has against the surface color alone. + let translucent = BLACK.opacity(0.5); + assert!(contrast_ratio(WHITE, translucent, WHITE) < contrast_ratio(WHITE, BLACK, WHITE)); + } + + #[test] + fn passing_colors_are_returned_unchanged() { + let text = hsla(0.6, 0.2, 0.9, 1.); + assert_eq!( + contrast_adjusted(text, BLACK, BLACK, MIN_TEXT_CONTRAST), + text + ); + } + + #[test] + fn failing_colors_are_lightened_just_enough() { + // Mid grey on near-black fails; the fix must clear the floor, keep hue and + // saturation, and stop well short of pure white. + let surface = hsla(0.6, 0.1, 0.12, 1.); + let text = hsla(0.6, 0.3, 0.35, 1.); + assert!(contrast_ratio(text, surface, surface) < MIN_TEXT_CONTRAST); + + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + assert!(contrast_ratio(fixed, surface, surface) >= MIN_TEXT_CONTRAST); + assert_eq!((fixed.h, fixed.s, fixed.a), (text.h, text.s, text.a)); + assert!(fixed.l > text.l, "should lighten against a dark surface"); + assert!(fixed.l < 1., "should not clamp to white"); + } + + #[test] + fn failing_colors_are_darkened_against_a_light_surface() { + let surface = hsla(0.1, 0.1, 0.95, 1.); + let text = hsla(0.1, 0.3, 0.75, 1.); + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + + assert!(contrast_ratio(fixed, surface, surface) >= MIN_TEXT_CONTRAST); + assert!(fixed.l < text.l, "should darken against a light surface"); + } + + #[test] + fn unreachable_floors_fall_back_to_the_readable_extreme() { + // Correction only moves away from the surface, so text already darker than a + // surface this dark cannot reach 4.5:1 even at black (~3.0:1). Black is still + // the most readable answer in that direction, so it is what we return. + let surface = hsla(0., 0., 0.35, 1.); + let text = hsla(0., 0., 0.30, 1.); + assert!(contrast_ratio(BLACK, surface, surface) < MIN_TEXT_CONTRAST); + + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + assert_eq!(fixed.l, 0.); + } +} diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 96f7ff98..2b4cb732 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -12,6 +12,7 @@ use std::{ }; mod color; +pub(crate) mod contrast; mod elevation; mod fluent_tokens; mod registry; diff --git a/crates/ui/src/theme/tests.rs b/crates/ui/src/theme/tests.rs index 23bd0265..21fac331 100644 --- a/crates/ui/src/theme/tests.rs +++ b/crates/ui/src/theme/tests.rs @@ -6,9 +6,12 @@ use std::{ use gpui::Hsla; -use super::{Colorize as _, ThemeColor, ThemeConfig, ThemeMaterial, ThemeSet, try_parse_color}; +use super::{ + Colorize as _, ThemeColor, ThemeConfig, ThemeMaterial, ThemeSet, + contrast::{MIN_TEXT_CONTRAST, contrast_adjusted, contrast_ratio}, + try_parse_color, +}; -const MIN_TEXT_CONTRAST: f32 = 4.5; const MIN_CHART_CONTRAST: f32 = 3.; const MIN_CHART_COLOR_DISTANCE: f32 = 0.05; const MIN_INTERACTION_STATE_DISTANCE: f32 = 0.02; @@ -97,20 +100,6 @@ fn linear_srgb(channel: f32) -> f32 { } } -fn relative_luminance(color: [f32; 4]) -> f32 { - 0.2126 * linear_srgb(color[0]) + 0.7152 * linear_srgb(color[1]) + 0.0722 * linear_srgb(color[2]) -} - -fn contrast_ratio(foreground: Hsla, surface: Hsla, background: Hsla) -> f32 { - let background = hsla_to_rgba(background); - let surface = composite(hsla_to_rgba(surface), background); - let foreground = composite(hsla_to_rgba(foreground), surface); - let foreground_luminance = relative_luminance(foreground); - let surface_luminance = relative_luminance(surface); - (foreground_luminance.max(surface_luminance) + 0.05) - / (foreground_luminance.min(surface_luminance) + 0.05) -} - fn oklab(color: Hsla, background: Hsla) -> [f32; 3] { let color = composite(hsla_to_rgba(color), hsla_to_rgba(background)); let red = linear_srgb(color[0]); @@ -221,6 +210,23 @@ fn bundled_themes_meet_text_contrast_floor() { ); } +/// The flyout material composited over `background`, as +/// [`crate::flyout_material_color`] resolves it at runtime. +fn flyout_material(config: &ThemeConfig) -> Hsla { + let mut material = ThemeMaterial::default(); + material.apply_config(config.material.as_ref(), &ThemeMaterial::default()); + + let (acrylic, opacity) = if config.mode.is_dark() { + (material.acrylic_default_dark, material.flyout_dark_opacity) + } else { + ( + material.acrylic_default_light, + material.flyout_light_opacity, + ) + }; + acrylic.opacity(opacity) +} + /// Flyout materials (popover, menu, select popup, command palette, editor popovers) /// sit *above* the window background and are lighter than it in dark mode, so text /// on a flyout has less contrast than the same text on `background`. This checks the @@ -228,37 +234,30 @@ fn bundled_themes_meet_text_contrast_floor() { /// against `background`, which is what [`bundled_themes_meet_text_contrast_floor`] /// covers. /// -/// Currently failing, deliberately: `popover.foreground` passes everywhere, but -/// `muted.foreground` lands between 3.27:1 and 4.48:1 on the flyout material in -/// *every* bundled dark theme (Default Dark: 3.74:1) against a 4.5:1 floor. That is -/// a theme-layer problem — the same token is also used on cards and other raised -/// surfaces — so fixing it means either lightening `muted.foreground` across the 22 -/// dark themes or introducing a distinct raised-surface secondary role. Both are -/// larger than a flyout layout pass, so the check is recorded here rather than -/// silently dropped. Run with `cargo test -- --ignored` to see the current numbers. +/// `popover.foreground` is used as-is; the secondary role is +/// [`crate::flyout_secondary_foreground`], which contrast-corrects +/// `muted.foreground` for this surface. Raw `muted.foreground` is sub-AA on the +/// flyout material in every bundled dark theme (3.27:1 to 4.48:1, Default Dark +/// 3.74:1) — see [`bundled_theme_flyout_secondary_needs_correction_in_dark_mode`]. #[test] -#[ignore = "known failure: muted.foreground is sub-AA on flyout materials in all bundled dark themes"] fn bundled_themes_meet_flyout_text_contrast_floor() { let mut failures = Vec::new(); for (path, config) in bundled_theme_configs() { let colors = resolve_colors(&config); - let mut material = ThemeMaterial::default(); - material.apply_config(config.material.as_ref(), &ThemeMaterial::default()); - - let (acrylic, opacity) = if config.mode.is_dark() { - (material.acrylic_default_dark, material.flyout_dark_opacity) - } else { - ( - material.acrylic_default_light, - material.flyout_light_opacity, - ) - }; - let flyout = acrylic.opacity(opacity); + let flyout = flyout_material(&config); for (name, foreground) in [ ("flyout label", colors.popover_foreground), - ("flyout secondary", colors.muted_foreground), + ( + "flyout secondary", + contrast_adjusted( + colors.muted_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ), + ), ] { let ratio = contrast_ratio(foreground, flyout, colors.background); if !ratio.is_finite() || ratio < MIN_TEXT_CONTRAST { @@ -278,6 +277,63 @@ fn bundled_themes_meet_flyout_text_contrast_floor() { ); } +/// Pins *why* [`crate::flyout_secondary_foreground`] corrects rather than aliasing +/// `muted.foreground`: on dark materials the aliased token is routinely sub-AA, and +/// the correction both fixes those and leaves already-readable themes untouched. +/// +/// Note it is not every dark theme — Catppuccin Frappe, for one, already clears the +/// floor on its own flyout material and is returned unchanged. +#[test] +fn bundled_theme_flyout_secondary_corrects_only_where_needed() { + let mut dark_needing_correction = 0; + + for (path, config) in bundled_theme_configs() { + let colors = resolve_colors(&config); + let flyout = flyout_material(&config); + let corrected = contrast_adjusted( + colors.muted_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ); + + if corrected == colors.muted_foreground { + // Left alone only when it already passes. + assert!( + contrast_ratio(colors.muted_foreground, flyout, colors.background) + >= MIN_TEXT_CONTRAST, + "{} / {}: sub-AA secondary was left uncorrected", + path.display(), + config.name + ); + continue; + } + + // Corrections move away from the material: lighter on dark, darker on light. + if config.mode.is_dark() { + assert!( + corrected.l > colors.muted_foreground.l, + "{} / {}: correction should lighten on a dark material", + path.display(), + config.name + ); + dark_needing_correction += 1; + } else { + assert!( + corrected.l < colors.muted_foreground.l, + "{} / {}: correction should darken on a light material", + path.display(), + config.name + ); + } + } + + assert!( + dark_needing_correction > 0, + "no dark theme needed a correction, so the role is an alias and can be removed" + ); +} + #[test] fn default_theme_semantic_active_states_remain_distinct() { let (_, config) = bundled_theme_configs() From 74c053cb8e4cbb501361a2a292051fefb49d953d Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 09:30:52 -0700 Subject: [PATCH 11/20] fix(surface): derive surface tint from the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every elevated surface resolved its color from fixed Fluent constants: flyout from acrylic_default (#2C2C2C/#FCFCFC), panel from mica_base (#202020/#F3F3F3), card from pure white. Under a tinted theme the app chrome carried the theme while its menus, panels and cards stayed neutral grey, so flyouts read as foreign cards dropped onto the window. Derive all of them the same way instead: take the theme anchor color and blend it toward the mode's elevation pole — white in dark mode, where surfaces rise toward light, black in light mode, where materials sit a step under a white window. Blend fractions are calibrated so the neutral Default theme reproduces the old Fluent values exactly, so it renders pixel-identical while tinted themes keep their hue. Collapsing onto the plain Popover and Sidebar sources was measured and rejected: popover resolves equal to background in every bundled theme, so the Default Dark menu would have dropped to #0A0A0A and vanished into the window. The variants are renamed for what they now derive — ElevatedPopover, ElevatedSidebar, ElevatedBackground — and no constant-backed color source remains. Drop the 8 Fluent color constants, their ThemeMaterial fields and their schema wiring. Third-party theme files carrying those keys still parse; ThemeConfig has no deny_unknown_fields, so the keys are simply ignored. Opacity and blur-radius material tokens stay, still consumed. Also fixes the flyout text hierarchy, which shared one value across three roles: section headers, disabled labels and enabled subtitles all rendered at luma 128, while a disabled row's own shortcut and icon sat at 142 — brighter than the label they belonged to. Adds flyout_disabled_foreground and routes every disabled branch through it, giving a three-step ladder (250/142/92 in Default Dark) that holds its hue across themes. Tests now derive the material through the renderer's own flyout_base_color so they cannot drift from the painted surface. That caught a real regression: Alduin's deliberately dim foreground fell to 4.40:1 on its brighter tinted material, so flyout_primary_foreground now takes the same minimal AA correction as the secondary role, a no-op for the other 21 themes. --- crates/story/src/stories/menu_story.rs | 10 ++- crates/ui/src/menu/menu_item.rs | 2 +- crates/ui/src/menu/popup_menu.rs | 17 ++++- crates/ui/src/surface/flyout.rs | 30 +++++++- crates/ui/src/surface/mod.rs | 100 ++++++++++++++++++------- crates/ui/src/theme/fluent_tokens.rs | 8 -- crates/ui/src/theme/mod.rs | 8 -- crates/ui/src/theme/schema.rs | 48 ------------ crates/ui/src/theme/tests.rs | 46 +++++++----- docs/docs/theme.md | 12 ++- 10 files changed, 161 insertions(+), 120 deletions(-) diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index d0d3402c..5ee78b04 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -5,7 +5,7 @@ use gpui::{ use gpui_component::{ ActiveTheme as _, IconName, Side, StyledExt, button::Button, - h_flex, + flyout_secondary_foreground, h_flex, menu::{ContextMenuExt, DropdownMenu as _, PopupMenuItem}, v_flex, }; @@ -177,7 +177,13 @@ impl Render for MenuStory { v_flex().child("Inspect selection").child( div() .text_xs() - .text_color(cx.theme().muted_foreground) + // Custom flyout content should use the + // flyout secondary role, not the window + // `muted_foreground`, which is sub-AA on + // the flyout material in dark themes. + .text_color( + flyout_secondary_foreground(cx), + ) .child("Open details panel"), ) }) diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 67a82a5b..542b4261 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -147,7 +147,7 @@ impl RenderOnce for MenuItemElement { }) }) .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) + this.text_color(crate::flyout_disabled_foreground(cx)) }) .children(self.children) .map(|this| { diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 3e74a787..734effa5 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -9,7 +9,8 @@ use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; use crate::{ ActiveTheme, ElementExt, FlyoutTokens, Icon, IconName, Sizable as _, SurfaceContext, - flyout_primary_foreground, flyout_secondary_foreground, flyout_selected_secondary_foreground, + flyout_disabled_foreground, flyout_primary_foreground, flyout_secondary_foreground, + flyout_selected_secondary_foreground, }; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ @@ -1001,6 +1002,8 @@ impl PopupMenu { let action = action?; let color = if selected && !disabled { flyout_selected_secondary_foreground(cx) + } else if disabled { + flyout_disabled_foreground(cx) } else { flyout_secondary_foreground(cx) }; @@ -1048,6 +1051,8 @@ impl PopupMenu { cx.theme().primary_foreground } else if checked && !disabled { cx.theme().primary + } else if disabled { + flyout_disabled_foreground(cx) } else { flyout_secondary_foreground(cx) }; @@ -1103,7 +1108,7 @@ impl PopupMenu { .text_color(if selected && !disabled { cx.theme().primary_foreground } else if disabled { - flyout_secondary_foreground(cx) + flyout_disabled_foreground(cx) } else { cx.theme().primary }), @@ -1159,6 +1164,10 @@ impl PopupMenu { .child( h_flex() .cursor_default() + // `disabled(true)` above only makes the header inert; its text + // stays on the secondary step so it separates from genuinely + // disabled rows one step further down. + .text_color(flyout_secondary_foreground(cx)) .items_center() .gap(tokens.icon_gap) .children(Self::render_icon( @@ -1215,6 +1224,8 @@ impl PopupMenu { self.render_key_binding(action.as_deref(), selected, *disabled, window, cx); let accessory_color = if selected && !disabled { flyout_selected_secondary_foreground(cx) + } else if *disabled { + flyout_disabled_foreground(cx) } else { flyout_secondary_foreground(cx) }; @@ -1322,6 +1333,8 @@ impl PopupMenu { .child(Icon::new(IconName::ChevronRight).xsmall().text_color( if selected && !disabled { flyout_selected_secondary_foreground(cx) + } else if *disabled { + flyout_disabled_foreground(cx) } else { flyout_secondary_foreground(cx) }, diff --git a/crates/ui/src/surface/flyout.rs b/crates/ui/src/surface/flyout.rs index 8c720534..f3309fb4 100644 --- a/crates/ui/src/surface/flyout.rs +++ b/crates/ui/src/surface/flyout.rs @@ -145,7 +145,7 @@ pub fn flyout_material_color(cx: &App) -> Hsla { } /// Secondary text color inside a flyout: shortcuts, subtitles, categories, -/// section headers, disabled rows and inactive accessory icons. +/// section headers and enabled accessory icons. /// /// Flyout materials sit *above* the window background — in dark mode they are /// noticeably lighter than it — so [`Theme::muted_foreground`], which themes tune @@ -168,9 +168,18 @@ pub fn flyout_secondary_foreground(cx: &App) -> Hsla { } /// Primary text color inside a flyout: row labels and prose body. -#[inline] +/// +/// Usually [`Theme::popover_foreground`] as-is; corrected by the minimum lightness +/// change when a theme's own label color falls under AA on the flyout material +/// (deliberately dim themes such as Alduin, where even the window foreground is a +/// mid grey). pub fn flyout_primary_foreground(cx: &App) -> Hsla { - cx.theme().popover_foreground + contrast_adjusted( + cx.theme().popover_foreground, + flyout_material_color(cx), + cx.theme().background, + MIN_TEXT_CONTRAST, + ) } /// Secondary text color on a *selected* row, where the row paints @@ -180,6 +189,21 @@ pub fn flyout_selected_secondary_foreground(cx: &App) -> Hsla { cx.theme().primary_foreground.opacity(0.8) } +/// Opacity applied to [`flyout_secondary_foreground`] for disabled content. +const DISABLED_OPACITY: f32 = 0.5; + +/// Text and icon color for a disabled flyout row — its label, shortcut and icons. +/// +/// One clear step below [`flyout_secondary_foreground`], so a disabled row reads +/// as unavailable rather than merely secondary, and so section headers (which +/// stay at the secondary step) separate from it. Deriving it from the corrected +/// secondary keeps the three-step ladder consistent in every theme. WCAG 1.4.3 +/// exempts disabled controls from the AA floor, which is what allows this role +/// to sit below it. +pub fn flyout_disabled_foreground(cx: &App) -> Hsla { + flyout_secondary_foreground(cx).opacity(DISABLED_OPACITY) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui/src/surface/mod.rs b/crates/ui/src/surface/mod.rs index 93206d61..0a8ba918 100644 --- a/crates/ui/src/surface/mod.rs +++ b/crates/ui/src/surface/mod.rs @@ -18,8 +18,8 @@ mod flyout; pub use flyout::*; use gpui::{ - App, Div, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, Pixels, Resource, Styled, - StyledImage, Window, div, img, px, + App, Div, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, Pixels, Resource, Rgba, + Styled, StyledImage, Window, div, img, px, }; use crate::{ActiveTheme, StyledExt, ThemeShadowToken, global_state::GlobalState}; @@ -100,15 +100,65 @@ impl ElevationToken { } /// Source for surface background color from theme. +/// +/// Every variant derives from a theme color, so custom themes tint every surface +/// automatically. The `Elevated*` variants add the material's luminance character +/// on top of the theme color — see [`elevated`]. #[derive(Debug, Clone, Copy, Default)] pub enum SurfaceColorSource { #[default] Popover, - Acrylic, - Mica, - White, Sidebar, Background, + /// [`crate::Theme::popover`] lifted to flyout-material luminance. + ElevatedPopover, + /// [`crate::Theme::sidebar`] lifted to panel-material luminance. + ElevatedSidebar, + /// [`crate::Theme::background`] lifted toward white for the card wash. + ElevatedBackground, +} + +/// Blend fractions lifting a theme color toward the mode's elevation pole — white +/// in dark mode (surfaces rise toward light), black in light mode (materials sit a +/// step under a white window). Calibrated so the neutral default theme reproduces +/// the Fluent material values these replaced — acrylic `#2C2C2C`/`#FCFCFC` and +/// mica `#202020`/`#F3F3F3` over the default `#0A0A0A`/`#FFFFFF` window — while +/// tinted themes keep their hue instead of collapsing to those neutrals. +const FLYOUT_LIFT_DARK: f32 = 34. / 245.; +const FLYOUT_LIFT_LIGHT: f32 = 3. / 255.; +const PANEL_LIFT_DARK: f32 = 22. / 245.; +const PANEL_LIFT_LIGHT: f32 = 12. / 255.; +/// The card wash blends far toward white in both modes (it replaced a pure white +/// wash) but keeps enough of the background to carry the theme's hue. +const CARD_WASH_LIFT: f32 = 0.85; + +/// Blends `base` toward `pole` (per sRGB channel) by `amount`, keeping alpha. +fn mix_toward(base: Hsla, pole: f32, amount: f32) -> Hsla { + let base = Rgba::from(base); + let channel = |value: f32| value + (pole - value) * amount; + Hsla::from(Rgba { + r: channel(base.r), + g: channel(base.g), + b: channel(base.b), + a: base.a, + }) +} + +/// A theme color carried to a material's luminance step: lifted toward white in +/// dark mode, settled toward black in light mode. +pub(crate) fn elevated(base: Hsla, is_dark: bool, dark_lift: f32, light_lift: f32) -> Hsla { + if is_dark { + mix_toward(base, 1.0, dark_lift) + } else { + mix_toward(base, 0.0, light_lift) + } +} + +/// The flyout material's base color for a given theme popover color, before the +/// flyout opacity is applied. Shared with the theme contrast tests so they measure +/// the same surface the runtime renders. +pub(crate) fn flyout_base_color(popover: Hsla, is_dark: bool) -> Hsla { + elevated(popover, is_dark, FLYOUT_LIFT_DARK, FLYOUT_LIFT_LIGHT) } /// Background configuration with light/dark mode variants. @@ -132,25 +182,21 @@ impl Default for SurfaceBackground { impl SurfaceBackground { /// Resolves the background color based on theme mode and opacity settings. pub fn resolve(&self, cx: &App) -> Hsla { + let is_dark = cx.theme().mode.is_dark(); let base = match self.color_source { SurfaceColorSource::Popover => cx.theme().popover, - SurfaceColorSource::Acrylic => { - if cx.theme().mode.is_dark() { - cx.theme().material.acrylic_default_dark - } else { - cx.theme().material.acrylic_default_light - } - } - SurfaceColorSource::Mica => { - if cx.theme().mode.is_dark() { - cx.theme().material.mica_base_dark - } else { - cx.theme().material.mica_base_light - } - } - SurfaceColorSource::White => gpui::white(), SurfaceColorSource::Sidebar => cx.theme().sidebar, SurfaceColorSource::Background => cx.theme().background, + SurfaceColorSource::ElevatedPopover => flyout_base_color(cx.theme().popover, is_dark), + SurfaceColorSource::ElevatedSidebar => elevated( + cx.theme().sidebar, + is_dark, + PANEL_LIFT_DARK, + PANEL_LIFT_LIGHT, + ), + SurfaceColorSource::ElevatedBackground => { + mix_toward(cx.theme().background, 1.0, CARD_WASH_LIFT) + } }; let opacity = if cx.theme().mode.is_dark() { self.dark_opacity @@ -260,7 +306,7 @@ impl SurfacePreset { /// /// - 24px blur radius /// - Subtle noise - /// - Acrylic background at 0.86/0.88 opacity + /// - Elevated popover background at 0.86/0.88 opacity /// - Medium elevation with subtle stroke /// - 12px border radius pub fn flyout() -> Self { @@ -269,7 +315,7 @@ impl SurfacePreset { blur_radius: Some(px(24.0)), noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Acrylic, + color_source: SurfaceColorSource::ElevatedPopover, light_opacity: 0.86, dark_opacity: 0.88, }, @@ -286,7 +332,7 @@ impl SurfacePreset { /// /// - 48px blur radius /// - Subtle noise - /// - Mica background at 0.88/0.90 opacity + /// - Elevated sidebar background at 0.88/0.90 opacity /// - Large elevation with subtle stroke /// - 16px border radius pub fn panel() -> Self { @@ -295,7 +341,7 @@ impl SurfacePreset { blur_radius: Some(px(48.0)), noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Mica, + color_source: SurfaceColorSource::ElevatedSidebar, light_opacity: 0.88, dark_opacity: 0.90, }, @@ -312,7 +358,7 @@ impl SurfacePreset { /// /// - No blur /// - No noise - /// - White background at 0.70/0.05 opacity + /// - Theme-tinted near-white wash at 0.70/0.05 opacity /// - Small elevation with default border /// - Uses theme radius pub fn card() -> Self { @@ -321,7 +367,7 @@ impl SurfacePreset { blur_radius: None, noise_intensity: NoiseIntensity::None, background: SurfaceBackground { - color_source: SurfaceColorSource::White, + color_source: SurfaceColorSource::ElevatedBackground, light_opacity: 0.70, dark_opacity: 0.05, }, @@ -415,7 +461,7 @@ impl SurfacePreset { if let Some(ref stroke) = self.stroke { let stroke_color = if matches!( self.background.color_source, - SurfaceColorSource::Acrylic | SurfaceColorSource::Mica + SurfaceColorSource::ElevatedPopover | SurfaceColorSource::ElevatedSidebar ) && matches!(stroke.color, StrokeColor::Subtle) { cx.theme().control_stroke diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index 730f0462..0dffd134 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -64,14 +64,6 @@ pub(crate) fn theme_material_defaults() -> ThemeMaterial { layer_dark: fluent_color("#3A3A3A4C"), layer_alt_light: fluent_color("#FFFFFFFF"), layer_alt_dark: fluent_color("#FFFFFF0D"), - mica_base_light: fluent_color("#F3F3F3"), - mica_base_dark: fluent_color("#202020"), - mica_base_alt_light: fluent_color("#DADADA80"), - mica_base_alt_dark: fluent_color("#0A0A0A00"), - acrylic_base_light: fluent_color("#F3F3F3"), - acrylic_base_dark: fluent_color("#202020"), - acrylic_default_light: fluent_color("#FCFCFC"), - acrylic_default_dark: fluent_color("#2C2C2C"), } } diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 2b4cb732..4b2f35fc 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -107,14 +107,6 @@ pub struct ThemeMaterial { pub layer_dark: Hsla, pub layer_alt_light: Hsla, pub layer_alt_dark: Hsla, - pub mica_base_light: Hsla, - pub mica_base_dark: Hsla, - pub mica_base_alt_light: Hsla, - pub mica_base_alt_dark: Hsla, - pub acrylic_base_light: Hsla, - pub acrylic_base_dark: Hsla, - pub acrylic_default_light: Hsla, - pub acrylic_default_dark: Hsla, } impl Default for ThemeMaterial { diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 8903a2c3..da476277 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -136,14 +136,6 @@ pub struct ThemeMaterialConfig { pub layer_dark: Option, pub layer_alt_light: Option, pub layer_alt_dark: Option, - pub mica_base_light: Option, - pub mica_base_dark: Option, - pub mica_base_alt_light: Option, - pub mica_base_alt_dark: Option, - pub acrylic_base_light: Option, - pub acrylic_base_dark: Option, - pub acrylic_default_light: Option, - pub acrylic_default_dark: Option, } impl ThemeMotion { @@ -335,46 +327,6 @@ impl ThemeMaterial { .as_ref() .and_then(parse_config_color) .unwrap_or(default_theme.layer_alt_dark); - self.mica_base_light = config - .mica_base_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_light); - self.mica_base_dark = config - .mica_base_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_dark); - self.mica_base_alt_light = config - .mica_base_alt_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_alt_light); - self.mica_base_alt_dark = config - .mica_base_alt_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_alt_dark); - self.acrylic_base_light = config - .acrylic_base_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_base_light); - self.acrylic_base_dark = config - .acrylic_base_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_base_dark); - self.acrylic_default_light = config - .acrylic_default_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_default_light); - self.acrylic_default_dark = config - .acrylic_default_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_default_dark); return; } diff --git a/crates/ui/src/theme/tests.rs b/crates/ui/src/theme/tests.rs index 21fac331..5e831f74 100644 --- a/crates/ui/src/theme/tests.rs +++ b/crates/ui/src/theme/tests.rs @@ -211,20 +211,20 @@ fn bundled_themes_meet_text_contrast_floor() { } /// The flyout material composited over `background`, as -/// [`crate::flyout_material_color`] resolves it at runtime. -fn flyout_material(config: &ThemeConfig) -> Hsla { +/// [`crate::flyout_material_color`] resolves it at runtime. Derives the base color +/// through [`crate::surface::flyout_base_color`] — the same function the renderer +/// uses — so these tests measure the surface that is actually painted. +fn flyout_material(config: &ThemeConfig, colors: &ThemeColor) -> Hsla { let mut material = ThemeMaterial::default(); material.apply_config(config.material.as_ref(), &ThemeMaterial::default()); - let (acrylic, opacity) = if config.mode.is_dark() { - (material.acrylic_default_dark, material.flyout_dark_opacity) + let is_dark = config.mode.is_dark(); + let opacity = if is_dark { + material.flyout_dark_opacity } else { - ( - material.acrylic_default_light, - material.flyout_light_opacity, - ) + material.flyout_light_opacity }; - acrylic.opacity(opacity) + crate::surface::flyout_base_color(colors.popover, is_dark).opacity(opacity) } /// Flyout materials (popover, menu, select popup, command palette, editor popovers) @@ -234,21 +234,29 @@ fn flyout_material(config: &ThemeConfig) -> Hsla { /// against `background`, which is what [`bundled_themes_meet_text_contrast_floor`] /// covers. /// -/// `popover.foreground` is used as-is; the secondary role is -/// [`crate::flyout_secondary_foreground`], which contrast-corrects -/// `muted.foreground` for this surface. Raw `muted.foreground` is sub-AA on the -/// flyout material in every bundled dark theme (3.27:1 to 4.48:1, Default Dark -/// 3.74:1) — see [`bundled_theme_flyout_secondary_needs_correction_in_dark_mode`]. +/// Both text roles are the corrected ones the runtime paints: +/// [`crate::flyout_primary_foreground`] (usually `popover.foreground` untouched; +/// corrected only in deliberately dim themes such as Alduin) and +/// [`crate::flyout_secondary_foreground`], which corrects `muted.foreground` — +/// raw, that token is sub-AA on the flyout material in every bundled dark theme. #[test] fn bundled_themes_meet_flyout_text_contrast_floor() { let mut failures = Vec::new(); for (path, config) in bundled_theme_configs() { let colors = resolve_colors(&config); - let flyout = flyout_material(&config); + let flyout = flyout_material(&config, &colors); for (name, foreground) in [ - ("flyout label", colors.popover_foreground), + ( + "flyout label", + contrast_adjusted( + colors.popover_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ), + ), ( "flyout secondary", contrast_adjusted( @@ -281,15 +289,15 @@ fn bundled_themes_meet_flyout_text_contrast_floor() { /// `muted.foreground`: on dark materials the aliased token is routinely sub-AA, and /// the correction both fixes those and leaves already-readable themes untouched. /// -/// Note it is not every dark theme — Catppuccin Frappe, for one, already clears the -/// floor on its own flyout material and is returned unchanged. +/// Note it is not every dark theme — Catppuccin Macchiato and macOS Classic Dark +/// already clear the floor on their own flyout materials and are returned unchanged. #[test] fn bundled_theme_flyout_secondary_corrects_only_where_needed() { let mut dark_needing_correction = 0; for (path, config) in bundled_theme_configs() { let colors = resolve_colors(&config); - let flyout = flyout_material(&config); + let flyout = flyout_material(&config, &colors); let corrected = contrast_adjusted( colors.muted_foreground, flyout, diff --git a/docs/docs/theme.md b/docs/docs/theme.md index 44ae700e..ba1581b4 100644 --- a/docs/docs/theme.md +++ b/docs/docs/theme.md @@ -44,9 +44,17 @@ Key relationships: surface has a single left rail. - Two type steps only: `label_size` (Fluent body) for row labels, `meta_size` (Fluent caption) for shortcuts, subtitles, categories and section headers. +- The material's tint is theme-derived: the flyout base color is `popover` + lifted to the material's luminance step (panels lift `sidebar`, the card wash + lifts `background`), so custom themes tint every surface automatically. - Text roles come from `flyout_primary_foreground` / `flyout_secondary_foreground` - (and `flyout_selected_secondary_foreground` on a selected row) rather than - ad-hoc `foreground.opacity(..)` values. + (with `flyout_selected_secondary_foreground` on a selected row and + `flyout_disabled_foreground` for disabled rows) rather than ad-hoc + `foreground.opacity(..)` values. Both roles are contrast-corrected against the + composited flyout material — the primary role only in deliberately dim themes, + the secondary role in most dark themes — and the disabled role sits one clear + step below the secondary, so labels, supporting text and disabled content stay + three distinct levels in every theme. ## AppShell integration From 4cbf5e5a0c3b5e53472f2ae50e7390ce9294e049 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 09:50:39 -0700 Subject: [PATCH 12/20] chore(skills): add apple-design skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interaction and motion guidance used by the flyout polish passes. Written for SwiftUI, so it applies here as principles — direct manipulation, material treatment, typography, Reduce Motion — rather than as APIs. --- .agents/skills/apple-design/SKILL.md | 188 +++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 .agents/skills/apple-design/SKILL.md diff --git a/.agents/skills/apple-design/SKILL.md b/.agents/skills/apple-design/SKILL.md new file mode 100644 index 00000000..52241238 --- /dev/null +++ b/.agents/skills/apple-design/SKILL.md @@ -0,0 +1,188 @@ +--- +name: apple-design +description: SwiftUI-native guidance for responsive interaction, gesture-driven motion, Liquid Glass, typography, accessibility, and Reduce Motion on iOS, iPadOS, and macOS 26+. +--- + +# Apple Design + +Use this skill for interaction and motion work in Digests. The repo targets iOS, iPadOS, and macOS 26+ and uses SwiftUI first. Keep views declarative, keep expensive work out of `body`, and use the existing design and animation wrappers. + +The core test is direct manipulation: feedback starts with the gesture, follows the current presentation value, carries release velocity when appropriate, and remains interruptible. + +## 1. Response and direct manipulation + +- Give press feedback at touch-down. Commit the action at touch-up unless the control is continuous. +- For drags, update the presented value continuously. Do not wait for the gesture to end. +- Keep transient gesture state in `@GestureState`; keep the committed/resting value in `@State` or an injected observable owner. +- Respect the user's grab offset. Do not snap content to its center when a drag begins. +- Use `DragGesture` and SwiftUI gesture composition. Prefer the smallest `minimumDistance` that avoids accidental activation. +- Resolve competing gestures deliberately with `simultaneousGesture`, `highPriorityGesture`, or `exclusively`, and preserve scrolling when the child gesture has not clearly won. + +```swift +struct DraggableCard: View { + @GestureState private var translation: CGSize = .zero + @State private var settledOffset: CGSize = .zero + + var body: some View { + CardContent() + .offset( + x: settledOffset.width + translation.width, + y: settledOffset.height + translation.height + ) + .gesture( + DragGesture(minimumDistance: 10) + .updating($translation) { value, state, _ in + state = value.translation + } + .onEnded { value in + withAnimation(.spring(response: 0.35, dampingFraction: 1.0)) { + settledOffset.width += value.translation.width + settledOffset.height += value.translation.height + } + } + ) + } +} +``` + +## 2. Interruptible motion + +- Animate from the value currently presented on screen. A gesture that starts during a settling animation must take over without a jump. +- Never disable input while an animation is running. +- Use `withAnimation` at the state mutation boundary, not around per-pixel gesture updates or raw scroll deltas. +- Scope `.animation(_:value:)` to a stable, meaningful `Equatable` value. Do not attach an ambient animation to a navigation tree. +- Use critically damped springs by default (`dampingFraction: 1.0`). Use a small amount of bounce only when the user supplied momentum, such as a flick or throw. +- Animate independent axes independently when their targets or velocities differ. +- Keep connected transitions to one to three meaningful elements. Let system navigation transitions do most of the work; do not match text blocks, metadata, or entire complex rows. + +For drag release, use SwiftUI's projected endpoint when the interaction should carry momentum, then choose a snap target from that projection. Pass the release direction into the spring decision; do not decide only from the release position. + +```swift +.onEnded { value in + let projectedX = value.predictedEndTranslation.width + let targetX = nearestSnapPoint(to: settledOffset.width + projectedX) + + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + settledOffset.width = targetX + } +} +``` + +## 3. Sheets, boundaries, and spatial consistency + +- Enter and exit along the same path. A sheet that comes from the bottom should dismiss toward the bottom. +- Anchor a popover or sheet to the control that opened it. The source and destination should remain spatially legible. +- Use a small hysteresis threshold before committing a drag direction. Once intent is clear, cancel losing recognizers and track one-to-one. +- At a boundary, rubber-band progressively instead of stopping at a hard edge. Release should settle to a valid bound. +- Keep the UI usable while a transition settles; a new gesture retargets the current state. +- For navigation continuity, follow the repo's connected-transition contracts in `.claude/ai_docs/animation_architecture.md`. + +## 4. Timeline and drawing + +Use `Canvas` for dense, repeated drawing such as the launch rings. Use `TimelineView` for display-sampled animation. Keep geometry pure and deterministic; do not create one SwiftUI view per glyph or mutate state from a drawing closure. + +```pseudocode +TimelineView(animationSchedule) { timeline in + Canvas { context, size in + let frame = scene.frame(at: timeline.date, size: size) + draw(frame, in: &context) + } +} +``` + +- Use one timeline and one immutable scene for a field. +- Pause the timeline when the scene reaches a static hold; retain the final frame rather than continuing a no-op clock. +- Keep per-frame work bounded and compositor-friendly. Precompute stable traits and resolve repeated assets once per pass. +- Do not animate layout, large text trees, or unrelated navigation state to make a drawing feel busy. +- Keep `TimelineView`/`Canvas` rendering synchronous and free of I/O, parsing, and task creation. + +## 5. Liquid Glass and depth + +- App-owned glass goes through `digestsGlassSurface(...)`. +- Group glass-to-glass morphs with `DigestsGlassEffectContainer`; use one namespace per morph group. +- Never scatter raw `glassEffect` calls or availability checks through features. The wrappers provide the iOS Liquid Glass path and the macOS material fallback. +- Do not nest glass surfaces. Use system-owned navigation, tab, and toolbar chrome where the platform provides it. +- Use opaque content surfaces where translucency would reduce reading contrast. Material weight should communicate hierarchy, not decoration. +- Use `glassEffectID` only for a real glass-to-glass morph. Use `matchedGeometryEffect` for non-glass content, and never both for the same element. + +```swift +DigestsGlassEffectContainer(spacing: DigestsDesign.Spacing.medium) { + HStack { + FilterButton() + SortButton() + } + .digestsGlassSurface( + useGlass: true, + cornerRadius: DigestsDesign.CornerRadius.large, + style: .interactive + ) +} +``` + +## 6. Accessibility and Reduce Motion + +- Read `accessibilityReduceMotion` and route animation decisions through `DigestsDesign.Animation.respectingReduceMotion` or the feature's established policy. +- Reduce Motion keeps causality and state feedback but removes travel, parallax, elastic overshoot, and decorative loops. Prefer a short opacity change or an immediate state change. +- Do not make essential information depend on motion, timing, color, translucency, or haptics. +- Provide an `accessibilityLabel`, `accessibilityValue`, and named `accessibilityAction` for custom controls. Keep VoiceOver focus on the invoking control after a local state change. +- Support Dynamic Type with semantic `Font` styles and layouts that can stack. Test large text and accessibility spacing on iPhone, iPad, and Mac. +- Support keyboard and pointer input on iPad and macOS without making touch gestures the only path. +- Use `sensoryFeedback` only for meaningful commits, errors, or snaps. It must not be the sole confirmation and must not fire for every frame of a gesture. + +```swift +struct AnimatedBadge: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPresented = false + + var body: some View { + Badge() + .opacity(isPresented ? 1 : 0) + .animation( + reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 1.0), + value: isPresented + ) + .accessibilityLabel("New items") + .accessibilityValue(isPresented ? "Shown" : "Hidden") + } +} +``` + +## 7. Typography and layout + +- Start with the platform system font and semantic text styles. Use a custom face only for a documented product reason. +- Let Dynamic Type determine text size. Avoid fixed dimensions that truncate or prevent stacking at larger sizes. +- Treat weight, leading, and hierarchy as a set. Use spacing and contrast to group related controls. +- Prefer adaptive SwiftUI layout (`ViewThatFits`, grids, size classes, and platform conditionals) over device-specific coordinates. +- Keep content typography separate from navigation, controls, metadata, and Settings typography. Follow the repo's `readerTypeface` boundary. + +## 8. Concurrency and ownership + +- Keep presentation state on the main actor. Put network, parsing, persistence, and other heavy work in the existing service or actor owner. +- Use Swift Concurrency with structured tasks, cancellation, and explicit `Sendable` boundaries. Do not start tasks from `body`. +- A timeline or gesture should publish values; it should not become a second data manager, queue, retry loop, or persistence path. +- Preserve the repo's ownership and injection rules. Do not add hidden global fallbacks or parallel animation coordinators. + +## Quick reference + +| Need | SwiftUI/repo choice | +| --- | --- | +| Immediate press feedback | State mutation at touch-down | +| Continuous drag | `DragGesture` + `@GestureState` | +| Momentum landing | `predictedEndTranslation` + spring | +| Default spring | `.spring(response: 0.3...0.4, dampingFraction: 1.0)` | +| Interrupt a transition | Retarget the current presented state | +| Dense animated drawing | `TimelineView` + one `Canvas` | +| Static hold | Pause the timeline and retain the settled frame | +| App-owned glass | `digestsGlassSurface(...)` | +| Glass morph grouping | `DigestsGlassEffectContainer` | +| Reduced motion | Cross-fade or settle immediately | +| Custom control semantics | Label, value, and named accessibility action | +| Connected navigation | Follow `animation_architecture.md`; match only meaningful elements | + +## Process + +1. Define the user's intent, the direct manipulation value, and the committed state. +2. Prototype the gesture and its interruption path before polishing materials or decoration. +3. Test touch, keyboard/pointer, VoiceOver, Dynamic Type, Reduce Motion, light/dark appearance, rotation, and split-view changes. +4. Check for ownership violations: no work in `body`, no duplicate manager, no raw glass API, no ambient navigation animation. +5. Review motion at normal speed and in slow motion. Keep only motion that explains state, direction, hierarchy, or completion. From 0abf5cf135d10171aa4e874a14dc68de12c842e4 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 11:04:31 -0700 Subject: [PATCH 13/20] feat(animation): unify flyout motion on a shared token set Four surfaces had no motion at all despite being fully wired for it. The submenu instrumented 20 Entering frames while its pixels showed a one-frame pop: opacity and transform wrappers have no effect on an anchored() subtree unless it renders through deferred(). The context menu only animated because it happened to do so already. Wrapping the submenu the same way makes the identical animation code render, and explains how it had been escaping its parent's overflow_hidden clip. Hover card, select popup and command palette simply had no exit path. Every exit was also being cut off partway. Presence close windows ran on the fade token (83ms) while close animations ran 187ms, so surfaces unmounted at 44% of their curve and popped. Both now read one exit token, with a 33ms unmount grace because presence timers and the animation clock do not start on the same frame. Consolidate ThemeMotion from 18 fields to 10: four durations (fade, exit, enter, emphasis), one spring, four easings. normal and slow durations had zero consumers; fast_dismiss_easing was dead and byte-identical to fast_invoke; the mild and medium springs measured within three frames of each other at 60Hz and collapse to one. Exits use the standard curve, not Fluent's accelerate, which measured a 5.1 per-frame step into its final frame and reads as a snap. Every flyout now enters and exits on the same grammar, sliding from its trigger rather than its own center, with exit at roughly 0.9x enter. Measured by frame diff at 60fps CFR. Submenu 1-frame pop to 182ms open and 232ms close; hover card to 166/216ms; select popup to 249ms both ways; popover close from a truncated 99ms to a complete 215ms. --- .../src/stories/floating_sidebar_story.rs | 13 +- crates/ui/src/accordion.rs | 21 +-- crates/ui/src/animation.rs | 153 ++++++------------ crates/ui/src/badge.rs | 4 +- crates/ui/src/checkbox.rs | 3 +- crates/ui/src/collapsible.rs | 4 +- crates/ui/src/command_palette/mod.rs | 2 +- crates/ui/src/command_palette/view.rs | 35 +++- crates/ui/src/dialog.rs | 37 +++-- crates/ui/src/floating_sidebar/mod.rs | 18 +-- crates/ui/src/hover_card.rs | 97 ++++++++++- crates/ui/src/menu/context_menu.rs | 24 +-- crates/ui/src/menu/dropdown_menu.rs | 6 +- crates/ui/src/menu/menu_item.rs | 8 +- crates/ui/src/menu/popup_menu.rs | 108 ++++++------- crates/ui/src/notification.rs | 8 +- crates/ui/src/popover.rs | 22 ++- crates/ui/src/progress/progress.rs | 4 +- crates/ui/src/progress/progress_circle.rs | 4 +- crates/ui/src/select.rs | 99 ++++++++++-- crates/ui/src/sheet.rs | 4 +- crates/ui/src/sidebar/group.rs | 5 +- crates/ui/src/sidebar/menu.rs | 22 +-- crates/ui/src/sidebar/mod.rs | 24 +-- crates/ui/src/switch.rs | 4 +- crates/ui/src/theme/fluent_tokens.rs | 28 ++-- crates/ui/src/theme/mod.rs | 41 +++-- crates/ui/src/theme/schema.rs | 100 ++++-------- crates/ui/src/time/date_picker.rs | 4 +- docs/docs/theme.md | 24 +++ 30 files changed, 503 insertions(+), 423 deletions(-) diff --git a/crates/story/src/stories/floating_sidebar_story.rs b/crates/story/src/stories/floating_sidebar_story.rs index 80e8ebdc..6e612da4 100644 --- a/crates/story/src/stories/floating_sidebar_story.rs +++ b/crates/story/src/stories/floating_sidebar_story.rs @@ -7,10 +7,7 @@ use gpui::{ use gpui_component::{ ActiveTheme, ElevationToken, FloatingSidebar, Icon, IconName, Side, Sizable, StyledExt, - animation::{ - PresenceOptions, SpringPreset, keyed_presence, reduced_motion, spring_preset_duration_ms, - theme_animation, - }, + animation::{PresenceOptions, keyed_presence, reduced_motion, theme_animation}, button::Button, h_flex, radio::RadioGroup, @@ -93,11 +90,11 @@ impl FloatingSidebarStory { .as_f32() .abs() .min(full_distance); - let spring_duration_ms = spring_preset_duration_ms(&motion, SpringPreset::Medium); + let spring_duration_ms = motion.enter_duration_ms; let base_duration_ms = if self.collapsed { - spring_duration_ms.max(motion.soft_dismiss_duration_ms) + spring_duration_ms.max(motion.exit_duration_ms) } else { - spring_duration_ms.max(motion.fast_duration_ms) + spring_duration_ms.max(motion.enter_duration_ms) }; self.content_transition_duration_ms = ((f32::from(base_duration_ms) * remaining_distance / full_distance).round() as u16) @@ -116,7 +113,7 @@ impl FloatingSidebarStory { let content_transition = if content_presence.transition_active() { theme_animation( self.content_transition_duration_ms, - &motion.point_to_point_easing, + &motion.standard_easing, reduced_motion, ) .map(|animation| { diff --git a/crates/ui/src/accordion.rs b/crates/ui/src/accordion.rs index cd4784ee..60005b32 100644 --- a/crates/ui/src/accordion.rs +++ b/crates/ui/src/accordion.rs @@ -9,8 +9,7 @@ use gpui::{ use crate::{ ActiveTheme as _, Icon, IconName, Sizable, Size, animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, + PresenceOptions, PresencePhase, keyed_presence, spring_animation, standard_animation, }, global_state::GlobalState, h_flex, v_flex, @@ -251,21 +250,15 @@ impl RenderOnce for AccordionItem { 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 spring_preset = SpringPreset::Mild; - let layout_anim = point_to_point_animation(&motion, reduced_motion); + let layout_anim = standard_animation(&motion, reduced_motion); let close_anim = layout_anim.clone(); let open_layout_anim = layout_anim; - let open_transform_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); - let chevron_open_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); - let chevron_close_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let chevron_open_anim = spring_animation(&motion, reduced_motion); + let chevron_close_anim = spring_animation(&motion, reduced_motion); let presence_key = SharedString::from(format!("accordion-presence-{}", self.key_prefix)); - let presence_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, spring_preset).max(motion.fast_duration_ms) - }; - let open_duration = Duration::from_millis(u64::from(presence_duration_ms)); - let close_duration = Duration::from_millis(u64::from(presence_duration_ms)); + let open_duration = Duration::from_millis(u64::from(motion.enter_duration_ms)); + let close_duration = Duration::from_millis(u64::from(motion.exit_duration_ms)); let presence = keyed_presence( presence_key, self.open, diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index f06d76a3..188aa7c0 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -96,110 +96,89 @@ pub fn theme_animation(duration_ms: u16, easing: &str, reduced_motion: bool) -> 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 { +/// Enter animation: `enter` duration on the decelerate curve. For opacity/reveal +/// of surfaces that are appearing. +pub fn enter_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.fast_duration_ms, - &motion.fast_invoke_easing, + motion.enter_duration_ms, + &motion.decelerate_easing, reduced_motion, ) } -/// Soft dismiss animation (167ms, soft_dismiss_easing). -pub fn soft_dismiss_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Exit animation: `exit` duration on the standard curve. The one dismiss +/// motion — presence close windows must use [`exit_duration`] so this can play +/// to completion before its element unmounts. Decelerating rather than +/// accelerating: an accelerating opacity fade dumps most of its change into the +/// final frame and reads as a snap at 60Hz (measured ~5x the per-frame delta of +/// this curve's tail). +pub fn exit_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, + motion.exit_duration_ms, + &motion.standard_easing, reduced_motion, ) } -/// Point-to-point animation (187ms, point_to_point_easing). -pub fn point_to_point_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Point-to-point animation: `enter` duration on the standard curve. For moves +/// between two on-screen states (switch knobs, progress, widths). +pub fn standard_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.fast_duration_ms, - &motion.point_to_point_easing, + motion.enter_duration_ms, + &motion.standard_easing, reduced_motion, ) } -/// Fade animation (83ms, linear). +/// Fade animation (83ms, linear) for micro state changes. 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 { +/// Emphasis animation: long overshoot curve for attention accents. +pub fn emphasis_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.strong_invoke_duration_ms, - &motion.strong_invoke_easing, + motion.emphasis_duration_ms, + &motion.emphasis_easing, reduced_motion, ) } -pub const DEFAULT_SPRING_DAMPING_RATIO: f32 = 0.75; -pub const DEFAULT_SPRING_FREQUENCY: f32 = 1.8; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SpringPreset { - Mild, - Medium, +/// The enter window as a [`Duration`] — how long a presence must keep an +/// entering element mounted (also the spring settle window). +pub fn enter_duration(motion: &ThemeMotion) -> Duration { + Duration::from_millis(u64::from(motion.enter_duration_ms)) } -/// Duration (ms) for a spring preset. -pub fn spring_preset_duration_ms(motion: &ThemeMotion, preset: SpringPreset) -> u16 { - match preset { - SpringPreset::Mild => motion.spring_mild_duration_ms, - SpringPreset::Medium => motion.spring_medium_duration_ms, - } +/// Two frames of headroom after the exit animation before unmount: presence +/// timers and the animation clock do not start on the same frame, and without +/// slack the last frames of a fade get clipped (measured as a visible pop). +/// The extra frames render at opacity 0, so the slack is invisible. +const EXIT_UNMOUNT_GRACE_MS: u16 = 33; + +/// The exit window as a [`Duration`] — how long a presence must keep an exiting +/// element mounted so [`exit_animation`] plays to completion. Slightly longer +/// than the animation itself; see [`EXIT_UNMOUNT_GRACE_MS`]. +pub fn exit_duration(motion: &ThemeMotion) -> Duration { + Duration::from_millis(u64::from(motion.exit_duration_ms + EXIT_UNMOUNT_GRACE_MS)) } -/// Spring animation preset for transform-only motion. -pub fn spring_preset_animation( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> Option { +/// The one spring, for transform-only reveal motion. Settles within the `enter` +/// window. Unbounded easing: pair it with transforms, not opacity. +pub fn spring_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { if reduced_motion { return None; } - let (duration_ms, damping_ratio, frequency) = match preset { - SpringPreset::Mild => ( - motion.spring_mild_duration_ms, - motion.spring_mild_damping_ratio, - motion.spring_mild_frequency, - ), - SpringPreset::Medium => ( - motion.spring_medium_duration_ms, - motion.spring_medium_damping_ratio, - motion.spring_medium_frequency, - ), - }; - Some( - Animation::new(Duration::from_millis(u64::from(duration_ms))) - .with_unbounded_easing(spring(damping_ratio, frequency)), + Animation::new(enter_duration(motion)) + .with_unbounded_easing(spring(motion.spring_damping_ratio, motion.spring_frequency)), ) } /// Shared open/close durations for expand-collapse patterns. -pub fn expand_collapse_durations( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> (Duration, Duration) { - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(motion, preset).max(motion.fast_duration_ms) - }; - let close_duration_ms = motion.soft_dismiss_duration_ms; - - ( - Duration::from_millis(u64::from(open_duration_ms)), - Duration::from_millis(u64::from(close_duration_ms)), - ) +pub fn expand_collapse_durations(motion: &ThemeMotion) -> (Duration, Duration) { + (enter_duration(motion), exit_duration(motion)) } /// Shared layout animation for expand-collapse wrappers. @@ -209,40 +188,12 @@ pub fn expand_collapse_layout_animation( entering: bool, ) -> Option { if entering { - theme_animation( - motion.fast_duration_ms, - &motion.fast_invoke_easing, - reduced_motion, - ) + enter_animation(motion, reduced_motion) } else { - theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, - reduced_motion, - ) + exit_animation(motion, reduced_motion) } } -/// Shared subtle spring for transform-only content reveal motion. -/// -/// This is a semantic wrapper over `spring_preset_animation` for call sites that -/// animate inner content translation/scale/rotation during reveal, as opposed to -/// `expand_collapse_layout_animation` which is for layout-driven expand/collapse -/// wrappers. It delegates to `spring_preset_animation` with the provided motion, -/// reduced-motion flag, and spring preset. -pub fn subtle_reveal_transform_animation( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> Option { - spring_preset_animation(motion, reduced_motion, preset) -} - -/// Spring invoke animation (uses unbounded easing, transform-only). -pub fn spring_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { - spring_preset_animation(motion, reduced_motion, SpringPreset::Mild) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PresencePhase { Entering, @@ -386,7 +337,7 @@ pub fn keyed_presence( #[cfg(test)] mod tests { use super::{ - cubic_bezier, cubic_bezier_unbounded, parse_cubic_bezier_easing, spring_invoke_animation, + cubic_bezier, cubic_bezier_unbounded, parse_cubic_bezier_easing, spring_animation, }; use crate::ThemeMotion; @@ -439,9 +390,9 @@ mod tests { } #[test] - fn spring_invoke_respects_reduced_motion() { + fn spring_respects_reduced_motion() { let motion = ThemeMotion::default(); - assert!(spring_invoke_animation(&motion, true).is_none()); - assert!(spring_invoke_animation(&motion, false).is_some()); + assert!(spring_animation(&motion, true).is_none()); + assert!(spring_animation(&motion, false).is_some()); } } diff --git a/crates/ui/src/badge.rs b/crates/ui/src/badge.rs index b4800a44..1cd085e7 100644 --- a/crates/ui/src/badge.rs +++ b/crates/ui/src/badge.rs @@ -5,7 +5,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, Icon, Sizable, Size, StyledExt, animation::strong_invoke_animation, + ActiveTheme, Icon, Sizable, Size, StyledExt, animation::emphasis_animation, global_state::GlobalState, h_flex, white, }; @@ -126,7 +126,7 @@ impl RenderOnce for Badge { 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) + emphasis_animation(motion, reduced_motion) }); div() diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index f363be2a..7c84866d 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -173,7 +173,8 @@ 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.enter_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 811005f6..953babcb 100644 --- a/crates/ui/src/collapsible.rs +++ b/crates/ui/src/collapsible.rs @@ -4,7 +4,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, StyledExt, animation::fast_invoke_animation, global_state::GlobalState, v_flex, + ActiveTheme, StyledExt, animation::enter_animation, global_state::GlobalState, v_flex, }; /// Generous max for animated height reveal. Content fully visible @@ -67,7 +67,7 @@ impl RenderOnce for Collapsible { 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 anim = enter_animation(motion, reduced_motion); let mut non_content = Vec::new(); let mut content_elements = Vec::new(); diff --git a/crates/ui/src/command_palette/mod.rs b/crates/ui/src/command_palette/mod.rs index cfb51ddd..ec20658f 100644 --- a/crates/ui/src/command_palette/mod.rs +++ b/crates/ui/src/command_palette/mod.rs @@ -50,7 +50,7 @@ pub(crate) fn reveal_delay(cx: &App) -> std::time::Duration { pub(crate) fn reveal_query_delay(cx: &App) -> std::time::Duration { reveal_delay(cx) - + std::time::Duration::from_millis(u64::from(cx.theme().motion.spring_mild_duration_ms)) + + std::time::Duration::from_millis(u64::from(cx.theme().motion.enter_duration_ms)) } use gpui::{App, AppContext as _, Entity, KeyBinding, ParentElement as _, Styled, Window, actions}; diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index bf2b5fbd..d5700752 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -5,7 +5,7 @@ use super::reveal_delay; use super::state::{CommandPaletteEvent, CommandPaletteState}; use super::types::{CommandPaletteConfig, MatchedItem}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::animation::{fade_animation, spring_invoke_animation}; +use crate::animation::{fade_animation, spring_animation, standard_animation}; use crate::global_state::GlobalState; use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; @@ -594,7 +594,7 @@ impl Render for CommandPaletteView { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); let reveal_opacity_animation = fade_animation(&motion, reduced_motion); - let reveal_transform_animation = spring_invoke_animation(&motion, reduced_motion); + let reveal_transform_animation = spring_animation(&motion, reduced_motion); // Focus input once after opening to avoid render jitter if !self.did_focus { @@ -775,7 +775,7 @@ impl Render for CommandPaletteView { }) }); - SurfacePreset::flyout() + let surface = SurfacePreset::flyout() .with_radius(tokens.radius) .wrap_with_bounds( content, @@ -786,6 +786,33 @@ impl Render for CommandPaletteView { surface_ctx, ) .h(expanded_height) - .w(px(config.width)) + .w(px(config.width)); + + // Enter motion for the surface itself (it previously popped in while only + // the results animated). Mount-only: the view is created on open, so the + // animation plays exactly once. Exit is owned by the host that removes + // the view, so there is no exit animation here. + let open_fade_anim = standard_animation(&motion, reduced_motion); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let transformed = if let Some(anim) = open_transform_anim { + div() + .child(surface) + .with_animation("command-palette-open-transform", anim, |el, delta| { + el.translate_y(px(4.0 * (1.0 - delta))) + }) + .into_any_element() + } else { + surface.into_any_element() + }; + if let Some(anim) = open_fade_anim { + div() + .child(transformed) + .with_animation("command-palette-open-fade", anim, |el, delta| { + el.opacity(delta.clamp(0.0, 1.0)) + }) + .into_any_element() + } else { + transformed + } } } diff --git a/crates/ui/src/dialog.rs b/crates/ui/src/dialog.rs index 6ca43c7f..ea721b69 100644 --- a/crates/ui/src/dialog.rs +++ b/crates/ui/src/dialog.rs @@ -13,9 +13,8 @@ use crate::{ TITLE_BAR_HEIGHT, WindowExt as _, actions::{Cancel, Confirm}, animation::{ - PresenceOptions, PresencePhase, SpringPreset, fade_animation, fast_invoke_animation, - keyed_presence, point_to_point_animation, soft_dismiss_animation, spring_preset_animation, - spring_preset_duration_ms, + PresenceOptions, PresencePhase, enter_animation, exit_animation, fade_animation, + keyed_presence, spring_animation, standard_animation, }, button::{Button, ButtonVariant, ButtonVariants as _}, global_state::GlobalState, @@ -57,8 +56,8 @@ 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) + .enter_duration_ms + .max(motion.exit_duration_ms) .max(motion.fade_duration_ms), )) } @@ -463,10 +462,12 @@ impl RenderOnce for Dialog { } let open_duration = Duration::from_millis(u64::from(if reduced_motion { - cx.theme().motion.fast_duration_ms + cx.theme().motion.enter_duration_ms } else { - spring_preset_duration_ms(&cx.theme().motion, SpringPreset::Medium) - .max(cx.theme().motion.fast_duration_ms) + cx.theme() + .motion + .enter_duration_ms + .max(cx.theme().motion.enter_duration_ms) })); let close_duration = close_animation_duration(cx); let presence = keyed_presence( @@ -484,21 +485,19 @@ impl RenderOnce for Dialog { let transition_active = presence.transition_active(); let motion = &cx.theme().motion; - let open_panel_layout_animation = point_to_point_animation(motion, reduced_motion) - .or_else(|| fast_invoke_animation(motion, reduced_motion)) + let open_panel_layout_animation = standard_animation(motion, reduced_motion) + .or_else(|| enter_animation(motion, reduced_motion)) .unwrap_or_else(|| { gpui::Animation::new(std::time::Duration::from_millis(u64::from( - motion.fast_duration_ms, - ))) - }); - let open_panel_transform_animation = - spring_preset_animation(motion, reduced_motion, SpringPreset::Medium); - let close_panel_animation = - soft_dismiss_animation(motion, reduced_motion).unwrap_or_else(|| { - gpui::Animation::new(std::time::Duration::from_millis(u64::from( - motion.soft_dismiss_duration_ms, + motion.enter_duration_ms, ))) }); + let open_panel_transform_animation = spring_animation(motion, reduced_motion); + let close_panel_animation = exit_animation(motion, reduced_motion).unwrap_or_else(|| { + gpui::Animation::new(std::time::Duration::from_millis(u64::from( + motion.exit_duration_ms, + ))) + }); let fade_in_animation = fade_animation(motion, reduced_motion).unwrap_or_else(|| { gpui::Animation::new(std::time::Duration::from_millis(u64::from( motion.fade_duration_ms, diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs index a5337099..1e379d21 100644 --- a/crates/ui/src/floating_sidebar/mod.rs +++ b/crates/ui/src/floating_sidebar/mod.rs @@ -11,9 +11,7 @@ use gpui::{ use crate::{ ActiveTheme, ElevationToken, Side, StyledExt, - animation::{ - PresenceOptions, SpringPreset, keyed_presence, spring_preset_duration_ms, theme_animation, - }, + animation::{PresenceOptions, keyed_presence, theme_animation}, global_state::GlobalState, sidebar::{COLLAPSED_WIDTH, DEFAULT_WIDTH, Sidebar, SidebarItem}, sidebar_shell::SidebarShell, @@ -333,16 +331,16 @@ impl RenderOnce for FloatingSidebar { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let width_spring_duration_ms = spring_preset_duration_ms(&motion, SpringPreset::Medium); + let width_spring_duration_ms = motion.enter_duration_ms; let target_width = if collapsed { COLLAPSED_WIDTH } else { expanded_width }; let base_duration_ms = if collapsed { - width_spring_duration_ms.max(motion.soft_dismiss_duration_ms) + width_spring_duration_ms.max(motion.exit_duration_ms) } else { - width_spring_duration_ms.max(motion.fast_duration_ms) + width_spring_duration_ms.max(motion.enter_duration_ms) }; state.update(cx, |state, _| { state.begin_width_transition(collapsed, target_width, expanded_width, base_duration_ms); @@ -442,11 +440,9 @@ impl RenderOnce for FloatingSidebar { }); if transition_active { - if let Some(animation) = theme_animation( - width_duration_ms, - &motion.point_to_point_easing, - reduced_motion, - ) { + if let Some(animation) = + theme_animation(width_duration_ms, &motion.standard_easing, reduced_motion) + { shell = shell.animate_width_from( from_width, SharedString::from(format!( diff --git a/crates/ui/src/hover_card.rs b/crates/ui/src/hover_card.rs index 99aeb3b8..f0a4d4a9 100644 --- a/crates/ui/src/hover_card.rs +++ b/crates/ui/src/hover_card.rs @@ -1,12 +1,21 @@ use gpui::{ - AnyElement, App, Bounds, Context, ElementId, InteractiveElement as _, IntoElement, - ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement, StyleRefinement, Styled, - Task, Window, div, prelude::FluentBuilder as _, + AnimationExt as _, AnyElement, App, Bounds, Context, ElementId, InteractiveElement as _, + IntoElement, ParentElement, Pixels, Render, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Task, Window, div, + prelude::FluentBuilder as _, px, }; use std::rc::Rc; use std::time::Duration; -use crate::{Anchor, ElementExt, StyledExt as _, popover::Popover}; +use crate::{ + ActiveTheme as _, Anchor, ElementExt, StyledExt as _, + animation::{ + self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, + standard_animation, + }, + global_state::GlobalState, + popover::Popover, +}; /// A hover card element that displays content when hovering over a trigger element. /// @@ -293,10 +302,36 @@ impl RenderOnce for HoverCard { }), ); - if !open { + // Same presence and motion as Popover: enter spring+fade, exit fade — + // previously the card popped in and out with no transition at all. + let motion = cx.theme().motion.clone(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let presence = keyed_presence( + SharedString::from(format!("hover-card-presence-{}", state.entity_id())), + open, + !reduced_motion, + animation::enter_duration(&motion), + animation::exit_duration(&motion), + PresenceOptions::default(), + window, + cx, + ); + if !presence.should_render() { return root; } + let open_fade_anim = standard_animation(&motion, reduced_motion); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let close_anim = exit_animation(&motion, reduced_motion); + let vertical_direction = if matches!( + self.anchor, + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight + ) { + -1.0 + } else { + 1.0 + }; + let popover_content = Popover::render_popover_content(self.anchor, self.appearance, window, cx) .overflow_hidden() @@ -307,7 +342,57 @@ impl RenderOnce for HoverCard { this.child(state.update(cx, |state, cx| (content)(state, window, cx))) }) .children(self.children) - .refine_style(&self.style); + .refine_style(&self.style) + .map(move |el| { + if !presence.transition_active() { + el.into_any_element() + } else if matches!(presence.phase, PresencePhase::Entering) { + let transformed = if let Some(anim) = open_transform_anim { + div() + .child(el) + .with_animation( + SharedString::from("hover-card-open-transform"), + anim, + move |el, delta| { + el.translate_y(px(4.0 * (1.0 - delta) * vertical_direction)) + }, + ) + .into_any_element() + } else { + el.into_any_element() + }; + if let Some(anim) = open_fade_anim { + div() + .child(transformed) + .with_animation( + SharedString::from("hover-card-open-fade"), + anim, + move |el, delta| { + el.opacity(presence.progress(delta).clamp(0.0, 1.0)) + }, + ) + .into_any_element() + } else { + transformed + } + } else if let Some(anim) = close_anim { + div() + .child(el) + .with_animation( + SharedString::from("hover-card-close"), + anim, + move |el, delta| { + let progress = presence.progress(delta).clamp(0.0, 1.0); + el.opacity(progress).translate_y(px(2.0 + * (1.0 - progress) + * vertical_direction)) + }, + ) + .into_any_element() + } else { + el.into_any_element() + } + }); root.child(Popover::render_popover( self.anchor, diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index bb3f5814..72021b4f 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -1,4 +1,4 @@ -use std::{cell::RefCell, rc::Rc, time::Duration}; +use std::{cell::RefCell, rc::Rc}; use gpui::{ AnimationExt as _, AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, @@ -11,8 +11,8 @@ use gpui::{ use crate::{ ActiveTheme, animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, + self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, + standard_animation, }, global_state::GlobalState, menu::PopupMenu, @@ -176,18 +176,12 @@ impl Element for ContextMenu< let mut menu_element = None; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, SpringPreset::Mild) - .max(motion.fast_duration_ms) - }; let presence = keyed_presence( SharedString::from(format!("context-menu-presence-{:?}", this.id)), open, !reduced_motion, - Duration::from_millis(u64::from(open_duration_ms)), - Duration::from_millis(u64::from(motion.fast_duration_ms)), + animation::enter_duration(&motion), + animation::exit_duration(&motion), PresenceOptions::default(), window, cx, @@ -200,11 +194,9 @@ impl Element for ContextMenu< .unwrap_or(false); if has_menu_item { - let open_opacity_animation = - point_to_point_animation(&motion, reduced_motion); - let open_transform_animation = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Mild); - let close_animation = point_to_point_animation(&motion, reduced_motion); + let open_opacity_animation = standard_animation(&motion, reduced_motion); + let open_transform_animation = spring_animation(&motion, reduced_motion); + let close_animation = exit_animation(&motion, reduced_motion); let positioned_menu = anchored() .position(position) .snap_to_window_with_margin(px(8.)) diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index f77edb13..b85a0a22 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -128,9 +128,9 @@ where let dismiss_duration = if reduced_motion { std::time::Duration::ZERO } else { - std::time::Duration::from_millis(u64::from( - cx.theme().motion.fade_duration_ms, - )) + // Must outlast the exit animation or + // the menu pops mid-fade. + crate::animation::exit_duration(&cx.theme().motion) }; cx.spawn({ let menu_state = menu_state.clone(); diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 542b4261..5f68496e 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -1,7 +1,5 @@ use crate::{ - ActiveTheme, Disableable, StyledExt, - animation::{SpringPreset, spring_preset_animation}, - global_state::GlobalState, + ActiveTheme, Disableable, StyledExt, animation::spring_animation, global_state::GlobalState, h_flex, }; use gpui::{ @@ -103,9 +101,7 @@ impl RenderOnce for MenuItemElement { }; let reduced_motion = GlobalState::global(cx).reduced_motion(); let selection_animation = became_selected - .then(|| { - spring_preset_animation(&cx.theme().motion, reduced_motion, SpringPreset::Mild) - }) + .then(|| spring_animation(&cx.theme().motion, reduced_motion)) .flatten(); let selected = self.selected; let selection_animation_id = diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 734effa5..a8c0951f 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1,8 +1,8 @@ use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::actions::{SelectLeft, SelectRight}; use crate::animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, + self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, + standard_animation, }; use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; @@ -21,7 +21,7 @@ use gpui::{ px, }; use gpui::{ClickEvent, MouseDownEvent, OwnedMenuItem, Point, Subscription}; -use std::{rc::Rc, time::Duration}; +use std::rc::Rc; const CONTEXT: &str = "PopupMenu"; @@ -1280,12 +1280,6 @@ impl PopupMenu { } => { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, SpringPreset::Medium) - .max(motion.fast_duration_ms) - }; let submenu_presence = keyed_presence( SharedString::from(format!( "popup-menu-submenu-presence-{}-{}", @@ -1294,16 +1288,15 @@ impl PopupMenu { )), selected, !reduced_motion, - Duration::from_millis(u64::from(open_duration_ms)), - Duration::from_millis(u64::from(motion.fade_duration_ms)), + animation::enter_duration(&motion), + animation::exit_duration(&motion), PresenceOptions::default(), window, cx, ); - let submenu_open_opacity_anim = point_to_point_animation(&motion, reduced_motion); - let submenu_open_transform_anim = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Medium); - let submenu_close_anim = point_to_point_animation(&motion, reduced_motion); + let submenu_open_opacity_anim = standard_animation(&motion, reduced_motion); + let submenu_open_transform_anim = spring_animation(&motion, reduced_motion); + let submenu_close_anim = exit_animation(&motion, reduced_motion); this.selected(selected) .disabled(*disabled) @@ -1352,25 +1345,23 @@ impl PopupMenu { } else { 1.0 }; - let submenu = anchored() - .anchor(anchor) - .child( - div() - .id("submenu") - .occlude() - .when(is_bottom_pos, |this| this.bottom_0()) - .when(!is_bottom_pos, |this| this.top_neg_1()) - .left(left) - .child(menu.clone()), - ) - .snap_to_window_with_margin(Edges::all(EDGE_PADDING)); - - if submenu_presence.transition_active() { + let submenu_inner = div() + .id("submenu") + .occlude() + .when(is_bottom_pos, |this| this.bottom_0()) + .when(!is_bottom_pos, |this| this.top_neg_1()) + .left(left) + .child(menu.clone()); + + // The motion wraps the *content inside* `anchored`: + // wrappers outside it have no visual effect, because + // anchored repositions its children after layout. + let animated_inner = if submenu_presence.transition_active() { if matches!(submenu_presence.phase, PresencePhase::Entering) { let transformed = if let Some(anim) = submenu_open_transform_anim { div() - .child(submenu) + .child(submenu_inner) .with_animation( SharedString::from(format!( "popup-submenu-open-transform-{}", @@ -1385,7 +1376,7 @@ impl PopupMenu { ) .into_any_element() } else { - div().child(submenu).into_any_element() + submenu_inner.into_any_element() }; if let Some(anim) = submenu_open_opacity_anim { div() @@ -1411,33 +1402,40 @@ impl PopupMenu { .opacity(submenu_presence.progress(1.0)) .into_any_element() } + } else if let Some(anim) = submenu_close_anim { + div() + .child(submenu_inner) + .with_animation( + SharedString::from(format!( + "popup-submenu-close-motion-{}", + ix + )), + anim, + move |el, delta| { + let progress = submenu_presence + .progress(delta) + .clamp(0.0, 1.0); + el.opacity(progress).translate_x(px(direction + * 6.0 + * (1.0 - progress))) + }, + ) + .into_any_element() } else { - if let Some(anim) = submenu_close_anim { - div() - .child(submenu) - .with_animation( - SharedString::from(format!( - "popup-submenu-close-motion-{}", - ix - )), - anim, - move |el, delta| { - let progress = submenu_presence - .progress(delta) - .clamp(0.0, 1.0); - el.opacity(progress).translate_x(px(direction - * 6.0 - * (1.0 - progress))) - }, - ) - .into_any_element() - } else { - submenu.into_any_element() - } + submenu_inner.into_any_element() } } else { - submenu.into_any_element() - } + submenu_inner.into_any_element() + }; + + gpui::deferred( + anchored() + .anchor(anchor) + .child(animated_inner) + .snap_to_window_with_margin(Edges::all(EDGE_PADDING)), + ) + .with_priority(2) + .into_any_element() }) }) } diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 68db7a2e..d86498c2 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -19,7 +19,7 @@ use smol::Timer; use crate::{ ActiveTheme as _, Anchor, Edges, Icon, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, - animation::{fast_invoke_animation, soft_dismiss_animation}, + animation::{enter_animation, exit_animation}, button::{Button, ButtonVariants as _}, global_state::GlobalState, h_flex, v_flex, @@ -255,7 +255,7 @@ impl Notification { let dismiss_duration = if reduced_motion { Duration::ZERO } else { - Duration::from_millis(u64::from(cx.theme().motion.soft_dismiss_duration_ms)) + Duration::from_millis(u64::from(cx.theme().motion.exit_duration_ms)) }; cx.spawn(async move |view, cx| { @@ -311,9 +311,9 @@ impl Render for Notification { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = &cx.theme().motion; let animation = if closing { - soft_dismiss_animation(motion, reduced_motion) + exit_animation(motion, reduced_motion) } else { - fast_invoke_animation(motion, reduced_motion) + enter_animation(motion, reduced_motion) }; let notification = h_flex() diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index d1a7737c..1e99d9da 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -12,8 +12,8 @@ use crate::{ actions::Cancel, anchored, animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, + self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, + standard_animation, }, flyout_primary_foreground, global_state::GlobalState, @@ -402,17 +402,14 @@ impl RenderOnce for Popover { let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, SpringPreset::Mild).max(motion.fast_duration_ms) - }; + // The presence windows are the same tokens the animations run on, so an + // exit always plays to completion before its element unmounts. let presence = keyed_presence( SharedString::from(format!("popover-presence-{}", popover_id)), open, !reduced_motion, - std::time::Duration::from_millis(u64::from(open_duration_ms)), - std::time::Duration::from_millis(u64::from(motion.fade_duration_ms)), + animation::enter_duration(&motion), + animation::exit_duration(&motion), PresenceOptions { animate_on_mount: true, }, @@ -423,10 +420,9 @@ impl RenderOnce for Popover { return el; } - let open_fade_anim = point_to_point_animation(&motion, reduced_motion); - let open_transform_anim = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Mild); - let close_anim = point_to_point_animation(&motion, reduced_motion); + let open_fade_anim = standard_animation(&motion, reduced_motion); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let close_anim = exit_animation(&motion, reduced_motion); let vertical_direction = if matches!( self.anchor, Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight diff --git a/crates/ui/src/progress/progress.rs b/crates/ui/src/progress/progress.rs index 796ef8e9..503a68e1 100644 --- a/crates/ui/src/progress/progress.rs +++ b/crates/ui/src/progress/progress.rs @@ -122,11 +122,11 @@ impl RenderOnce for Progress { } let duration = Duration::from_millis(u64::from( - cx.theme().motion.fast_duration_ms, + cx.theme().motion.enter_duration_ms, )); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let state = state.clone(); diff --git a/crates/ui/src/progress/progress_circle.rs b/crates/ui/src/progress/progress_circle.rs index e6bec8ab..a7228480 100644 --- a/crates/ui/src/progress/progress_circle.rs +++ b/crates/ui/src/progress/progress_circle.rs @@ -192,10 +192,10 @@ impl RenderOnce for ProgressCircle { } let duration = - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + Duration::from_millis(u64::from(cx.theme().motion.enter_duration_ms)); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let state = state.clone(); diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index bec86613..b5228e06 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -11,7 +11,10 @@ use crate::{ ActiveTheme, Disableable, ElementExt as _, FlyoutTokens, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, actions::{Cancel, Confirm, SelectDown, SelectUp}, - animation::fast_invoke_animation, + animation::{ + self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, + standard_animation, + }, global_state::GlobalState, h_flex, input::clear_button, @@ -921,10 +924,41 @@ where move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) }), ) - .when(self.open, |this| { - let motion = &cx.theme().motion; + .map(|this| { + let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - let anim = fast_invoke_animation(motion, reduced_motion); + // Presence keeps the popup mounted through its exit, and its + // windows are the same tokens the animations run on. + let presence = keyed_presence( + SharedString::from(format!( + "select-popup-presence-{}", + cx.entity().entity_id() + )), + self.open, + !reduced_motion, + animation::enter_duration(&motion), + animation::exit_duration(&motion), + PresenceOptions::default(), + window, + cx, + ); + if !presence.should_render() { + return this; + } + + // Same motion as every other flyout: spring slide from the + // trigger with a fade in, accelerate fade back out. + let vertical_direction = if matches!( + placement.anchor, + Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter + ) { + -1.0 + } else { + 1.0 + }; + let open_fade_anim = standard_animation(&motion, reduced_motion); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let close_anim = exit_animation(&motion, reduced_motion); this.child( deferred( @@ -959,11 +993,58 @@ where 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() + if !presence.transition_active() { + el.into_any_element() + } else if matches!(presence.phase, PresencePhase::Entering) { + let transformed = if let Some(anim) = open_transform_anim { + div() + .child(el) + .with_animation( + "select-open-transform", + anim, + move |el, delta| { + el.translate_y(px(4.0 + * (1.0 - delta) + * vertical_direction)) + }, + ) + .into_any_element() + } else { + el.into_any_element() + }; + if let Some(anim) = open_fade_anim { + div() + .child(transformed) + .with_animation( + "select-open-fade", + anim, + move |el, delta| { + el.opacity( + presence + .progress(delta) + .clamp(0.0, 1.0), + ) + }, + ) + .into_any_element() + } else { + transformed + } + } else if let Some(anim) = close_anim { + div() + .child(el) + .with_animation( + "select-close", + anim, + move |el, delta| { + let progress = + presence.progress(delta).clamp(0.0, 1.0); + el.opacity(progress).translate_y(px(2.0 + * (1.0 - progress) + * vertical_direction)) + }, + ) + .into_any_element() } else { el.into_any_element() } diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs index 4b59f897..b75d5f6d 100644 --- a/crates/ui/src/sheet.rs +++ b/crates/ui/src/sheet.rs @@ -13,7 +13,7 @@ use crate::{ ActiveTheme, FocusTrapElement as _, IconName, Placement, Sizable, StyledExt as _, WindowExt as _, actions::Cancel, - animation::fast_invoke_animation, + animation::enter_animation, button::{Button, ButtonVariants as _}, dialog::overlay_color, global_state::GlobalState, @@ -147,7 +147,7 @@ impl RenderOnce for Sheet { let top = cx.theme().sheet.margin_top; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = &cx.theme().motion; - let slide_animation = fast_invoke_animation(motion, reduced_motion); + let slide_animation = enter_animation(motion, reduced_motion); let on_close = self.on_close.clone(); let base_size = window.text_style().font_size; diff --git a/crates/ui/src/sidebar/group.rs b/crates/ui/src/sidebar/group.rs index 6866cfab..b88cf534 100644 --- a/crates/ui/src/sidebar/group.rs +++ b/crates/ui/src/sidebar/group.rs @@ -1,7 +1,7 @@ use crate::{ ActiveTheme, Collapsible, animation::{ - PresenceOptions, PresencePhase, SpringPreset, expand_collapse_durations, + PresenceOptions, PresencePhase, expand_collapse_durations, expand_collapse_layout_animation, keyed_presence, }, global_state::GlobalState, @@ -70,8 +70,7 @@ impl SidebarItem for SidebarGroup { let id = id.into(); let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let (open_duration, close_duration) = - expand_collapse_durations(&motion, reduced_motion, SpringPreset::Mild); + let (open_duration, close_duration) = expand_collapse_durations(&motion); let label_presence = keyed_presence( SharedString::from(format!("{}-group-label-presence", id)), !self.collapsed, diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 5ae2a20d..9c3d9512 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -2,9 +2,9 @@ use crate::{ ActiveTheme as _, Anchor, Collapsible, FlyoutTokens, Icon, IconName, Selectable, Sizable as _, StyledExt, SurfaceContext, SurfacePreset, animation::{ - PresenceOptions, PresencePhase, PresenceTransition, SpringPreset, + PresenceOptions, PresencePhase, PresenceTransition, exit_animation, expand_collapse_durations, expand_collapse_layout_animation, keyed_presence, - subtle_reveal_transform_animation, theme_animation, + spring_animation, }, button::{Button, ButtonVariants as _}, flyout_primary_foreground, @@ -414,8 +414,7 @@ impl SidebarItem for SidebarMenuItem { let show_collapsed_submenu = is_submenu && is_collapsed; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let (open_duration, close_duration) = - expand_collapse_durations(&motion, reduced_motion, SpringPreset::Mild); + let (open_duration, close_duration) = expand_collapse_durations(&motion); let submenu_presence = keyed_presence( SharedString::from(format!("{}-submenu-presence", state_key)), is_open, @@ -430,15 +429,9 @@ impl SidebarItem for SidebarMenuItem { let submenu_entering = matches!(submenu_presence.phase, PresencePhase::Entering); let submenu_layout_anim = expand_collapse_layout_animation(&motion, reduced_motion, submenu_entering); - let submenu_transform_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); - let chevron_open_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); - let chevron_close_anim = theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, - reduced_motion, - ); + let submenu_transform_anim = spring_animation(&motion, reduced_motion); + let chevron_open_anim = spring_animation(&motion, reduced_motion); + let chevron_close_anim = exit_animation(&motion, reduced_motion); let item_content_presence = keyed_presence( SharedString::from(format!("{}-item-content-presence", state_key)), !is_collapsed, @@ -455,8 +448,7 @@ impl SidebarItem for SidebarMenuItem { reduced_motion, matches!(item_content_presence.phase, PresencePhase::Entering), ); - let item_content_transform_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); + let item_content_transform_anim = spring_animation(&motion, reduced_motion); let item_element = h_flex() .size_full() diff --git a/crates/ui/src/sidebar/mod.rs b/crates/ui/src/sidebar/mod.rs index feb92d49..12979eb3 100644 --- a/crates/ui/src/sidebar/mod.rs +++ b/crates/ui/src/sidebar/mod.rs @@ -1,9 +1,6 @@ use crate::{ ActiveTheme, Collapsible, Icon, IconName, Side, Sizable, StyledExt, - animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, spring_preset_duration_ms, - theme_animation, - }, + animation::{PresenceOptions, PresencePhase, keyed_presence, theme_animation}, button::{Button, ButtonVariants}, global_state::GlobalState, h_flex, @@ -253,17 +250,11 @@ impl RenderOnce for Sidebar { let sidebar_id = self.id.clone(); let expanded_width = self.width; let collapsed_width = COLLAPSED_WIDTH; - let width_spring_preset = SpringPreset::Medium; - let width_spring_duration_ms = spring_preset_duration_ms(&motion, width_spring_preset); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - width_spring_duration_ms.max(motion.fast_duration_ms) - }; + let open_duration_ms = motion.enter_duration_ms; let close_duration_ms = if reduced_motion { - motion.soft_dismiss_duration_ms + motion.exit_duration_ms } else { - width_spring_duration_ms.max(motion.soft_dismiss_duration_ms) + motion.enter_duration_ms.max(motion.exit_duration_ms) }; let presence = keyed_presence( SharedString::from(format!("{}-collapsed-presence", sidebar_id)), @@ -435,11 +426,8 @@ impl RenderOnce for Sidebar { if !transition_active { sidebar.into_any_element() } else { - let width_anim = theme_animation( - width_duration_ms, - &motion.point_to_point_easing, - reduced_motion, - ); + let width_anim = + theme_animation(width_duration_ms, &motion.standard_easing, reduced_motion); if let Some(width_anim) = width_anim { sidebar .with_animation( diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index 79b7d743..c83f2cf0 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -165,11 +165,11 @@ impl RenderOnce for Switch { if value_changed && !reduced_motion { let duration = Duration::from_millis(u64::from( - cx.theme().motion.fast_duration_ms, + cx.theme().motion.enter_duration_ms, )); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let toggle_state = toggle_state.clone(); diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index 0dffd134..52e764a7 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -4,25 +4,17 @@ use crate::{ThemeElevation, ThemeMaterial, ThemeMotion, ThemeShadowToken, try_pa pub(crate) fn theme_motion_defaults() -> ThemeMotion { ThemeMotion { - // Fluent animation tokens: 187 / 333 / 500 ms cadence - fast_duration_ms: 187, - normal_duration_ms: 333, - slow_duration_ms: 500, - strong_invoke_duration_ms: 667, - soft_dismiss_duration_ms: 167, + // Fluent cadence: 83 fade / 167 dismiss / 187 invoke / 667 emphasis. fade_duration_ms: 83, - // Spring presets for transform-only open motion. - spring_mild_duration_ms: 187, - spring_medium_duration_ms: 240, - spring_mild_damping_ratio: 0.78, - spring_medium_damping_ratio: 0.70, - spring_mild_frequency: 2.0, - spring_medium_frequency: 1.6, - fast_invoke_easing: "cubic-bezier(0, 0, 0, 1)".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(), + exit_duration_ms: 167, + enter_duration_ms: 187, + emphasis_duration_ms: 667, + // One spring for transform reveals; settles within the enter window. + spring_damping_ratio: 0.78, + spring_frequency: 2.0, + decelerate_easing: "cubic-bezier(0, 0, 0, 1)".into(), + standard_easing: "cubic-bezier(0.55, 0.55, 0, 1)".into(), + emphasis_easing: "cubic-bezier(0.13, 1.62, 0, 0.92)".into(), fade_easing: "linear".into(), } } diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 4b2f35fc..d2310f66 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -40,25 +40,34 @@ pub enum ThemeShadowToken { Xl, } +/// Motion tokens: four durations, one spring, five easing curves. +/// +/// Every animated surface draws from this set so the system moves as one: +/// +/// - `fade` (83ms) — micro state changes: tooltips, carets, tab strips. +/// - `exit` (167ms) — every dismiss. Presence close windows and close animations +/// share this token, so an exit can never outlive its element. +/// - `enter` (187ms) — every reveal. Also the settle window for the spring, so a +/// fade and its transform partner end together. +/// - `emphasis` (667ms, overshoot) — attention accents (badges), used sparingly. +/// +/// One spring (`spring_damping_ratio`/`spring_frequency`) drives all transform +/// reveals; a mild/medium split existed before but measured within a few frames +/// of each other at 60Hz and collapsed into this single token. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ThemeMotion { - pub fast_duration_ms: u16, - pub normal_duration_ms: u16, - pub slow_duration_ms: u16, - pub strong_invoke_duration_ms: u16, - pub soft_dismiss_duration_ms: u16, pub fade_duration_ms: u16, - pub spring_mild_duration_ms: u16, - pub spring_medium_duration_ms: u16, - pub spring_mild_damping_ratio: f32, - pub spring_medium_damping_ratio: f32, - pub spring_mild_frequency: f32, - pub spring_medium_frequency: f32, - pub fast_invoke_easing: SharedString, - pub strong_invoke_easing: SharedString, - pub fast_dismiss_easing: SharedString, - pub soft_dismiss_easing: SharedString, - pub point_to_point_easing: SharedString, + pub exit_duration_ms: u16, + pub enter_duration_ms: u16, + pub emphasis_duration_ms: u16, + pub spring_damping_ratio: f32, + pub spring_frequency: f32, + /// Fast-out curve for enters: most of the travel happens immediately. + pub decelerate_easing: SharedString, + /// Symmetric curve for point-to-point moves (switches, progress, widths). + pub standard_easing: SharedString, + /// Overshoot curve for emphasis accents. + pub emphasis_easing: SharedString, pub fade_easing: SharedString, } diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index da476277..64f35ce1 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -81,23 +81,15 @@ pub struct ThemeConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct ThemeMotionConfig { - pub fast_duration_ms: Option, - pub normal_duration_ms: Option, - pub slow_duration_ms: Option, - pub strong_invoke_duration_ms: Option, - pub soft_dismiss_duration_ms: Option, pub fade_duration_ms: Option, - pub spring_mild_duration_ms: Option, - pub spring_medium_duration_ms: Option, - pub spring_mild_damping_ratio: Option, - pub spring_medium_damping_ratio: Option, - pub spring_mild_frequency: Option, - pub spring_medium_frequency: Option, - pub fast_invoke_easing: Option, - pub strong_invoke_easing: Option, - pub fast_dismiss_easing: Option, - pub soft_dismiss_easing: Option, - pub point_to_point_easing: Option, + pub exit_duration_ms: Option, + pub enter_duration_ms: Option, + pub emphasis_duration_ms: Option, + pub spring_damping_ratio: Option, + pub spring_frequency: Option, + pub decelerate_easing: Option, + pub standard_easing: Option, + pub emphasis_easing: Option, pub fade_easing: Option, } @@ -141,66 +133,38 @@ pub struct ThemeMaterialConfig { impl ThemeMotion { fn apply_config(&mut self, config: Option<&ThemeMotionConfig>, default_theme: &ThemeMotion) { if let Some(config) = config { - self.fast_duration_ms = config - .fast_duration_ms - .unwrap_or(default_theme.fast_duration_ms); - self.normal_duration_ms = config - .normal_duration_ms - .unwrap_or(default_theme.normal_duration_ms); - self.slow_duration_ms = config - .slow_duration_ms - .unwrap_or(default_theme.slow_duration_ms); - self.strong_invoke_duration_ms = config - .strong_invoke_duration_ms - .unwrap_or(default_theme.strong_invoke_duration_ms); - self.soft_dismiss_duration_ms = config - .soft_dismiss_duration_ms - .unwrap_or(default_theme.soft_dismiss_duration_ms); self.fade_duration_ms = config .fade_duration_ms .unwrap_or(default_theme.fade_duration_ms); - self.spring_mild_duration_ms = config - .spring_mild_duration_ms - .unwrap_or(default_theme.spring_mild_duration_ms); - self.spring_medium_duration_ms = config - .spring_medium_duration_ms - .unwrap_or(default_theme.spring_medium_duration_ms); - self.spring_mild_damping_ratio = config - .spring_mild_damping_ratio - .unwrap_or(default_theme.spring_mild_damping_ratio); - self.spring_medium_damping_ratio = config - .spring_medium_damping_ratio - .unwrap_or(default_theme.spring_medium_damping_ratio); - self.spring_mild_frequency = config - .spring_mild_frequency - .unwrap_or(default_theme.spring_mild_frequency); - self.spring_medium_frequency = config - .spring_medium_frequency - .unwrap_or(default_theme.spring_medium_frequency); - self.fast_invoke_easing = config - .fast_invoke_easing + self.exit_duration_ms = config + .exit_duration_ms + .unwrap_or(default_theme.exit_duration_ms); + self.enter_duration_ms = config + .enter_duration_ms + .unwrap_or(default_theme.enter_duration_ms); + self.emphasis_duration_ms = config + .emphasis_duration_ms + .unwrap_or(default_theme.emphasis_duration_ms); + self.spring_damping_ratio = config + .spring_damping_ratio + .unwrap_or(default_theme.spring_damping_ratio); + self.spring_frequency = config + .spring_frequency + .unwrap_or(default_theme.spring_frequency); + self.decelerate_easing = config + .decelerate_easing .as_ref() - .unwrap_or(&default_theme.fast_invoke_easing) + .unwrap_or(&default_theme.decelerate_easing) .clone(); - self.strong_invoke_easing = config - .strong_invoke_easing + self.standard_easing = config + .standard_easing .as_ref() - .unwrap_or(&default_theme.strong_invoke_easing) + .unwrap_or(&default_theme.standard_easing) .clone(); - self.fast_dismiss_easing = config - .fast_dismiss_easing + self.emphasis_easing = config + .emphasis_easing .as_ref() - .unwrap_or(&default_theme.fast_dismiss_easing) - .clone(); - self.soft_dismiss_easing = config - .soft_dismiss_easing - .as_ref() - .unwrap_or(&default_theme.soft_dismiss_easing) - .clone(); - self.point_to_point_easing = config - .point_to_point_easing - .as_ref() - .unwrap_or(&default_theme.point_to_point_easing) + .unwrap_or(&default_theme.emphasis_easing) .clone(); self.fade_easing = config .fade_easing diff --git a/crates/ui/src/time/date_picker.rs b/crates/ui/src/time/date_picker.rs index 80509de7..255a1ad9 100644 --- a/crates/ui/src/time/date_picker.rs +++ b/crates/ui/src/time/date_picker.rs @@ -13,7 +13,7 @@ use rust_i18n::t; use crate::{ ActiveTheme, Disableable, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt as _, actions::{Cancel, Confirm}, - animation::fast_invoke_animation, + animation::enter_animation, button::{Button, ButtonVariants as _}, global_state::GlobalState, h_flex, @@ -443,7 +443,7 @@ 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); + let anim = enter_animation(motion, reduced_motion); this.child( deferred( diff --git a/docs/docs/theme.md b/docs/docs/theme.md index ba1581b4..b3936784 100644 --- a/docs/docs/theme.md +++ b/docs/docs/theme.md @@ -56,6 +56,30 @@ Key relationships: step below the secondary, so labels, supporting text and disabled content stay three distinct levels in every theme. +## Motion tokens + +`theme.motion` is four durations, one spring, and four easing curves — every +animated surface draws from this set so the system moves as one: + +- `fade_duration_ms` (83) — micro state changes: tooltips, carets, tab strips. +- `exit_duration_ms` (167) — every dismiss. Presence close windows use the same + token as the close animations, so an exit always plays to completion before + its element unmounts. +- `enter_duration_ms` (187) — every reveal, and the settle window for the + spring, so a fade and its transform partner end together. +- `emphasis_duration_ms` (667) — the overshoot accent (badges), used sparingly. +- `spring_damping_ratio` / `spring_frequency` (0.78 / 2.0) — the one spring for + transform reveals. +- Easings: `decelerate_easing` for enters, `standard_easing` for + point-to-point moves and exits, `emphasis_easing` for the overshoot, + `fade_easing` (linear). + +Flyouts share one motion grammar: enter = spring slide from the trigger plus a +standard fade over `enter`; exit = standard fade over `exit`. Use +`animation::enter_animation` / `exit_animation` / `spring_animation` / +`standard_animation` / `fade_animation` rather than raw durations, and +`animation::enter_duration` / `exit_duration` for presence windows. + ## AppShell integration AppShell can initialize the registry, persist mode/name in its From 9f8284e41ab843bdff48b9bd43fed301cce3010c Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 11:32:12 -0700 Subject: [PATCH 14/20] feat(dialog): add deferred-unmount contract for animated exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog layer half-had this already: close_dialog deferred unmount, but only when the chrome itself animated. The command palette opts out of chrome animation, so it fell through to instant finalize and popped out in a single frame. The design conflated 'chrome animates' with 'defer unmount'. Separate the two. Dialog::defer_close opts a host into staying mounted through the exit window with a non-animating chrome, so content can own its own exit visuals. Hosts that do not opt in take the old path unchanged, and reduced motion always unmounts immediately. Content learns it is closing through the new ClosingScope and is_layer_closing. The flag is pushed during layout as well as paint: entity children build their trees in the layout pass, so a paint-only scope is invisible to a child view's render. The deferral window is its own ceiling — an exit_duration timer finalizes unconditionally, so there is no completion signal that can be dropped and leak a mounted view. The sheet had the same defect and takes the same shape. Fixes two chrome bugs the contract exposed: both dialog wrapper layers rendered presence.progress(1.0), which is 0 during Exiting, so a non-animating dialog went fully invisible for the whole deferral window. Timing moves onto the shared motion tokens, replacing the dialog's bespoke close duration that left only 20ms of slack over its own exit animation. Measured at 60fps: palette close from a 1-frame pop to a complete 232ms fade, sheet close from instant unmount to a 183ms slide mirroring its enter, dialogs unchanged in kind with their marginal tail-clip resolved. The palette renders the opaque fallback material while closing, because backdrop blur ignores element opacity and a wrapper fade over a blurred subtree does not fade at all. Remove that swap once the renderer honors element opacity. --- crates/ui/src/closing_scope.rs | 116 ++++++++++++++++++++++++++ crates/ui/src/command_palette/mod.rs | 3 + crates/ui/src/command_palette/view.rs | 43 ++++++++-- crates/ui/src/dialog.rs | 98 +++++++++++++++------- crates/ui/src/global_state.rs | 20 +++++ crates/ui/src/lib.rs | 2 + crates/ui/src/root.rs | 50 +++++++++-- crates/ui/src/sheet.rs | 43 +++++++--- 8 files changed, 317 insertions(+), 58 deletions(-) create mode 100644 crates/ui/src/closing_scope.rs diff --git a/crates/ui/src/closing_scope.rs b/crates/ui/src/closing_scope.rs new file mode 100644 index 00000000..df4515a4 --- /dev/null +++ b/crates/ui/src/closing_scope.rs @@ -0,0 +1,116 @@ +//! ClosingScope — exposes a root layer's closing state to its content. +//! +//! The Root keeps a closing dialog or sheet mounted for the exit window +//! ([`crate::animation::exit_duration`]) before unmounting it. This element is +//! how content inside that layer *learns* it is closing, so it can render its +//! own exit animation during that window: the layer wraps its content in a +//! `ClosingScope`, and content reads [`is_layer_closing`] during render. +//! +//! The value is pushed during layout as well as paint: entity children build +//! their element trees during the layout pass, so a paint-only scope (like the +//! reduced-motion scope) would be invisible to a child view's `render`. + +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Window, +}; + +use crate::global_state::GlobalState; + +/// Returns whether the enclosing root layer (dialog, sheet) is closing. +/// +/// Content hosted in a layer that defers its unmount (see +/// [`crate::dialog::Dialog::defer_close`]) can use this to switch to an exit +/// animation; the layer stays mounted for +/// [`crate::animation::exit_duration`], then is torn down regardless — the +/// window is the ceiling, there is no completion signal to forget. +pub fn is_layer_closing(cx: &App) -> bool { + GlobalState::global(cx).layer_closing() +} + +/// A wrapper element that provides the layer-closing context to its children. +pub struct ClosingScope { + closing: bool, + child: Option, +} + +impl ClosingScope { + /// Wrap `child`, exposing `closing` to everything inside it via + /// [`is_layer_closing`]. + pub fn new(closing: bool, child: impl IntoElement) -> Self { + Self { + closing, + child: Some(child.into_any_element()), + } + } + + fn scoped(&self, cx: &mut App, f: impl FnOnce(&mut App) -> R) -> R { + GlobalState::global_mut(cx).push_layer_closing(self.closing); + let result = f(cx); + GlobalState::global_mut(cx).pop_layer_closing(); + result + } +} + +impl IntoElement for ClosingScope { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Layout state for ClosingScope, holds the child element. +pub struct ClosingScopeLayoutState { + child: AnyElement, +} + +impl Element for ClosingScope { + type RequestLayoutState = ClosingScopeLayoutState; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut child = self.child.take().expect("ClosingScope child already taken"); + let layout_id = self.scoped(cx, |cx| child.request_layout(window, cx)); + (layout_id, ClosingScopeLayoutState { child }) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.scoped(cx, |cx| request_layout.child.prepaint(window, cx)); + } + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.scoped(cx, |cx| request_layout.child.paint(window, cx)); + } +} diff --git a/crates/ui/src/command_palette/mod.rs b/crates/ui/src/command_palette/mod.rs index ec20658f..890f67b1 100644 --- a/crates/ui/src/command_palette/mod.rs +++ b/crates/ui/src/command_palette/mod.rs @@ -156,6 +156,9 @@ impl CommandPalette { .overlay_closable(true) .keyboard(true) .animate(false) + // No dialog-chrome animation, but keep the dialog mounted + // through the exit window so the palette can animate out. + .defer_close(true) .appearance(false) .close_button(false) .p_0() diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index d5700752..4a9b319a 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -5,15 +5,15 @@ use super::reveal_delay; use super::state::{CommandPaletteEvent, CommandPaletteState}; use super::types::{CommandPaletteConfig, MatchedItem}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::animation::{fade_animation, spring_animation, standard_animation}; +use crate::animation::{exit_animation, fade_animation, spring_animation, standard_animation}; use crate::global_state::GlobalState; use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; use crate::spinner::Spinner; use crate::{ ActiveTheme, FlyoutTokens, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, - VirtualListScrollHandle, WindowExt as _, flyout_secondary_foreground, h_flex, v_flex, - v_virtual_list, + VirtualListScrollHandle, WindowExt as _, flyout_secondary_foreground, h_flex, is_layer_closing, + v_flex, v_virtual_list, }; use gpui::{ AnimationExt, App, AppContext as _, Context, ElementId, Entity, FocusHandle, Focusable, @@ -635,7 +635,19 @@ impl Render for CommandPaletteView { px(0.0) }; - let surface_ctx = SurfaceContext::new(cx); + // While the host dialog defers unmount for our exit animation, render + // the opaque (blur-off) material: the renderer cannot yet fade a + // backdrop-blur subtree through a wrapper opacity — the blur layer + // ignores it — so the exit fades the opaque fallback instead. Remove + // when blur honors element opacity in gpui. + let closing = is_layer_closing(cx); + let surface_ctx = if closing { + SurfaceContext { + blur_enabled: false, + } + } else { + SurfaceContext::new(cx) + }; let content = v_flex() .key_context(CONTEXT) @@ -788,10 +800,25 @@ impl Render for CommandPaletteView { .h(expanded_height) .w(px(config.width)); - // Enter motion for the surface itself (it previously popped in while only - // the results animated). Mount-only: the view is created on open, so the - // animation plays exactly once. Exit is owned by the host that removes - // the view, so there is no exit animation here. + // Enter motion for the surface itself (it previously popped in while + // only the results animated). Mount-only: the view is created on open, + // so the animation plays exactly once. On close the host dialog defers + // unmount (`defer_close`) and flags the subtree via `is_layer_closing`, + // which is when the exit below runs. + if closing { + return if let Some(anim) = exit_animation(&motion, reduced_motion) { + div() + .child(surface) + .with_animation("command-palette-exit", anim, |el, delta| { + let progress = (1.0 - delta).clamp(0.0, 1.0); + el.opacity(progress).translate_y(px(4.0 * delta)) + }) + .into_any_element() + } else { + surface.into_any_element() + }; + } + let open_fade_anim = standard_animation(&motion, reduced_motion); let open_transform_anim = spring_animation(&motion, reduced_motion); let transformed = if let Some(anim) = open_transform_anim { diff --git a/crates/ui/src/dialog.rs b/crates/ui/src/dialog.rs index ea721b69..c09007a3 100644 --- a/crates/ui/src/dialog.rs +++ b/crates/ui/src/dialog.rs @@ -1,4 +1,4 @@ -use std::{rc::Rc, time::Duration}; +use std::rc::Rc; use gpui::{ AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Edges, ElementId, @@ -9,7 +9,7 @@ use gpui::{ use rust_i18n::t; use crate::{ - ActiveTheme as _, FocusTrapElement as _, IconName, Root, Sizable as _, StyledExt, + ActiveTheme as _, ClosingScope, FocusTrapElement as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _, actions::{Cancel, Confirm}, animation::{ @@ -52,16 +52,6 @@ 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 - .enter_duration_ms - .max(motion.exit_duration_ms) - .max(motion.fade_duration_ms), - )) -} - type RenderButtonFn = Box AnyElement>; type FooterFn = Box Vec>; @@ -131,6 +121,7 @@ pub struct Dialog { overlay_closable: bool, keyboard: bool, animate: bool, + defer_close: bool, appearance: bool, /// This will be change when open the dialog, the focus handle is create when open the dialog. @@ -164,6 +155,7 @@ impl Dialog { overlay: true, keyboard: true, animate: true, + defer_close: false, appearance: true, id: 0, layer_ix: 0, @@ -182,6 +174,14 @@ impl Dialog { self.animate && !GlobalState::global(cx).reduced_motion() } + /// Whether closing should keep the dialog mounted for the exit window + /// before unmounting: true when the chrome animates, or when the opener + /// requested [`Dialog::defer_close`] for content-driven exits. Reduced + /// motion always unmounts immediately. + pub(crate) fn should_defer_close(&self, cx: &App) -> bool { + (self.animate || self.defer_close) && !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()); @@ -325,6 +325,19 @@ impl Dialog { self } + /// Keep the dialog mounted through the exit window while closing, so + /// content can run its own exit animation even when the dialog chrome does + /// not animate (`animate(false)`). + /// + /// Content learns the closing state via [`crate::is_layer_closing`]. The + /// window is [`crate::animation::exit_duration`] and is also the ceiling — + /// the dialog is torn down when it elapses whether or not the content + /// finished; there is no completion signal to leak. + pub fn defer_close(mut self, defer: bool) -> Self { + self.defer_close = defer; + self + } + /// Set whether the dialog renders its default background, border, radius, and shadow. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; @@ -364,6 +377,11 @@ impl RenderOnce for Dialog { let has_title = self.title.is_some(); let reduced_motion = GlobalState::global(cx).reduced_motion(); let should_animate = self.should_animate(cx); + // The presence runs whenever the close is deferred — including + // content-driven exits with a non-animating chrome — so the Exiting + // phase exists for the whole deferral window. + let presence_active = self.should_defer_close(cx); + let closing = self.closing; let target_open = !self.closing; let appearance = self.appearance; @@ -461,19 +479,13 @@ impl RenderOnce for Dialog { paddings.top -= px(6.); } - let open_duration = Duration::from_millis(u64::from(if reduced_motion { - cx.theme().motion.enter_duration_ms - } else { - cx.theme() - .motion - .enter_duration_ms - .max(cx.theme().motion.enter_duration_ms) - })); - let close_duration = close_animation_duration(cx); + let open_duration = crate::animation::enter_duration(&cx.theme().motion); + let close_duration = crate::animation::exit_duration(&cx.theme().motion); + let presence = keyed_presence( SharedString::from(format!("dialog-{}-presence", dialog_id)), target_open, - should_animate, + presence_active, open_duration, close_duration, PresenceOptions { @@ -635,13 +647,19 @@ impl RenderOnce for Dialog { })) .child( div().flex_1().overflow_hidden().child( - // Body - v_flex() - .size_full() - .overflow_y_scrollbar() - .pl(paddings.left) - .pr(paddings.right) - .children(self.children), + // Body. ClosingScope exposes the closing + // state so content can run its own exit + // animation during the deferral window + // (see `Dialog::defer_close`). + ClosingScope::new( + closing, + v_flex() + .size_full() + .overflow_y_scrollbar() + .pl(paddings.left) + .pr(paddings.right) + .children(self.children), + ), ), ) .when_some(self.footer, |this, footer| { @@ -662,7 +680,16 @@ impl RenderOnce for Dialog { }) .map(move |this| { if !should_animate || !transition_active { - let progress = presence.progress(1.0); + // A non-animating chrome stays fully present + // while a content-driven deferred close plays + // out; the content owns the exit visuals. + let progress = if !should_animate + && matches!(presence.phase, PresencePhase::Exiting) + { + 1.0 + } else { + presence.progress(1.0) + }; this.when(appearance, |this| { this.shadow(dialog_shadow(progress)) }) @@ -730,7 +757,16 @@ impl RenderOnce for Dialog { ) .map(move |this| { if !should_animate || !transition_active { - this.opacity(presence.progress(1.0)).into_any_element() + // Hold the layer visible through a content-driven + // deferred close (see `Dialog::defer_close`). + let progress = if !should_animate + && matches!(presence.phase, PresencePhase::Exiting) + { + 1.0 + } else { + presence.progress(1.0) + }; + this.opacity(progress).into_any_element() } else { let fade_animation = if matches!(presence.phase, PresencePhase::Entering) { diff --git a/crates/ui/src/global_state.rs b/crates/ui/src/global_state.rs index 4c037e7b..ddf403e5 100644 --- a/crates/ui/src/global_state.rs +++ b/crates/ui/src/global_state.rs @@ -14,6 +14,8 @@ pub struct GlobalState { blur_enabled_stack: Vec, /// Stack for reduced_motion context values. reduced_motion_stack: Vec, + /// Stack for layer_closing context values (see [`crate::ClosingScope`]). + layer_closing_stack: Vec, /// Stack for floating inset values. floating_inset_stack: Vec, } @@ -24,6 +26,7 @@ impl GlobalState { text_view_state_stack: Vec::new(), blur_enabled_stack: vec![true], // Default to enabled reduced_motion_stack: vec![false], // Default to not reduced + layer_closing_stack: vec![false], // Default to not closing floating_inset_stack: vec![px(4.0)], } } @@ -91,6 +94,23 @@ impl GlobalState { } } + /// Returns whether the enclosing root layer (dialog, sheet) is closing. + pub fn layer_closing(&self) -> bool { + self.layer_closing_stack.last().copied().unwrap_or(false) + } + + /// Push a layer_closing value onto the context stack. + pub(crate) fn push_layer_closing(&mut self, closing: bool) { + self.layer_closing_stack.push(closing); + } + + /// Pop a layer_closing value from the context stack. + pub(crate) fn pop_layer_closing(&mut self) { + if self.layer_closing_stack.len() > 1 { + self.layer_closing_stack.pop(); + } + } + /// Returns the current floating inset from the context stack. pub fn floating_inset(&self) -> Pixels { self.floating_inset_stack.last().copied().unwrap_or(px(4.0)) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b801bb91..c6896666 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -2,6 +2,7 @@ use gpui::{App, SharedString}; use std::ops::Deref; mod anchored; +mod closing_scope; mod element_ext; mod event; mod floating_sidebar; @@ -81,6 +82,7 @@ pub mod tree; pub use crate::Disableable; pub(crate) use anchored::*; +pub use closing_scope::{ClosingScope, is_layer_closing}; pub use element_ext::ElementExt; pub use event::InteractiveElementExt; pub use floating_sidebar::*; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index e593ba82..b2a71918 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -1,7 +1,8 @@ use crate::{ ActiveTheme, Anchor, ElementExt, Placement, StyledExt, - dialog::{Dialog, close_animation_duration}, + dialog::Dialog, focus_trap::FocusTrapManager, + global_state::GlobalState, input::InputState, notification::{Notification, NotificationList}, sheet::Sheet, @@ -44,6 +45,7 @@ pub(crate) struct ActiveSheet { /// The previous focused handle before opening the Sheet. previous_focused_handle: Option, placement: Placement, + closing: bool, builder: Rc Sheet + 'static>, } @@ -170,6 +172,7 @@ impl Root { sheet = (active_sheet.builder)(sheet, window, cx); sheet.focus_handle = active_sheet.focus_handle.clone(); sheet.placement = active_sheet.placement; + sheet.closing = active_sheet.closing; let size = sheet.size; @@ -286,19 +289,21 @@ impl Root { .and_then(|h| h.upgrade()); let dialog_id = active_dialog.id; - let should_animate_close = { + let should_defer_close = { let mut dialog = Dialog::new(window, cx); dialog = (active_dialog.builder)(dialog, window, cx); - dialog.should_animate(cx) + dialog.should_defer_close(cx) }; - if !should_animate_close { + if !should_defer_close { self.finalize_dialog_close(dialog_id, restore_focus, window, cx); return; } active_dialog.closing = true; - let duration = close_animation_duration(cx); + // The deferral window doubles as the ceiling: teardown happens when it + // elapses whether or not the content finished animating. + let duration = crate::animation::exit_duration(&cx.theme().motion); window .spawn(cx, async move |cx| { cx.background_executor().timer(duration).await; @@ -346,12 +351,13 @@ impl Root { focus_handle, previous_focused_handle, placement, + closing: false, builder: Rc::new(build), }); cx.notify(); } - pub fn close_sheet(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + fn finalize_sheet_close(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { self.focused_input = None; if let Some(previous_handle) = self .active_sheet @@ -365,6 +371,38 @@ impl Root { cx.notify(); } + pub fn close_sheet(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + let Some(active_sheet) = self.active_sheet.as_mut() else { + return; + }; + if active_sheet.closing { + return; + } + + if GlobalState::global(cx).reduced_motion() { + self.finalize_sheet_close(window, cx); + return; + } + + // Keep the sheet mounted for the exit window so it can slide out; the + // window is also the ceiling — teardown happens when it elapses. + active_sheet.closing = true; + let duration = crate::animation::exit_duration(&cx.theme().motion); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(duration).await; + _ = cx.update(|window, cx| { + Root::update(window, cx, |root, window, cx| { + if root.active_sheet.as_ref().is_some_and(|s| s.closing) { + root.finalize_sheet_close(window, cx); + } + }); + }); + }) + .detach(); + cx.notify(); + } + pub fn push_notification( &mut self, note: impl Into, diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs index b75d5f6d..8266f6a7 100644 --- a/crates/ui/src/sheet.rs +++ b/crates/ui/src/sheet.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use gpui::{ AnimationExt as _, AnyElement, App, ClickEvent, DefiniteLength, DismissEvent, Edges, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, RenderOnce, StyleRefinement, Styled, Window, WindowControlArea, - anchored, div, point, prelude::FluentBuilder as _, px, + ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, + WindowControlArea, anchored, div, point, prelude::FluentBuilder as _, px, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -13,7 +13,7 @@ use crate::{ ActiveTheme, FocusTrapElement as _, IconName, Placement, Sizable, StyledExt as _, WindowExt as _, actions::Cancel, - animation::enter_animation, + animation::{enter_animation, exit_animation}, button::{Button, ButtonVariants as _}, dialog::overlay_color, global_state::GlobalState, @@ -57,6 +57,7 @@ pub struct Sheet { children: Vec, overlay: bool, overlay_closable: bool, + pub(crate) closing: bool, } impl Sheet { @@ -73,6 +74,7 @@ impl Sheet { children: Vec::new(), overlay: true, overlay_closable: true, + closing: false, on_close: Rc::new(|_, _, _| {}), } } @@ -147,7 +149,14 @@ impl RenderOnce for Sheet { let top = cx.theme().sheet.margin_top; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = &cx.theme().motion; - let slide_animation = enter_animation(motion, reduced_motion); + let closing = self.closing; + // While closing, the Root keeps the sheet mounted for the exit window + // and this reversed slide plays; the window is also the ceiling. + let slide_animation = if closing { + exit_animation(motion, reduced_motion) + } else { + enter_animation(motion, reduced_motion) + }; let on_close = self.on_close.clone(); let base_size = window.text_style().font_size; @@ -281,15 +290,23 @@ impl RenderOnce for Sheet { }) .map(move |this| match slide_animation { Some(anim) => this - .with_animation("slide", anim, 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), - }) - }) + .with_animation( + SharedString::from(format!("slide-{}", u8::from(closing))), + anim, + move |this, delta| { + // Enter slides in from the edge; + // closing runs the same path back out. + let progress = + if closing { 1.0 - delta } else { delta }; + let y = px(-100.) + progress * 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(), None => this.into_any_element(), }), From 740d955e7bd608ffaaaf673ebaebf0b813fef15c Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 11:49:24 -0700 Subject: [PATCH 15/20] refactor(animation): share one presence helper across flyouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The animation pass left popover, context menu, submenu, select and hover card running roughly ninety lines each of open/close wiring that had converged on identical behavior. Identical was the precondition for extracting it; before that pass they genuinely differed. Add flyout_presence for the shared enter/exit windows and unmount grace, FlyoutSlide for the slide geometry, and flyout_motion for the one open and close motion. Slide distances stay per-surface parameters rather than being normalized to a single value, because the goal here is to remove duplication without retuning anything. The command palette deliberately stays off the helper. Its motion is dialog-hosted — a mount-only enter and a defer_close-window exit driven by is_layer_closing, with no presence of its own — so folding it in would mean escape hatches for a surface that does not share the shape. Every surface re-recorded at 60fps and compared against the pre-refactor figures at matched noise floors. Submenu, select and hover card open measured frame-exact; the rest sit within one frame at 60Hz, which is capture variance rather than behavior, and the formulas are unchanged. Net 165 lines removed. --- crates/ui/src/animation.rs | 148 ++++++++++++++++++++++++++++- crates/ui/src/hover_card.rs | 70 ++------------ crates/ui/src/menu/context_menu.rs | 84 +++------------- crates/ui/src/menu/popup_menu.rs | 101 +++----------------- crates/ui/src/popover.rs | 81 ++-------------- crates/ui/src/select.rs | 91 ++++-------------- 6 files changed, 205 insertions(+), 370 deletions(-) diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index 188aa7c0..11db1cb3 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -1,7 +1,10 @@ -use gpui::{Animation, App, SharedString, Window, spring}; +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, Axis, IntoElement, ParentElement as _, + SharedString, Styled as _, Window, div, px, spring, +}; use std::time::Duration; -use crate::{ThemeMotion, global_state::GlobalState}; +use crate::{ActiveTheme as _, ThemeMotion, global_state::GlobalState}; /// Returns whether motion should be reduced for the current component context. pub fn reduced_motion(cx: &App) -> bool { @@ -334,6 +337,147 @@ pub fn keyed_presence( } } +/// The slide a flyout travels as it enters and exits, in logical pixels. +/// +/// `direction` is +1.0 when the surface opens away from its trigger along the +/// positive axis (downward / rightward) and -1.0 when flipped. +#[derive(Clone, Copy, Debug)] +pub struct FlyoutSlide { + pub axis: Axis, + pub direction: f32, + pub enter_distance: f32, + pub exit_distance: f32, +} + +impl FlyoutSlide { + /// Vertical slide with the shared flyout distances (4px in, 2px out). + pub fn vertical(direction: f32) -> Self { + Self { + axis: Axis::Vertical, + direction, + enter_distance: 4.0, + exit_distance: 2.0, + } + } + + /// Horizontal slide with the submenu distances (6px both ways). + pub fn horizontal(direction: f32) -> Self { + Self { + axis: Axis::Horizontal, + direction, + enter_distance: 6.0, + exit_distance: 6.0, + } + } + + /// Override the exit travel distance. + pub fn exit_distance(mut self, distance: f32) -> Self { + self.exit_distance = distance; + self + } + + fn offset(&self, distance: f32, progress: f32) -> gpui::Pixels { + px(distance * (1.0 - progress) * self.direction) + } +} + +/// The shared presence for flyout surfaces: enter/exit windows come from the +/// motion tokens (so an exit always outlives its animation) and reduced motion +/// applies the final state immediately. +pub fn flyout_presence( + key: SharedString, + open: bool, + options: PresenceOptions, + window: &mut Window, + cx: &mut App, +) -> PresenceTransition { + let motion = cx.theme().motion.clone(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + keyed_presence( + key, + open, + !reduced_motion, + enter_duration(&motion), + exit_duration(&motion), + options, + window, + cx, + ) +} + +/// The one flyout open/close motion, shared by popover, context menu, submenu, +/// select popup and hover card: enter is the spring sliding from the trigger +/// plus a standard fade, exit is a standard fade sliding back. +/// +/// Wrap the surface element with this at the point where its host already +/// paints it (inside any `deferred`/`anchored` — wrappers outside `anchored` +/// have no visual effect). `id_base` scopes the animation element ids per +/// surface instance. +pub fn flyout_motion( + id_base: impl Into, + presence: PresenceTransition, + slide: FlyoutSlide, + motion: &ThemeMotion, + reduced_motion: bool, + el: impl IntoElement, +) -> AnyElement { + if !presence.transition_active() { + return el.into_any_element(); + } + + let id_base = id_base.into(); + if matches!(presence.phase, PresencePhase::Entering) { + let transformed = if let Some(anim) = spring_animation(motion, reduced_motion) { + div() + .child(el.into_any_element()) + .with_animation( + SharedString::from(format!("{}-open-transform", id_base)), + anim, + move |el, delta| match slide.axis { + Axis::Vertical => el.translate_y(slide.offset(slide.enter_distance, delta)), + Axis::Horizontal => { + el.translate_x(slide.offset(slide.enter_distance, delta)) + } + }, + ) + .into_any_element() + } else { + el.into_any_element() + }; + if let Some(anim) = standard_animation(motion, reduced_motion) { + div() + .child(transformed) + .with_animation( + SharedString::from(format!("{}-open-fade", id_base)), + anim, + move |el, delta| el.opacity(presence.progress(delta).clamp(0.0, 1.0)), + ) + .into_any_element() + } else { + transformed + } + } else if let Some(anim) = exit_animation(motion, reduced_motion) { + div() + .child(el.into_any_element()) + .with_animation( + SharedString::from(format!("{}-close", id_base)), + anim, + move |el, delta| { + let progress = presence.progress(delta).clamp(0.0, 1.0); + let offset = slide.offset(slide.exit_distance, progress); + let el = el.opacity(progress); + match slide.axis { + Axis::Vertical => el.translate_y(offset), + Axis::Horizontal => el.translate_x(offset), + } + }, + ) + .into_any_element() + } else { + el.into_any_element() + } +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/ui/src/hover_card.rs b/crates/ui/src/hover_card.rs index f0a4d4a9..0f1b6519 100644 --- a/crates/ui/src/hover_card.rs +++ b/crates/ui/src/hover_card.rs @@ -1,18 +1,14 @@ use gpui::{ - AnimationExt as _, AnyElement, App, Bounds, Context, ElementId, InteractiveElement as _, - IntoElement, ParentElement, Pixels, Render, RenderOnce, SharedString, - StatefulInteractiveElement, StyleRefinement, Styled, Task, Window, div, - prelude::FluentBuilder as _, px, + AnyElement, App, Bounds, Context, ElementId, InteractiveElement as _, IntoElement, + ParentElement, Pixels, Render, RenderOnce, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Task, Window, div, prelude::FluentBuilder as _, }; use std::rc::Rc; use std::time::Duration; use crate::{ ActiveTheme as _, Anchor, ElementExt, StyledExt as _, - animation::{ - self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, - standard_animation, - }, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, global_state::GlobalState, popover::Popover, }; @@ -306,12 +302,9 @@ impl RenderOnce for HoverCard { // previously the card popped in and out with no transition at all. let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - let presence = keyed_presence( + let presence = flyout_presence( SharedString::from(format!("hover-card-presence-{}", state.entity_id())), open, - !reduced_motion, - animation::enter_duration(&motion), - animation::exit_duration(&motion), PresenceOptions::default(), window, cx, @@ -320,9 +313,6 @@ impl RenderOnce for HoverCard { return root; } - let open_fade_anim = standard_animation(&motion, reduced_motion); - let open_transform_anim = spring_animation(&motion, reduced_motion); - let close_anim = exit_animation(&motion, reduced_motion); let vertical_direction = if matches!( self.anchor, Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight @@ -331,6 +321,7 @@ impl RenderOnce for HoverCard { } else { 1.0 }; + let slide = FlyoutSlide::vertical(vertical_direction); let popover_content = Popover::render_popover_content(self.anchor, self.appearance, window, cx) @@ -344,54 +335,7 @@ impl RenderOnce for HoverCard { .children(self.children) .refine_style(&self.style) .map(move |el| { - if !presence.transition_active() { - el.into_any_element() - } else if matches!(presence.phase, PresencePhase::Entering) { - let transformed = if let Some(anim) = open_transform_anim { - div() - .child(el) - .with_animation( - SharedString::from("hover-card-open-transform"), - anim, - move |el, delta| { - el.translate_y(px(4.0 * (1.0 - delta) * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - }; - if let Some(anim) = open_fade_anim { - div() - .child(transformed) - .with_animation( - SharedString::from("hover-card-open-fade"), - anim, - move |el, delta| { - el.opacity(presence.progress(delta).clamp(0.0, 1.0)) - }, - ) - .into_any_element() - } else { - transformed - } - } else if let Some(anim) = close_anim { - div() - .child(el) - .with_animation( - SharedString::from("hover-card-close"), - anim, - move |el, delta| { - let progress = presence.progress(delta).clamp(0.0, 1.0); - el.opacity(progress).translate_y(px(2.0 - * (1.0 - progress) - * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - } + flyout_motion("hover-card", presence, slide, &motion, reduced_motion, el) }); root.child(Popover::render_popover( diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 72021b4f..bc2a02fc 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -1,19 +1,15 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnimationExt as _, AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, - Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, SharedString, - StyleRefinement, Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, - px, + AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, Focusable, + GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement, + MouseButton, MouseDownEvent, ParentElement, Pixels, Point, SharedString, StyleRefinement, + Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px, }; use crate::{ ActiveTheme, - animation::{ - self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, - standard_animation, - }, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, global_state::GlobalState, menu::PopupMenu, }; @@ -176,12 +172,9 @@ impl Element for ContextMenu< let mut menu_element = None; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let presence = keyed_presence( + let presence = flyout_presence( SharedString::from(format!("context-menu-presence-{:?}", this.id)), open, - !reduced_motion, - animation::enter_duration(&motion), - animation::exit_duration(&motion), PresenceOptions::default(), window, cx, @@ -194,9 +187,6 @@ impl Element for ContextMenu< .unwrap_or(false); if has_menu_item { - let open_opacity_animation = standard_animation(&motion, reduced_motion); - let open_transform_animation = spring_animation(&motion, reduced_motion); - let close_animation = exit_animation(&motion, reduced_motion); let positioned_menu = anchored() .position(position) .snap_to_window_with_margin(px(8.)) @@ -208,60 +198,14 @@ impl Element for ContextMenu< this.child(menu) }); - let animated_menu = if presence.transition_active() { - if matches!(presence.phase, PresencePhase::Entering) { - let transformed = if let Some(animation) = open_transform_animation - { - div() - .child(positioned_menu) - .with_animation( - "context-menu-enter-transform", - animation, - |menu, delta| menu.translate_y(px(4.0 * (1.0 - delta))), - ) - .into_any_element() - } else { - div().child(positioned_menu).into_any_element() - }; - - if let Some(animation) = open_opacity_animation { - div() - .child(transformed) - .with_animation( - "context-menu-enter-opacity", - animation, - move |menu, delta| { - menu.opacity( - presence.progress(delta).clamp(0.0, 1.0), - ) - }, - ) - .into_any_element() - } else { - div() - .child(transformed) - .opacity(presence.progress(1.0)) - .into_any_element() - } - } else if let Some(animation) = close_animation { - div() - .child(positioned_menu) - .with_animation( - "context-menu-exit", - animation, - move |menu, delta| { - let progress = presence.progress(delta).clamp(0.0, 1.0); - menu.opacity(progress) - .translate_y(px(2.0 * (1.0 - progress))) - }, - ) - .into_any_element() - } else { - div().child(positioned_menu).into_any_element() - } - } else { - div().child(positioned_menu).into_any_element() - }; + let animated_menu = flyout_motion( + "context-menu", + presence, + FlyoutSlide::vertical(1.0), + &motion, + reduced_motion, + positioned_menu, + ); menu_element = Some( deferred( diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index a8c0951f..414b2a14 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1,9 +1,6 @@ use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::actions::{SelectLeft, SelectRight}; -use crate::animation::{ - self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, - standard_animation, -}; +use crate::animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}; use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; @@ -14,11 +11,10 @@ use crate::{ }; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ - Action, AnimationExt as _, 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, + 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, }; use gpui::{ClickEvent, MouseDownEvent, OwnedMenuItem, Point, Subscription}; use std::rc::Rc; @@ -1280,23 +1276,17 @@ impl PopupMenu { } => { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let submenu_presence = keyed_presence( + let submenu_presence = flyout_presence( SharedString::from(format!( "popup-menu-submenu-presence-{}-{}", cx.entity().entity_id(), ix )), selected, - !reduced_motion, - animation::enter_duration(&motion), - animation::exit_duration(&motion), PresenceOptions::default(), window, cx, ); - let submenu_open_opacity_anim = standard_animation(&motion, reduced_motion); - let submenu_open_transform_anim = spring_animation(&motion, reduced_motion); - let submenu_close_anim = exit_animation(&motion, reduced_motion); this.selected(selected) .disabled(*disabled) @@ -1356,77 +1346,14 @@ impl PopupMenu { // The motion wraps the *content inside* `anchored`: // wrappers outside it have no visual effect, because // anchored repositions its children after layout. - let animated_inner = if submenu_presence.transition_active() { - if matches!(submenu_presence.phase, PresencePhase::Entering) { - let transformed = - if let Some(anim) = submenu_open_transform_anim { - div() - .child(submenu_inner) - .with_animation( - SharedString::from(format!( - "popup-submenu-open-transform-{}", - ix - )), - anim, - move |el, delta| { - el.translate_x(px(direction - * 6.0 - * (1.0 - delta))) - }, - ) - .into_any_element() - } else { - submenu_inner.into_any_element() - }; - if let Some(anim) = submenu_open_opacity_anim { - div() - .child(transformed) - .with_animation( - SharedString::from(format!( - "popup-submenu-open-opacity-{}", - ix - )), - anim, - move |el, delta| { - el.opacity( - submenu_presence - .progress(delta) - .clamp(0.0, 1.0), - ) - }, - ) - .into_any_element() - } else { - div() - .child(transformed) - .opacity(submenu_presence.progress(1.0)) - .into_any_element() - } - } else if let Some(anim) = submenu_close_anim { - div() - .child(submenu_inner) - .with_animation( - SharedString::from(format!( - "popup-submenu-close-motion-{}", - ix - )), - anim, - move |el, delta| { - let progress = submenu_presence - .progress(delta) - .clamp(0.0, 1.0); - el.opacity(progress).translate_x(px(direction - * 6.0 - * (1.0 - progress))) - }, - ) - .into_any_element() - } else { - submenu_inner.into_any_element() - } - } else { - submenu_inner.into_any_element() - }; + let animated_inner = flyout_motion( + SharedString::from(format!("popup-submenu-{}", ix)), + submenu_presence, + FlyoutSlide::horizontal(direction), + &motion, + reduced_motion, + submenu_inner, + ); gpui::deferred( anchored() diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 1e99d9da..626f51ed 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,8 +1,8 @@ use gpui::{ - 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, + 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; @@ -11,10 +11,7 @@ use crate::{ SurfacePreset, actions::Cancel, anchored, - animation::{ - self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, - standard_animation, - }, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, flyout_primary_foreground, global_state::GlobalState, v_flex, @@ -402,14 +399,9 @@ impl RenderOnce for Popover { let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - // The presence windows are the same tokens the animations run on, so an - // exit always plays to completion before its element unmounts. - let presence = keyed_presence( + let presence = flyout_presence( SharedString::from(format!("popover-presence-{}", popover_id)), open, - !reduced_motion, - animation::enter_duration(&motion), - animation::exit_duration(&motion), PresenceOptions { animate_on_mount: true, }, @@ -420,9 +412,6 @@ impl RenderOnce for Popover { return el; } - let open_fade_anim = standard_animation(&motion, reduced_motion); - let open_transform_anim = spring_animation(&motion, reduced_motion); - let close_anim = exit_animation(&motion, reduced_motion); let vertical_direction = if matches!( self.anchor, Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight @@ -431,6 +420,7 @@ impl RenderOnce for Popover { } else { 1.0 }; + let slide = FlyoutSlide::vertical(vertical_direction).exit_distance(4.0); let popover_content = Self::render_popover_content(self.anchor, self.appearance, window, cx) @@ -454,62 +444,7 @@ impl RenderOnce for Popover { }) .refine_style(&self.style) .map(move |el| { - if !presence.transition_active() { - el.opacity(presence.progress(1.0)) - .translate_y(px(0.0)) - .into_any_element() - } else if matches!(presence.phase, PresencePhase::Entering) { - let translated = if let Some(anim) = open_transform_anim { - div() - .child(el) - .with_animation( - SharedString::from("popover-open-transform"), - anim, - move |el, delta| { - el.translate_y(px(4.0 * (1.0 - delta) * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - }; - if let Some(anim) = open_fade_anim { - div() - .child(translated) - .with_animation( - SharedString::from("popover-open-fade"), - anim, - move |el, delta| { - let opacity = presence.progress(delta).clamp(0.0, 1.0); - el.opacity(opacity) - }, - ) - .into_any_element() - } else { - div() - .child(translated) - .opacity(presence.progress(1.0)) - .into_any_element() - } - } else { - if let Some(anim) = close_anim { - el.with_animation( - SharedString::from(format!( - "popover-close-motion-{}", - u8::from(matches!(presence.phase, PresencePhase::Entering)) - )), - anim, - move |el, delta| { - let progress = presence.progress(delta).clamp(0.0, 1.0); - let offset = px(4.0 * (1.0 - progress) * vertical_direction); - el.opacity(progress).translate_y(offset) - }, - ) - .into_any_element() - } else { - el.into_any_element() - } - } + flyout_motion("popover", presence, slide, &motion, reduced_motion, el) }); el.child(Self::render_popover( diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index b5228e06..26416081 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -1,9 +1,9 @@ use gpui::{ - AbsoluteLength, Anchor, 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, point, prelude::FluentBuilder, px, rems, + AbsoluteLength, Anchor, 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, point, prelude::FluentBuilder, px, rems, }; use rust_i18n::t; @@ -11,10 +11,7 @@ use crate::{ ActiveTheme, Disableable, ElementExt as _, FlyoutTokens, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, actions::{Cancel, Confirm, SelectDown, SelectUp}, - animation::{ - self, PresenceOptions, PresencePhase, exit_animation, keyed_presence, spring_animation, - standard_animation, - }, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, global_state::GlobalState, h_flex, input::clear_button, @@ -927,17 +924,12 @@ where .map(|this| { let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - // Presence keeps the popup mounted through its exit, and its - // windows are the same tokens the animations run on. - let presence = keyed_presence( + let presence = flyout_presence( SharedString::from(format!( "select-popup-presence-{}", cx.entity().entity_id() )), self.open, - !reduced_motion, - animation::enter_duration(&motion), - animation::exit_duration(&motion), PresenceOptions::default(), window, cx, @@ -946,8 +938,6 @@ where return this; } - // Same motion as every other flyout: spring slide from the - // trigger with a fade in, accelerate fade back out. let vertical_direction = if matches!( placement.anchor, Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter @@ -956,9 +946,7 @@ where } else { 1.0 }; - let open_fade_anim = standard_animation(&motion, reduced_motion); - let open_transform_anim = spring_animation(&motion, reduced_motion); - let close_anim = exit_animation(&motion, reduced_motion); + let slide = FlyoutSlide::vertical(vertical_direction); this.child( deferred( @@ -993,61 +981,14 @@ where this.escape(&Cancel, window, cx); })) .map(|el| { - if !presence.transition_active() { - el.into_any_element() - } else if matches!(presence.phase, PresencePhase::Entering) { - let transformed = if let Some(anim) = open_transform_anim { - div() - .child(el) - .with_animation( - "select-open-transform", - anim, - move |el, delta| { - el.translate_y(px(4.0 - * (1.0 - delta) - * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - }; - if let Some(anim) = open_fade_anim { - div() - .child(transformed) - .with_animation( - "select-open-fade", - anim, - move |el, delta| { - el.opacity( - presence - .progress(delta) - .clamp(0.0, 1.0), - ) - }, - ) - .into_any_element() - } else { - transformed - } - } else if let Some(anim) = close_anim { - div() - .child(el) - .with_animation( - "select-close", - anim, - move |el, delta| { - let progress = - presence.progress(delta).clamp(0.0, 1.0); - el.opacity(progress).translate_y(px(2.0 - * (1.0 - progress) - * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - } + flyout_motion( + "select", + presence, + slide, + &motion, + reduced_motion, + el, + ) }), ), ) From f1c24d40e8206d1ebaa0579dd9f3cc57d8fbecac Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 14:45:35 -0700 Subject: [PATCH 16/20] build(gpui): bump to backdrop blur opacity fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backdrop blur ignored element opacity: paint_backdrop_blur was the one paint method that never read it, and BackdropBlur carried no opacity field, so a flyout fading to zero kept painting a full-strength blurred backdrop until unmount and then dropped it in a single frame. Opacity is now a final-composite parameter on the primitive, deliberately invisible to planning, source capture, clustering and pyramid generation, so surfaces mid-fade at different opacities still share one cluster and one blur texture. Folding it into the surface alpha lets the blend unit interpolate against the framebuffer, which is what the sharp backdrop would have been lerped against anyway — same result, no extra bind. Measured against the three candidate mechanisms on a real 215ms exit: composite weight holds a 1.05 unevenness against 3.17 for radius scaling and 4.56 for a threshold skip, whose detail unevenness of 35.5 is the pop itself. Drops the command palette's blur-off closing workaround, which existed only because a wrapper fade over a blurred subtree did not fade. Verified on macOS. The DirectX/HLSL and wgpu paths compile only in CI — no local toolchain for those targets — so they are unverified here. --- Cargo.lock | 46 +++++++++++++-------------- Cargo.toml | 4 +-- crates/ui/src/command_palette/view.rs | 13 +------- vendor/gpui | 2 +- 4 files changed, 27 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36be076d..32659630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "gpui_util", "indexmap", @@ -1878,7 +1878,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "proc-macro2", "quote", @@ -3069,7 +3069,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "accesskit", "anyhow", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "accesskit", "accesskit_unix", @@ -3394,7 +3394,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "accesskit", "accesskit_macos", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "console_error_panic_hook", "gpui", @@ -3465,7 +3465,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "schemars", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "log", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3509,7 +3509,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "bytemuck", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "accesskit", "accesskit_windows", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "async-compression", @@ -4868,7 +4868,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "bindgen", @@ -5797,7 +5797,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "collections", "serde", @@ -6835,7 +6835,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "derive_refineable", ] @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "async-task", "backtrace", @@ -7949,7 +7949,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "heapless", "log", @@ -9334,7 +9334,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "async-fs", @@ -9373,7 +9373,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "perf", "quote", @@ -11187,7 +11187,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "anyhow", "chrono", @@ -11204,7 +11204,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" dependencies = [ "tracing", "tracing-subscriber", @@ -11215,7 +11215,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 2cc8a4d7..3247790c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,8 @@ gpui-component-macros = { path = "crates/macros", version = "0.6.0" } gpui-component-assets = { path = "crates/assets", version = "0.6.0" } story = { path = "crates/story" } -gpui = { git = "https://github.com/BumpyClock/gpui", rev = "6646f575c0dc5febbba6791df805e2b6bc2ff3de", features = ["font-kit"] } -gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "6646f575c0dc5febbba6791df805e2b6bc2ff3de", features = ["font-kit"] } +gpui = { git = "https://github.com/BumpyClock/gpui", rev = "d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f", features = ["font-kit"] } +gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f", features = ["font-kit"] } gpui-macros = "0.2.2" sum-tree = { version = "0.2.0", package = "zed-sum-tree" } # reqwest = { version = "0.12.15-zed", package = "zed-reqwest" } diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 4a9b319a..6d7c6619 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -635,19 +635,8 @@ impl Render for CommandPaletteView { px(0.0) }; - // While the host dialog defers unmount for our exit animation, render - // the opaque (blur-off) material: the renderer cannot yet fade a - // backdrop-blur subtree through a wrapper opacity — the blur layer - // ignores it — so the exit fades the opaque fallback instead. Remove - // when blur honors element opacity in gpui. let closing = is_layer_closing(cx); - let surface_ctx = if closing { - SurfaceContext { - blur_enabled: false, - } - } else { - SurfaceContext::new(cx) - }; + let surface_ctx = SurfaceContext::new(cx); let content = v_flex() .key_context(CONTEXT) diff --git a/vendor/gpui b/vendor/gpui index 6646f575..d2b94c15 160000 --- a/vendor/gpui +++ b/vendor/gpui @@ -1 +1 @@ -Subproject commit 6646f575c0dc5febbba6791df805e2b6bc2ff3de +Subproject commit d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f From 66f1ee72797770322bda3b877b7617037d6c6650 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 14:51:51 -0700 Subject: [PATCH 17/20] fix(sheet): slide the panel its full extent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slide travel was a fixed 100px regardless of panel size, so a default 350px sheet appeared already 71% of the way in and then settled the last 100px — and once the exit mirrored the enter, it left the same way. Derive travel from the panel's own extent along its placement axis, so it enters from off-screen and leaves completely. Also give the notification's dismiss timer the same 33ms unmount grace the other layer surfaces use. Presence timers and the animation clock do not start on the same frame, so an exit window equal to the animation clips its final frame. --- crates/ui/src/notification.rs | 2 +- crates/ui/src/sheet.rs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index d86498c2..4e4654e3 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -255,7 +255,7 @@ impl Notification { let dismiss_duration = if reduced_motion { Duration::ZERO } else { - Duration::from_millis(u64::from(cx.theme().motion.exit_duration_ms)) + crate::animation::exit_duration(&cx.theme().motion) }; cx.spawn(async move |view, cx| { diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs index 8266f6a7..07163dca 100644 --- a/crates/ui/src/sheet.rs +++ b/crates/ui/src/sheet.rs @@ -1,9 +1,9 @@ use std::rc::Rc; use gpui::{ - AnimationExt as _, AnyElement, App, ClickEvent, DefiniteLength, DismissEvent, Edges, - EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, + AbsoluteLength, AnimationExt as _, AnyElement, App, ClickEvent, DefiniteLength, DismissEvent, + Edges, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, WindowControlArea, anchored, div, point, prelude::FluentBuilder as _, px, }; use schemars::JsonSchema; @@ -161,6 +161,17 @@ impl RenderOnce for Sheet { let base_size = window.text_style().font_size; let rem_size = window.rem_size(); + // Slide the panel's full extent along its placement axis. A fixed + // travel shorter than the panel makes it appear part-way in and then + // settle, rather than entering from off-screen. + let travel = self.size.to_pixels( + AbsoluteLength::Pixels(if placement.is_horizontal() { + size.width + } else { + size.height + }), + rem_size, + ); let mut paddings = Edges::all(px(16.)); if let Some(pl) = self.style.padding.left { paddings.left = pl.to_pixels(base_size, rem_size); @@ -298,7 +309,7 @@ impl RenderOnce for Sheet { // closing runs the same path back out. let progress = if closing { 1.0 - delta } else { delta }; - let y = px(-100.) + progress * px(100.); + let y = -travel + progress * travel; this.map(|this| match placement { Placement::Top => this.top(top + y), Placement::Right => this.right(y), From 8b9693f83fff4afc1301bccd3ec6060b977ef1c1 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 15:06:57 -0700 Subject: [PATCH 18/20] build(gpui): point at the merged blur opacity fix Repoints from the branch commit to the squashed merge on the fork's main, so the pin stays valid if the branch is deleted. --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 4 ++-- vendor/gpui | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32659630..d2cb4ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "gpui_util", "indexmap", @@ -1878,7 +1878,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "proc-macro2", "quote", @@ -3069,7 +3069,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "anyhow", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_unix", @@ -3394,7 +3394,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_macos", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "console_error_panic_hook", "gpui", @@ -3465,7 +3465,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "schemars", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "log", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3509,7 +3509,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "bytemuck", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_windows", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "async-compression", @@ -4868,7 +4868,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "bindgen", @@ -5797,7 +5797,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "collections", "serde", @@ -6835,7 +6835,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "derive_refineable", ] @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "async-task", "backtrace", @@ -7949,7 +7949,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "heapless", "log", @@ -9334,7 +9334,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "async-fs", @@ -9373,7 +9373,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "perf", "quote", @@ -11187,7 +11187,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "chrono", @@ -11204,7 +11204,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "tracing", "tracing-subscriber", @@ -11215,7 +11215,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f#d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 3247790c..b0bdb708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,8 @@ gpui-component-macros = { path = "crates/macros", version = "0.6.0" } gpui-component-assets = { path = "crates/assets", version = "0.6.0" } story = { path = "crates/story" } -gpui = { git = "https://github.com/BumpyClock/gpui", rev = "d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f", features = ["font-kit"] } -gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f", features = ["font-kit"] } +gpui = { git = "https://github.com/BumpyClock/gpui", rev = "1eeb173f71fad65c25cc8ea22319715703f05b5b", features = ["font-kit"] } +gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "1eeb173f71fad65c25cc8ea22319715703f05b5b", features = ["font-kit"] } gpui-macros = "0.2.2" sum-tree = { version = "0.2.0", package = "zed-sum-tree" } # reqwest = { version = "0.12.15-zed", package = "zed-reqwest" } diff --git a/vendor/gpui b/vendor/gpui index d2b94c15..1eeb173f 160000 --- a/vendor/gpui +++ b/vendor/gpui @@ -1 +1 @@ -Subproject commit d2b94c156cf1e0ffd2e6548c559a8d2cfbe7ce4f +Subproject commit 1eeb173f71fad65c25cc8ea22319715703f05b5b From 823f781c41a17f55be1743e29be98ded5380774a Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 15:20:17 -0700 Subject: [PATCH 19/20] ci(windows): stop pwsh aborting the expected-failure smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transactional startup smoke deliberately runs a binary that exits 2, so cargo writes 'process didn't exit successfully' to stderr. Actions runs pwsh with $ErrorActionPreference='Stop', where a native command's stderr under 2>&1 raises a terminating NativeCommandError — so the script died before reaching its own exit-code and marker checks, and the step failed without ever printing why. The app under test was behaving correctly the whole time: it emitted APP_SHELL_FAIL_START_REACHED and exited 2, exactly as the macOS leg asserts. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb9ecbd7..d89f0557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,12 @@ jobs: if: ${{ matrix.run_on == 'windows-latest' }} shell: pwsh run: | + # This step expects the run to fail, so cargo writes "process didn't + # exit successfully" to stderr. Actions runs pwsh with + # $ErrorActionPreference='Stop', where a native command's stderr under + # 2>&1 raises a terminating NativeCommandError — killing the script + # before the checks below ever run. + $ErrorActionPreference = 'Continue' $output = cargo run -p app_shell -- --fail-start 2>&1 | Out-String $exitCode = $LASTEXITCODE Write-Output $output From e5ea80a8231fc32f15f4262c642b278ef814c095 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 28 Jul 2026 15:26:06 -0700 Subject: [PATCH 20/20] ci(windows): make the expected-failure smoke robust in pwsh The previous attempt set $ErrorActionPreference='Continue' and still failed, so the terminating-error theory was only half the story: the runner appends `exit $LASTEXITCODE` after the step script, which propagates cargo's exit 2 as a step failure even when every assertion in the step passes. Redirect all streams to a file instead of piping through 2>&1, disable native-error promotion, and exit explicitly so the step's status reflects the assertions rather than the exit code of a command that is supposed to fail. --- .github/workflows/ci.yml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d89f0557..0a9e2a75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,18 +137,26 @@ jobs: if: ${{ matrix.run_on == 'windows-latest' }} shell: pwsh run: | - # This step expects the run to fail, so cargo writes "process didn't - # exit successfully" to stderr. Actions runs pwsh with - # $ErrorActionPreference='Stop', where a native command's stderr under - # 2>&1 raises a terminating NativeCommandError — killing the script - # before the checks below ever run. + # This step expects the binary to fail, so cargo exits non-zero and + # writes to stderr. Both trip pwsh: a native command's stderr through + # a 2>&1 pipeline can raise a terminating NativeCommandError, and the + # runner appends `exit $LASTEXITCODE`, which would propagate cargo's + # exit 2 as a step failure even when the assertions pass. + # + # So: redirect all streams to a file rather than piping, suppress + # native-error promotion, and exit explicitly. $ErrorActionPreference = 'Continue' - $output = cargo run -p app_shell -- --fail-start 2>&1 | Out-String + $PSNativeCommandUseErrorActionPreference = $false + cargo run -p app_shell -- --fail-start *> app_shell_fail_start.log $exitCode = $LASTEXITCODE + $output = Get-Content app_shell_fail_start.log -Raw Write-Output $output if ($exitCode -ne 2) { - throw "expected transactional startup failure to exit 2, got $exitCode" + Write-Output "expected transactional startup failure to exit 2, got $exitCode" + exit 1 } if (-not $output.Contains("APP_SHELL_FAIL_START_REACHED")) { - throw "transactional start callback marker was not emitted" + Write-Output "transactional start callback marker was not emitted" + exit 1 } + exit 0