gpui_base/plot/hover.rs
1//! Hover tracking shared by every [`Plot`]: which datum the cursor is on, how
2//! far its hover has faded in, and where a pointer following it has glided to.
3//!
4//! This is behavior only. A styled layer draws the crosshair, dots and tooltip
5//! box, and projects its timing through [`PlotMotion`](crate::PlotMotion).
6use gpui::{App, Pixels, Point, Window};
7
8use crate::{
9 Spring, Theme,
10 motion::{TransitionId, spring, transition},
11};
12
13/// The datum the cursor resolved to, returned from
14/// [`Plot::tooltip_state`](super::Plot::tooltip_state).
15///
16/// Positions are relative to the plot's origin.
17#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub struct TooltipState {
20 /// The hovered datum's index in the plot's data.
21 pub index: usize,
22 /// Where a crosshair marking the datum sits.
23 pub cross_line: Point<Pixels>,
24 /// The data points to mark, one per series at the hovered datum.
25 pub dots: Vec<Point<Pixels>>,
26}
27
28impl TooltipState {
29 pub fn new(index: usize, cross_line: Point<Pixels>, dots: Vec<Point<Pixels>>) -> Self {
30 Self {
31 index,
32 cross_line,
33 dots,
34 }
35 }
36}
37
38/// The datum a plot has under the pointer this frame, handed to
39/// [`Plot::hover`](super::Plot::hover).
40///
41/// Carries the [`TooltipState`] the cursor resolved to and how far the hover
42/// has faded in. After the cursor leaves, the state lingers here while the
43/// progress eases back to zero, so a hover-driven presentation can fade out
44/// over the last datum instead of vanishing.
45#[derive(Clone)]
46pub struct PlotHover {
47 state: TooltipState,
48 progress: f32,
49 hovered: bool,
50}
51
52impl PlotHover {
53 /// The hovered datum: the one under the cursor, or the last one while the
54 /// hover fades out.
55 pub fn state(&self) -> &TooltipState {
56 &self.state
57 }
58
59 /// How far the hover has faded in, from `0` to `1`.
60 ///
61 /// Rises over the active [`PlotMotion`](crate::PlotMotion)'s enter when the
62 /// cursor lands on a datum and falls back over its exit after it leaves,
63 /// during which [`Self::is_hovered`] is false.
64 pub fn progress(&self) -> f32 {
65 self.progress
66 }
67
68 #[deprecated(since = "0.7.0", note = "use `progress`")]
69 pub fn focus(&self) -> f32 {
70 self.progress()
71 }
72
73 /// Whether the cursor is on the datum, as opposed to the state lingering
74 /// while its hover fades out.
75 pub fn is_hovered(&self) -> bool {
76 self.hovered
77 }
78
79 /// Whether this is the first frame the cursor is on a datum: the hover has
80 /// not started fading in yet. A position that follows the hovered datum
81 /// adopts it here instead of travelling from where the last hover ended.
82 pub fn is_entering(&self) -> bool {
83 self.hovered && self.progress == 0.
84 }
85
86 /// Follow `target` on the [pointer spring](pointer_spring), adopting the
87 /// target on the entering frame instead of travelling from where the last
88 /// hover ended.
89 ///
90 /// For a position a plot paints with, such as the center of a highlighted
91 /// band or the crosshair a styled tooltip draws.
92 pub fn glide(
93 &self,
94 id: impl Into<TransitionId>,
95 target: Pixels,
96 window: &mut Window,
97 cx: &mut App,
98 ) -> Pixels {
99 let policy = pointer_spring(cx).with_travel(!self.is_entering());
100 spring(id, target, policy, window, cx)
101 }
102}
103
104/// The spring a hover pointer — the crosshair, highlight band or hover dot —
105/// follows the hovered datum with: the active [`PlotMotion`](crate::PlotMotion)'s
106/// pointer, which snaps unless a styled layer projects one.
107pub fn pointer_spring(cx: &App) -> Spring {
108 Theme::global(cx).plot.motion().pointer()
109}
110
111/// The last datum the cursor resolved to, where the cursor was and how far the
112/// hover has faded in, kept in element state so the hover can fade out over it
113/// after the cursor leaves and so an overlay can read the fade without being
114/// handed it; see [`hover_progress`].
115struct HoverMemory {
116 state: Option<TooltipState>,
117 cursor: Point<Pixels>,
118 progress: f32,
119 /// Whether this frame is the first the cursor is on a datum; see
120 /// [`PlotHover::is_entering`].
121 entering: bool,
122}
123
124impl Default for HoverMemory {
125 fn default() -> Self {
126 Self {
127 state: None,
128 cursor: Point::default(),
129 // An overlay rendered outside a plot's tracking is fully opaque.
130 progress: 1.,
131 entering: false,
132 }
133 }
134}
135
136/// The element-state key of a plot's [`HoverMemory`], within the plot's scope.
137const HOVER_MEMORY: &str = "__plot-hover";
138
139/// Resolve the datum a plot shows this frame from the `live` state the cursor
140/// resolved to.
141///
142/// While `live` is `Some` it is shown as is. After the cursor leaves, the last
143/// state lingers with its progress easing to zero over the active
144/// [`PlotMotion`](crate::PlotMotion)'s exit, then is dropped. Called by
145/// [`PlotElement`](super::PlotElement) within the plot's element scope; the
146/// returned cursor is the live one, or the last one while the state lingers.
147pub(super) fn track_hover(
148 live: Option<TooltipState>,
149 cursor: Option<Point<Pixels>>,
150 window: &mut Window,
151 cx: &mut App,
152) -> Option<(PlotHover, Point<Pixels>)> {
153 let hovered = live.is_some();
154 let memory = window.use_keyed_state(HOVER_MEMORY, cx, |_, _| HoverMemory::default());
155
156 let theme = Theme::global(cx);
157 let motion = theme.plot.motion();
158 let policy = if hovered {
159 motion.enter().clone()
160 } else {
161 motion.exit().clone()
162 };
163 let progress = transition(
164 (HOVER_MEMORY, "progress"),
165 if hovered { 1. } else { 0. },
166 policy,
167 window,
168 cx,
169 );
170
171 memory.update(cx, |memory, _| {
172 if let (Some(live), Some(cursor)) = (live, cursor) {
173 memory.state = Some(live);
174 memory.cursor = cursor;
175 }
176 memory.progress = progress;
177 memory.entering = hovered && progress == 0.;
178 if !hovered && progress <= 0. {
179 memory.state = None;
180 }
181 });
182
183 let memory = memory.read(cx);
184 let state = memory.state.clone()?;
185 Some((
186 PlotHover {
187 state,
188 progress,
189 hovered,
190 },
191 memory.cursor,
192 ))
193}
194
195/// How far the enclosing plot's hover has faded in this frame, from `0` to `1`;
196/// see [`PlotHover::progress`].
197///
198/// For an overlay a plot returns from [`Plot::tooltip`](super::Plot::tooltip),
199/// which renders within the plot's element scope and fades with its hover
200/// without being handed the progress.
201///
202/// This reads the hover the enclosing [`PlotElement`](super::PlotElement)
203/// tracked in its element scope, so it is only meaningful while that plot is
204/// rendering its overlay. Anywhere else it reads no tracked hover and returns
205/// `1`.
206pub fn hover_progress(window: &mut Window, cx: &mut App) -> f32 {
207 window
208 .use_keyed_state(HOVER_MEMORY, cx, |_, _| HoverMemory::default())
209 .read(cx)
210 .progress
211}
212
213/// Whether this frame is the first the enclosing plot's cursor is on a datum;
214/// see [`PlotHover::is_entering`].
215///
216/// Like [`hover_progress`], this reads the enclosing plot's element scope and
217/// is only meaningful while that plot is rendering its overlay. Anywhere else
218/// it returns `false`.
219pub fn is_hover_entering(window: &mut Window, cx: &mut App) -> bool {
220 window
221 .use_keyed_state(HOVER_MEMORY, cx, |_, _| HoverMemory::default())
222 .read(cx)
223 .entering
224}
225
226#[cfg(test)]
227mod tests {
228 use gpui::{point, px};
229
230 use super::*;
231
232 #[test]
233 fn test_plot_hover_readers() {
234 let state = TooltipState::new(2, point(px(10.), px(20.)), vec![]);
235 let hover = PlotHover {
236 state,
237 progress: 1.,
238 hovered: true,
239 };
240 assert_eq!(hover.state().index, 2);
241 assert!(hover.is_hovered());
242 // Fully faded in: a pointer keeps travelling rather than snapping.
243 assert!(!hover.is_entering());
244
245 // The first hovered frame, before the fade has started.
246 let entering = PlotHover {
247 progress: 0.,
248 ..hover.clone()
249 };
250 assert!(entering.is_entering());
251
252 // Fading out after the cursor left: neither hovered nor entering.
253 let lingering = PlotHover {
254 progress: 0.4,
255 hovered: false,
256 ..hover
257 };
258 assert!(!lingering.is_hovered());
259 assert!(!lingering.is_entering());
260 }
261}