Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions crates/component/src/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use crate::{
window_border,
};
use gpui::{
App, AppContext, Context, DefiniteLength, ElementId, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement as _, Render, RenderOnce, Styled, WeakFocusHandle, Window, div,
prelude::FluentBuilder as _,
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 _,
};
use gpui_base::{TextSelection, TextSelectionScopeId};
use std::{any::TypeId, rc::Rc};
Expand All @@ -28,6 +28,9 @@ pub(crate) struct WindowState {
pub(super) focused_input: Option<AnyInputState>,
pub notification: Entity<NotificationList>,
pub(crate) tooltip_overlay: Entity<gpui_base::TooltipOverlay>,
/// Bounds of the tooltip trigger last pressed, whose tooltip stays
/// dismissed until the pointer moves outside them.
tooltip_pressed_trigger: Option<Bounds<Pixels>>,
pub(crate) native_menu_overlay: Entity<FallbackMenuOverlay>,
touch_selection_overlay: Entity<WindowTouchSelectionOverlay>,
sheet_size: Option<DefiniteLength>,
Expand Down Expand Up @@ -78,6 +81,7 @@ impl WindowState {
notification: cx.new(|cx| NotificationList::new(window, cx)),
tooltip_overlay: cx
.new(|_| gpui_base::TooltipOverlay::new().render_with(render_tooltip)),
tooltip_pressed_trigger: None,
native_menu_overlay: cx.new(|_| FallbackMenuOverlay::new()),
touch_selection_overlay: cx.new(|cx| WindowTouchSelectionOverlay::new(window, cx)),
sheet_size: None,
Expand Down Expand Up @@ -425,6 +429,48 @@ impl WindowState {
Some(root.read(cx).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<Pixels>, window: &mut Window, cx: &mut App) {
Self::try_update(window, cx, |state, _, cx| {
state.tooltip_pressed_trigger = Some(bounds);
cx.notify();
});
}

pub(crate) fn is_tooltip_trigger_pressed(
bounds: Bounds<Pixels>,
window: &Window,
cx: &App,
) -> bool {
Self::try_read(window, cx)
.is_some_and(|state| state.tooltip_pressed_trigger == Some(bounds))
}

/// Ends the press on the first pointer move outside the trigger. It is
/// window-level because a removed or moved trigger never reports a
/// hover-leave.
fn tooltip_press_listener(&self, cx: &Context<Self>) -> Option<impl IntoElement + use<>> {
let bounds = self.tooltip_pressed_trigger?;
let state = cx.entity().downgrade();
Some(canvas(
|_, _, _| {},
move |_, _, window, _| {
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if phase != DispatchPhase::Capture || bounds.contains(&event.position) {
return;
}
_ = state.update(cx, |state, cx| {
if state.tooltip_pressed_trigger == Some(bounds) {
state.tooltip_pressed_trigger = None;
cx.notify();
}
});
});
},
))
}

/// Get the fallback native-menu overlay entity for this window.
pub(crate) fn native_menu_overlay(
window: &Window,
Expand Down Expand Up @@ -470,6 +516,7 @@ impl Render for WindowState {
.child(WindowStateLayers { root: cx.entity() })
.child(self.touch_selection_overlay.clone())
.child(self.tooltip_overlay.clone())
.children(self.tooltip_press_listener(cx))
.child(self.native_menu_overlay.clone())
}
}
Expand Down
221 changes: 220 additions & 1 deletion crates/component/src/tooltip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,15 @@ pub(crate) trait ManagedTooltipExt:
let trigger_bounds_cell = trigger_bounds_cell.clone();
let build_tooltip = build_tooltip.clone();
move |hovered, window, cx| {
let bounds = trigger_bounds_cell.get();
// 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 *hovered {
let bounds = trigger_bounds_cell.get();
overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| {
let build = build_tooltip.clone();
let request = BaseTooltipRequest::new(bounds, move |window, cx| {
Expand All @@ -291,6 +297,7 @@ pub(crate) trait ManagedTooltipExt:
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
WindowState::press_tooltip_trigger(trigger_bounds_cell.get(), window, cx);
if let Some(overlay) = WindowState::tooltip_overlay(window, cx) {
overlay.update(cx, |overlay, cx| {
overlay.hide(cx);
Expand All @@ -301,3 +308,215 @@ pub(crate) trait ManagedTooltipExt:
}

impl<E: StatefulInteractiveElement + crate::ElementExt> ManagedTooltipExt for E {}

#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc, time::Duration};

use gpui::{
AppContext as _, Context, Entity, InputEvent as _, InteractiveElement as _, IntoElement,
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render,
StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, Window,
div, point, prelude::FluentBuilder as _, px,
};

use super::ManagedTooltipExt as _;
use crate::Root;

/// A trigger that removes itself when clicked, like a button that
/// replaces the view it sits in.
struct Probe {
shown: bool,
removes_on_click: bool,
builds: Rc<Cell<usize>>,
}

impl Render for Probe {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let builds = self.builds.clone();
div().size_full().when(self.shown, |this| {
this.child(
div()
.id("trigger")
.absolute()
.left(px(10.))
.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()
})
.on_click(cx.listener(|this, _, _, cx| {
if this.removes_on_click {
this.shown = false;
}
cx.notify();
})),
)
})
}
}

fn open(
cx: &mut TestAppContext,
removes_on_click: bool,
builds: Rc<Cell<usize>>,
) -> (VisualTestContext, Entity<Probe>) {
cx.update(crate::init);
let probe = cx.new(|_| Probe {
shown: true,
removes_on_click,
builds,
});
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)
}

/// Presses at `at` and releases a pixel away, as most real clicks do.
fn click_with_drift(cx: &mut VisualTestContext, at: gpui::Point<gpui::Pixels>) {
let drift = point(at.x + px(1.), at.y);
cx.simulate_mouse_down(at, MouseButton::Left, Default::default());
cx.simulate_mouse_move(drift, Some(MouseButton::Left), Default::default());
cx.simulate_mouse_up(drift, MouseButton::Left, Default::default());
}

fn draw(cx: &mut VisualTestContext) {
cx.update(|window, cx| {
let _ = window.draw(cx);
})
}

/// Lets any show delay or grace period run out, then draws.
fn wait(cx: &mut VisualTestContext) {
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
draw(cx);
}

/// 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]
fn a_press_dismisses_the_tooltip_until_the_pointer_leaves(cx: &mut TestAppContext) {
let builds = Rc::new(Cell::new(0));
let (mut cx, _) = open(cx, false, builds.clone());
let at = point(px(50.), px(25.));
cx.simulate_mouse_move(at, None, Default::default());
wait(&mut cx);
assert!(builds.get() > 0, "the hovered trigger shows its tooltip");

// Hover reads false while the button is held, so the move after the
// release is a hover-enter that must not show the tooltip.
click_with_drift(&mut cx, at);
cx.simulate_mouse_move(point(px(52.), px(25.)), None, Default::default());
wait(&mut cx);
let before = builds.get();
draw(&mut cx);
assert_eq!(builds.get(), before, "no tooltip while the pointer stays");

cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default());
wait(&mut cx);
cx.simulate_mouse_move(at, None, Default::default());
wait(&mut cx);
let before = builds.get();
draw(&mut cx);
assert!(builds.get() > before, "re-entering shows the tooltip again");
}

/// A pointer move that lands between the release and the next frame is
/// dispatched to the old frame, where the trigger is still hovered, so it
/// used to request the tooltip again. The trigger is gone from the next
/// frame, nothing ever sent the hover-leave, and the tooltip appeared
/// after the show delay and stayed.
#[gpui::test]
fn clicking_a_trigger_away_does_not_leave_its_tooltip(cx: &mut TestAppContext) {
let builds = Rc::new(Cell::new(0));
let (mut cx, _) = open(cx, true, builds.clone());

let at = point(px(50.), px(25.));
cx.simulate_mouse_move(at, None, Default::default());
draw(&mut cx);
wait(&mut cx);
draw(&mut cx);
assert!(builds.get() > 0, "the hovered trigger shows its tooltip");

// Press, drift a pixel while held, release, and drift again before
// the next frame is drawn.
cx.simulate_mouse_down(at, MouseButton::Left, Default::default());
let drift = point(px(51.), px(25.));
cx.simulate_mouse_move(drift, Some(MouseButton::Left), Default::default());
// One update, so no frame is drawn between the two: the test
// platform otherwise draws a dirty window after every event.
cx.update(|window, cx| {
window.dispatch_event(
MouseUpEvent {
button: MouseButton::Left,
position: drift,
modifiers: Default::default(),
click_count: 1,
}
.to_platform_input(),
cx,
);
window.dispatch_event(
MouseMoveEvent {
position: point(px(52.), px(25.)),
pressed_button: None,
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
});
draw(&mut cx);
wait(&mut cx);

let before = builds.get();
draw(&mut cx);
assert_eq!(
builds.get(),
before,
"no tooltip may be shown for a trigger the click removed"
);
}

/// A trigger that a click removed never reports a hover-leave, so the
/// press must end when the pointer moves away, or a trigger later shown
/// at the same bounds would lose its tooltip.
#[gpui::test]
fn a_press_on_a_removed_trigger_ends_when_the_pointer_moves_away(cx: &mut TestAppContext) {
let builds = Rc::new(Cell::new(0));
let (mut cx, probe) = open(cx, true, builds.clone());
let at = point(px(50.), px(25.));
cx.simulate_mouse_move(at, None, Default::default());
wait(&mut cx);

cx.simulate_click(at, Default::default());
wait(&mut cx);
cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default());
wait(&mut cx);

cx.update(|_, cx| {
probe.update(cx, |probe, cx| {
probe.shown = true;
cx.notify();
})
});
wait(&mut cx);
let before = builds.get();
cx.simulate_mouse_move(at, None, Default::default());
wait(&mut cx);
draw(&mut cx);
assert!(
builds.get() > before,
"the trigger shown again has its tooltip"
);
}
}
Loading