1use gpui_kit::base::motion::spring;
5pub use gpui_kit::base::plot::{PlotHover, TooltipState};
6use gpui_kit::base::plot::{hover_progress, is_hover_entering, pointer_spring};
7use gpui_kit::{
8 AnyElement, App, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, Pixels, Point,
9 RenderOnce, SharedString, Size, StyleRefinement, Styled, Window, deferred, div, point,
10 prelude::FluentBuilder, px, rems,
11};
12
13use crate::ActiveTheme;
14
15fn v_flex() -> Div {
16 div().flex().flex_col()
17}
18
19fn h_flex() -> Div {
20 div().flex().flex_row().items_center()
21}
22
23const GLIDE: &str = "__plot-tooltip-glide";
25
26#[derive(Default)]
27pub enum CrossLineAxis {
28 #[default]
29 Vertical,
30 Horizontal,
31 Both,
32}
33
34impl CrossLineAxis {
35 #[inline]
37 pub fn show_vertical(&self) -> bool {
38 matches!(self, CrossLineAxis::Vertical | CrossLineAxis::Both)
39 }
40
41 #[inline]
43 pub fn show_horizontal(&self) -> bool {
44 matches!(self, CrossLineAxis::Horizontal | CrossLineAxis::Both)
45 }
46}
47
48#[derive(IntoElement)]
49pub struct CrossLine {
50 point: Point<Pixels>,
51 vertical: (f32, Option<f32>),
54 horizontal: (f32, Option<f32>),
57 thickness: Pixels,
59 dashed: bool,
61 direction: CrossLineAxis,
62}
63
64impl CrossLine {
65 pub fn new(point: Point<Pixels>) -> Self {
66 Self {
67 point,
68 vertical: (0., None),
69 horizontal: (0., None),
70 thickness: px(1.),
71 dashed: true,
72 direction: Default::default(),
73 }
74 }
75
76 pub fn band(mut self, thickness: impl Into<Pixels>) -> Self {
80 self.thickness = thickness.into();
81 self.dashed = false;
82 self
83 }
84
85 pub fn horizontal(mut self) -> Self {
87 self.direction = CrossLineAxis::Horizontal;
88 self
89 }
90
91 pub fn both(mut self) -> Self {
93 self.direction = CrossLineAxis::Both;
94 self
95 }
96
97 pub fn height(mut self, height: f32) -> Self {
99 self.vertical.1 = Some(height);
100 self
101 }
102
103 pub fn width(mut self, width: f32) -> Self {
105 self.horizontal.1 = Some(width);
106 self
107 }
108
109 pub fn span(mut self, start: f32, length: f32) -> Self {
112 self.vertical = (start, Some(length));
113 self
114 }
115
116 pub fn h_span(mut self, start: f32, length: f32) -> Self {
119 self.horizontal = (start, Some(length));
120 self
121 }
122}
123
124impl From<Point<Pixels>> for CrossLine {
125 fn from(value: Point<Pixels>) -> Self {
126 Self::new(value)
127 }
128}
129
130impl CrossLine {
131 fn line(&self, vertical: bool, cx: &App) -> Div {
135 let t = cx.macos();
136 let color = if self.dashed {
138 t.control_border()
139 } else {
140 t.hover_fill()
141 };
142 let thickness = if self.dashed { px(0.) } else { self.thickness };
144 let (start, length) = if vertical {
147 self.vertical
148 } else {
149 self.horizontal
150 };
151
152 let el = div().absolute();
153 let el = if vertical {
154 el.left(self.point.x - thickness * 0.5)
155 .w(thickness)
156 .top(px(start))
157 .map(|el| match length {
158 Some(length) => el.h(px(length)),
159 None => el.h_full(),
160 })
161 } else {
162 el.top(self.point.y - thickness * 0.5)
163 .h(thickness)
164 .left(px(start))
165 .map(|el| match length {
166 Some(length) => el.w(px(length)),
167 None => el.w_full(),
168 })
169 };
170
171 if self.dashed {
172 let el = if vertical {
173 el.border_l_1()
174 } else {
175 el.border_t_1()
176 };
177 el.border_dashed().border_color(color)
178 } else {
179 el.bg(color)
180 }
181 }
182}
183
184impl RenderOnce for CrossLine {
185 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
186 let vertical = self.direction.show_vertical().then(|| self.line(true, cx));
187 let horizontal = self
188 .direction
189 .show_horizontal()
190 .then(|| self.line(false, cx));
191
192 div()
193 .size_full()
194 .absolute()
195 .top_0()
196 .left_0()
197 .children(vertical)
198 .children(horizontal)
199 }
200}
201
202#[derive(IntoElement)]
203pub struct Dot {
206 point: Point<Pixels>,
207 size: Pixels,
208 stroke: Hsla,
209 fill: Hsla,
210 halo: Option<Pixels>,
212}
213
214impl Dot {
215 pub fn new(point: Point<Pixels>) -> Self {
216 Self {
217 point,
218 size: px(6.),
219 stroke: gpui_kit::transparent_black(),
220 fill: gpui_kit::transparent_black(),
221 halo: None,
222 }
223 }
224
225 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
227 self.size = size.into();
228 self
229 }
230
231 pub fn halo(mut self, size: impl Into<Pixels>) -> Self {
238 self.halo = Some(size.into());
239 self
240 }
241
242 pub fn stroke(mut self, stroke: Hsla) -> Self {
244 self.stroke = stroke;
245 self
246 }
247
248 pub fn fill(mut self, fill: Hsla) -> Self {
250 self.fill = fill;
251 self
252 }
253}
254
255impl RenderOnce for Dot {
256 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
257 let border_width = px(1.);
258 let offset = self.size / 2.;
261
262 let dot = div()
263 .absolute()
264 .w(self.size)
265 .h(self.size)
266 .border(border_width)
267 .border_color(self.stroke)
268 .bg(self.fill)
269 .left(self.point.x - offset)
270 .top(self.point.y - offset);
271
272 let halo = self.halo.map(|halo| {
275 div()
276 .absolute()
277 .size(halo)
278 .bg(self.fill.opacity(0.2))
279 .left(self.point.x - halo / 2.)
280 .top(self.point.y - halo / 2.)
281 });
282
283 div().absolute().top_0().left_0().children(halo).child(dot)
284 }
285}
286
287struct TooltipRow {
289 color: Option<Hsla>,
290 label: SharedString,
291 value: SharedString,
292 value_color: Option<Hsla>,
293}
294
295#[derive(IntoElement)]
296pub struct Tooltip {
297 base: Div,
298 gap: Pixels,
299 cross_line: Option<CrossLine>,
300 dots: Option<Vec<Dot>>,
301 appearance: bool,
302 title: Option<SharedString>,
303 rows: Vec<TooltipRow>,
304 cursor: Point<Pixels>,
306 within: Size<Pixels>,
309 progress: Option<f32>,
311 glide: bool,
313}
314
315impl Tooltip {
316 pub fn new(cursor: Point<Pixels>, within: Size<Pixels>) -> Self {
318 Self {
319 base: v_flex().gap(rems(0.25)),
323 gap: px(0.),
324 cross_line: None,
325 dots: None,
326 appearance: true,
327 title: None,
328 rows: Vec::new(),
329 cursor,
330 within,
331 progress: None,
332 glide: true,
333 }
334 }
335
336 pub fn glide(mut self, glide: bool) -> Self {
347 self.glide = glide;
348 self
349 }
350
351 pub fn progress(mut self, progress: f32) -> Self {
358 self.progress = Some(progress.clamp(0., 1.));
359 self
360 }
361
362 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
364 self.title = Some(title.into());
365 self
366 }
367
368 pub fn row(
370 mut self,
371 color: impl Into<Hsla>,
372 label: impl Into<SharedString>,
373 value: impl Into<SharedString>,
374 ) -> Self {
375 self.rows.push(TooltipRow {
376 color: Some(color.into()),
377 label: label.into(),
378 value: value.into(),
379 value_color: None,
380 });
381 self
382 }
383
384 pub fn plain_row(
390 mut self,
391 label: impl Into<SharedString>,
392 value: impl Into<SharedString>,
393 ) -> Self {
394 self.rows.push(TooltipRow {
395 color: None,
396 label: label.into(),
397 value: value.into(),
398 value_color: None,
399 });
400 self
401 }
402
403 pub fn value_color(mut self, color: impl Into<Hsla>) -> Self {
409 if let Some(row) = self.rows.last_mut() {
410 row.value_color = Some(color.into());
411 }
412 self
413 }
414
415 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
417 self.gap = gap.into();
418 self
419 }
420
421 pub fn cross_line(mut self, cross_line: CrossLine) -> Self {
423 self.cross_line = Some(cross_line);
424 self
425 }
426
427 pub fn dots(mut self, dots: impl IntoIterator<Item = Dot>) -> Self {
429 self.dots = Some(dots.into_iter().collect());
430 self
431 }
432
433 pub fn appearance(mut self, appearance: bool) -> Self {
435 self.appearance = appearance;
436 self
437 }
438}
439
440impl Styled for Tooltip {
441 fn style(&mut self) -> &mut StyleRefinement {
442 self.base.style()
443 }
444}
445
446impl ParentElement for Tooltip {
447 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
448 self.base.extend(elements);
449 }
450}
451
452fn has_swatches(rows: &[TooltipRow]) -> bool {
455 rows.iter().any(|row| row.color.is_some())
456}
457
458#[cfg(test)]
459impl Tooltip {
460 pub(crate) fn title_for_test(&self) -> Option<&SharedString> {
461 self.title.as_ref()
462 }
463
464 pub(crate) fn rows_for_test(&self) -> Vec<(SharedString, Option<Hsla>)> {
465 self.rows
466 .iter()
467 .map(|row| (row.value.clone(), row.value_color))
468 .collect()
469 }
470}
471
472impl RenderOnce for Tooltip {
473 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
474 let tracked_progress = hover_progress(window, cx);
477 let entering = is_hover_entering(window, cx);
478 let Tooltip {
479 base,
480 gap,
481 mut cross_line,
482 mut dots,
483 appearance,
484 title,
485 rows,
486 cursor,
487 within,
488 progress,
489 glide,
490 } = self;
491 let progress = progress.unwrap_or(tracked_progress);
492 let t = cx.macos().clone();
493
494 if glide {
495 let policy = pointer_spring(cx).with_travel(!entering);
496 if let Some(line) = cross_line.as_mut() {
497 if line.direction.show_vertical() {
498 line.point.x = spring((GLIDE, "x"), line.point.x, policy, window, cx);
499 }
500 if line.direction.show_horizontal() {
501 line.point.y = spring((GLIDE, "y"), line.point.y, policy, window, cx);
502 }
503 }
504 for (i, dot) in dots.iter_mut().flatten().enumerate() {
505 dot.point = point(
506 spring(
507 ElementId::named_usize("__plot-hover-dot-x", i),
508 dot.point.x,
509 policy,
510 window,
511 cx,
512 ),
513 spring(
514 ElementId::named_usize("__plot-hover-dot-y", i),
515 dot.point.y,
516 policy,
517 window,
518 cx,
519 ),
520 );
521 }
522 }
523 for dot in dots.iter_mut().flatten() {
525 dot.halo = dot.halo.map(|halo| halo * progress);
526 }
527
528 let content = if title.is_some() || !rows.is_empty() {
530 let swatched = has_swatches(&rows);
531 v_flex()
532 .gap(rems(0.25))
533 .when_some(title, |this, title| {
534 this.child(
535 div()
536 .text_color(t.bright)
537 .font_weight(FontWeight::BOLD)
538 .child(title),
539 )
540 })
541 .children(rows.into_iter().map(|row| {
542 h_flex()
543 .justify_between()
544 .gap(rems(0.75))
545 .child(
546 h_flex()
547 .gap(rems(0.375))
548 .when(swatched, |this| {
549 this.child(
550 div()
551 .flex_shrink_0()
552 .size(rems(0.5))
553 .when_some(row.color, |this, color| this.bg(color)),
554 )
555 })
556 .child(div().text_color(t.secondary).child(row.label)),
557 )
558 .child(
560 div()
561 .font_family(t.mono_font.clone())
562 .text_color(row.value_color.unwrap_or(t.foreground))
563 .child(row.value),
564 )
565 }))
566 } else {
567 base
568 };
569 let content = content.text_size(rems(0.6875));
574
575 div()
576 .size_full()
577 .absolute()
578 .top_0()
579 .left_0()
580 .opacity(progress)
581 .when_some(cross_line, |this, cross_line| this.child(cross_line))
582 .when_some(dots, |this, dots| this.children(dots))
583 .child(deferred(content.map(|mut this| {
588 if !appearance {
589 return this.size_full().relative().opacity(progress);
590 }
591
592 let min_w_unset = this.style().min_size.width.is_none();
595
596 this.absolute()
599 .opacity(progress)
600 .when(min_w_unset, |c| c.min_w(rems(9.)))
601 .max_w(rems(20.))
602 .px(rems(0.625))
603 .py(rems(0.375))
604 .border_1()
605 .rounded_none()
606 .border_color(t.control_border())
607 .bg(t.background)
608 .text_color(t.foreground)
609 .font_family(t.font.clone())
610 .map(|c| {
611 if cursor.x < within.width * 0.5 {
612 c.left(cursor.x + gap)
613 } else {
614 c.right(within.width - cursor.x + gap)
615 }
616 })
617 .map(|c| {
618 if cursor.y < within.height * 0.5 {
619 c.top(cursor.y + gap)
620 } else {
621 c.bottom(within.height - cursor.y + gap)
622 }
623 })
624 })))
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use gpui_kit::{point, px};
631
632 use super::*;
633
634 #[gpui_kit::test]
635 fn hovered_dot_and_halo_share_the_data_point_center(cx: &mut gpui_kit::TestAppContext) {
636 struct Marker;
637 impl gpui_kit::Render for Marker {
638 fn render(
639 &mut self,
640 _: &mut Window,
641 _: &mut gpui_kit::Context<Self>,
642 ) -> impl IntoElement {
643 div().size(px(100.)).relative().child(
644 Dot::new(point(px(40.), px(50.)))
645 .size(px(8.))
646 .halo(px(12.))
647 .fill(gpui_kit::blue())
648 .stroke(gpui_kit::black()),
649 )
650 }
651 }
652
653 let (_, cx) = cx.add_window_view(|_, _| Marker);
654 cx.update(|window, cx| window.draw(cx).clear(cx));
655 cx.update(|window, _| {
656 let quads = window.painted_quads();
657 let dot = quads
658 .iter()
659 .find(|quad| quad.background == gpui_kit::Background::from(gpui_kit::blue()))
660 .expect("dot is painted");
661 let halo = quads
662 .iter()
663 .find(|quad| {
664 quad.background == gpui_kit::Background::from(gpui_kit::blue().opacity(0.2))
665 })
666 .expect("halo is painted");
667 assert_eq!(dot.bounds.center(), halo.bounds.center());
668 assert_eq!(
669 dot.bounds.center(),
670 point(px(40.), px(50.)).scale(window.scale_factor())
671 );
672 });
673 }
674
675 #[test]
676 fn a_value_color_colors_only_the_row_added_last() {
677 let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)))
678 .value_color(gpui_kit::red())
679 .row(gpui_kit::blue(), "Open", "1")
680 .row(gpui_kit::blue(), "Close", "2")
681 .value_color(gpui_kit::green());
682 let colors: Vec<_> = tooltip.rows.iter().map(|row| row.value_color).collect();
683 assert_eq!(colors, vec![None, Some(gpui_kit::green())]);
684 }
685
686 #[test]
687 fn a_plain_row_has_no_swatch_and_takes_a_value_color() {
688 let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)))
689 .row(gpui_kit::blue(), "Call", "1")
690 .plain_row("Total", "3")
691 .value_color(gpui_kit::red());
692 let rows: Vec<_> = tooltip
693 .rows
694 .iter()
695 .map(|row| (row.color, row.value_color))
696 .collect();
697 assert_eq!(
698 rows,
699 vec![
700 (Some(gpui_kit::blue()), None),
701 (None, Some(gpui_kit::red()))
702 ]
703 );
704 }
705
706 #[test]
707 fn plain_rows_keep_a_swatch_slot_only_beside_series_rows() {
708 let tooltip = || Tooltip::new(point(px(0.), px(0.)), gpui_kit::size(px(100.), px(100.)));
709 let mixed = tooltip()
710 .row(gpui_kit::blue(), "Call", "1")
711 .plain_row("Total", "3");
712 let plain = tooltip().plain_row("Total", "3").plain_row("Ratio", "0.5");
713 assert!(has_swatches(&mixed.rows));
714 assert!(!has_swatches(&plain.rows));
715 }
716}