Skip to main content

gpui_kit_grid/state/
tooltip.rs

1//! Tooltips: what the tooltip hooks of the delegate know and return, the
2//! hovered body cell with its tooltip slot, and the tooltips of header
3//! cells.
4//!
5//! The body paints without an element per cell, so a cell tooltip hangs on
6//! one transparent div over the hovered cell, planned with the frame. Its
7//! id is the hovered row's key and column, so a row moving away from under
8//! the pointer is a new element: GPUI drops the old tooltip, and a tooltip
9//! never shows another row's data. While the id stays, the frame writes the
10//! hook's fresh result into the slot the shown tooltip reads, so a ticking
11//! price updates in place (Design Guides, "Motion"). A pinned row's key is
12//! of its band's own key space, so its slot hashes the edge with the key: a
13//! band row and a data row with the same key are different elements. A
14//! group caption's text and aggregate cells hash its group path
15//! (`group_hash`), a group footer's cells its path (`footer_hash`), the
16//! totals row's cells its band (`totals_hash`).
17
18use std::{
19    cell::{Cell, RefCell},
20    fmt,
21    rc::Rc,
22};
23
24use gpui_kit::component::tooltip::Tooltip;
25use gpui_kit::{
26    AnyElement, App, Bounds, Context, ElementId, Empty, InteractiveElement as _, IntoElement,
27    Pixels, Point, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, px,
28};
29
30use rust_i18n::t;
31
32use super::{
33    CELL_PADDING, ColumnOf, HeaderLabel, LiveGridDelegate, LiveGridState,
34    edit::EditState,
35    header::{HEADER_SORT_LANE, SORT_GAP, SORT_ICON, header_icon_lane, header_label_style},
36    pinned::BodyLine,
37};
38use crate::model::{CellValue, ColumnIx, GridColumns, GridRow};
39
40/// Tooltip content of a cell or a header cell: text, or an element built
41/// each time the tooltip draws.
42#[derive(Clone)]
43pub struct CellTooltip {
44    content: TooltipContent,
45}
46
47#[derive(Clone)]
48enum TooltipContent {
49    Text(SharedString),
50    Element(Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>),
51}
52
53impl CellTooltip {
54    /// A text tooltip.
55    pub fn text(text: impl Into<SharedString>) -> Self {
56        Self {
57            content: TooltipContent::Text(text.into()),
58        }
59    }
60
61    /// A tooltip with custom content, built each time the tooltip draws.
62    pub fn element(build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
63        Self {
64            content: TooltipContent::Element(Rc::new(build)),
65        }
66    }
67
68    /// The text of a text tooltip; `None` for custom content.
69    pub fn as_text(&self) -> Option<&SharedString> {
70        match &self.content {
71            TooltipContent::Text(text) => Some(text),
72            TooltipContent::Element(_) => None,
73        }
74    }
75
76    /// The content as an element.
77    fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
78        match &self.content {
79            TooltipContent::Text(text) => text.clone().into_any_element(),
80            TooltipContent::Element(build) => build(window, cx),
81        }
82    }
83
84    /// The tooltip view of gpui-kit with this content.
85    pub(super) fn into_view(self, window: &mut Window, cx: &mut App) -> gpui_kit::AnyView {
86        Tooltip::element(move |window, cx| self.render(window, cx)).build(window, cx)
87    }
88}
89
90impl fmt::Debug for CellTooltip {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match &self.content {
93            TooltipContent::Text(text) => f.debug_tuple("CellTooltip::Text").field(text).finish(),
94            TooltipContent::Element(_) => f.write_str("CellTooltip::Element"),
95        }
96    }
97}
98
99/// What [`LiveGridDelegate::tooltip`] knows about the hovered cell.
100pub struct CellTooltipContext<'a, R: GridRow> {
101    pub(super) row: &'a R,
102    pub(super) column: R::Column,
103    pub(super) value: CellValue<'a>,
104    pub(super) text: &'a str,
105    pub(super) truncated: bool,
106    pub(super) edit_state: Option<&'a EditState>,
107}
108
109impl<'a, R: GridRow> CellTooltipContext<'a, R> {
110    /// The row.
111    pub fn row(&self) -> &'a R {
112        self.row
113    }
114
115    /// The column.
116    pub fn column(&self) -> R::Column {
117        self.column
118    }
119
120    /// The value shown in the cell.
121    pub fn value(&self) -> CellValue<'a> {
122        self.value
123    }
124
125    /// The full text of the cell, as painted before truncation.
126    pub fn text(&self) -> &str {
127        self.text
128    }
129
130    /// Returns true if the painted text was cut with "…".
131    pub fn is_truncated(&self) -> bool {
132        self.truncated
133    }
134
135    /// The cell's edits: a commit that waits for an answer or for the
136    /// store, or a refused one; `None` for a cell with no edits.
137    pub fn edit_state(&self) -> Option<&'a EditState> {
138        self.edit_state
139    }
140
141    /// The default tooltip: "Saving…" while a commit waits for an answer,
142    /// "Not saved: " and the reason after a refused one; otherwise the full
143    /// text of a truncated cell, and none for a cell that shows all its
144    /// text.
145    pub fn default_tooltip(&self) -> Option<CellTooltip> {
146        match self.edit_state {
147            Some(EditState::Pending) => {
148                return Some(CellTooltip::text(SharedString::from(
149                    t!("live_grid.edit.pending").into_owned(),
150                )));
151            }
152            Some(EditState::Failed(error)) => {
153                return Some(CellTooltip::text(SharedString::from(
154                    t!("live_grid.edit.failed", message = error.message()).into_owned(),
155                )));
156            }
157            _ => {}
158        }
159        self.truncated
160            .then(|| CellTooltip::text(SharedString::from(self.text.to_owned())))
161    }
162}
163
164/// What [`LiveGridDelegate::header_tooltip`] knows about the hovered header
165/// cell.
166pub struct HeaderTooltipContext<'a, C> {
167    pub(super) column: C,
168    pub(super) title: &'a SharedString,
169    pub(super) truncated: bool,
170    // The title, an icon or both.
171    pub(super) label: &'a HeaderLabel,
172}
173
174impl<'a, C: GridColumns> HeaderTooltipContext<'a, C> {
175    /// The column.
176    pub fn column(&self) -> C {
177        self.column
178    }
179
180    /// The column title.
181    pub fn title(&self) -> &'a SharedString {
182        self.title
183    }
184
185    /// Returns true if the title is cut with "…"; false for a header that
186    /// shows its icon alone.
187    pub fn is_truncated(&self) -> bool {
188        self.truncated
189    }
190
191    /// What the header shows: the title, an icon and the title, or the icon
192    /// alone.
193    pub fn header_label(&self) -> &'a HeaderLabel {
194        self.label
195    }
196
197    /// The full title when it is truncated, and always for a header that
198    /// shows its icon alone, whose meaning would otherwise rest on the icon;
199    /// otherwise none.
200    pub fn default_tooltip(&self) -> Option<CellTooltip> {
201        (self.truncated || matches!(self.label, HeaderLabel::Icon(_)))
202            .then(|| CellTooltip::text(self.title.clone()))
203    }
204}
205
206/// The tooltip of the hovered cell, shared by the frame, which writes the
207/// hook's result every frame, and the shown tooltip view, which draws it.
208#[derive(Default)]
209pub(crate) struct TooltipSlot {
210    content: RefCell<Option<CellTooltip>>,
211    /// GPUI built the tooltip view for this slot: it is shown, or was until
212    /// a press (GPUI does not report hiding it).
213    shown: Cell<bool>,
214}
215
216impl TooltipSlot {
217    fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
218        // Cloned out first: the content may read anything but this slot.
219        let content = self.content.borrow().clone();
220        match content {
221            Some(content) => content.render(window, cx),
222            None => Empty.into_any_element(),
223        }
224    }
225}
226
227/// The hovered body cell of one frame: the transparent div the body lays
228/// out, prepaints and paints over the cell (as `uniform_list` does its
229/// items), carrying the GPUI tooltip.
230pub(crate) struct CellHover {
231    id: ElementId,
232    bounds: Bounds<Pixels>,
233    slot: Rc<TooltipSlot>,
234}
235
236impl CellHover {
237    /// The cell within the mask of its zone, in window coordinates.
238    pub(crate) fn bounds(&self) -> Bounds<Pixels> {
239        self.bounds
240    }
241
242    /// The div over the cell.
243    pub(crate) fn into_element(self) -> AnyElement {
244        let slot = self.slot;
245        div()
246            .id(self.id)
247            .w(self.bounds.size.width)
248            .h(self.bounds.size.height)
249            .tooltip(move |window, cx| {
250                slot.shown.set(true);
251                let slot = slot.clone();
252                Tooltip::element(move |window, cx| slot.render(window, cx)).build(window, cx)
253            })
254            .into_any_element()
255    }
256}
257
258/// The hovered body cell as the frame found it.
259pub(super) struct HoveredCell {
260    pub(super) key_hash: u64,
261    pub(super) column: ColumnIx,
262    pub(super) id: &'static str,
263    pub(super) bounds: Bounds<Pixels>,
264    pub(super) tooltip: Option<CellTooltip>,
265}
266
267/// The slot of the hovered cell, by the identity its div's id is made of.
268struct HoverSlot {
269    key_hash: u64,
270    column: ColumnIx,
271    slot: Rc<TooltipSlot>,
272}
273
274/// The pointer over the body and the header, for tooltips.
275#[derive(Default)]
276pub(super) struct TooltipState {
277    /// The last pointer position over the body.
278    pub(super) position: Option<Point<Pixels>>,
279    /// The body line and column under `position` when it was set: a move
280    /// within one cell does not repaint.
281    cell: Option<(BodyLine, Option<ColumnIx>)>,
282    slot: Option<HoverSlot>,
283    /// The header cell under the pointer.
284    header: Option<ColumnIx>,
285}
286
287impl TooltipState {
288    /// A press hides GPUI's tooltip.
289    pub(super) fn on_press(&mut self) {
290        if let Some(hover) = &self.slot {
291            hover.slot.shown.set(false);
292        }
293    }
294
295    /// Returns true while the tooltip of the hovered cell is shown.
296    pub(super) fn is_shown(&self) -> bool {
297        self.slot
298            .as_ref()
299            .is_some_and(|hover| hover.slot.shown.get())
300    }
301
302    /// The header cell under the pointer.
303    pub(super) fn header(&self) -> Option<ColumnIx> {
304        self.header
305    }
306}
307
308impl<D: LiveGridDelegate> LiveGridState<D> {
309    /// The screen line of the data rows under a window position, from the
310    /// top of the data rows; `None` over a pinned band or outside the body.
311    pub(super) fn line_at(&self, position: Point<Pixels>) -> Option<usize> {
312        let rows = self.rows_bounds();
313        let y = position.y - rows.origin.y + self.scroll_offset;
314        (position.y >= rows.origin.y && position.y < rows.bottom())
315            .then(|| (y / self.row_height).floor() as usize)
316    }
317
318    /// A button went down anywhere in the window, in the capture phase:
319    /// GPUI hides the shown tooltip, and an open editor the press is
320    /// outside of ends first.
321    pub(crate) fn on_window_mouse_down(
322        &mut self,
323        position: Point<Pixels>,
324        window: &mut Window,
325        cx: &mut Context<Self>,
326    ) {
327        self.tooltip.on_press();
328        self.commit_edit_on_press(position, window, cx);
329    }
330
331    /// The pointer moved over the body (`Some`) or off it (`None`). Repaints
332    /// only when the cell under it changes: O(1) for the line, O(log
333    /// columns) for the column.
334    pub(crate) fn set_hover_position(
335        &mut self,
336        position: Option<Point<Pixels>>,
337        cx: &mut Context<Self>,
338    ) {
339        let cell = position.and_then(|position| {
340            Some((
341                self.body_line_at(position)?,
342                self.column_at_position(position),
343            ))
344        });
345        self.tooltip.position = position;
346        self.update_link_hover(position, cx);
347        if self.tooltip.cell != cell {
348            self.tooltip.cell = cell;
349            cx.notify();
350        }
351    }
352
353    /// The line and column of the cell under the pointer this frame, if a
354    /// tooltip may show: not while a press, a column gesture or the menu is
355    /// in progress. Called after the frame has its bounds and scroll
356    /// position, so a row moved under a resting pointer is the new row.
357    pub(super) fn hover_target(&self) -> Option<(BodyLine, ColumnIx)> {
358        if self.press.is_some() || self.is_column_gesture_active() || self.is_menu_open() {
359            return None;
360        }
361        let position = self.tooltip.position?;
362        Some((
363            self.body_line_at(position)?,
364            self.column_at_position(position)?,
365        ))
366    }
367
368    /// The body line and the pointer position this frame, if a tooltip may
369    /// show (as [`Self::hover_target`]): a group caption's text is not a
370    /// column's cell.
371    pub(super) fn hover_point(&self) -> Option<(BodyLine, Point<Pixels>)> {
372        if self.press.is_some() || self.is_column_gesture_active() || self.is_menu_open() {
373            return None;
374        }
375        let position = self.tooltip.position?;
376        Some((self.body_line_at(position)?, position))
377    }
378
379    /// Keeps the slot of the hovered cell while its identity stays, fills
380    /// it with the hook's fresh result, and plans the div. Without a
381    /// tooltip there is no div.
382    pub(super) fn finish_hover(&mut self, hovered: Option<HoveredCell>) -> Option<CellHover> {
383        let Some(HoveredCell {
384            key_hash,
385            column,
386            id,
387            bounds,
388            tooltip: Some(tooltip),
389        }) = hovered.filter(|hovered| {
390            hovered.bounds.size.width > px(0.) && hovered.bounds.size.height > px(0.)
391        })
392        else {
393            self.tooltip.slot = None;
394            return None;
395        };
396        let slot = match &self.tooltip.slot {
397            Some(hover) if hover.key_hash == key_hash && hover.column == column => {
398                hover.slot.clone()
399            }
400            _ => {
401                let slot = Rc::new(TooltipSlot::default());
402                self.tooltip.slot = Some(HoverSlot {
403                    key_hash,
404                    column,
405                    slot: slot.clone(),
406                });
407                slot
408            }
409        };
410        slot.content.replace(Some(tooltip));
411        Some(CellHover {
412            id: ElementId::NamedInteger(SharedString::new_static(id), key_hash),
413            bounds,
414            slot,
415        })
416    }
417
418    /// Returns true while a cell tooltip is shown: GPUI built it for the
419    /// hovered cell, which has not changed since, and no button was pressed
420    /// (what `ReorderContext::is_tooltip_shown` tells the reorder policy).
421    #[cfg(any(test, feature = "test-support"))]
422    pub fn is_tooltip_shown(&self) -> bool {
423        self.tooltip.is_shown()
424    }
425
426    /// The text of the tooltip the last frame planned for the body cell
427    /// under the pointer, shown or not yet; `None` without one, or for
428    /// custom content.
429    #[cfg(any(test, feature = "test-support"))]
430    pub fn hovered_tooltip_text(&self) -> Option<SharedString> {
431        let hover = self.tooltip.slot.as_ref()?;
432        let content = hover.slot.content.borrow();
433        content.as_ref()?.as_text().cloned()
434    }
435
436    /// The pointer entered or left a header cell.
437    pub(super) fn on_header_cell_hover(
438        &mut self,
439        column: ColumnIx,
440        hovered: bool,
441        cx: &mut Context<Self>,
442    ) {
443        let header = if hovered {
444            Some(column)
445        } else if self.tooltip.header == Some(column) {
446            None
447        } else {
448            return;
449        };
450        if self.tooltip.header != header {
451            self.tooltip.header = header;
452            cx.notify();
453        }
454    }
455
456    /// The tooltip of the hovered header cell, from the delegate. Called in
457    /// `render`, once, for that cell only. The title is truncated when its
458    /// width (shaped once and cached, as autofit measures it) and the sort
459    /// indicator do not fit between the cell's paddings.
460    pub(super) fn header_tooltip(
461        &mut self,
462        window: &Window,
463        cx: &App,
464    ) -> Option<(ColumnIx, CellTooltip)> {
465        let ix = self.tooltip.header()?;
466        let column = self
467            .columns
468            .get(ix)
469            .filter(|_| self.columns.is_visible(ix))?;
470        let title = column.title.clone();
471        let label = column.header_label.clone();
472        let column: ColumnOf<D> = column.column;
473        let width = self.columns.width(ix);
474        let sort = self.view.sort();
475        let indicator = match sort.iter().position(|(sorted, _)| sorted.ix() == ix) {
476            None => px(0.),
477            Some(_) if sort.len() >= 2 => HEADER_SORT_LANE,
478            Some(_) => SORT_GAP + SORT_ICON,
479        };
480        let (font, font_size) = header_label_style(window);
481        // An icon before the title takes room from it; a header that shows
482        // the icon alone truncates no title.
483        let truncated = match label {
484            HeaderLabel::Icon(_) => false,
485            HeaderLabel::Title | HeaderLabel::IconAndTitle(_) => {
486                let title_width = self
487                    .header
488                    .labels
489                    .width(ix, &title, &font, font_size, window);
490                let icon = header_icon_lane(&label, self.row_height);
491                title_width > width - CELL_PADDING * 2. - indicator - icon
492            }
493        };
494        let context = HeaderTooltipContext {
495            column,
496            title: &title,
497            truncated,
498            label: &label,
499        };
500        let tooltip = self.delegate.header_tooltip(&context, cx)?;
501        Some((ix, tooltip))
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use std::hash::{Hash as _, Hasher as _};
508
509    use gpui_kit::SharedString;
510    use rustc_hash::FxHasher;
511
512    use super::{CellTooltip, CellTooltipContext, HeaderLabel, HeaderTooltipContext};
513    use crate::{
514        model::{CellValue, ColumnIx, GridColumns, GridRow},
515        state::key_hash,
516    };
517
518    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
519    struct Note;
520
521    impl GridColumns for Note {
522        fn all() -> &'static [Self] {
523            &[Note]
524        }
525
526        fn id(self) -> &'static str {
527            "note"
528        }
529
530        fn ix(self) -> ColumnIx {
531            ColumnIx::new(0)
532        }
533    }
534
535    struct Row(String);
536
537    impl GridRow for Row {
538        type Key = String;
539        type Column = Note;
540
541        fn key(&self) -> &String {
542            &self.0
543        }
544
545        fn value(&self, _: Note) -> CellValue<'_> {
546            CellValue::Text(&self.0)
547        }
548    }
549
550    #[test]
551    fn test_default_tooltips() {
552        let row = Row("A note too long for its column".into());
553        let cell = |truncated| CellTooltipContext {
554            row: &row,
555            column: Note,
556            value: row.value(Note),
557            text: &row.0,
558            truncated,
559            edit_state: None,
560        };
561        // The full text, only when truncated.
562        let tooltip = cell(true).default_tooltip().expect("truncated");
563        assert_eq!(tooltip.as_text().map(|text| &**text), Some(row.0.as_str()));
564        assert!(cell(false).default_tooltip().is_none());
565        assert_eq!(cell(true).text(), row.0);
566        assert_eq!(cell(true).value(), CellValue::Text(&row.0));
567        assert!(cell(true).is_truncated());
568
569        let title = SharedString::from("Notes for the trader");
570        let header = |truncated| HeaderTooltipContext {
571            column: Note,
572            title: &title,
573            truncated,
574            label: &HeaderLabel::Title,
575        };
576        let tooltip = header(true).default_tooltip().expect("truncated");
577        assert_eq!(tooltip.as_text(), Some(&title));
578        assert!(header(false).default_tooltip().is_none());
579        assert_eq!(header(false).column(), Note);
580        // A header that shows its icon alone names it.
581        let icon = HeaderLabel::Icon("icons/check.svg".into());
582        let icon_only = HeaderTooltipContext {
583            column: Note,
584            title: &title,
585            truncated: false,
586            label: &icon,
587        };
588        assert_eq!(icon_only.header_label(), &icon);
589        let tooltip = icon_only.default_tooltip().expect("an icon alone");
590        assert_eq!(tooltip.as_text(), Some(&title));
591
592        let custom = CellTooltip::element(|_, _| unreachable!("never drawn here"));
593        assert!(custom.as_text().is_none());
594        assert_eq!(format!("{custom:?}"), "CellTooltip::Element");
595    }
596
597    #[test]
598    fn test_key_hash() {
599        // `FxHasher` over the key, as `state/a11y.rs` hashes row keys.
600        let mut hasher = FxHasher::default();
601        42u64.hash(&mut hasher);
602        assert_eq!(key_hash(&42u64), hasher.finish());
603        assert_eq!(key_hash("key"), key_hash(&String::from("key")));
604        assert_ne!(key_hash(&1u32), key_hash(&2u32));
605    }
606}