diff --git a/crates/component/src/root.rs b/crates/component/src/root.rs index 96345f1b25..9eb970a364 100644 --- a/crates/component/src/root.rs +++ b/crates/component/src/root.rs @@ -11,8 +11,9 @@ use crate::{ }; use gpui::{ App, AppContext, Bounds, Context, DefiniteLength, DispatchPhase, ElementId, Entity, - FocusHandle, InteractiveElement, IntoElement, MouseMoveEvent, ParentElement as _, Pixels, - Render, RenderOnce, Styled, WeakFocusHandle, Window, canvas, div, prelude::FluentBuilder as _, + FocusHandle, GlobalElementId, InteractiveElement, IntoElement, MouseMoveEvent, + ParentElement as _, Pixels, Render, RenderOnce, Styled, WeakFocusHandle, Window, canvas, div, + prelude::FluentBuilder as _, }; use gpui_base::{TextSelection, TextSelectionScopeId}; use std::{any::TypeId, rc::Rc}; @@ -28,6 +29,7 @@ pub(crate) struct WindowState { pub(super) focused_input: Option, pub notification: Entity, pub(crate) tooltip_overlay: Entity, + tooltip_hovered_trigger: Option, /// Bounds of the tooltip trigger last pressed, whose tooltip stays /// dismissed until the pointer moves outside them. tooltip_pressed_trigger: Option>, @@ -81,6 +83,7 @@ impl WindowState { notification: cx.new(|cx| NotificationList::new(window, cx)), tooltip_overlay: cx .new(|_| gpui_base::TooltipOverlay::new().render_with(render_tooltip)), + tooltip_hovered_trigger: None, tooltip_pressed_trigger: None, native_menu_overlay: cx.new(|_| FallbackMenuOverlay::new()), touch_selection_overlay: cx.new(|cx| WindowTouchSelectionOverlay::new(window, cx)), @@ -429,6 +432,26 @@ impl WindowState { Some(root.read(cx).tooltip_overlay.clone()) } + /// Only the current trigger may request a hide. Hover listeners run in + /// reverse paint order, so a new trigger can enter before the old one leaves. + pub(crate) fn tooltip_overlay_for_trigger( + trigger: &GlobalElementId, + hovered: bool, + window: &Window, + cx: &mut App, + ) -> Option> { + Self::entity(window, cx)?.update(cx, |state, _| { + if hovered { + state.tooltip_hovered_trigger = Some(trigger.clone()); + } else if state.tooltip_hovered_trigger.as_ref() == Some(trigger) { + state.tooltip_hovered_trigger = None; + } else { + return None; + } + Some(state.tooltip_overlay.clone()) + }) + } + /// Keeps the tooltip of the trigger at `bounds` dismissed until the /// pointer moves outside them. pub(crate) fn press_tooltip_trigger(bounds: Bounds, window: &mut Window, cx: &mut App) { diff --git a/crates/component/src/tooltip.rs b/crates/component/src/tooltip.rs index 37a0983d13..4e5a6d3fd6 100644 --- a/crates/component/src/tooltip.rs +++ b/crates/component/src/tooltip.rs @@ -1,10 +1,10 @@ use crate::root::WindowState; -use std::{cell::Cell, rc::Rc, time::Duration}; +use std::{cell::RefCell, rc::Rc, time::Duration}; use gpui::{ - Action, AnyElement, AnyView, App, AppContext, Bounds, Context, ElementId, IntoElement, - MouseButton, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, - StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px, + Action, AnyElement, AnyView, App, AppContext, Bounds, Context, ElementId, GlobalElementId, + IntoElement, MouseButton, ParentElement, Pixels, Render, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px, }; pub use gpui_base::TooltipDefaults; use gpui_base::{ @@ -229,6 +229,12 @@ impl ComponentTooltip { // ── Internal managed tooltip trait ────────────────────────────────────────── +#[derive(Default)] +struct TooltipTrigger { + id: Option, + bounds: Bounds, +} + pub(crate) trait ManagedTooltipExt: StatefulInteractiveElement + crate::ElementExt + Sized { @@ -254,24 +260,34 @@ pub(crate) trait ManagedTooltipExt: build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, ) -> Self { let build_tooltip = Rc::new(build_tooltip); - let trigger_bounds_cell: Rc>> = Rc::new(Cell::new(Bounds::default())); - let bounds_writer = trigger_bounds_cell.clone(); - - self.on_prepaint(move |bounds, _, _| { - bounds_writer.set(bounds); + let trigger = Rc::new(RefCell::new(TooltipTrigger::default())); + let trigger_writer = trigger.clone(); + + self.on_prepaint(move |bounds, window, _| { + let id = window.with_global_id("managed-tooltip-trigger".into(), |id, _| id.clone()); + *trigger_writer.borrow_mut() = TooltipTrigger { + id: Some(id), + bounds, + }; }) .on_hover({ - let trigger_bounds_cell = trigger_bounds_cell.clone(); + let trigger = trigger.clone(); let build_tooltip = build_tooltip.clone(); move |hovered, window, cx| { - let bounds = trigger_bounds_cell.get(); + let trigger = trigger.borrow(); + let Some(id) = trigger.id.as_ref() else { + return; + }; + let bounds = trigger.bounds; // A press keeps the tooltip dismissed until the pointer leaves // the trigger. This also drops the hover-enter that a click // removing the trigger dispatches to the stale frame. if *hovered && WindowState::is_tooltip_trigger_pressed(bounds, window, cx) { return; } - if let Some(overlay) = WindowState::tooltip_overlay(window, cx) { + if let Some(overlay) = + WindowState::tooltip_overlay_for_trigger(id, *hovered, window, cx) + { if *hovered { overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| { let build = build_tooltip.clone(); @@ -297,7 +313,7 @@ pub(crate) trait ManagedTooltipExt: } }) .on_mouse_down(MouseButton::Left, move |_, window, cx| { - WindowState::press_tooltip_trigger(trigger_bounds_cell.get(), window, cx); + WindowState::press_tooltip_trigger(trigger.borrow().bounds, window, cx); if let Some(overlay) = WindowState::tooltip_overlay(window, cx) { overlay.update(cx, |overlay, cx| { overlay.hide(cx); @@ -401,6 +417,107 @@ mod tests { draw(cx); } + struct AdjacentTriggers { + builds: [Rc>; 2], + } + + impl Render for AdjacentTriggers { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().children((0..2).map(|index| { + let builds = self.builds[index].clone(); + div() + .id(("trigger", index)) + .absolute() + .left(px(10. + index as f32 * 100.)) + .top(px(10.)) + .w(px(100.)) + .h(px(30.)) + .managed_tooltip(move |_, cx| { + builds.set(builds.get() + 1); + cx.new(|_| super::Tooltip::new("tip")).into() + }) + })) + } + } + + fn open_adjacent_triggers( + cx: &mut TestAppContext, + ) -> (VisualTestContext, Entity) { + cx.update(crate::init); + let probe = cx.new(|_| AdjacentTriggers { + builds: Default::default(), + }); + let window = cx.update(|cx| { + cx.open_window(Default::default(), |window, cx| { + cx.new(|cx| Root::new(probe.clone(), window, cx)) + }) + .unwrap() + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + draw(&mut cx); + (cx, probe) + } + + fn assert_tooltip_shown( + cx: &mut VisualTestContext, + probe: &Entity, + index: usize, + ) { + let builds = cx.update(|_, cx| probe.read(cx).builds.clone()); + let before = builds.each_ref().map(|builds| builds.get()); + draw(cx); + assert!( + builds[index].get() > before[index], + "the hovered tooltip stays visible" + ); + assert_eq!(builds[1 - index].get(), before[1 - index]); + } + + #[gpui::test] + fn tooltips_switch_in_both_directions(cx: &mut TestAppContext) { + let (mut cx, probe) = open_adjacent_triggers(cx); + for (step, index) in [0, 1, 0].into_iter().enumerate() { + let at = point(px(50. + index as f32 * 100.), px(25.)); + cx.simulate_mouse_move(at, None, Default::default()); + if step > 0 { + draw(&mut cx); + assert_tooltip_shown(&mut cx, &probe, index); + } + wait(&mut cx); + assert_tooltip_shown(&mut cx, &probe, index); + // Rebuilding the triggers must preserve ownership for hover-leave. + cx.update(|_, cx| probe.update(cx, |_, cx| cx.notify())); + draw(&mut cx); + } + cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default()); + wait(&mut cx); + let builds = cx.update(|_, cx| probe.read(cx).builds.clone()); + let before = builds.each_ref().map(|builds| builds.get()); + draw(&mut cx); + assert_eq!(builds.each_ref().map(|builds| builds.get()), before); + } + + #[gpui::test] + fn leaving_a_trigger_does_not_cancel_the_next_tooltip_delay(cx: &mut TestAppContext) { + for (source, destination) in [(0, 1), (1, 0)] { + let (mut cx, probe) = open_adjacent_triggers(cx); + cx.simulate_mouse_move( + point(px(50. + source as f32 * 100.), px(25.)), + None, + Default::default(), + ); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + cx.simulate_mouse_move( + point(px(50. + destination as f32 * 100.), px(25.)), + None, + Default::default(), + ); + wait(&mut cx); + assert_tooltip_shown(&mut cx, &probe, destination); + } + } + /// A press dismisses the tooltip, and it stays dismissed while the /// pointer stays on the trigger; leaving and coming back shows it again. #[gpui::test]