1use 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#[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 pub fn text(text: impl Into<SharedString>) -> Self {
56 Self {
57 content: TooltipContent::Text(text.into()),
58 }
59 }
60
61 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 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 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 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
99pub 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 pub fn row(&self) -> &'a R {
112 self.row
113 }
114
115 pub fn column(&self) -> R::Column {
117 self.column
118 }
119
120 pub fn value(&self) -> CellValue<'a> {
122 self.value
123 }
124
125 pub fn text(&self) -> &str {
127 self.text
128 }
129
130 pub fn is_truncated(&self) -> bool {
132 self.truncated
133 }
134
135 pub fn edit_state(&self) -> Option<&'a EditState> {
138 self.edit_state
139 }
140
141 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
164pub struct HeaderTooltipContext<'a, C> {
167 pub(super) column: C,
168 pub(super) title: &'a SharedString,
169 pub(super) truncated: bool,
170 pub(super) label: &'a HeaderLabel,
172}
173
174impl<'a, C: GridColumns> HeaderTooltipContext<'a, C> {
175 pub fn column(&self) -> C {
177 self.column
178 }
179
180 pub fn title(&self) -> &'a SharedString {
182 self.title
183 }
184
185 pub fn is_truncated(&self) -> bool {
188 self.truncated
189 }
190
191 pub fn header_label(&self) -> &'a HeaderLabel {
194 self.label
195 }
196
197 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#[derive(Default)]
209pub(crate) struct TooltipSlot {
210 content: RefCell<Option<CellTooltip>>,
211 shown: Cell<bool>,
214}
215
216impl TooltipSlot {
217 fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
218 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
227pub(crate) struct CellHover {
231 id: ElementId,
232 bounds: Bounds<Pixels>,
233 slot: Rc<TooltipSlot>,
234}
235
236impl CellHover {
237 pub(crate) fn bounds(&self) -> Bounds<Pixels> {
239 self.bounds
240 }
241
242 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
258pub(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
267struct HoverSlot {
269 key_hash: u64,
270 column: ColumnIx,
271 slot: Rc<TooltipSlot>,
272}
273
274#[derive(Default)]
276pub(super) struct TooltipState {
277 pub(super) position: Option<Point<Pixels>>,
279 cell: Option<(BodyLine, Option<ColumnIx>)>,
282 slot: Option<HoverSlot>,
283 header: Option<ColumnIx>,
285}
286
287impl TooltipState {
288 pub(super) fn on_press(&mut self) {
290 if let Some(hover) = &self.slot {
291 hover.slot.shown.set(false);
292 }
293 }
294
295 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 pub(super) fn header(&self) -> Option<ColumnIx> {
304 self.header
305 }
306}
307
308impl<D: LiveGridDelegate> LiveGridState<D> {
309 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 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 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 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 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 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 #[cfg(any(test, feature = "test-support"))]
422 pub fn is_tooltip_shown(&self) -> bool {
423 self.tooltip.is_shown()
424 }
425
426 #[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 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 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 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 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 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 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}