diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 966654cfb..0d1b7dad6 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -256,10 +256,10 @@ impl ChatScreen { /// `button` as the opener of `popup`, with `menu` attached below or /// above it while the popup is open. - fn with_menu( + fn with_menu( &self, popup: ChatPopup, - button: gpui::Stateful
, + button: E, placement: Placement, window: &mut Window, cx: &mut Context, @@ -427,11 +427,15 @@ impl ChatScreen { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_pill()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))) .when(self.attachment_picking, |el| el.opacity(0.5)) .tooltip(widgets::tooltip("Attach files or images", None)) @@ -452,11 +456,23 @@ impl ChatScreen { .justify_center() .rounded(theme::RADIUS_SM) .when(recording, |el| el.bg(gpui::rgb(theme::status_error()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if recording { + gpui::rgb(theme::status_error()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(if recording { + theme::status_error() + } else { + theme::bg_sidebar_pill() + }), + ) }) + .hover(|style| style.cursor_pointer()) .when(transcribing, |el| el.opacity(0.5)) .tooltip(widgets::tooltip( if recording { @@ -689,11 +705,19 @@ impl ChatScreen { .when(is_selected, |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if is_selected { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(move |_event, _window, cx: &mut gpui::App| { chat.update(cx, |chat, cx| chat.complete_slash_command(&name, cx)) .ok(); @@ -854,7 +878,14 @@ impl ChatScreen { .cursor_pointer() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated()))) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_elevated()), + ) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.close_side_thread(cx); })) @@ -994,7 +1025,10 @@ impl ChatScreen { .px_3() .py_2() .cursor_pointer() - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated()))) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_elevated())) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_plan_collapsed(cx); })) @@ -1283,11 +1317,15 @@ impl ChatScreen { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_pill()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip( if expanded { "Shrink composer" @@ -1468,7 +1506,7 @@ fn chip( chevron: bool, active: bool, highlight: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let color = if highlight { theme::accent() } else if active { @@ -1492,11 +1530,15 @@ fn chip( .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(color)) .when(active, |el| el.bg(gpui::rgb(theme::bg_sidebar_pill()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if active { + gpui::rgb(theme::bg_sidebar_pill()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_sidebar_pill())) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .children(leading.map(|name| icon(name, px(16.), color))) .child(div().whitespace_nowrap().child(label)) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a7988846b..4407e08c1 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -3822,9 +3822,15 @@ impl ChatScreen { .py_0p5() .rounded(theme::RADIUS_SM) .text_color(gpui::rgb(theme::accent())) - .hover(|style| { - style.bg(theme::overlay_hover()).cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + theme::overlay_hover(), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(crate::update::UpdateRequested(action)); })) @@ -3839,7 +3845,15 @@ impl ChatScreen { .px_2() .py_0p5() .rounded(theme::RADIUS_SM) - .hover(|style| style.bg(theme::overlay_hover()).cursor_pointer()) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + theme::overlay_hover(), + ) + }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(|_, _, _, cx| { cx.emit(crate::update::UpdateRequested( crate::update::UpdateAction::Later, @@ -7061,11 +7075,15 @@ impl ChatScreen { .pl(mark_padding) .rounded(theme::RADIUS_SM) .focus_visible(widgets::focus_ring) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) // Inside a drag region: a press here is a click, not a drag. .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { diff --git a/apps/maple-agent/app/src/ui/chat/project_folder.rs b/apps/maple-agent/app/src/ui/chat/project_folder.rs index 890573548..7e471a3fb 100644 --- a/apps/maple-agent/app/src/ui/chat/project_folder.rs +++ b/apps/maple-agent/app/src/ui/chat/project_folder.rs @@ -575,7 +575,15 @@ impl ProjectFolderDialog { .when(self.highlighted == Some(index), |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) - .hover(|row| row.bg(theme::overlay_hover()).cursor_pointer()) + .map(|element| { + let rest: gpui::Hsla = if self.highlighted == Some(index) { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(element, rest, theme::overlay_hover()) + }) + .hover(|row| row.cursor_pointer()) .on_click(cx.listener(move |this, _, window, cx| { window.focus(&this.focus(Control::List, cx), cx); this.enter_folder(index, cx); diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index 57e81c32c..fc7df6ee4 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -293,13 +293,15 @@ impl ChatScreen { .justify_center() .rounded(theme::RADIUS_SM) .when(busy, |button| button.opacity(0.5)) - .when(!busy, |button| { - button.hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + .map(|button| { + crate::ui::motion::hover_background( + button, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .when(busy, |button| button.highlighted(false)) + .when(!busy, |button| button.hover(|style| style.cursor_pointer())) .child(icon(icon_name, px(12.), theme::text_secondary())) }; Some( diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 81281ffc2..d7b0682b6 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -2875,11 +2875,15 @@ impl Sidebar { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip_for_action( "Clear search", &super::ChatEscape, @@ -2961,9 +2965,8 @@ impl Sidebar { .text_sm() .text_color(gpui::rgb(color)) .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + // Pointer feedback stays immediate while the background eases. + style.cursor_pointer() }) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .tooltip(widgets::tooltip_for_action(label, action)) @@ -2976,6 +2979,13 @@ impl Sidebar { }) .child(icon(name, px(16.), color)) .child(label) + .map(|element| { + crate::ui::motion::hover_background( + element, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + }) .into_any_element() } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), @@ -3018,11 +3028,15 @@ impl Sidebar { .px_2() .py_1() .rounded(ROW_RADIUS) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); this.hover_heading(SidebarEntry::SectionLabel(section), cx); @@ -3109,11 +3123,15 @@ impl Sidebar { .px_2() .py_1() .rounded(ROW_RADIUS) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_archived_visibility(cx); this.hover_heading(SidebarEntry::ArchivedHeader, cx); @@ -3284,14 +3302,20 @@ impl Sidebar { fn set_hovered_entry(&mut self, entry: Option, cx: &mut Context) { self.hover_scroll_top = self.list.logical_scroll_top(); + let header_was_hovered = self.projects_header_hovered(); self.hovered_entry = entry; let section = entry.and_then(|entry| self.entry_sections.get(entry).copied().flatten()); - if self.hovered_section != section { + if self.hovered_section != section || header_was_hovered != self.projects_header_hovered() { self.hovered_section = section; cx.notify(); } } + fn projects_header_hovered(&self) -> bool { + self.hovered_entry.and_then(|ix| self.entries.get(ix)) + == Some(&SidebarEntry::ProjectsHeader) + } + #[cfg(test)] pub(super) fn section_hover_is_clear_for_test(&self) -> bool { self.hovered_entry.is_none() && self.hovered_section.is_none() @@ -3310,17 +3334,13 @@ impl Sidebar { id: &'static str, section: SidebarHoverSection, ) -> AnyElement { + let visible = !expanded || self.section_hovered(section); let chevron = div() .id((id, 0_u64)) .flex_none() .size(px(14.)) .flex() .items_center() - .opacity(if !expanded || self.section_hovered(section) { - 1. - } else { - 0. - }) .child(icon( if expanded { "chevron-down" @@ -3330,7 +3350,20 @@ impl Sidebar { px(14.), theme::text_secondary(), )); - chevron.into_any_element() + if section == SidebarHoverSection::Projects { + crate::ui::motion::hover_background( + chevron, + gpui::transparent_black(), + gpui::transparent_black(), + ) + .highlighted(false) + .revealed(visible) + .into_any_element() + } else { + chevron + .opacity(if visible { 1. } else { 0. }) + .into_any_element() + } } /// The Projects header: its label folds the list under it, and its @@ -3429,13 +3462,12 @@ impl Sidebar { .into_any_element() } - fn view_button(&self, open: bool) -> gpui::Stateful
{ + fn view_button(&self, open: bool) -> crate::ui::motion::HoverBackground { header_button("view-options", "list-filter", "View options", open) + .revealed(open) .debug_selector(|| "view-options".into()) .when(!open, |button| { - button - .opacity(0.) - .group_hover("projects-header-actions", |style| style.opacity(1.)) + button.revealed(self.projects_header_hovered()) }) } @@ -3514,14 +3546,10 @@ impl Sidebar { } /// Add project stays visible until there is a project to browse. - fn new_project_button(&self, open: bool) -> gpui::Stateful
{ + fn new_project_button(&self, open: bool) -> crate::ui::motion::HoverBackground { header_button("new-project", "folder-plus", "New project", open) .debug_selector(|| "new-project".to_owned()) - .when(!open && !self.project_roots.is_empty(), |button| { - button - .opacity(0.) - .group_hover("projects-header-actions", |style| style.opacity(1.)) - }) + .revealed(open || self.project_roots.is_empty() || self.projects_header_hovered()) } fn new_project_computer_menu(&self) -> Menu { @@ -3631,14 +3659,18 @@ impl Sidebar { .when(menu_open, |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .when(hover, |row| { - row.hover(|style| { - if self.dragging { - style - } else { - style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - } - }) + .map(|row| { + let rest = if menu_open { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + row, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + .when(!hover || self.dragging, |row| row.highlighted(false)) }) .on_click(cx.listener(move |this, _event, _window, cx| { if !this.collapsed_projects.remove(&toggle_target) { @@ -3865,7 +3897,7 @@ impl Sidebar { application_selected: bool, window: &mut Window, cx: &mut Context, - ) -> gpui::Stateful
{ + ) -> crate::ui::motion::HoverBackground { let SidebarTaskEntry { session: index, pinned: is_pinned, @@ -3919,8 +3951,17 @@ impl Sidebar { .text_color(gpui::rgb(theme::text_sidebar())) // A row being renamed is a text field: no hover highlight and // no hover actions over it, so the edit is plain to see. - .when(!renaming && !self.dragging, |el| { - el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) + .map(|row| { + let rest = if is_selected { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(renaming || self.dragging, |row| row.highlighted(false)) + .when(!renaming && !self.dragging, |row| { + row.hover(|style| style.cursor_pointer()) + }) }) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); @@ -3966,11 +4007,15 @@ impl Sidebar { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip("Unpin", None)) .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); @@ -4138,7 +4183,7 @@ pub(super) fn row_action( icon_name: &'static str, label: &'static str, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { row_button(id, group, icon_name, label) .tooltip(widgets::tooltip(label, None)) .on_click(on_click) @@ -4151,7 +4196,7 @@ fn row_menu_button( group: &SharedString, label: &'static str, open: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { row_button(id, group, "ellipsis", label).when(open, |button| { button .opacity(1.) @@ -4166,7 +4211,7 @@ fn header_button( icon_name: &'static str, label: &'static str, open: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { div() .id(id) .role(gpui::Role::Button) @@ -4177,11 +4222,15 @@ fn header_button( .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .when(open, |button| { button.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) @@ -4197,7 +4246,7 @@ fn row_button( group: &SharedString, icon_name: &'static str, label: &'static str, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { div() .debug_selector(|| id.to_string()) .id(id) @@ -4211,11 +4260,15 @@ fn row_button( .rounded(theme::RADIUS_SM) .opacity(0.) .group_hover(group.clone(), |style| style.opacity(1.)) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::border()))) .child(icon(icon_name, px(14.), theme::text_secondary())) } @@ -4281,6 +4334,114 @@ fn remote_root_display_name(root: &str) -> String { mod tests { use super::{remote_root_display_name, root_display_name}; + #[gpui::test] + fn projects_header_reveal_tracks_its_buttons_and_keeps_view_menu_open( + cx: &mut gpui::TestAppContext, + ) { + use super::*; + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + cx.update(|cx| cx.set_reduce_motion(true)); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let sidebar = chat.update(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.view.group = SidebarGroup::List; + sidebar.set_sessions( + (1..=100) + .map(|ix| AgentSessionSummary { + id: format!("s{ix}"), + title: format!("Task {ix}"), + project_root: "/tmp/proj".into(), + created_ms: 0, + updated_ms: 100 - ix, + message_count: 1, + model: None, + web_enabled: true, + state: AgentTaskState::Active, + acp: false, + }) + .collect(), + cx, + ); + }); + struct Host(Entity); + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(300.)) + .h(px(400.)) + .flex() + .flex_col() + .child(self.0.clone()) + } + } + let (_host, cx) = cx.add_window_view(|_, _| Host(sidebar.clone())); + cx.simulate_resize(gpui::size(px(600.), px(400.))); + cx.run_until_parked(); + let outside = gpui::point(px(500.), px(350.)); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + let header = cx.debug_bounds("projects-header").unwrap(); + let view = cx.debug_bounds("view-options").unwrap(); + let add = cx.debug_bounds("new-project").unwrap(); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + + for position in [header.center(), view.center(), add.center()] { + cx.simulate_mouse_move(position, None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert_eq!(cx.debug_bounds("projects-header"), Some(header)); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + } + let task = cx.debug_bounds("session-s1").unwrap(); + cx.simulate_mouse_move(task.center(), None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| { + sidebar.section_hovered(SidebarHoverSection::Projects) + })); + assert!( + !sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered()), + "hovering a task in the Projects section must not reveal header actions" + ); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + + cx.simulate_mouse_move(header.center(), None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + cx.simulate_event(gpui::ScrollWheelEvent { + position: header.center(), + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-1600.))), + ..Default::default() + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("projects-header").is_none()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + sidebar.update(cx, |sidebar, cx| { + sidebar.list.scroll_to(gpui::ListOffset::default()); + cx.notify(); + }); + cx.run_until_parked(); + assert_eq!(cx.debug_bounds("projects-header"), Some(header)); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + + cx.simulate_mouse_move(header.center(), None, gpui::Modifiers::default()); + cx.simulate_mouse_move(view.center(), None, gpui::Modifiers::default()); + cx.simulate_click(view.center(), gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.popup.is_open(&SidebarPopup::View))); + assert!(cx.debug_bounds("sidebar-group").is_some()); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert!(sidebar.update(cx, |sidebar, _| sidebar.popup.is_open(&SidebarPopup::View))); + assert!(cx.debug_bounds("sidebar-group").is_some()); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + } + #[gpui::test] fn sidebar_remeasures_an_offscreen_heading_when_its_gap_changes(cx: &mut gpui::TestAppContext) { use super::*; diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index 642f8f31c..abb7b38c0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2263,8 +2263,15 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .when(!loading, |row| { - row.hover(|s| s.cursor_pointer().bg(gpui::rgb(hover_bg))) + .map(|row| { + let rest = if selected { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(loading, |row| row.highlighted(false)) + .when(!loading, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |this, _, _, cx| { if !loading { @@ -2300,12 +2307,15 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(move |s| { - if dragging { - s + .map(|row| { + let rest = if selected { + gpui::rgb(theme::bg_sidebar_row_hover()).into() } else { - s.bg(gpui::rgb(hover_bg)).cursor_pointer() - } + gpui::transparent_black() + }; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(dragging, |row| row.highlighted(false)) + .when(!dragging, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) @@ -2386,12 +2396,11 @@ impl Sidebar { .gap_1() .text_xs() .text_color(gpui::rgb(theme::text_secondary())) - .hover(move |s| { - if dragging { - s - } else { - s.bg(gpui::rgb(hover_bg)).cursor_pointer() - } + .map(|row| { + let rest = gpui::transparent_black(); + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(dragging, |row| row.highlighted(false)) + .when(!dragging, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |this, _, _, cx| { if !this.toggle_computer_group(&request, cx) { diff --git a/apps/maple-agent/app/src/ui/chat/speech.rs b/apps/maple-agent/app/src/ui/chat/speech.rs index 363299dc4..1ba7b99cf 100644 --- a/apps/maple-agent/app/src/ui/chat/speech.rs +++ b/apps/maple-agent/app/src/ui/chat/speech.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use gpui::{ - Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, div, px, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, }; use super::{ChatScreen, SpeechState}; @@ -213,7 +213,7 @@ pub(super) fn speak_message_button( text: SharedString, speech: Option<&SpeechState>, chat: gpui::WeakEntity, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let item_id = item_id.to_string(); let (glyph, label) = match speech { None => ( @@ -246,9 +246,12 @@ pub(super) fn speak_message_button( .text_color(gpui::rgb(theme::text_muted())) .opacity(if active { 1. } else { 0. }) .group_hover(group.clone(), |style| style.opacity(1.)) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_elevated())) + }) .hover(|style| { style - .bg(gpui::rgb(theme::bg_elevated())) .text_color(gpui::rgb(theme::text_secondary())) .cursor_pointer() }) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index cbfb7272d..bd7799f23 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -7618,10 +7618,15 @@ mod state_tests { // With a task menu open, the folder has no strip. An outside // press closes the menu and still reaches the folder underneath. press(cx, "menu-session-s1"); - cx.update(|window, _| window.refresh()); - cx.run_until_parked(); + // As in press(), a cached view may need one extra paint for bounds. + let header = (0..2) + .find_map(|_| { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + cx.debug_bounds("folder-/tmp/proj") + }) + .expect("folder header"); assert!(cx.debug_bounds("menu-project-/tmp/proj").is_none()); - let header = cx.debug_bounds("folder-/tmp/proj").expect("folder header"); cx.simulate_click( gpui::point(header.right() - px(30.), header.center().y), gpui::Modifiers::default(), diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 392313a77..a82b23bbe 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -449,7 +449,7 @@ fn copy_message_button( group: &SharedString, text: SharedString, view: Option, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { widgets::copy_button( SharedString::from(format!("copy-message-{item_id}")), text, @@ -1619,11 +1619,21 @@ pub(super) fn render_question_card( && selection.is_some_and(|s| s.cursor == Some(option_index)), |row| row.bg(gpui::rgb(theme::bg_sidebar_row_hover())), ) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if entry.multi_select + && selection.is_some_and(|s| s.cursor == Some(option_index)) + { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click({ cx.listener(move |this, _event, _window, cx| { this.toggle_question_option(question_index, option_index, cx); diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index fe2910f1b..9b430a066 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -578,7 +578,7 @@ impl Render for LoginScreen { } } -fn submit_button(busy: bool, cx: &mut Context) -> gpui::Stateful
{ +fn submit_button(busy: bool, cx: &mut Context) -> crate::ui::motion::HoverBackground { widgets::primary_button("login-submit") .w_full() .mt_1() @@ -597,7 +597,7 @@ fn oauth_button( provider: OAuthProvider, busy: bool, cx: &mut Context, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { widgets::secondary_button(gpui::SharedString::from(format!( "oauth-{}", provider.label().to_lowercase() diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 86c277557..3007b3ea5 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -11,8 +11,8 @@ //! gpui's oneshot `with_animation`: it requests frames only until the //! animation ends, then the window goes idle again. //! -//! Nothing here animates hover or press: gpui has no style transitions, -//! and a stepped scale change on hover reads as flicker. +//! Hover plates use a short, reversible color transition. Press feedback +//! remains immediate; no control scales or changes layout on hover. use std::cell::RefCell; use std::collections::HashSet; @@ -210,3 +210,567 @@ pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { let wave = ((phase + shift) * std::f32::consts::TAU).sin() * 0.5 + 0.5; low + (high - low) * wave } + +/// A short, symmetric transition for hover plates and control reveals. +/// Press and focus styles remain native GPUI refinements. +pub const HOVER: Duration = Duration::from_millis(120); + +#[derive(Clone, Copy)] +struct HoverTransition { + from: f32, + target: f32, + started: Instant, +} + +impl HoverTransition { + fn new(visible: bool, now: Instant) -> Self { + let value = if visible { 1. } else { 0. }; + Self { + from: value, + target: value, + started: now, + } + } + + fn value(&self, now: Instant) -> f32 { + let t = (now.saturating_duration_since(self.started).as_secs_f32() / HOVER.as_secs_f32()) + .min(1.); + let eased = t * t * (3. - 2. * t); + self.from + (self.target - self.from) * eased + } + + fn update(&mut self, hovered: bool, now: Instant, reduced: bool) -> (f32, bool) { + let target = if hovered { 1. } else { 0. }; + if reduced { + self.from = target; + self.target = target; + } else if self.target != target { + self.from = self.value(now); + self.target = target; + self.started = now; + } + let value = self.value(now); + (value, value != self.target) + } +} + +// Visibility and background hover can have different targets on one control. +struct RevealTransition(HoverTransition); + +// Premultiplied RGB avoids a dark flash when fading from transparent. +fn hover_color(rest: gpui::Hsla, hover: gpui::Hsla, amount: f32) -> gpui::Hsla { + if amount == 0. { + return rest; + } + if amount == 1. { + return hover; + } + let rest = gpui::Rgba::from(rest); + let hover = gpui::Rgba::from(hover); + let alpha = rest.a + (hover.a - rest.a) * amount; + let channel = |a: f32, b: f32| { + if alpha == 0. { + 0. + } else { + (a * rest.a * (1. - amount) + b * hover.a * amount) / alpha + } + }; + gpui::Rgba { + r: channel(rest.r, hover.r), + g: channel(rest.g, hover.g), + b: channel(rest.b, hover.b), + a: alpha, + } + .into() +} + +/// Animate a control's background without changing its layout, identity, +/// handlers, tooltip, or accessibility node. Keep native `.hover()` for +/// non-background styles and native `.active()` for immediate press feedback. +pub fn hover_background( + element: gpui::Stateful, + rest: impl Into, + hover: impl Into, +) -> HoverBackground { + HoverBackground { + element, + rest: rest.into(), + hover: hover.into(), + highlighted: None, + revealed: None, + #[cfg(test)] + observed: None, + #[cfg(test)] + observed_opacity: None, + #[cfg(test)] + test_now: None, + } +} + +pub struct HoverBackground { + element: gpui::Stateful, + rest: gpui::Hsla, + hover: gpui::Hsla, + highlighted: Option, + revealed: Option, + #[cfg(test)] + observed: Option>>, + #[cfg(test)] + observed_opacity: Option>>, + #[cfg(test)] + test_now: Option, +} + +impl HoverBackground { + /// Override the resting background, including selected or open states. + pub fn bg(mut self, color: impl Into) -> Self { + self.rest = color.into(); + self.element.style().background = Some(self.rest.into()); + self + } + + /// Menus share one highlight between pointer and keyboard navigation. + /// Use that existing state instead of maintaining a second hover target. + pub fn highlighted(mut self, highlighted: bool) -> Self { + self.highlighted = Some(highlighted); + self + } + + /// Fade visibility in place, keeping the control's hitbox and layout. + /// Native focus styles can still reveal a keyboard target immediately. + pub fn revealed(mut self, visible: bool) -> Self { + self.revealed = Some(visible); + self + } +} + +impl Styled for HoverBackground { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.element.style() + } +} +impl gpui::InteractiveElement for HoverBackground { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + gpui::InteractiveElement::interactivity(&mut self.element) + } +} +impl gpui::StatefulInteractiveElement for HoverBackground {} +impl gpui::ParentElement for HoverBackground { + fn extend(&mut self, elements: impl IntoIterator) { + gpui::ParentElement::extend(&mut self.element, elements); + } +} +impl IntoElement for HoverBackground { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +impl Element for HoverBackground { + type RequestLayoutState = as Element>::RequestLayoutState; + type PrepaintState = as Element>::PrepaintState; + fn id(&self) -> Option { + self.element.id() + } + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + self.element.source_location() + } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + fn write_a11y_info(&self, node: &mut gpui::accesskit::Node) { + self.element.write_a11y_info(node); + } + fn a11y_synthetic_children( + &mut self, + state: &mut Self::PrepaintState, + builder: &mut gpui::A11ySubtreeBuilder, + ) { + self.element.a11y_synthetic_children(state, builder); + } + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + self.element.request_layout(id, inspector, window, cx) + } + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.element + .prepaint(id, inspector, bounds, layout, window, cx) + } + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let hovered = self.highlighted.unwrap_or_else(|| { + hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + && !cx.has_active_drag() + }); + let now = Instant::now(); + #[cfg(test)] + let now = self.test_now.unwrap_or(now); + let (amount, animating) = window.with_element_state( + id.expect("hover controls have an id"), + |state: Option, _| { + let mut state = state.unwrap_or_else(|| HoverTransition::new(false, now)); + let frame = state.update(hovered, now, cx.reduce_motion()); + (frame, state) + }, + ); + self.element.style().background = Some(hover_color(self.rest, self.hover, amount).into()); + let reveal_animating = if let Some(visible) = self.revealed { + let (opacity, animating) = window.with_element_state( + id.expect("revealed controls have an id"), + |state: Option, _| { + // An initially visible control should not fade in on mount. + let mut state = state + .unwrap_or_else(|| RevealTransition(HoverTransition::new(visible, now))); + let frame = state.0.update(visible, now, cx.reduce_motion()); + (frame, state) + }, + ); + self.element.style().opacity = Some(opacity); + animating + } else { + false + }; + if animating || reveal_animating { + window.request_animation_frame(); + } + if self.highlighted.is_none() + && let Some(hitbox) = hitbox.clone() + { + let view = window.current_view(); + window.on_mouse_event(move |_: &gpui::MouseMoveEvent, phase, window, cx| { + if phase == gpui::DispatchPhase::Capture && hitbox.is_hovered(window) != hovered { + cx.notify(view); + } + }); + } + self.element + .paint(id, inspector, bounds, layout, hitbox, window, cx); + #[cfg(test)] + if let Some(observed) = &self.observed { + use gpui::InteractiveElement; + *observed.borrow_mut() = self + .element + .interactivity() + .compute_style(id, hitbox.as_ref(), window, cx) + .background + .unwrap_or_default(); + } + #[cfg(test)] + if let Some(observed) = &self.observed_opacity { + use gpui::InteractiveElement; + observed.set( + self.element + .interactivity() + .compute_style(id, hitbox.as_ref(), window, cx) + .opacity + .unwrap_or(1.), + ); + } + } +} + +#[cfg(test)] +mod hover_tests { + use super::*; + #[test] + fn hover_reverses_from_the_displayed_value_and_settles() { + let now = Instant::now(); + let mut transition = HoverTransition::new(false, now); + assert_eq!(transition.update(false, now, false), (0., false)); + assert_eq!(transition.update(true, now, false), (0., true)); + let halfway = now + HOVER / 2; + assert_eq!(transition.update(true, halfway, false), (0.5, true)); + assert_eq!(transition.update(false, halfway, false), (0.5, true)); + assert_eq!( + transition.update(false, halfway + HOVER, false), + (0., false) + ); + assert_eq!( + transition.update(false, halfway + HOVER * 2, false), + (0., false) + ); + } + #[test] + fn reduced_motion_settles_an_interrupted_hover_immediately() { + let now = Instant::now(); + let mut transition = HoverTransition::new(false, now); + transition.update(true, now, false); + assert_eq!(transition.update(true, now + HOVER / 4, true), (1., false)); + assert_eq!(transition.update(false, now + HOVER / 2, true), (0., false)); + } + + #[test] + fn hover_eases_symmetrically_without_darkening_transparent_colors() { + let now = Instant::now(); + let mut transition = HoverTransition::new(false, now); + transition.update(true, now, false); + let early = transition.value(now + HOVER / 4); + let late = transition.value(now + HOVER * 3 / 4); + assert!(early < 0.25 && late > 0.75); + assert!((early + late - 1.).abs() <= 2. * f32::EPSILON); + let white = gpui::hsla(0., 0., 1., 1.); + let middle = gpui::Rgba::from(hover_color(gpui::transparent_black(), white, 0.5)); + assert_eq!(middle.r, 1.); + assert_eq!(middle.g, 1.); + assert_eq!(middle.b, 1.); + assert_eq!(middle.a, 0.5); + } + + #[gpui::test] + fn reveal_fades_reverses_and_preserves_focus_and_geometry(cx: &mut gpui::TestAppContext) { + use gpui::{div, prelude::*, px}; + use std::{cell::Cell, rc::Rc}; + struct Host { + visible: bool, + now: Instant, + focus: gpui::FocusHandle, + observed: Rc>, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let mut button = super::super::widgets::secondary_button("reveal-test") + .debug_selector(|| "reveal-test".into()) + .track_focus(&self.focus) + .focus_visible(|style| style.opacity(1.)) + .highlighted(false) + .revealed(self.visible) + .child("Action"); + button.observed_opacity = Some(self.observed.clone()); + button.test_now = Some(self.now); + div().size_full().p(px(20.)).child(button) + } + } + let observed = Rc::new(Cell::new(0.)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (host, cx) = cx.add_window_view(|_, cx| Host { + visible: true, + now: Instant::now(), + focus: cx.focus_handle(), + observed: observed.clone(), + }); + cx.run_until_parked(); + let bounds = cx.debug_bounds("reveal-test").unwrap(); + assert_eq!( + observed.get(), + 1., + "visible controls do not fade in on mount" + ); + let set_visible = |visible, cx: &mut gpui::VisualTestContext| { + host.update(cx, |host, cx| { + host.visible = visible; + cx.notify(); + }); + cx.run_until_parked(); + }; + let frame_after = |duration, cx: &mut gpui::VisualTestContext| { + host.update(cx, |host, cx| { + host.now += duration; + cx.notify(); + }); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + }; + set_visible(false, cx); + assert!(observed.get() > 0., "hide must not snap off"); + frame_after(HOVER / 4, cx); + assert!(observed.get() > 0. && observed.get() < 1.); + set_visible(true, cx); + assert!( + observed.get() > 0. && observed.get() < 1., + "reverse in place" + ); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 1.); + set_visible(false, cx); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 0.); + set_visible(true, cx); + assert!(observed.get() < 1., "show must not snap on"); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 1.); + assert_eq!(cx.debug_bounds("reveal-test").unwrap(), bounds); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + set_visible(false, cx); + assert_eq!(observed.get(), 0.); + set_visible(true, cx); + assert_eq!(observed.get(), 1.); + set_visible(false, cx); + let focus = host.update(cx, |host, _| host.focus.clone()); + cx.simulate_keystrokes("tab"); + cx.update(|window, cx| focus.focus(window, cx)); + cx.run_until_parked(); + assert_eq!(observed.get(), 1., "keyboard focus reveals immediately"); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + } + + #[gpui::test] + fn shared_button_preserves_hover_click_and_pressed_feedback(cx: &mut gpui::TestAppContext) { + use gpui::{Modifiers, MouseButton, div, prelude::*, px}; + use std::rc::Rc; + struct Host { + observed: Rc>, + clicks: Rc>, + hovered: Rc>, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + let hovered = self.hovered.clone(); + let mut button = super::super::widgets::secondary_button("hover-test") + .debug_selector(|| "hover-test".into()) + .on_hover(move |value, _, _| hovered.set(*value)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + .child("Action"); + button.observed = Some(self.observed.clone()); + div().size_full().child(button) + } + } + let observed = Rc::new(RefCell::new(gpui::Fill::default())); + let clicks = Rc::new(std::cell::Cell::new(0)); + let hovered = Rc::new(std::cell::Cell::new(false)); + cx.update(|cx| cx.set_reduce_motion(true)); + let (_, cx) = cx.add_window_view(|_, _| Host { + observed: observed.clone(), + clicks: clicks.clone(), + hovered: hovered.clone(), + }); + cx.run_until_parked(); + let bounds = cx.debug_bounds("hover-test").unwrap(); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + assert!(hovered.get()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_row_selected())) + ); + cx.simulate_mouse_down(bounds.center(), MouseButton::Left, Modifiers::default()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::border())) + ); + cx.simulate_mouse_up(bounds.center(), MouseButton::Left, Modifiers::default()); + assert_eq!(clicks.get(), 1); + cx.simulate_mouse_move( + gpui::point(bounds.right() + px(20.), bounds.bottom() + px(20.)), + None, + Modifiers::default(), + ); + assert!(!hovered.get()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_pill())) + ); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + } + + #[gpui::test] + fn cached_child_hover_animates_reverses_and_goes_idle(cx: &mut gpui::TestAppContext) { + use gpui::{Modifiers, div, prelude::*}; + use std::{cell::Cell, rc::Rc}; + struct Control { + observed: Rc>, + renders: Rc>, + } + impl gpui::Render for Control { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + let mut button = super::super::widgets::secondary_button("cached-hover") + .debug_selector(|| "cached-hover".into()) + .child("Action"); + button.observed = Some(self.observed.clone()); + div().size_full().child(button) + } + } + struct Host { + control: gpui::Entity, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().size_full().child( + self.control.clone().cached( + gpui::StyleRefinement::default() + .w(gpui::px(200.)) + .h(gpui::px(80.)), + ), + ) + } + } + let observed = Rc::new(RefCell::new(gpui::Fill::default())); + let child_renders = Rc::new(Cell::new(0)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (_, cx) = cx.add_window_view(|_, cx| Host { + control: cx.new(|_| Control { + observed: observed.clone(), + renders: child_renders.clone(), + }), + }); + cx.run_until_parked(); + let rest = gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_pill())); + let hover = gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_row_selected())); + let bounds = cx.debug_bounds("cached-hover").unwrap(); + let outside = gpui::point( + bounds.right() + gpui::px(20.), + bounds.bottom() + gpui::px(20.), + ); + cx.simulate_mouse_move(outside, None, Modifiers::default()); + let child_count = child_renders.get(); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + assert!(cx.update(|window, cx| window.simulate_next_frame(cx)) > 0); + cx.run_until_parked(); + // The primitive uses real monotonic time; the test executor's clock + // does not advance Instant. One bounded wait samples an in-flight fade. + std::thread::sleep(HOVER / 4); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_ne!(*observed.borrow(), rest); + cx.simulate_mouse_move(outside, None, Modifiers::default()); + assert_ne!(*observed.borrow(), rest, "leaving must not snap to rest"); + assert!(cx.update(|window, cx| window.simulate_next_frame(cx)) > 0); + cx.run_until_parked(); + std::thread::sleep(HOVER + Duration::from_millis(20)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(*observed.borrow(), rest); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + std::thread::sleep(HOVER + Duration::from_millis(20)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(*observed.borrow(), hover); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + assert!(child_renders.get() > child_count); + } +} diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index 621b87cab..f39191b64 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -429,13 +429,13 @@ impl Popup { /// closed trigger stays an ordinary button: it shows its pressed state, /// and any other open popup still sees the press and closes. Commands /// and keys that open a menu call [`Self::open`] instead. - pub(crate) fn trigger( + pub(crate) fn trigger( &self, key: K, - button: Stateful
, + button: E, cx: &mut Context, toggle: impl Fn(&mut V, &mut Window, &mut Context) + 'static, - ) -> Stateful
{ + ) -> E { let open = self.is_open(&key); let get = self.get; button @@ -1249,10 +1249,7 @@ fn item_row( }; row.aria_toggled(if on { Toggled::True } else { Toggled::False }) }) - .when(highlighted, |row| { - row.aria_active_descendant() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) + .when(highlighted, |row| row.aria_active_descendant()) .flex() .items_center() .gap_2() @@ -1387,10 +1384,17 @@ fn item_row( .children(submenu.panel) }) .relative(); - match style { - Some(style) => style(row_element).into_any_element(), - None => row_element.into_any_element(), - } + let row_element = match style { + Some(style) => style(row_element), + None => row_element, + }; + crate::ui::motion::hover_background( + row_element, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + .highlighted(highlighted) + .into_any_element() } #[derive(Clone, Copy)] diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 93bcaf38f..715d6744b 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -1710,7 +1710,20 @@ impl SettingsScreen { .when(application_selected, |row| { row.border_l_2().border_color(gpui::rgb(theme::accent())) }) - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated())).cursor_pointer()) + .map(|element| { + let rest: gpui::Hsla = gpui::rgb(if selected { + theme::bg_elevated() + } else { + theme::bg_sidebar() + }) + .into(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_elevated()), + ) + }) + .hover(|style| style.cursor_pointer()) .on_click({ let section = *section; cx.listener(move |this, _event, _window, cx| { @@ -2826,11 +2839,19 @@ impl SettingsScreen { theme::text_secondary() })) .when(active, |el| el.bg(gpui::rgb(theme::bg_sidebar_row_hover()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if active { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .child(label) }; div() diff --git a/apps/maple-agent/app/src/ui/titlebar.rs b/apps/maple-agent/app/src/ui/titlebar.rs index db2358b89..4107c7710 100644 --- a/apps/maple-agent/app/src/ui/titlebar.rs +++ b/apps/maple-agent/app/src/ui/titlebar.rs @@ -184,11 +184,15 @@ fn control( .bg(gpui::rgb(theme::bg_title_control())) .text_xs() .text_color(gpui::rgb(theme::text_primary())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_title_control_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::rgb(theme::bg_title_control()).into(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_title_control_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_mouse_down(gpui::MouseButton::Left, { let action = std::rc::Rc::new(action); move |_event, window, cx| { diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index 76cbca4e0..ac367dbf6 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -1,9 +1,8 @@ //! Shared chrome for the gpui screens: popup panels, buttons, switches, //! tooltips, card rows, input frames, and banners. //! -//! Every helper returns a bare `div()` builder, so it costs the same as -//! the inline chain it replaces: no allocation, no parsing, nothing -//! cached. Callers keep their own ids, children, and click handlers. +//! Helpers preserve the div builder API and callers keep their ids, children, +//! and click handlers. Hover backgrounds share a short reversible transition. //! //! Shapes follow the brand kit: buttons are pills, small controls use //! `theme::RADIUS_SM`, popups and inputs `theme::RADIUS_MD`, cards @@ -20,6 +19,7 @@ use gpui::{ }; use super::icons::icon; +use super::motion::{HoverBackground, hover_background}; use super::theme; /// How long an icon-only control waits before showing its tooltip. Icons @@ -92,49 +92,60 @@ fn pill(id: impl Into) -> Stateful
{ } /// Accent-filled action button: coral pill with a light label. -pub fn primary_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::accent())) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| style.bg(gpui::rgb(theme::accent_hover())).cursor_pointer()) - .active(|style| press_depth(style.bg(gpui::rgb(theme::send_bottom())))) +pub fn primary_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .bg(gpui::rgb(theme::accent())) + .text_color(gpui::rgb(theme::on_accent())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.bg(gpui::rgb(theme::send_bottom())))), + gpui::rgb(theme::accent()), + gpui::rgb(theme::accent_hover()), + ) } /// Neutral pill for the second action beside a [`primary_button`] /// (Cancel, Back, alternate sign-in): a pebble fill that darkens on hover. -pub fn secondary_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .text_color(gpui::rgb(theme::text_primary())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() - }) - .active(|style| press_depth(style.bg(gpui::rgb(theme::border())))) +pub fn secondary_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .bg(gpui::rgb(theme::bg_sidebar_pill())) + .text_color(gpui::rgb(theme::text_primary())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.bg(gpui::rgb(theme::border())))), + gpui::rgb(theme::bg_sidebar_pill()), + gpui::rgb(theme::bg_sidebar_row_selected()), + ) } /// Text-only button for low-emphasis actions: no fill, no border, and /// the label brightens on hover. -pub fn ghost_button(id: impl Into) -> Stateful
{ - pill(id) - .text_color(gpui::rgb(theme::text_secondary())) - .hover(|style| { - style - .text_color(gpui::rgb(theme::text_primary())) - .bg(theme::overlay_hover()) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))) +pub fn ghost_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .text_color(gpui::rgb(theme::text_secondary())) + .hover(|style| { + style + .text_color(gpui::rgb(theme::text_primary())) + .cursor_pointer() + }) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))), + gpui::transparent_black(), + theme::overlay_hover(), + ) } /// Destructive pill (Remove, Deny): error fill with a light label. -pub fn danger_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::status_error())) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| style.opacity(0.9).cursor_pointer()) - .active(|style| press_depth(style.opacity(0.85))) +pub fn danger_button(id: impl Into) -> HoverBackground { + let error: Hsla = gpui::rgb(theme::status_error()).into(); + hover_background( + pill(id) + .text_color(gpui::rgb(theme::on_accent())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.opacity(0.85))), + error, + gpui::hsla(error.h, error.s, error.l, 0.9), + ) } /// Square icon-only button with a hover plate, used in list rows and @@ -147,7 +158,7 @@ pub fn icon_button( label: &'static str, size: Pixels, color: u32, -) -> Stateful
{ +) -> HoverBackground { icon_button_base(id, name, label, size, color).tooltip(tooltip(label, None)) } @@ -161,7 +172,7 @@ pub fn icon_button_for_action( size: Pixels, color: u32, action: &dyn Action, -) -> Stateful
{ +) -> HoverBackground { icon_button_base(id, name, label, size, color).tooltip(tooltip_for_action(label, action)) } @@ -171,26 +182,26 @@ fn icon_button_base( label: &'static str, size: Pixels, color: u32, -) -> Stateful
{ - div() - .id(id) - .role(Role::Button) - .aria_label(label) - .flex_none() - .size_7() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .focus_visible(focus_ring) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - .tooltip_show_delay(ICON_TOOLTIP_DELAY) - .child(icon(name, size, color)) +) -> HoverBackground { + hover_background( + div() + .id(id) + .role(Role::Button) + .aria_label(label) + .flex_none() + .size_7() + .flex() + .items_center() + .justify_center() + .rounded(theme::RADIUS_SM) + .focus_visible(focus_ring) + .hover(|style| style.cursor_pointer()) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) + .tooltip_show_delay(ICON_TOOLTIP_DELAY) + .child(icon(name, size, color)), + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) } /// On/off switch: a pill track with a knob that sits right when `on`. @@ -565,7 +576,7 @@ pub fn copy_button( text: SharedString, reveal: Option<&SharedString>, view: Option, -) -> Stateful
{ +) -> super::motion::HoverBackground { let id = id.into(); let copied = copied_recently(&id); let click_id = id.clone(); @@ -593,7 +604,6 @@ pub fn copy_button( .focus_visible(focus_ring) .hover(|style| { style - .bg(theme::overlay_hover()) .text_color(gpui::rgb(theme::text_secondary())) .cursor_pointer() }) @@ -623,6 +633,13 @@ pub fn copy_button( }, )) .child(if copied { "Copied" } else { "Copy" }) + .map(|button| { + super::motion::hover_background( + button, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + }) } #[cfg(test)]