From 9ab7562cc535b0dbf5fcea6dfec3da73ca929890 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:44:30 -0500 Subject: [PATCH 1/2] Agent: show a details card beside the hovered task row A row clips its title to one line, and nothing in the sidebar says when a task last changed. Hovering a row now shows a card beside it, like the Tauri app's and Codex's: the title up to three lines, the time since the task's last activity ("5m", "3h") at the top right, and the project with its folder underneath. Pressing the folder opens it in the file manager when the tasks are on this computer. The row's accessible description gains the local date and time of the last activity, so the keyboard and screen readers get it too; an absolute time never goes stale, and new activity rewrites only its own row's description. The pointer can move onto the card, so it is a real floating panel and goes through popup.rs: a new hover_panel draws it beside its row (new Placement::Beside) the way menus are drawn, with a hitbox and without taking focus, so the keyboard stays in the list. The card stays while the pointer is on the row or the card and goes 200 ms after it leaves both, enough to cross the gap and shorter than a tooltip's delay. On the row's buttons the card gives way to their tooltips. gpui reports hover only when the pointer moves, so rows that move under a still pointer would leave the card on the wrong task. A scroll (by wheel or a keyboard reveal), a reorder that moves the hovered row, a menu, a rename, collapsing the sidebar, or the window losing focus ends the hover; every pointer move over a row claims it again, also while another app is in front, as in other Mac apps. A one-minute timer keeps the age current, owned by the hover, so it stops whenever the hover ends. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/mod.rs | 14 +- .../maple-agent/app/src/ui/chat/navigation.rs | 6 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 422 +++++++++++++++++- .../app/src/ui/chat/sidebar_paging.rs | 38 ++ apps/maple-agent/app/src/ui/chat/tests.rs | 293 ++++++++++++ apps/maple-agent/app/src/ui/popup.rs | 38 ++ 6 files changed, 797 insertions(+), 14 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a5331a4e9..849321f5d 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -928,6 +928,10 @@ impl ChatScreen { return; } self.sidebar_collapsed = collapsed; + if collapsed { + // Its rows leave the window without seeing the pointer leave. + self.sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + } cx.notify(); } @@ -4017,7 +4021,8 @@ impl ChatScreen { let Some((row, request)) = self.task_step_target(delta, cx) else { return; }; - self.sidebar.read(cx).reveal_row(row); + self.sidebar + .update(cx, |sidebar, cx| sidebar.reveal_row(row, cx)); if request.computer.as_deref() == self.backend.remote_host() { if let Destination::Task(id) = request.destination { self.select_session(&id, cx); @@ -6339,7 +6344,12 @@ impl Render for ChatScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Refreshed every frame; activation changes force a redraw, so // this tracks focus closely enough to gate notifications. - self.window_active = window.is_window_active(); + let was_active = std::mem::replace(&mut self.window_active, window.is_window_active()); + if was_active && !self.window_active { + // Another app took over under a still pointer, which reports + // nothing; the sidebar's hover card goes with the window's focus. + self.sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + } let vim_in_sidebar = self.application_vim_enabled && self.application_vim.region == self::navigation::ChatRegion::Sidebar; self.sidebar diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 8be1af2ee..f0493d22c 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -895,9 +895,11 @@ impl ChatScreen { self.focus_application_vim(window, cx); } - fn reveal_application_selection(&self, cx: &gpui::App) { + fn reveal_application_selection(&self, cx: &mut gpui::App) { match self.application_vim.region { - ChatRegion::Sidebar => self.sidebar.read(cx).vim_reveal(), + ChatRegion::Sidebar => self + .sidebar + .update(cx, |sidebar, cx| sidebar.vim_reveal(cx)), ChatRegion::Transcript => { if let Some(item_id) = self.selected_transcript_id() && let Some(&(row, _)) = self.timeline_index.get(item_id) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index c1c4b7da0..2e17c0526 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -60,9 +60,10 @@ pub(crate) struct BrowseState { use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use gpui::{ - AnyElement, AppContext, Context, Div, Entity, EventEmitter, Focusable, SharedString, + AnyElement, AppContext, Context, Div, Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, Task, WeakEntity, Window, div, prelude::*, px, }; use maple_agent::agent::{ @@ -74,7 +75,7 @@ use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; use crate::ui::icons::{icon, spinner_with_id, wordmark}; -use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; +use crate::ui::popup::{self, Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::ui::theme; use crate::ui::titlebar; @@ -169,6 +170,9 @@ pub(super) struct SidebarRow { /// Animation id of the running indicator. pub(super) spinner_id: SharedString, pub(super) pin_id: SharedString, + card_id: SharedString, + card_folder_id: SharedString, + actions_id: SharedString, pub(super) menu_id: SharedString, /// Id of the overflow-menu panel itself. pub(super) menu_panel_id: SharedString, @@ -181,6 +185,7 @@ pub(super) struct SidebarRow { pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, + path: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, } @@ -196,6 +201,9 @@ impl SidebarRow { step_id: SharedString::from(format!("step-session-{id}")), spinner_id: SharedString::from(format!("spinner-session-{id}")), pin_id: SharedString::from(format!("pin-session-{id}")), + card_id: SharedString::from(format!("card-session-{id}")), + card_folder_id: SharedString::from(format!("card-folder-{id}")), + actions_id: SharedString::from(format!("actions-session-{id}")), menu_id: SharedString::from(format!("menu-session-{id}")), menu_panel_id: SharedString::from(format!("task-menu-{id}")), menu_rename_id: SharedString::from(format!("rename-task-{id}")), @@ -206,6 +214,7 @@ impl SidebarRow { menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), + path: SharedString::from(session.project_root.clone()), search: session.title.to_lowercase(), } } @@ -214,6 +223,21 @@ impl SidebarRow { /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; +const CARD_AGE_REFRESH: Duration = Duration::from_secs(60); +// Enough time to cross the gap, shorter than a button's tooltip delay. +const CARD_CLOSE_DELAY: Duration = Duration::from_millis(200); + +struct HoveredTask { + session: Arc, + entry: usize, + age: SharedString, + on_row: bool, + on_card: bool, + // Dropping these tasks cancels the pending close and age refresh. + closing: Option>, + _age_refresh: Task<()>, +} + /// The sidebar's popup menus. One is open at a time; a project's own menu /// opens inside the switcher (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] @@ -412,6 +436,8 @@ pub(super) struct Sidebar { active_expanded: bool, settled_expanded: bool, archived_expanded: bool, + hovered: Option, + on_row_actions: Option>, switcher_roots: Vec, computer_switcher_roots: Vec>, scope_label: SharedString, @@ -434,6 +460,9 @@ pub(super) struct Sidebar { /// Task renames sent to the backend, for tests. #[cfg(test)] renames_sent: Vec<(String, String)>, + /// Folders the sidebar asked the file manager to open, for tests. + #[cfg(test)] + folders_opened: Vec, // Persisted in the app settings. pinned_tasks: Vec, /// Latest owner metadata seen per path, independent of current membership. @@ -584,6 +613,7 @@ impl Sidebar { self.archived_expanded = state.archived_expanded; // A project filter belongs to the previous execution computer. self.project_filter = None; + self.end_hover(cx); if self.pages.enabled && scope_changed { self.refresh_pages(cx); } @@ -623,6 +653,14 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); + let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); + // Scrolling moves the rows under a still pointer, which reports + // nothing until it moves; the hover ends and its next move claims + // whichever row is under it. + let sidebar = cx.entity().downgrade(); + list.set_scroll_handler(move |_event, _window, cx| { + sidebar.update(cx, |this, cx| this.end_hover(cx)).ok(); + }); Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), @@ -640,7 +678,7 @@ impl Sidebar { recent_roots: Vec::new(), application_vim_enabled, vim_active: false, - list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)), + list, entries: Vec::new(), rows: Vec::new(), pinned_rows: Vec::new(), @@ -651,6 +689,8 @@ impl Sidebar { active_expanded: true, settled_expanded: true, archived_expanded: false, + hovered: None, + on_row_actions: None, switcher_roots: Vec::new(), computer_switcher_roots: Vec::new(), scope_label: "All projects".into(), @@ -666,6 +706,8 @@ impl Sidebar { rename_focus_out: None, #[cfg(test)] renames_sent: Vec::new(), + #[cfg(test)] + folders_opened: Vec::new(), pinned_tasks: remote_settings .as_ref() .map_or_else(|| settings.pinned_tasks.clone(), |s| s.pinned_tasks.clone()), @@ -983,7 +1025,10 @@ impl Sidebar { Some(rows[target].clone()) } - pub(super) fn reveal_row(&self, row: usize) { + /// Scroll `row` into view. The rows move under a still pointer, so + /// this ends the hover the way a wheel scroll does. + pub(super) fn reveal_row(&mut self, row: usize, cx: &mut Context) { + self.end_hover(cx); self.list.scroll_to_reveal_item(row); } @@ -1034,6 +1079,16 @@ impl Sidebar { } } + #[cfg(test)] + pub(super) fn hovered(&self) -> Option<&str> { + self.hovered.as_ref().map(|hovered| &*hovered.session) + } + + #[cfg(test)] + pub(super) fn card_age(&self) -> Option<&str> { + self.hovered.as_ref().map(|hovered| hovered.age.as_ref()) + } + #[cfg(test)] pub(super) fn project_name_for_test(&self, target: &ProjectTarget) -> String { self.project_name(target) @@ -1104,6 +1159,11 @@ impl Sidebar { &self.renames_sent } + #[cfg(test)] + pub(super) fn folders_opened(&self) -> &[String] { + &self.folders_opened + } + #[cfg(test)] pub(super) fn rename_target(&self) -> Option { self.rename.clone() @@ -1122,7 +1182,22 @@ impl Sidebar { #[cfg(test)] pub(super) fn open_task_menu_for_test(&mut self, session_id: &str, cx: &mut Context) { - self.popup.open(SidebarPopup::Task(session_id.into()), cx); + if !self.popup.is_open(&SidebarPopup::Task(session_id.into())) { + self.toggle_task_menu(session_id.into(), cx); + } + } + + /// Move a task's activity time without the screen's sync, so only the + /// hover card's own refresh can see it. + #[cfg(test)] + pub(super) fn set_updated_ms_for_test(&mut self, session_id: &str, updated_ms: i64) { + if let Some(session) = self + .sessions + .iter_mut() + .find(|session| session.id == session_id) + { + session.updated_ms = updated_ms; + } } #[cfg(test)] @@ -1405,6 +1480,11 @@ impl Sidebar { if self.application_vim_enabled { self.vim_reconcile(); } + // A row that left or moved in the list never reports the pointer + // leaving it; one that stayed may have new activity. + if self.hovered.is_some() { + self.refresh_card_age(now_ms()); + } } /// A row changed its height (a rename field came or went): let the @@ -1436,6 +1516,7 @@ impl Sidebar { && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id && row.title.as_ref() == session.title + && row.path.as_ref() == session.project_root && self.root_name_matches(&session.project_root, &row.project_name) }); if fresh { @@ -1575,6 +1656,7 @@ impl Sidebar { /// Open or close the project switcher menu from its header. pub(super) fn toggle_switcher_menu(&mut self, cx: &mut Context) { + self.end_hover(cx); self.project_popup.close(cx); self.popup.toggle(SidebarPopup::Switcher, cx); } @@ -1585,6 +1667,7 @@ impl Sidebar { if open == self.switcher_menu_open() { return; } + self.end_hover(cx); self.project_popup.close(cx); if open { let current = self @@ -1640,6 +1723,7 @@ impl Sidebar { .unwrap_or_default(), RenameTarget::Project(target) => self.project_name(target), }; + self.end_hover(cx); let chat = self.chat.clone(); let sidebar = cx.entity().downgrade(); let escape = sidebar.clone(); @@ -1804,6 +1888,12 @@ impl Sidebar { /// Show the project's folder in the file manager. fn open_folder(&mut self, root: &str, cx: &mut Context) { + // Tests record the request instead of opening a window. + #[cfg(test)] + if cfg!(test) { + self.folders_opened.push(root.to_string()); + return; + } let path = root.to_string(); self.call( async move { @@ -1854,8 +1944,142 @@ impl Sidebar { ); } + // Every move can reclaim a hover ended by scrolling or a menu. + fn claim_hover(&mut self, session_id: &Arc, cx: &mut Context) { + if self.card_suppressed() || self.on_row_actions.as_ref() == Some(session_id) { + return; + } + if let Some(hovered) = &mut self.hovered + && hovered.session == *session_id + { + if !hovered.on_row { + hovered.on_row = true; + self.hover_moved(cx); + } + return; + } + let Some(entry) = self.task_entry(session_id) else { + return; + }; + self.hovered = Some(HoveredTask { + session: Arc::clone(session_id), + entry, + age: SharedString::default(), + on_row: true, + on_card: false, + closing: None, + _age_refresh: cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(CARD_AGE_REFRESH).await; + if this + .update(cx, |this, cx| { + if this.refresh_card_age(now_ms()) { + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }), + }); + self.refresh_card_age(now_ms()); + cx.notify(); + } + + /// The pointer left a task row. Rows report in no fixed order, so + /// leaving a row touches only its own hover. + fn release_hover(&mut self, session_id: &Arc, cx: &mut Context) { + if let Some(hovered) = &mut self.hovered + && hovered.session == *session_id + { + hovered.on_row = false; + self.hover_moved(cx); + } + } + + fn set_on_row_actions(&mut self, session_id: &Arc, on: bool, cx: &mut Context) { + if on { + self.on_row_actions = Some(Arc::clone(session_id)); + self.release_hover(session_id, cx); + } else if self.on_row_actions.as_ref() == Some(session_id) { + self.on_row_actions = None; + } + } + + fn set_on_card(&mut self, on: bool, cx: &mut Context) { + if let Some(hovered) = &mut self.hovered { + hovered.on_card = on; + self.hover_moved(cx); + } + } + + fn hover_moved(&mut self, cx: &mut Context) { + let Some(hovered) = &mut self.hovered else { + return; + }; + if hovered.on_row || hovered.on_card { + hovered.closing = None; + } else if hovered.closing.is_none() { + hovered.closing = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CARD_CLOSE_DELAY).await; + this.update(cx, |this, cx| this.end_hover(cx)).ok(); + })); + } + } + + pub(super) fn end_hover(&mut self, cx: &mut Context) { + if self.hovered.take().is_some() { + cx.notify(); + } + } + + fn task_entry(&self, session_id: &str) -> Option { + self.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::Task(task) + if self + .sessions + .get(task.session) + .is_some_and(|session| *session.id == *session_id)) + }) + } + + /// Bring the hover card's age up to `now`, or end the hover when its + /// row left the list or moved in it. Reports whether the card changed. + pub(super) fn refresh_card_age(&mut self, now: i64) -> bool { + let Some(hovered) = &self.hovered else { + return false; + }; + let updated_ms = self + .task_entry(&hovered.session) + .filter(|entry| *entry == hovered.entry) + .and_then(|entry| match &self.entries[entry] { + SidebarEntry::Task(task) => self.sessions.get(task.session), + _ => None, + }) + .map(|session| session.updated_ms); + let Some(updated_ms) = updated_ms else { + self.hovered = None; + return true; + }; + let age = age_label(now, updated_ms); + let hovered = self.hovered.as_mut().unwrap(); + if age == hovered.age { + return false; + } + hovered.age = age; + true + } + + // Project menus are nested inside the switcher. + fn card_suppressed(&self) -> bool { + self.popup.open_key().is_some() || self.rename.is_some() + } + /// Open or close the overflow menu of one task row. fn toggle_task_menu(&mut self, session_id: Arc, cx: &mut Context) { + self.end_hover(cx); self.popup.toggle(SidebarPopup::Task(session_id), cx); } @@ -2165,7 +2389,7 @@ impl Sidebar { let row = self.vim_target_row(&target); self.vim_selected = Some(target); if let Some(row) = row { - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); } cx.notify(); true @@ -2183,7 +2407,7 @@ impl Sidebar { return false; }; self.vim_selected = Some(target); - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); cx.notify(); true } @@ -2195,13 +2419,13 @@ impl Sidebar { } /// Scroll the selected target into view. - pub(super) fn vim_reveal(&self) { + pub(super) fn vim_reveal(&mut self, cx: &mut Context) { if let Some(row) = self .vim_selected .as_ref() .and_then(|target| self.vim_target_row(target)) { - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); } } @@ -2212,7 +2436,7 @@ impl Sidebar { Some(SidebarTarget::Archived) => self.set_archived_expanded(expanded, cx), _ => {} } - self.vim_reveal(); + self.vim_reveal(cx); } fn vim_reconcile(&mut self) { @@ -2754,7 +2978,17 @@ impl Sidebar { let activity = (!archived) .then(|| self.session_activity(&row.id)) .flatten(); + let card = self + .hovered + .as_ref() + .filter(|hovered| hovered.session == row.id) + .map(|hovered| { + // Another computer's folder cannot open on this one. + let local = self.backend.remote_host().is_none(); + task_card(row, &hovered.age, local, cx) + }); let session_id = Arc::clone(&row.id); + let hover_id = Arc::clone(&row.id); let step_id = Arc::clone(&row.id); let rename_id = Arc::clone(&row.id); let rung = TaskRung::of(task); @@ -2796,6 +3030,18 @@ impl Sidebar { .cursor_pointer() }) }) + .on_mouse_move({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, _event, _window, cx| this.claim_hover(&hover_id, cx)) + }) + .on_hover({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, hovered: &bool, _window, cx| { + if !*hovered { + this.release_hover(&hover_id, cx); + } + }) + }) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); })) @@ -2845,6 +3091,12 @@ impl Sidebar { .cursor_pointer() }) .tooltip(widgets::tooltip("Unpin", None)) + .on_hover({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, on: &bool, _window, cx| { + this.set_on_row_actions(&hover_id, *on, cx); + }) + }) .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); this.toggle_task_pin(&pin_id, cx); @@ -2855,6 +3107,7 @@ impl Sidebar { .when(!renaming, |el| { el.child( div() + .id(row.actions_id.clone()) .absolute() .top_0() .bottom_0() @@ -2870,6 +3123,9 @@ impl Sidebar { .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) + .on_hover(cx.listener(move |this, on: &bool, _window, cx| { + this.set_on_row_actions(&hover_id, *on, cx); + })) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -2893,6 +3149,7 @@ impl Sidebar { )), ) }) + .children(card) } /// The collapse button in the top row. @@ -3108,6 +3365,151 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } } +/// Width of a task row's hover card. +const CARD_WIDTH: Pixels = px(300.); + +/// The current time in Unix milliseconds, as session records stamp it. +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// Time since `then_ms`, short: "now", "5m", "3h", "2d", "3w", "1y". +/// Empty when the time is unknown. +fn age_label(now_ms: i64, then_ms: i64) -> SharedString { + const MINUTE: i64 = 60_000; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + const WEEK: i64 = 7 * DAY; + const YEAR: i64 = 365 * DAY; + if then_ms <= 0 { + return SharedString::default(); + } + let age = now_ms - then_ms; + if age < MINUTE { + return "now".into(); + } + let (count, unit) = if age < HOUR { + (age / MINUTE, 'm') + } else if age < DAY { + (age / HOUR, 'h') + } else if age < WEEK { + (age / DAY, 'd') + } else if age < YEAR { + (age / WEEK, 'w') + } else { + (age / YEAR, 'y') + }; + format!("{count}{unit}").into() +} + +/// The task's title, age, project, and folder in a panel beside its row. +fn task_card( + row: &SidebarRow, + age: &SharedString, + can_open: bool, + cx: &mut Context, +) -> AnyElement { + let path = row.path.clone(); + let folder = div() + .id(row.card_folder_id.clone()) + .debug_selector(|| row.card_folder_id.to_string()) + .flex() + .items_start() + .gap_2() + .px_2() + .py_1p5() + .rounded(theme::RADIUS_SM) + .child(div().flex_none().h_5().flex().items_center().child(icon( + "folder", + px(13.), + theme::text_muted(), + ))) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .line_clamp(1) + .text_ellipsis() + .text_color(gpui::rgb(theme::text_secondary())) + .child(row.project_name.clone()), + ) + .child( + div() + .font_family(crate::assets::FONT_MONO) + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(row.path.clone()), + ), + ) + .when(can_open, |folder| { + folder + .role(gpui::Role::Button) + .aria_label("Open folder") + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_hover()))) + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.open_folder(&path, cx); + })) + }); + let card = div() + .id(row.card_id.clone()) + .debug_selector(|| row.card_id.to_string()) + .occlude() + .role(gpui::Role::Group) + .aria_label(row.title.clone()) + .w(CARD_WIDTH) + .p_1p5() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .shadow_md() + .flex() + .flex_col() + .gap_0p5() + .text_sm() + // A selected row's title weight would carry over to the card. + .font_weight(gpui::FontWeight::NORMAL) + .text_color(gpui::rgb(theme::text_primary())) + .on_hover(cx.listener(|this, on: &bool, _window, cx| this.set_on_card(*on, cx))) + .child( + div() + .flex() + .items_start() + .gap_3() + .px_2() + .pt_1p5() + .pb_1() + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(3) + .text_ellipsis() + .child(row.title.clone()), + ) + // The title's first line is 20 px tall: centered in one, + // the age sits level with it. + .child( + div() + .flex_none() + .h_5() + .flex() + .items_center() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(age.clone()), + ), + ) + .child(folder); + popup::hover_panel(card, Placement::Beside) +} + /// Small icon button that shows only while the pointer is over its row. pub(super) fn row_action( id: SharedString, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs index 768b1e73b..e668286b1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -94,6 +94,44 @@ mod tests { assert!(published[0].is_empty()); } + #[gpui::test] + fn paged_hover_tracks_activity_and_ends_when_a_live_task_moves_it(cx: &mut gpui::TestAppContext) { + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + 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.read_with(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.pages = SidebarPages { + enabled: true, + ..SidebarPages::default() + }; + let now = now_ms(); + let mut first = page_summary("first"); + first.updated_ms = now - 60_000; + sidebar.apply_task_page(PageKind::Active, vec![first], false); + sidebar.rebuild_sections(); + sidebar.claim_hover(&Arc::from("first"), cx); + assert_eq!(sidebar.card_age(), Some("1m")); + + let mut latest = summary("first", AgentTaskState::Active); + latest.updated_ms = now; + sidebar.apply_live_summary(latest, cx); + assert_eq!(sidebar.card_age(), Some("now")); + + sidebar.apply_task_page(PageKind::Active, vec![page_summary("older")], true); + sidebar.rebuild_sections(); + assert_eq!(sidebar.hovered(), Some("first")); + + let mut newer = summary("newer", AgentTaskState::Active); + newer.updated_ms = now + 1; + sidebar.apply_live_summary(newer, cx); + assert_eq!(sidebar.hovered(), None); + }); + } + #[gpui::test] fn live_task_insert_preserves_the_loaded_cursor_boundary(cx: &mut gpui::TestAppContext) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1dd1eaf02..eb7755f95 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -16,6 +16,12 @@ mod state_tests { RecentProjectRoot, }; + /// A point on a task row's title, left of its buttons (given by the + /// row's menu button), where the pointer shows the row's hover card. + fn on_title(menu_button: gpui::Bounds) -> gpui::Point { + gpui::point(menu_button.left() - px(120.), menu_button.center().y) + } + fn summary(id: &str, title: &str) -> AgentSessionSummary { summary_at(id, title, "/tmp/proj") } @@ -5959,6 +5965,293 @@ mod state_tests { ); } + fn hovered( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Option { + sidebar.update(cx, |sidebar, _| sidebar.hovered().map(str::to_string)) + } + + fn advance_hover_clock(cx: &mut gpui::VisualTestContext, ms: u64) { + cx.executor() + .advance_clock(std::time::Duration::from_millis(ms)); + cx.run_until_parked(); + } + + #[gpui::test] + fn test_hover_card_follows_the_pointer_and_closes_after_leaving(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let first = on_title(cx.debug_bounds("menu-session-s1").expect("s1")); + let second = on_title(cx.debug_bounds("menu-session-s2").expect("s2")); + let away = gpui::point(px(1100.), px(700.)); + cx.simulate_mouse_move(first, None, Default::default()); + assert!(cx.debug_bounds("card-session-s1").is_some()); + cx.simulate_mouse_move(away, None, Default::default()); + advance_hover_clock(cx, 100); + cx.simulate_mouse_move(second, None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + assert!(cx.debug_bounds("card-session-s1").is_none()); + + let card = cx.debug_bounds("card-session-s2").expect("s2's card"); + cx.simulate_mouse_move( + gpui::point(card.left() - px(2.), card.top() + px(10.)), + None, + Default::default(), + ); + advance_hover_clock(cx, 100); + cx.simulate_mouse_move(card.center(), None, Default::default()); + advance_hover_clock(cx, 1_000); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + for at in [second, card.center()] { + cx.simulate_mouse_move(at, None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + assert!(cx.debug_bounds("card-session-s2").is_some()); + } + cx.simulate_mouse_move(away, None, Default::default()); + advance_hover_clock(cx, 100); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + advance_hover_clock(cx, 150); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s2").is_none()); + } + + #[gpui::test] + fn test_hover_card_opens_the_folder_from_an_inactive_window(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary_at("s2", "Two", "/tmp/other")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s2").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + press(cx, "card-folder-s2"); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.folders_opened().to_vec()), + vec!["/tmp/other".to_string()] + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()) + .as_deref(), + Some("s1") + ); + cx.deactivate_window(); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s2").is_none()); + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + + cx.deactivate_window(); + cx.run_until_parked(); + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + let folder = cx.debug_bounds("card-folder-s2").expect("the folder line"); + cx.simulate_mouse_move(folder.center(), None, Default::default()); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: folder.center(), + click_count: 1, + first_mouse: true, + ..Default::default() + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: folder.center(), + click_count: 1, + ..Default::default() + }); + cx.run_until_parked(); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.folders_opened().to_vec()), + vec!["/tmp/other".to_string(); 2] + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()) + .as_deref(), + Some("s1"), + "the click opens the folder, not the task" + ); + } + + #[gpui::test] + fn test_hover_card_wraps_long_local_and_remote_paths(cx: &mut TestAppContext) { + let component = "a".repeat(200); + let (_chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![ + summary_at("s0", "Task", &format!("/tmp/{component}/nested/project")), + summary_at( + "s1", + "Task", + &format!(r"C:\Users\Alex\{component}\nested\project"), + ), + ]; + this.sync_sidebar(cx); + }); + for (row_id, folder_id) in [ + ("menu-session-s0", "card-folder-s0"), + ("menu-session-s1", "card-folder-s1"), + ] { + let row = cx.debug_bounds(row_id).expect("row"); + cx.simulate_mouse_move(on_title(row), None, Default::default()); + assert!( + cx.debug_bounds(folder_id).expect("folder").size.height > px(100.), + "long components wrap past the old two-line clamp" + ); + } + } + + #[gpui::test] + fn test_hover_card_gives_way_to_buttons_menus_and_rename(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + let nudge = |cx: &mut gpui::VisualTestContext| { + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + }; + nudge(cx); + let rename = cx.debug_bounds("rename-session-s1").expect("rename"); + cx.simulate_mouse_move(rename.center(), None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx), None, "menus suppress the card"); + sidebar.update(cx, |sidebar, cx| { + sidebar.close_popups(cx); + sidebar.begin_rename(RenameTarget::Task("s1".to_string()), cx); + }); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx), None, "rename suppresses the card"); + sidebar.update(cx, |sidebar, cx| sidebar.cancel_rename(cx)); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + } + + #[gpui::test] + fn test_hover_card_refreshes_only_while_visible(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let notified = std::rc::Rc::new(std::cell::Cell::new(0)); + cx.update(|_, cx| { + let notified = notified.clone(); + cx.observe(&sidebar, move |_, _| notified.set(notified.get() + 1)) + .detach(); + }); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + let before = notified.get(); + advance_hover_clock(cx, 60_000); + assert_eq!(notified.get(), before, "unchanged age does not repaint"); + let age = sidebar.update(cx, |sidebar, _| sidebar.card_age().unwrap().to_string()); + sidebar.update(cx, |sidebar, _| sidebar.set_updated_ms_for_test("s1", 1)); + advance_hover_clock(cx, 60_000); + assert!( + notified.get() > before, + "the timer refreshes changed activity" + ); + assert_ne!( + sidebar.update(cx, |sidebar, _| sidebar.card_age().unwrap().to_string()), + age, + "the timer updates the card's age" + ); + sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + cx.run_until_parked(); + let before = notified.get(); + sidebar.update(cx, |sidebar, _| sidebar.set_updated_ms_for_test("s1", 0)); + advance_hover_clock(cx, 120_000); + assert_eq!(notified.get(), before, "ending hover cancels its timer"); + } + + #[gpui::test] + fn test_hover_card_ends_when_rows_move_or_keyboard_navigation_scrolls(cx: &mut TestAppContext) { + let now = chrono::Utc::now().timestamp_millis(); + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (0..40) + .map(|ix| { + let mut task = summary(&format!("s{ix}"), &format!("Task {ix}")); + task.updated_ms = now - (ix + 1) * 60_000; + task + }) + .collect(); + this.selected_session = Some("s38".to_string()); + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s0").expect("the first row")); + let nudge = |cx: &mut gpui::VisualTestContext, dx: f32| { + cx.simulate_mouse_move(at + gpui::point(px(dx), px(0.)), None, Default::default()); + }; + nudge(cx, 0.); + chat.update(cx, |this, cx| { + this.sessions[1].updated_ms = now; + this.sync_sidebar(cx); + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx, 1.); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + chat.update(cx, |this, cx| { + this.sessions.retain(|task| task.id != "s1"); + this.sync_sidebar(cx); + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s1").is_none()); + nudge(cx, 0.); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s0")); + cx.simulate_event(gpui::ScrollWheelEvent { + position: at, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-600.))), + ..Default::default() + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx, 0.); + assert!(hovered(&sidebar, cx).is_some()); + chat.update(cx, |this, cx| this.step_task(1, cx)); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(sidebar.update(cx, |sidebar, _| sidebar.list().logical_scroll_top().item_ix) > 0); + } + + #[gpui::test] + fn test_hover_card_ends_when_the_sidebar_collapses(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + chat.update(cx, |this, cx| this.toggle_sidebar_visibility(cx)); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + chat.update(cx, |this, cx| this.toggle_sidebar_visibility(cx)); + cx.run_until_parked(); + assert!(cx.debug_bounds("card-session-s1").is_none()); + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + assert!(cx.debug_bounds("card-session-s1").is_some()); + } + /// The header's project menu closes on a press outside it, like every /// other menu. It used to stay open. #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..13ee003dc 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -31,6 +31,9 @@ //! - Assistive technology sees a button that reports whether its popup is //! expanded, a named menu of named items, and the highlighted item as the //! focused one. +//! +//! [`hover_panel`] shares menu placement and occlusion without taking focus. +//! Its owner manages the hover lifecycle. use std::cell::Cell; use std::rc::Rc; @@ -642,6 +645,8 @@ pub(crate) enum Placement { AboveStart, /// At a point in window coordinates, for a right-click. At(Point), + /// Right of the trigger, sharing its top edge; flips left when needed. + Beside, } impl Placement { @@ -661,6 +666,7 @@ impl Placement { Placement::BelowEnd => (trigger, true, false, GAP), Placement::AboveStart => (trigger, false, true, GAP), Placement::At(position) => (Bounds::new(position, Size::default()), true, true, px(0.)), + Placement::Beside => return beside(trigger, size, window, margin), }; let room_below = window.height - margin - anchor.bottom() - gap; let room_above = anchor.top() - gap - margin; @@ -689,6 +695,28 @@ impl Placement { } } +/// Top-aligned beside the trigger, flipping left and clamping when needed. +fn beside( + trigger: Bounds, + size: Size, + window: Size, + margin: Pixels, +) -> Point { + let room_right = window.width - margin - trigger.right() - GAP; + let room_left = trigger.left() - GAP - margin; + let x = if first_side(true, size.width, room_right, room_left) { + trigger.right() + GAP + } else { + trigger.left() - GAP - size.width + }; + let x = x.min(window.width - margin - size.width).max(margin); + let y = trigger + .top() + .min(window.height - margin - size.height) + .max(margin); + point(x.round(), y.round()) +} + /// Whether a panel `length` long goes on the first of two sides, which /// leave `first` and `second` room: the preferred side when it fits there, /// the other when only that one fits, and the roomier one when neither @@ -703,6 +731,16 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) stays == prefer_first } +/// Place an occluding panel inside a `relative` row without taking focus. +pub(crate) fn hover_panel(panel: impl IntoElement, placement: Placement) -> AnyElement { + Float { + panel: Some(panel.into_any_element()), + placement, + priority: 1, + } + .into_any_element() +} + /// Draws a menu panel above everything, where [`Placement::origin`] puts /// it. Beside a trigger, this element covers the `relative` box around the /// trigger and takes that box's bounds. The panel is measured before it is From 6f2904d1667645302b2b07be69126c39a5c2ef2a Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:54:44 -0500 Subject: [PATCH 2/2] Agent: anchor the hover card to the row highlight Anchor the details card to the inset task highlight while preserving content alignment. Give hover panels a fixed six-pixel gap and preserve menu spacing and window-edge clamping. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 11 ++++--- .../app/src/ui/chat/sidebar_paging.rs | 4 ++- apps/maple-agent/app/src/ui/popup.rs | 33 ++++++++++++------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 2e17c0526..029c94c52 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -2998,7 +2998,8 @@ impl Sidebar { let renaming = rename_field.is_some(); div() .relative() - .w_full() + .w_auto() + .mx_2() .id(row.element_id.clone()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) @@ -3010,9 +3011,9 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .when(archived, |row| row.pl_6()) - .when(!archived, |row| row.pl_8()) - .pr_6() + .when(archived, |row| row.pl_4()) + .when(!archived, |row| row.pl_6()) + .pr_4() .py_1() .rounded(theme::RADIUS_MD) .text_sm() @@ -3119,7 +3120,7 @@ impl Sidebar { .items_center() .gap_1p5() .pl_2() - .pr_6() + .pr_4() .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs index e668286b1..d2fff1602 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -95,7 +95,9 @@ mod tests { } #[gpui::test] - fn paged_hover_tracks_activity_and_ends_when_a_live_task_moves_it(cx: &mut gpui::TestAppContext) { + fn paged_hover_tracks_activity_and_ends_when_a_live_task_moves_it( + cx: &mut gpui::TestAppContext, + ) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); cx.executor().allow_parking(); let backend = diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index 13ee003dc..098ef1317 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -70,6 +70,8 @@ gpui::actions!( /// Space kept between a trigger and the menu it opens. const GAP: Pixels = px(4.); +/// Space kept between a task row and its hover card. +const HOVER_GAP: Pixels = px(6.); /// Closest a menu comes to the window edge. const WINDOW_MARGIN: Pixels = px(8.); @@ -600,6 +602,7 @@ impl Popup { Float { panel: Some(motion::fade_in(panel, "popup-reveal").into_any_element()), placement, + gap: GAP, priority, } .into_any_element() @@ -660,13 +663,14 @@ impl Placement { size: Size, window: Size, margin: Pixels, + gap: Pixels, ) -> Point { let (anchor, prefer_below, prefer_start, gap) = match self { - Placement::BelowStart => (trigger, true, true, GAP), - Placement::BelowEnd => (trigger, true, false, GAP), - Placement::AboveStart => (trigger, false, true, GAP), + Placement::BelowStart => (trigger, true, true, gap), + Placement::BelowEnd => (trigger, true, false, gap), + Placement::AboveStart => (trigger, false, true, gap), Placement::At(position) => (Bounds::new(position, Size::default()), true, true, px(0.)), - Placement::Beside => return beside(trigger, size, window, margin), + Placement::Beside => return beside(trigger, size, window, margin, gap), }; let room_below = window.height - margin - anchor.bottom() - gap; let room_above = anchor.top() - gap - margin; @@ -701,13 +705,14 @@ fn beside( size: Size, window: Size, margin: Pixels, + gap: Pixels, ) -> Point { - let room_right = window.width - margin - trigger.right() - GAP; - let room_left = trigger.left() - GAP - margin; + let room_right = window.width - margin - trigger.right() - gap; + let room_left = trigger.left() - gap - margin; let x = if first_side(true, size.width, room_right, room_left) { - trigger.right() + GAP + trigger.right() + gap } else { - trigger.left() - GAP - size.width + trigger.left() - gap - size.width }; let x = x.min(window.width - margin - size.width).max(margin); let y = trigger @@ -736,6 +741,7 @@ pub(crate) fn hover_panel(panel: impl IntoElement, placement: Placement) -> AnyE Float { panel: Some(panel.into_any_element()), placement, + gap: HOVER_GAP, priority: 1, } .into_any_element() @@ -748,6 +754,7 @@ pub(crate) fn hover_panel(panel: impl IntoElement, placement: Placement) -> AnyE struct Float { panel: Option, placement: Placement, + gap: Pixels, priority: usize, } @@ -804,7 +811,7 @@ impl Element for Float { let margin = window_margin(window); let origin = self .placement - .origin(bounds, size, window.viewport_size(), margin); + .origin(bounds, size, window.viewport_size(), margin, self.gap); window.defer_draw(panel, origin, self.priority, None); } @@ -1667,8 +1674,9 @@ mod tests { let window = size(px(800.), px(600.)); let panel = size(px(200.), px(300.)); let trigger = |x: f32, y: f32| Bounds::new(point(px(x), px(y)), size(px(80.), px(20.))); - let origin = - |placement: Placement, trigger| placement.origin(trigger, panel, window, WINDOW_MARGIN); + let origin = |placement: Placement, trigger| { + placement.origin(trigger, panel, window, WINDOW_MARGIN, GAP) + }; // Room below: below, 4 px down, left edges shared. assert_eq!( origin(Placement::BelowStart, trigger(100., 100.)), @@ -1695,7 +1703,8 @@ mod tests { trigger(100., 250.), size(px(200.), px(560.)), window, - WINDOW_MARGIN + WINDOW_MARGIN, + GAP ), point(px(100.), px(32.)) );