diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd891f..627701d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ per crate. ## Unreleased +- Popups: `Flyout::new` now takes the trigger's window-space `Bounds` and anchors with window snapping (breaking, no in-repo callers); popup motion offsets with `top` instead of a margin; `fluent_core::layer` holds the shared deferred-priority table; menu rows wait 200 ms before closing an open submenu in favour of a sibling row (WI-3255). +- Pill, badge, dock, app-dot and date-time action radii and the tree/list badge font size now come from theme tokens; avatar and app-dot initials use `on_accent` (WI-3266). + +- Pressed (active) backgrounds on toggle button, checkbox, radio, chip, tabs, tree, list, toolbar, menus, dialogs, dock, ribbon and title bar; switch knob grows on hover/press; command palette disabled rows drop the opacity hack (WI-3265). + - New `list_item_height` component token (32px, Fluent 2); tree and data table rows use it instead of `menu_item_height` (WI-3259). - Text input error state and selection, and title-bar close hover, now use theme colours (WI-3264, WI-3266) diff --git a/crates/fluent-app/src/title_bar.rs b/crates/fluent-app/src/title_bar.rs index a820891..21eb7a8 100644 --- a/crates/fluent-app/src/title_bar.rs +++ b/crates/fluent-app/src/title_bar.rs @@ -101,6 +101,8 @@ impl Render for TitleBar { let fg = colors.on_neutral; let ctrl_hover = colors.subtle_hover; let close_hover = colors.status_error; + let ctrl_pressed = colors.subtle_selected; + let close_pressed = colors.status_error.opacity(0.8); // Drag: left-button press starts window move; consecutive presses // within DOUBLE_CLICK_WINDOW_MS toggle maximize / restore instead. @@ -189,6 +191,7 @@ impl Render for TitleBar { .justify_center() .cursor_pointer() .hover(move |s| s.bg(ctrl_hover)) + .active(move |s| s.bg(ctrl_pressed)) .on_click(min_handler) .child( svg() @@ -207,6 +210,7 @@ impl Render for TitleBar { .justify_center() .cursor_pointer() .hover(move |s| s.bg(ctrl_hover)) + .active(move |s| s.bg(ctrl_pressed)) .on_click(max_handler) .child( svg() @@ -225,6 +229,7 @@ impl Render for TitleBar { .justify_center() .cursor_pointer() .hover(move |s| s.bg(close_hover)) + .active(move |s| s.bg(close_pressed)) .on_click(close_handler) .child( svg() diff --git a/crates/fluent-core/src/layer.rs b/crates/fluent-core/src/layer.rs new file mode 100644 index 0000000..a8ce39f --- /dev/null +++ b/crates/fluent-core/src/layer.rs @@ -0,0 +1,21 @@ +//! Paint order for deferred popups. +//! +//! `deferred(..).with_priority(n)` paints higher priorities on top. Every popup +//! picks its priority from this table so independent popups stack the same way +//! wherever they are opened: tooltip > context menu > submenu > menu > +//! dropdown / popover. A popup nested in another uses the parent's priority +//! plus its depth (`SUBMENU + depth`, `CONTEXT_MENU + depth`), which stays +//! inside its tier because the tiers are spaced well apart. + +/// Popovers, suggestion lists and anything else with no stronger claim. +pub const POPOVER: usize = 0; +/// Dropdown, combobox and date/time picker lists. +pub const DROPDOWN: usize = 0; +/// Menu-button and menu-bar menus, and `Flyout` by default. +pub const MENU: usize = 10; +/// Submenus of menu-button and menu-bar menus; add the nesting depth. +pub const SUBMENU: usize = 20; +/// Pointer-positioned context menus and their submenus; add the nesting depth. +pub const CONTEXT_MENU: usize = 40; +/// Tooltips paint over every other popup. +pub const TOOLTIP: usize = 80; diff --git a/crates/fluent-core/src/lib.rs b/crates/fluent-core/src/lib.rs index 0954906..ef668c6 100644 --- a/crates/fluent-core/src/lib.rs +++ b/crates/fluent-core/src/lib.rs @@ -4,6 +4,7 @@ mod async_resource; mod colors; mod components; +pub mod layer; mod motion; mod radii; mod shadows; diff --git a/crates/fluent-core/src/motion.rs b/crates/fluent-core/src/motion.rs index 11649ea..7dd79c2 100644 --- a/crates/fluent-core/src/motion.rs +++ b/crates/fluent-core/src/motion.rs @@ -139,7 +139,10 @@ pub fn popup_motion_surface( } else { offset * (1.0 - delta) }; - surface.opacity(opacity).mt(px(y_offset)) + // Offset with `top`, not a margin: `anchored` ignores margins when it + // measures and snaps its child, so a margin would paint outside the + // snapped bounds for the length of the animation. + surface.opacity(opacity).relative().top(px(y_offset)) }, ) .into_any_element() diff --git a/crates/fluent-layout/src/command_palette.rs b/crates/fluent-layout/src/command_palette.rs index 07d33ff..fb2e444 100644 --- a/crates/fluent-layout/src/command_palette.rs +++ b/crates/fluent-layout/src/command_palette.rs @@ -563,7 +563,6 @@ impl Render for CommandPalette { .px(px(spacing.md)) .py(px(spacing.sm)) .bg(row_bg) - .when(disabled, |d| d.opacity(0.72)) .when(!disabled, |d| { d.cursor_pointer() .hover(move |s| s.bg(row_hover)) diff --git a/crates/fluent-layout/src/context_menu.rs b/crates/fluent-layout/src/context_menu.rs index bc5cc82..e3177b0 100644 --- a/crates/fluent-layout/src/context_menu.rs +++ b/crates/fluent-layout/src/context_menu.rs @@ -277,6 +277,7 @@ pub struct ContextMenu { focus_handle: Option, open_path: Vec, selected_path: Vec, + hover_intent: menu_tree::HoverIntent, } impl ContextMenu { @@ -292,6 +293,7 @@ impl ContextMenu { focus_handle: None, open_path: Vec::new(), selected_path: Vec::new(), + hover_intent: menu_tree::HoverIntent::default(), } } @@ -542,18 +544,22 @@ impl ContextMenu { if !disabled && !self.closing { row = row .on_mouse_move(cx.listener(move |menu: &mut ContextMenu, _, _, cx| { - if menu.closing { - return; - } - menu.selected_path = select_path.clone(); - menu.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + menu, + select_path.clone(), + hover_path.clone(), + cx, + ); })) .cursor_pointer() .hover({ let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |menu, _| menu.closing).unwrap_or(true) { return; @@ -607,18 +613,22 @@ impl ContextMenu { if !disabled && !self.closing { row = row .on_mouse_move(cx.listener(move |menu: &mut ContextMenu, _, _, cx| { - if menu.closing { - return; - } - menu.selected_path = select_path.clone(); - menu.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + menu, + select_path.clone(), + hover_path.clone(), + cx, + ); })) .cursor_pointer() .hover({ let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |menu, _| menu.closing).unwrap_or(true) { return; @@ -678,18 +688,22 @@ impl ContextMenu { if !disabled && !self.closing { row = row .on_mouse_move(cx.listener(move |menu: &mut ContextMenu, _, _, cx| { - if menu.closing { - return; - } - menu.selected_path = select_path.clone(); - menu.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + menu, + select_path.clone(), + hover_path.clone(), + cx, + ); })) .cursor_pointer() .hover({ let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |menu, _| menu.closing).unwrap_or(true) { return; @@ -747,13 +761,17 @@ impl ContextMenu { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_move(cx.listener(move |menu: &mut ContextMenu, _, _, cx| { - if menu.closing { - return; - } - menu.selected_path = select_path.clone(); - menu.open_path = select_path.clone(); - cx.notify(); + menu_tree::hover_row( + menu, + select_path.clone(), + select_path.clone(), + cx, + ); })); } @@ -769,7 +787,11 @@ impl ContextMenu { if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_menu(items, path.clone(), self_weak.clone(), env, cx); - row = row.child(menu_tree::submenu_flyout(submenu, env, path.len())); + row = row.child(menu_tree::submenu_flyout( + submenu, + env, + fluent_core::layer::CONTEXT_MENU + path.len(), + )); } menu = menu.child(row); } @@ -789,6 +811,20 @@ impl ContextMenu { } } +impl menu_tree::HoverMenu for ContextMenu { + fn is_closing(&self) -> bool { + self.closing + } + + fn hover_intent(&mut self) -> &mut menu_tree::HoverIntent { + &mut self.hover_intent + } + + fn paths_mut(&mut self) -> (&mut Vec, &mut Vec) { + (&mut self.selected_path, &mut self.open_path) + } +} + impl Render for ContextMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.observing_theme { @@ -842,9 +878,10 @@ impl Render for ContextMenu { menu.handle_key(ev, window, cx); }), ) - .child(deferred( - overlay.child(anchored().position(pos).child(menu)), - )) + .child( + deferred(overlay.child(anchored().position(pos).child(menu))) + .with_priority(fluent_core::layer::CONTEXT_MENU), + ) } } diff --git a/crates/fluent-layout/src/dialog.rs b/crates/fluent-layout/src/dialog.rs index 4faec21..a1196f2 100644 --- a/crates/fluent-layout/src/dialog.rs +++ b/crates/fluent-layout/src/dialog.rs @@ -120,6 +120,11 @@ impl RenderOnce for Dialog { } else { colors.neutral_hover }; + let pressed = if action.primary { + colors.accent_selected + } else { + colors.neutral_selected + }; let fg = if action.primary { colors.on_accent } else { @@ -144,6 +149,7 @@ impl RenderOnce for Dialog { .text_color(fg) .cursor_pointer() .hover(move |s| s.bg(hover)) + .active(move |s| s.bg(pressed)) .on_click(move |ev, win, app| handler(ev, win, app)) .child(action.label), ); @@ -180,6 +186,10 @@ impl RenderOnce for Dialog { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_click(|_, _, cx| ModalStack::pop(cx)) .child( svg() diff --git a/crates/fluent-layout/src/dock.rs b/crates/fluent-layout/src/dock.rs index 2a6fc64..5929470 100644 --- a/crates/fluent-layout/src/dock.rs +++ b/crates/fluent-layout/src/dock.rs @@ -114,6 +114,7 @@ impl Render for DockPanel { let panel_border = colors.panel_border; let on_neutral = colors.on_neutral; let hover_bg = colors.subtle_hover; + let pressed_bg = colors.subtle_selected; let handle_hover = colors.stroke_neutral_dim; let title = self.title.clone(); @@ -161,8 +162,9 @@ impl Render for DockPanel { .items_center() .justify_center() .cursor_pointer() - .rounded(px(2.0)) + .rounded(px(theme.radii.sm)) .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(pressed_bg)) .on_click(collapse_handler) .child( svg() diff --git a/crates/fluent-layout/src/flyout.rs b/crates/fluent-layout/src/flyout.rs index 5463056..946d070 100644 --- a/crates/fluent-layout/src/flyout.rs +++ b/crates/fluent-layout/src/flyout.rs @@ -1,55 +1,51 @@ use fluent_core::ThemeProvider as _; -use gpui::{deferred, div, prelude::*, px, AnyElement, IntoElement, RenderOnce, Window}; +use gpui::{ + anchored, deferred, prelude::*, px, Anchor, AnyElement, Bounds, IntoElement, Pixels, + RenderOnce, Window, +}; /// Standard flyout placements relative to the invoking element. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FlyoutPlacement { - /// Public API item. + /// Left edges aligned, just below the trigger. BelowStart, - /// Public API item. + /// Top edges aligned, just right of the trigger. RightStart, } -/// Lightweight local-positioned flyout wrapper. +/// A flyout anchored to the window-space bounds of its trigger. /// -/// Use this when the invoking element owns the flyout, such as menu-bar -/// dropdowns and cascade menus. Pointer-positioned context menus should use -/// `ContextMenu`. +/// The flyout is positioned in window coordinates, so the trigger need not be +/// its parent, and it snaps back inside the window. Read the trigger's bounds +/// from a prepaint canvas (as `Dropdown` does) and pass them in. Pointer-positioned +/// context menus should use `ContextMenu`. #[derive(IntoElement)] pub struct Flyout { child: AnyElement, + trigger: Bounds, placement: FlyoutPlacement, - trigger_width: f32, - trigger_height: f32, priority: usize, } impl Flyout { - /// Public API item. - pub fn new(child: impl IntoElement) -> Self { + /// A flyout showing `child` next to `trigger`, the trigger's bounds in window space. + pub fn new(trigger: Bounds, child: impl IntoElement) -> Self { Self { child: child.into_any_element(), + trigger, placement: FlyoutPlacement::BelowStart, - trigger_width: 0.0, - trigger_height: 0.0, - priority: 1, + priority: fluent_core::layer::MENU, } } - /// Public API item. + /// Where the flyout sits relative to the trigger. pub fn placement(mut self, placement: FlyoutPlacement) -> Self { self.placement = placement; self } - /// Public API item. - pub fn trigger_size(mut self, width: f32, height: f32) -> Self { - self.trigger_width = width; - self.trigger_height = height; - self - } - - /// Public API item. + /// Paint priority; defaults to `fluent_core::layer::MENU`. A flyout opened + /// from another popup passes that popup's priority plus one. pub fn priority(mut self, priority: usize) -> Self { self.priority = priority; self @@ -58,22 +54,9 @@ impl Flyout { impl RenderOnce for Flyout { fn render(self, _window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { - let mut root = div().absolute(); - root = match self.placement { - FlyoutPlacement::BelowStart => { - if self.trigger_height > 0.0 { - root.left(px(0.0)).top(px(self.trigger_height)) - } else { - root.left_0().top_full() - } - } - FlyoutPlacement::RightStart => { - if self.trigger_width > 0.0 { - root.left(px(self.trigger_width)).top(px(0.0)) - } else { - root.left_full().top_0() - } - } + let position = match self.placement { + FlyoutPlacement::BelowStart => self.trigger.bottom_left(), + FlyoutPlacement::RightStart => self.trigger.top_right(), }; let child = fluent_core::popup_motion_surface( @@ -82,59 +65,80 @@ impl RenderOnce for Flyout { cx.theme().motion(), self.child, ); - root.child(deferred(child).with_priority(self.priority)) + deferred( + anchored() + .anchor(Anchor::TopLeft) + .position(position) + .snap_to_window_with_margin(px(8.)) + .child(child), + ) + .with_priority(self.priority) } } #[cfg(test)] mod tests { use fluent_test_utils::{open_fluent_test_window, FluentInteractionExt as _}; - use gpui::{px, TestAppContext}; + use gpui::{div, point, px, size, TestAppContext}; use super::*; + const TRIGGER: Bounds = Bounds { + origin: gpui::Point { + x: px(100.), + y: px(100.), + }, + size: gpui::Size { + width: px(40.), + height: px(40.), + }, + }; + /// The entrance animation starts a couple of pixels off; allow for it. - fn near(a: gpui::Pixels, b: gpui::Pixels) -> bool { + fn near(a: Pixels, b: Pixels) -> bool { (f32::from(a) - f32::from(b)).abs() <= 4.0 } - struct Host(FlyoutPlacement); + /// The trigger is deliberately not the flyout's parent. + struct Host(FlyoutPlacement, Bounds); impl gpui::Render for Host { fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { div().size_full().child( - div() - .relative() - .left(px(100.)) - .top(px(100.)) - .size(px(40.)) - .debug_selector(|| "trigger".into()) - .child( - Flyout::new(div().size(px(30.)).debug_selector(|| "fly".into())) - .placement(self.0) - .trigger_size(40., 40.), - ), + Flyout::new(self.1, div().size(px(30.)).debug_selector(|| "fly".into())) + .placement(self.0), ) } } #[gpui::test] fn below_start_sits_under_the_trigger(cx: &mut TestAppContext) { - let (_, cx) = open_fluent_test_window(cx, |_, _| Host(FlyoutPlacement::BelowStart)); + let (_, cx) = + open_fluent_test_window(cx, |_, _| Host(FlyoutPlacement::BelowStart, TRIGGER)); cx.run_until_parked(); - let t = cx.selector_bounds("trigger").unwrap(); let f = cx.selector_bounds("fly").unwrap(); - assert!(near(f.origin.x, t.origin.x)); - assert!(near(f.origin.y, t.origin.y + t.size.height)); + assert!(near(f.origin.x, TRIGGER.origin.x)); + assert!(near(f.origin.y, TRIGGER.bottom_left().y)); } #[gpui::test] fn right_start_sits_beside_the_trigger(cx: &mut TestAppContext) { - let (_, cx) = open_fluent_test_window(cx, |_, _| Host(FlyoutPlacement::RightStart)); + let (_, cx) = + open_fluent_test_window(cx, |_, _| Host(FlyoutPlacement::RightStart, TRIGGER)); + cx.run_until_parked(); + let f = cx.selector_bounds("fly").unwrap(); + assert!(near(f.origin.x, TRIGGER.top_right().x)); + assert!(near(f.origin.y, TRIGGER.origin.y)); + } + + #[gpui::test] + fn snaps_back_inside_the_window(cx: &mut TestAppContext) { + let far = Bounds::new(point(px(5000.), px(5000.)), size(px(40.), px(40.))); + let (_, cx) = open_fluent_test_window(cx, |_, _| Host(FlyoutPlacement::BelowStart, far)); cx.run_until_parked(); - let t = cx.selector_bounds("trigger").unwrap(); let f = cx.selector_bounds("fly").unwrap(); - assert!(near(f.origin.x, t.origin.x + t.size.width)); - assert!(near(f.origin.y, t.origin.y)); + let window = cx.update(|window, _| window.viewport_size()); + assert!(f.origin.x + f.size.width <= window.width); + assert!(f.origin.y + f.size.height <= window.height); } } diff --git a/crates/fluent-layout/src/menu_bar.rs b/crates/fluent-layout/src/menu_bar.rs index 1f23586..9b120e9 100644 --- a/crates/fluent-layout/src/menu_bar.rs +++ b/crates/fluent-layout/src/menu_bar.rs @@ -97,6 +97,7 @@ pub struct MenuBar { active_index: usize, selected_path: Vec, open_path: Vec, + hover_intent: menu_tree::HoverIntent, focus_handle: FocusHandle, trigger_bounds: Vec>>>>, window_id: Option, @@ -139,6 +140,7 @@ impl MenuBar { active_index: 0, selected_path: Vec::new(), open_path: Vec::new(), + hover_intent: menu_tree::HoverIntent::default(), focus_handle: cx.focus_handle().tab_stop(true), trigger_bounds: Vec::new(), window_id: None, @@ -592,12 +594,12 @@ impl MenuBar { ) .on_mouse_move(cx.listener( move |bar: &mut MenuBar, _, _, cx| { - if bar.closing { - return; - } - bar.selected_path = select_path.clone(); - bar.open_path = hover_parent.clone(); - cx.notify(); + menu_tree::hover_row( + bar, + select_path.clone(), + hover_parent.clone(), + cx, + ); }, )); if !disabled && !self.closing { @@ -607,6 +609,10 @@ impl MenuBar { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |bar, _| bar.closing).unwrap_or(true) { return; @@ -659,12 +665,12 @@ impl MenuBar { ) .on_mouse_move(cx.listener( move |bar: &mut MenuBar, _, _, cx| { - if bar.closing { - return; - } - bar.selected_path = select_path.clone(); - bar.open_path = hover_parent.clone(); - cx.notify(); + menu_tree::hover_row( + bar, + select_path.clone(), + hover_parent.clone(), + cx, + ); }, )); if !disabled && !self.closing { @@ -674,6 +680,10 @@ impl MenuBar { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |bar, _| bar.closing).unwrap_or(true) { return; @@ -731,12 +741,12 @@ impl MenuBar { ) .on_mouse_move(cx.listener( move |bar: &mut MenuBar, _, _, cx| { - if bar.closing { - return; - } - bar.selected_path = select_path.clone(); - bar.open_path = hover_parent.clone(); - cx.notify(); + menu_tree::hover_row( + bar, + select_path.clone(), + hover_parent.clone(), + cx, + ); }, )); if !disabled && !self.closing { @@ -746,6 +756,10 @@ impl MenuBar { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer.update(cx, |bar, _| bar.closing).unwrap_or(true) { return; @@ -802,13 +816,17 @@ impl MenuBar { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_move(cx.listener(move |bar: &mut MenuBar, _, _, cx| { - if bar.closing { - return; - } - bar.selected_path = select_path.clone(); - bar.open_path = select_path.clone(); - cx.notify(); + menu_tree::hover_row( + bar, + select_path.clone(), + select_path.clone(), + cx, + ); })); } row = row @@ -823,7 +841,11 @@ impl MenuBar { if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_items(items, path.clone(), self_weak.clone(), env, cx); - row = row.child(menu_tree::submenu_flyout(submenu, env, 2 + path.len())); + row = row.child(menu_tree::submenu_flyout( + submenu, + env, + fluent_core::layer::SUBMENU + path.len(), + )); } menu = menu.child(row); } @@ -843,6 +865,20 @@ impl MenuBar { } } +impl menu_tree::HoverMenu for MenuBar { + fn is_closing(&self) -> bool { + self.closing + } + + fn hover_intent(&mut self) -> &mut menu_tree::HoverIntent { + &mut self.hover_intent + } + + fn paths_mut(&mut self) -> (&mut Vec, &mut Vec) { + (&mut self.selected_path, &mut self.open_path) + } +} + impl Render for MenuBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); @@ -864,6 +900,7 @@ impl Render for MenuBar { let bar_bg = colors.surface_dim; let trigger_hover_bg = colors.subtle_hover; + let trigger_pressed_bg = colors.subtle_selected; let trigger_active_bg = colors.neutral; let trigger_text = colors.on_neutral; @@ -924,6 +961,13 @@ impl Render for MenuBar { s } }) + .active(move |s| { + if open_idx != Some(idx) { + s.bg(trigger_pressed_bg) + } else { + s + } + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { win.focus(&focus_handle, cx); sw_click @@ -989,7 +1033,7 @@ impl Render for MenuBar { .child(item_list), ), ) - .with_priority(2), + .with_priority(fluent_core::layer::MENU), ) } else { None diff --git a/crates/fluent-layout/src/menu_button.rs b/crates/fluent-layout/src/menu_button.rs index da83a3b..726fac4 100644 --- a/crates/fluent-layout/src/menu_button.rs +++ b/crates/fluent-layout/src/menu_button.rs @@ -30,6 +30,7 @@ pub struct MenuButton { observing_theme: bool, open_path: Vec, selected_path: Vec, + hover_intent: menu_tree::HoverIntent, } impl MenuButton { @@ -50,6 +51,7 @@ impl MenuButton { observing_theme: false, open_path: Vec::new(), selected_path: Vec::new(), + hover_intent: menu_tree::HoverIntent::default(), } } @@ -311,12 +313,12 @@ impl MenuButton { ) .on_mouse_move(cx.listener( move |button: &mut MenuButton, _, _, cx| { - if button.closing { - return; - } - button.selected_path = select_path.clone(); - button.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + button, + select_path.clone(), + hover_path.clone(), + cx, + ); }, )); @@ -327,6 +329,10 @@ impl MenuButton { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer .update(cx, |button, _| button.closing) @@ -383,12 +389,12 @@ impl MenuButton { ) .on_mouse_move(cx.listener( move |button: &mut MenuButton, _, _, cx| { - if button.closing { - return; - } - button.selected_path = select_path.clone(); - button.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + button, + select_path.clone(), + hover_path.clone(), + cx, + ); }, )); @@ -399,6 +405,10 @@ impl MenuButton { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer .update(cx, |button, _| button.closing) @@ -460,12 +470,12 @@ impl MenuButton { ) .on_mouse_move(cx.listener( move |button: &mut MenuButton, _, _, cx| { - if button.closing { - return; - } - button.selected_path = select_path.clone(); - button.open_path = hover_path.clone(); - cx.notify(); + menu_tree::hover_row( + button, + select_path.clone(), + hover_path.clone(), + cx, + ); }, )); @@ -476,6 +486,10 @@ impl MenuButton { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_down(MouseButton::Left, move |_, win, cx| { if closer .update(cx, |button, _| button.closing) @@ -537,14 +551,18 @@ impl MenuButton { let hover = env.colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = env.colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_mouse_move(cx.listener( move |button: &mut MenuButton, _, _, cx| { - if button.closing { - return; - } - button.selected_path = select_path.clone(); - button.open_path = select_path.clone(); - cx.notify(); + menu_tree::hover_row( + button, + select_path.clone(), + select_path.clone(), + cx, + ); }, )); } @@ -561,7 +579,11 @@ impl MenuButton { if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_items(items, path.clone(), self_weak.clone(), env, cx); - row = row.child(menu_tree::submenu_flyout(submenu, env, path.len())); + row = row.child(menu_tree::submenu_flyout( + submenu, + env, + fluent_core::layer::SUBMENU + path.len(), + )); } menu = menu.child(row); } @@ -581,6 +603,20 @@ impl MenuButton { } } +impl menu_tree::HoverMenu for MenuButton { + fn is_closing(&self) -> bool { + self.closing + } + + fn hover_intent(&mut self) -> &mut menu_tree::HoverIntent { + &mut self.hover_intent + } + + fn paths_mut(&mut self) -> (&mut Vec, &mut Vec) { + (&mut self.selected_path, &mut self.open_path) + } +} + impl Render for MenuButton { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.observing_theme { @@ -672,6 +708,10 @@ impl Render for MenuButton { let hover = colors.neutral_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.neutral_selected; + move |s| s.bg(pressed) + }) .on_click(cx.listener( move |button: &mut MenuButton, ev: &ClickEvent, window, cx| { window.focus(&focus_handle, cx); @@ -722,15 +762,18 @@ impl Render for MenuButton { )); } - root = root.child(deferred( - overlay.child( - anchored() - .anchor(Anchor::TopLeft) - .position(self.popup_origin) - .snap_to_window_with_margin(px(menu_tree::SUBMENU_WINDOW_MARGIN)) - .child(menu), - ), - )); + root = root.child( + deferred( + overlay.child( + anchored() + .anchor(Anchor::TopLeft) + .position(self.popup_origin) + .snap_to_window_with_margin(px(menu_tree::SUBMENU_WINDOW_MARGIN)) + .child(menu), + ), + ) + .with_priority(fluent_core::layer::MENU), + ); } root @@ -779,6 +822,54 @@ mod tests { }); } + #[gpui::test] + fn hovering_a_sibling_row_waits_before_closing_the_open_submenu(cx: &mut TestAppContext) { + init_fluent_test(cx); + + let button = cx.new(|_| MenuButton::new("test-menu-button", "Actions").item(item("One"))); + button.update(cx, |button, cx| { + button.open_popup(cx); + button.selected_path = vec![1]; + button.open_path = vec![1]; + menu_tree::hover_row(button, vec![0], Vec::new(), cx); + assert_eq!(button.open_path, vec![1]); + assert_eq!(button.selected_path, vec![1]); + }); + + cx.executor() + .advance_clock(menu_tree::SUBMENU_CLOSE_DELAY / 2); + button.update(cx, |button, _| assert_eq!(button.open_path, vec![1])); + + cx.executor() + .advance_clock(menu_tree::SUBMENU_CLOSE_DELAY / 2); + button.update(cx, |button, _| { + assert!(button.open_path.is_empty()); + assert_eq!(button.selected_path, vec![0]); + }); + } + + #[gpui::test] + fn reaching_the_submenu_cancels_the_pending_close(cx: &mut TestAppContext) { + init_fluent_test(cx); + + let button = cx.new(|_| MenuButton::new("test-menu-button", "Actions").item(item("One"))); + button.update(cx, |button, cx| { + button.open_popup(cx); + button.selected_path = vec![1]; + button.open_path = vec![1]; + menu_tree::hover_row(button, vec![0], Vec::new(), cx); + menu_tree::hover_row(button, vec![1, 0], vec![1], cx); + assert_eq!(button.selected_path, vec![1, 0]); + }); + + cx.executor() + .advance_clock(menu_tree::SUBMENU_CLOSE_DELAY * 2); + button.update(cx, |button, _| { + assert_eq!(button.open_path, vec![1]); + assert_eq!(button.selected_path, vec![1, 0]); + }); + } + #[gpui::test] fn reopening_menu_button_cancels_stale_exit_timer(cx: &mut TestAppContext) { init_fluent_test(cx); diff --git a/crates/fluent-layout/src/menu_tree.rs b/crates/fluent-layout/src/menu_tree.rs index 00c5377..679abce 100644 --- a/crates/fluent-layout/src/menu_tree.rs +++ b/crates/fluent-layout/src/menu_tree.rs @@ -2,6 +2,8 @@ use fluent_core::TextRampExt; use fluent_core::{ ColorScheme, ComponentTokens, RadiiTokens, SpacingTokens, Theme, TypographyTokens, }; +use std::time::Duration; + use gpui::{ deferred, div, point, prelude::*, px, svg, AnyElement, App, Bounds, Display, Edges, Element, ElementId, GlobalElementId, InspectorElementId, Keystroke, LayoutId, Pixels, Position, @@ -287,6 +289,99 @@ pub(crate) fn leave_submenu(selected_path: &mut Vec, open_path: &mut Vec< } } +/// How long the pointer may rest on a sibling row before the open submenu +/// closes in its favour. A diagonal move from a submenu-trigger row into its +/// submenu crosses sibling rows on the way; without the delay each crossing +/// closes the submenu it is heading for. +pub(crate) const SUBMENU_CLOSE_DELAY: Duration = Duration::from_millis(200); + +/// A hover that would close the open submenu, waiting out +/// [`SUBMENU_CLOSE_DELAY`]. +#[derive(Default)] +pub(crate) struct HoverIntent { + pending: Option, + epoch: u64, +} + +struct PendingHover { + select: Vec, + open: Vec, + /// The selection when the hover was deferred; if it has changed (keyboard + /// navigation) the deferred hover is stale and is dropped. + from_selected: Vec, +} + +/// A menu whose rows react to pointer hover. +pub(crate) trait HoverMenu: Sized + 'static { + fn is_closing(&self) -> bool; + fn hover_intent(&mut self) -> &mut HoverIntent; + fn paths_mut(&mut self) -> (&mut Vec, &mut Vec); +} + +/// The pointer moved onto the row at `select`, whose own menu level is `open`. +/// +/// Moving onto a row that would close an open submenu waits +/// [`SUBMENU_CLOSE_DELAY`] first and is dropped if the pointer reaches any +/// other row meanwhile, such as one inside the submenu. Everything else +/// applies at once. +pub(crate) fn hover_row( + menu: &mut T, + select: Vec, + open: Vec, + cx: &mut gpui::Context, +) { + if menu.is_closing() { + return; + } + let (selected_path, open_path) = menu.paths_mut(); + let closes_submenu = open_path.len() > open.len() && path_starts_with(open_path, &open); + if !closes_submenu { + *selected_path = select; + *open_path = open; + let intent = menu.hover_intent(); + intent.pending = None; + intent.epoch = intent.epoch.wrapping_add(1); + cx.notify(); + return; + } + + let from_selected = selected_path.clone(); + let intent = menu.hover_intent(); + if intent.pending.as_ref().is_some_and(|p| p.select == select) { + return; + } + intent.epoch = intent.epoch.wrapping_add(1); + let epoch = intent.epoch; + intent.pending = Some(PendingHover { + select, + open, + from_selected, + }); + + cx.spawn(move |this: gpui::WeakEntity, cx: &mut gpui::AsyncApp| { + let mut cx = cx.clone(); + async move { + cx.background_executor().timer(SUBMENU_CLOSE_DELAY).await; + this.update(&mut cx, move |menu, cx| { + if menu.is_closing() || menu.hover_intent().epoch != epoch { + return; + } + let Some(pending) = menu.hover_intent().pending.take() else { + return; + }; + let (selected_path, open_path) = menu.paths_mut(); + if *selected_path == pending.from_selected { + *selected_path = pending.select; + *open_path = pending.open; + cx.notify(); + } + }) + .ok(); + } + }) + .detach(); +} + fn row_id(id_prefix: &'static str, path: &[usize]) -> ElementId { let mut id = ElementId::Name(id_prefix.into()); for idx in path { diff --git a/crates/fluent-layout/src/message_bar.rs b/crates/fluent-layout/src/message_bar.rs index 6758a17..59d1a7e 100644 --- a/crates/fluent-layout/src/message_bar.rs +++ b/crates/fluent-layout/src/message_bar.rs @@ -150,6 +150,7 @@ impl RenderOnce for MessageBar { .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(accent) .hover(move |s| s.bg(hover)) + .active(move |s| s.bg(colors.subtle_selected)) .on_click(move |ev, win, app| on_action(ev, win, app)) .child(label), ); @@ -170,6 +171,10 @@ impl RenderOnce for MessageBar { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_click(move |ev, win, app| on_dismiss(ev, win, app)) .child( svg() diff --git a/crates/fluent-layout/src/modal.rs b/crates/fluent-layout/src/modal.rs index 15d89f9..c6b8055 100644 --- a/crates/fluent-layout/src/modal.rs +++ b/crates/fluent-layout/src/modal.rs @@ -322,6 +322,10 @@ impl Render for MessageDialog { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_click(|_, _, cx| ModalStack::pop(cx)) .child( gpui::svg() @@ -367,6 +371,10 @@ impl Render for MessageDialog { let hover = colors.accent_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.accent_selected; + move |s| s.bg(pressed) + }) .on_click(|_, _, cx| ModalStack::pop(cx)) .child(dismiss_label), ), @@ -485,6 +493,7 @@ impl Render for ConfirmModal { .text_color(colors.on_neutral) .cursor_pointer() .hover(move |s| s.bg(colors.neutral_hover)) + .active(move |s| s.bg(colors.neutral_selected)) .on_click(cancel_handler) .child(cancel_label), ) @@ -501,6 +510,7 @@ impl Render for ConfirmModal { .text_color(colors.on_accent) .cursor_pointer() .hover(move |s| s.bg(colors.accent_hover)) + .active(move |s| s.bg(colors.accent_selected)) .on_click(confirm_handler) .child(confirm_label), ), diff --git a/crates/fluent-layout/src/nav.rs b/crates/fluent-layout/src/nav.rs index 59cce98..3bcb8ee 100644 --- a/crates/fluent-layout/src/nav.rs +++ b/crates/fluent-layout/src/nav.rs @@ -384,6 +384,7 @@ fn render_item( colors.panel_bg }) .hover(|s| s.bg(colors.subtle_hover)) + .active(|s| s.bg(colors.subtle_selected)) .on_click(click); if compact { let tooltip_label = label.clone(); diff --git a/crates/fluent-layout/src/popover.rs b/crates/fluent-layout/src/popover.rs index 5b5eca6..98cd548 100644 --- a/crates/fluent-layout/src/popover.rs +++ b/crates/fluent-layout/src/popover.rs @@ -57,13 +57,16 @@ impl RenderOnce for Popover { .child(self.content); let surface = fluent_core::popup_motion_surface("popover-motion", false, motion, surface); - div().child(deferred( - anchored() - .anchor(self.anchor) - .position(self.position) - .snap_to_window_with_margin(px(8.)) - .child(surface), - )) + div().child( + deferred( + anchored() + .anchor(self.anchor) + .position(self.position) + .snap_to_window_with_margin(px(8.)) + .child(surface), + ) + .with_priority(fluent_core::layer::POPOVER), + ) } } diff --git a/crates/fluent-layout/src/selectable_list.rs b/crates/fluent-layout/src/selectable_list.rs index 0d7c34c..629c2fa 100644 --- a/crates/fluent-layout/src/selectable_list.rs +++ b/crates/fluent-layout/src/selectable_list.rs @@ -501,16 +501,19 @@ fn render_row( .text_color(fg); if !item.disabled { - row = row.cursor_pointer().hover({ - let hover = colors.neutral_hover; - move |s| { - s.bg(if selected { - colors.neutral_selected - } else { - hover - }) - } - }); + row = row + .cursor_pointer() + .hover({ + let hover = colors.neutral_hover; + move |s| { + s.bg(if selected { + colors.neutral_selected + } else { + hover + }) + } + }) + .active(move |s| s.bg(colors.neutral_selected)); if select_handler.is_some() || activate_handler.is_some() { let id = item.id.clone(); @@ -619,9 +622,9 @@ fn render_row( .flex_none() .px(px(5.0)) .py(px(1.0)) - .rounded(px(3.0)) + .rounded(px(theme.radii.sm)) .bg(colors.neutral) - .text_size(px(10.0)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(badge), ); diff --git a/crates/fluent-layout/src/tab_strip.rs b/crates/fluent-layout/src/tab_strip.rs index ff3edda..8ffa022 100644 --- a/crates/fluent-layout/src/tab_strip.rs +++ b/crates/fluent-layout/src/tab_strip.rs @@ -228,6 +228,7 @@ impl Render for TabStrip { .border_r_1() .border_color(border_c) .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(colors.neutral_selected)) .on_click(select_handler); if let Some(icon_path) = tab_icon { @@ -254,6 +255,7 @@ impl Render for TabStrip { .rounded(px(radii.sm)) .cursor_pointer() .hover(move |s| s.bg(colors.neutral_hover)) + .active(move |s| s.bg(colors.neutral_selected)) .on_click(handler) .child( svg() diff --git a/crates/fluent-layout/src/toolbar.rs b/crates/fluent-layout/src/toolbar.rs index d348cc3..9aacd61 100644 --- a/crates/fluent-layout/src/toolbar.rs +++ b/crates/fluent-layout/src/toolbar.rs @@ -284,6 +284,10 @@ impl Toolbar { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) + }) .on_key_down(move |ev, win, app| { if matches!(ev.keystroke.key.as_str(), "enter" | "space") { key_click(&ClickEvent::default(), win, app); @@ -349,6 +353,10 @@ impl Toolbar { let hover = colors.neutral_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.neutral_selected; + move |s| s.bg(pressed) + }) .on_key_down({ let key_click = on_click.clone(); move |ev, win, app| { @@ -452,6 +460,10 @@ impl RenderOnce for Toolbar { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) + }) .child("More"); if let Some(handler) = self.on_overflow_click { diff --git a/crates/fluent-layout/src/tree.rs b/crates/fluent-layout/src/tree.rs index d1b009b..b4cdf8d 100644 --- a/crates/fluent-layout/src/tree.rs +++ b/crates/fluent-layout/src/tree.rs @@ -443,16 +443,19 @@ impl Tree { .text_color(fg); if !item.disabled { - row = row.cursor_pointer().hover({ - let hover = colors.neutral_hover; - move |s| { - s.bg(if is_selected { - colors.neutral_selected - } else { - hover - }) - } - }); + row = row + .cursor_pointer() + .hover({ + let hover = colors.neutral_hover; + move |s| { + s.bg(if is_selected { + colors.neutral_selected + } else { + hover + }) + } + }) + .active(move |s| s.bg(colors.neutral_selected)); if state.on_select.is_some() || state.on_activate.is_some() { let id = item.id.clone(); @@ -618,9 +621,9 @@ impl Tree { div() .flex_none() .px(px(4.0)) - .rounded(px(3.0)) + .rounded(px(theme.radii.sm)) .bg(colors.neutral) - .text_size(px(10.0)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(badge), ); diff --git a/crates/fluent-primitives/src/app_dot.rs b/crates/fluent-primitives/src/app_dot.rs index c3e2311..59caa9a 100644 --- a/crates/fluent-primitives/src/app_dot.rs +++ b/crates/fluent-primitives/src/app_dot.rs @@ -72,12 +72,12 @@ impl RenderOnce for AppDot { }; div() .size(px(self.size)) - .rounded(px(6.0)) + .rounded(px(cx.theme().radii.lg)) .bg(gradient_from_hue(hue)) .flex() .items_center() .justify_center() - .text_color(gpui::white()) + .text_color(cx.theme().colors.on_accent) .font_weight(FontWeight::BOLD) .text_size(px(text_size)) .line_height(px(text_size.round())) diff --git a/crates/fluent-primitives/src/avatar.rs b/crates/fluent-primitives/src/avatar.rs index dffdfa0..8b08c2d 100644 --- a/crates/fluent-primitives/src/avatar.rs +++ b/crates/fluent-primitives/src/avatar.rs @@ -76,7 +76,7 @@ impl RenderOnce for Avatar { typography.body.size }; base.bg(gradient_from_hue(hue)) - .text_color(gpui::white()) + .text_color(colors.on_accent) .text_size(px(text_size)) .line_height(px(text_size.round())) .font_weight(FontWeight::BOLD) diff --git a/crates/fluent-primitives/src/badge.rs b/crates/fluent-primitives/src/badge.rs index 6f10a1a..a0478ee 100644 --- a/crates/fluent-primitives/src/badge.rs +++ b/crates/fluent-primitives/src/badge.rs @@ -52,7 +52,7 @@ impl RenderOnce for PresenceBadge { .right(px(0.0)) .bottom(px(0.0)) .size(px(indicator_size)) - .rounded(px(9999.0)) + .rounded(px(cx.theme().radii.pill)) .bg(color) .border_2() .border_color(colors.surface), diff --git a/crates/fluent-primitives/src/checkbox.rs b/crates/fluent-primitives/src/checkbox.rs index ee527b7..77636d9 100644 --- a/crates/fluent-primitives/src/checkbox.rs +++ b/crates/fluent-primitives/src/checkbox.rs @@ -108,6 +108,11 @@ impl RenderOnce for Checkbox { } else { colors.surface }; + let box_pressed = if is_active { + colors.accent_selected + } else { + colors.neutral_selected + }; let box_border = if is_active { colors.accent } else if self.disabled { @@ -159,7 +164,8 @@ impl RenderOnce for Checkbox { .tab_stop(true) .fluent_focus(tab_index, focus_ring) .cursor_pointer() - .hover(move |s| s.border_color(colors.focus_ring)); + .hover(move |s| s.border_color(colors.focus_ring)) + .active(move |s| s.bg(box_pressed)); } if let Some(handler) = on_click.filter(|_| !disabled) { diff --git a/crates/fluent-primitives/src/chip.rs b/crates/fluent-primitives/src/chip.rs index b4db469..6117809 100644 --- a/crates/fluent-primitives/src/chip.rs +++ b/crates/fluent-primitives/src/chip.rs @@ -71,6 +71,7 @@ impl RenderOnce for Chip { .justify_center() .cursor_pointer() .hover(move |s| s.bg(colors.neutral_hover)) + .active(move |s| s.bg(colors.neutral_selected)) .on_click(move |ev, win, app| { if let Some(handler) = &on_dismiss { handler(ev, win, app); diff --git a/crates/fluent-primitives/src/combobox.rs b/crates/fluent-primitives/src/combobox.rs index eab7448..f5fa533 100644 --- a/crates/fluent-primitives/src/combobox.rs +++ b/crates/fluent-primitives/src/combobox.rs @@ -548,13 +548,16 @@ impl Render for Combobox { let motion_id = ("combobox-list-motion", self.motion_epoch); let list = fluent_core::popup_motion_surface(motion_id, exiting, motion, list); - root = root.child(deferred( - anchored() - .anchor(Anchor::TopLeft) - .position(self.drop_origin) - .snap_to_window_with_margin(px(8.)) - .child(list), - )); + root = root.child( + deferred( + anchored() + .anchor(Anchor::TopLeft) + .position(self.drop_origin) + .snap_to_window_with_margin(px(8.)) + .child(list), + ) + .with_priority(fluent_core::layer::DROPDOWN), + ); } root diff --git a/crates/fluent-primitives/src/date_time_picker.rs b/crates/fluent-primitives/src/date_time_picker.rs index ad7a5d1..225d6ba 100644 --- a/crates/fluent-primitives/src/date_time_picker.rs +++ b/crates/fluent-primitives/src/date_time_picker.rs @@ -1,4 +1,4 @@ -use fluent_core::TextRampExt; +use fluent_core::{TextRampExt, TypeRamp}; use std::sync::{Arc, Mutex}; use fluent_core::{Theme, ThemeProvider as _}; @@ -1036,6 +1036,8 @@ impl Render for DateTimePicker { "date-time-picker-clear", "Clear", self.closing, + radii.md, + &typography.caption, cx.listener(|picker: &mut DateTimePicker, _: &ClickEvent, _, cx| { if picker.closing { return; @@ -1047,6 +1049,8 @@ impl Render for DateTimePicker { "date-time-picker-done", "Done", self.closing, + radii.md, + &typography.caption, cx.listener(|picker: &mut DateTimePicker, _: &ClickEvent, _, cx| { picker.close_popup(cx); }), @@ -1057,13 +1061,16 @@ impl Render for DateTimePicker { let motion_id = ("date-time-picker-panel-motion", self.motion_epoch); let panel = fluent_core::popup_motion_surface(motion_id, exiting, motion, panel); - root = root.child(deferred( - anchored() - .anchor(Anchor::TopLeft) - .position(self.drop_origin) - .snap_to_window_with_margin(px(8.)) - .child(panel), - )); + root = root.child( + deferred( + anchored() + .anchor(Anchor::TopLeft) + .position(self.drop_origin) + .snap_to_window_with_margin(px(8.)) + .child(panel), + ) + .with_priority(fluent_core::layer::DROPDOWN), + ); } root @@ -1204,6 +1211,8 @@ fn action_button( id: &'static str, label: &'static str, disabled: bool, + radius: f32, + ramp: &TypeRamp, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { div() @@ -1213,8 +1222,8 @@ fn action_button( .justify_center() .h(px(30.0)) .px(px(10.0)) - .rounded(px(4.0)) - .text_size(px(12.0)) + .rounded(px(radius)) + .text_ramp(ramp) .cursor(if disabled { CursorStyle::Arrow } else { diff --git a/crates/fluent-primitives/src/dropdown.rs b/crates/fluent-primitives/src/dropdown.rs index 14d0b8c..5bf6bd1 100644 --- a/crates/fluent-primitives/src/dropdown.rs +++ b/crates/fluent-primitives/src/dropdown.rs @@ -454,13 +454,16 @@ impl Render for Dropdown { let motion_id = ("dropdown-list-motion", self.motion_epoch); let list = fluent_core::popup_motion_surface(motion_id, exiting, motion, list); - el = el.child(deferred( - anchored() - .anchor(Anchor::TopLeft) - .position(self.drop_origin) - .snap_to_window_with_margin(px(8.)) - .child(list), - )); + el = el.child( + deferred( + anchored() + .anchor(Anchor::TopLeft) + .position(self.drop_origin) + .snap_to_window_with_margin(px(8.)) + .child(list), + ) + .with_priority(fluent_core::layer::DROPDOWN), + ); } el diff --git a/crates/fluent-primitives/src/progress.rs b/crates/fluent-primitives/src/progress.rs index 744c7a9..35884e8 100644 --- a/crates/fluent-primitives/src/progress.rs +++ b/crates/fluent-primitives/src/progress.rs @@ -141,7 +141,7 @@ impl RenderOnce for ProgressBar { .absolute() .h_full() .w(relative(INDETERMINATE_WIDTH)) - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .bg(indicator_color) .with_animation( animation_id, @@ -157,7 +157,7 @@ impl RenderOnce for ProgressBar { div() .h_full() .w(relative(self.value)) - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .bg(indicator_color) .into_any_element() }; @@ -170,7 +170,7 @@ impl RenderOnce for ProgressBar { .w_full() .h(px(self.height)) .overflow_hidden() - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .bg(colors.neutral) .child(indicator) } diff --git a/crates/fluent-primitives/src/radio_group.rs b/crates/fluent-primitives/src/radio_group.rs index 0294a93..ad78ea7 100644 --- a/crates/fluent-primitives/src/radio_group.rs +++ b/crates/fluent-primitives/src/radio_group.rs @@ -224,6 +224,10 @@ impl RenderOnce for RadioGroup { .hover({ let hover = colors.subtle_hover; move |s| s.bg(hover) + }) + .active({ + let pressed = colors.subtle_selected; + move |s| s.bg(pressed) }); if let Some(handler) = on_select { let key_handler = handler.clone(); @@ -242,7 +246,7 @@ impl RenderOnce for RadioGroup { .child( div() .size(px(20.0)) - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .border_1() .border_color(outer_border) .flex() @@ -251,7 +255,7 @@ impl RenderOnce for RadioGroup { .child( div() .size(px(if checked { 10.0 } else { 0.0 })) - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .bg(colors.accent), ), ) diff --git a/crates/fluent-primitives/src/skeleton.rs b/crates/fluent-primitives/src/skeleton.rs index 0d34b1a..b1e823a 100644 --- a/crates/fluent-primitives/src/skeleton.rs +++ b/crates/fluent-primitives/src/skeleton.rs @@ -165,7 +165,7 @@ impl RenderOnce for SkeletonRow { .child( div() .size(px(self.avatar_size)) - .rounded(px(9999.0)) + .rounded(px(cx.theme().radii.pill)) .bg(base) .with_animation( circle_id, diff --git a/crates/fluent-primitives/src/slider.rs b/crates/fluent-primitives/src/slider.rs index 314b027..424bf00 100644 --- a/crates/fluent-primitives/src/slider.rs +++ b/crates/fluent-primitives/src/slider.rs @@ -188,15 +188,18 @@ impl RenderOnce for Slider { let mut track = div() .relative() - .rounded(px(9999.0)) + .rounded(px(cx.theme().radii.pill)) .bg(colors.neutral) .border_1() .border_color(colors.stroke_neutral); - let fill = div().absolute().rounded(px(9999.0)).bg(fill_color); + let fill = div() + .absolute() + .rounded(px(cx.theme().radii.pill)) + .bg(fill_color); let thumb = div() .absolute() .size(px(THUMB)) - .rounded(px(9999.0)) + .rounded(px(cx.theme().radii.pill)) .bg(thumb_color) .border_2() .border_color(colors.on_accent); diff --git a/crates/fluent-primitives/src/suggestion_popover.rs b/crates/fluent-primitives/src/suggestion_popover.rs index 2a35b74..8cd47c2 100644 --- a/crates/fluent-primitives/src/suggestion_popover.rs +++ b/crates/fluent-primitives/src/suggestion_popover.rs @@ -427,13 +427,16 @@ impl RenderOnce for SuggestionPopover { let motion_id = ("suggestion-popover-motion", self.motion_key); let list = fluent_core::popup_motion_surface(motion_id, self.exiting, motion, list); - div().child(deferred( - anchored() - .anchor(self.anchor) - .position(self.position) - .snap_to_window_with_margin(px(8.)) - .child(list), - )) + div().child( + deferred( + anchored() + .anchor(self.anchor) + .position(self.position) + .snap_to_window_with_margin(px(8.)) + .child(list), + ) + .with_priority(fluent_core::layer::POPOVER), + ) } } diff --git a/crates/fluent-primitives/src/switch.rs b/crates/fluent-primitives/src/switch.rs index 032f317..e3d92d0 100644 --- a/crates/fluent-primitives/src/switch.rs +++ b/crates/fluent-primitives/src/switch.rs @@ -108,6 +108,9 @@ impl RenderOnce for Switch { // Fluent 2: 40x20 track, 12px knob, 4px inset from the outer edge (3px inside the 1px border) let (from_ml, to_ml) = if self.on { (3.0, 23.0) } else { (23.0, 3.0) }; let motion = theme.motion(); + // Fluent 2: knob grows 12 -> 14px on hover and widens on press; the on-state width stops + // at 15px so the knob stays inside the 38px inner track. + let press_w = if on { 15.0 } else { 17.0 }; let debug_id = self.id.to_string(); let mut el = div() @@ -118,7 +121,8 @@ impl RenderOnce for Switch { .when_some(label, |d, name| d.aria_label(name)) .w(px(40.0)) .h(px(20.0)) - .rounded(px(9999.0)) + .group("fluent-switch") + .rounded(px(theme.radii.pill)) .bg(track_bg) .border_1() .border_color(track_border) @@ -142,10 +146,15 @@ impl RenderOnce for Switch { el.child( div() + .id("switch-knob") .absolute() .size(px(12.0)) + .when(!disabled, |d| { + d.group_hover("fluent-switch", |s| s.size(px(14.0))) + .group_active("fluent-switch", move |s| s.w(px(press_w)).h(px(14.0))) + }) .ml(px(to_ml)) - .rounded(px(9999.0)) + .rounded(px(theme.radii.pill)) .bg(thumb_color) .with_animation( ElementId::from(("switch-thumb", on as u64)), diff --git a/crates/fluent-primitives/src/toggle_button.rs b/crates/fluent-primitives/src/toggle_button.rs index 93e2acf..a8fc9b1 100644 --- a/crates/fluent-primitives/src/toggle_button.rs +++ b/crates/fluent-primitives/src/toggle_button.rs @@ -91,14 +91,21 @@ impl RenderOnce for ToggleButton { let radii = &theme.radii; let typography = &theme.typography; - let (bg, bg_hover, fg) = if self.selected { + let (bg, bg_hover, bg_pressed, fg) = if self.selected { ( colors.neutral_selected, colors.neutral_hover, + // Distinct from the selected bg so a press on a selected toggle is visible. + colors.neutral, colors.on_neutral_selected, ) } else { - (colors.subtle, colors.subtle_hover, colors.on_subtle) + ( + colors.subtle, + colors.subtle_hover, + colors.subtle_selected, + colors.on_subtle, + ) }; let fg = if self.disabled { @@ -169,7 +176,8 @@ impl RenderOnce for ToggleButton { .tab_stop(true) .fluent_focus(tab_index, focus_ring) .cursor_pointer() - .hover(move |s| s.bg(bg_hover)); + .hover(move |s| s.bg(bg_hover)) + .active(move |s| s.bg(bg_pressed)); } if let Some(handler) = on_click.filter(|_| !disabled) { diff --git a/crates/fluent-ribbon/src/bar.rs b/crates/fluent-ribbon/src/bar.rs index 4692701..2723f8a 100644 --- a/crates/fluent-ribbon/src/bar.rs +++ b/crates/fluent-ribbon/src/bar.rs @@ -187,9 +187,11 @@ impl RibbonBar { let typography = tokens.typography; let components = tokens.components; let btn_hover_bg = colors.subtle_hover; + let btn_pressed_bg = colors.subtle_selected; let fg = colors.on_neutral; let selected_bg = colors.neutral_selected; let hover_bg = colors.neutral_hover; + let pressed_bg = colors.neutral_selected; let sep_color = colors.ribbon_group_separator; match item { @@ -229,6 +231,7 @@ impl RibbonBar { .cursor_pointer() .rounded(px(radii.sm)) .hover(move |s| s.bg(btn_hover_bg)) + .active(move |s| s.bg(btn_pressed_bg)) .on_click(move |ev, win, app| { invoke(&bar, &label_c, app, |app| cb(ev, win, app)) }) @@ -282,6 +285,7 @@ impl RibbonBar { .cursor_pointer() .rounded(px(radii.sm)) .hover(move |s| s.bg(btn_hover_bg)) + .active(move |s| s.bg(btn_pressed_bg)) .on_click(move |ev, win, app| { invoke(&bar, &label_c, app, |app| cb(ev, win, app)) }) @@ -333,6 +337,7 @@ impl RibbonBar { .cursor_pointer() .rounded(px(radii.sm)) .hover(move |s| s.bg(btn_hover_bg)) + .active(move |s| s.bg(btn_pressed_bg)) .on_click(move |ev, win, app| { invoke(&bar, &label_c, app, |app| cb(ev, win, app)) }) @@ -389,6 +394,7 @@ impl RibbonBar { .cursor_pointer() .rounded(px(radii.sm)) .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(pressed_bg)) .on_click(move |ev, win, app| { invoke(&bar, &label_c, app, |app| cb(new_state, ev, win, app)) }) @@ -571,6 +577,7 @@ impl Render for RibbonBar { let tab_strip_bg = colors.tab_strip_bg; let tab_active_bg = colors.tab_active_bg; let tab_hover_bg = colors.tab_hover_bg; + let tab_pressed_bg = colors.neutral_selected; let tab_indicator = colors.ribbon_tab_indicator; let ctx_accent = colors.accent; let on_neutral = colors.on_neutral; @@ -660,6 +667,7 @@ impl Render for RibbonBar { .bg(tab_bg) .cursor_pointer() .hover(move |s| s.bg(tab_hover_bg)) + .active(move |s| s.bg(tab_pressed_bg)) .font_weight(if is_active { FontWeight::SEMIBOLD } else { @@ -816,6 +824,7 @@ impl Render for RibbonBar { .justify_center() .cursor_pointer() .hover(move |s| s.bg(tab_hover_bg)) + .active(move |s| s.bg(tab_pressed_bg)) .on_click(collapse_handler) .child( svg() @@ -917,6 +926,7 @@ impl Render for RibbonBar { let hover = colors.subtle_hover; move |s| s.bg(hover) }) + .active(move |s| s.bg(colors.subtle_selected)) .on_click(overflow_handler) .child( svg()