1use gpui::{
2 AnyElement, App, Div, ElementId, Half as _, Hsla, IntoElement, ParentElement, Pixels, Point,
3 RenderOnce, SharedString, Size, StyleRefinement, Styled, Window, deferred, div, point,
4 prelude::FluentBuilder, px,
5};
6use gpui_base::motion::spring;
7pub use gpui_base::plot::{PlotHover, TooltipState};
8use gpui_base::plot::{hover_progress, is_hover_entering, pointer_spring};
9
10use crate::ThemeStyled as _;
11use crate::{ActiveTheme, Colorize, StyledExt, h_flex, v_flex};
12
13const GLIDE: &str = "__plot-tooltip-glide";
15
16#[derive(Default)]
17pub enum CrossLineAxis {
18 #[default]
19 Vertical,
20 Horizontal,
21 Both,
22}
23
24impl CrossLineAxis {
25 #[inline]
27 pub fn show_vertical(&self) -> bool {
28 matches!(self, CrossLineAxis::Vertical | CrossLineAxis::Both)
29 }
30
31 #[inline]
33 pub fn show_horizontal(&self) -> bool {
34 matches!(self, CrossLineAxis::Horizontal | CrossLineAxis::Both)
35 }
36}
37
38#[derive(IntoElement)]
39pub struct CrossLine {
40 point: Point<Pixels>,
41 vertical: (f32, Option<f32>),
44 horizontal: (f32, Option<f32>),
47 thickness: Pixels,
49 dashed: bool,
51 direction: CrossLineAxis,
52}
53
54impl CrossLine {
55 pub fn new(point: Point<Pixels>) -> Self {
56 Self {
57 point,
58 vertical: (0., None),
59 horizontal: (0., None),
60 thickness: px(1.),
61 dashed: true,
62 direction: Default::default(),
63 }
64 }
65
66 pub fn band(mut self, thickness: impl Into<Pixels>) -> Self {
70 self.thickness = thickness.into();
71 self.dashed = false;
72 self
73 }
74
75 pub fn horizontal(mut self) -> Self {
77 self.direction = CrossLineAxis::Horizontal;
78 self
79 }
80
81 pub fn both(mut self) -> Self {
83 self.direction = CrossLineAxis::Both;
84 self
85 }
86
87 pub fn height(mut self, height: f32) -> Self {
89 self.vertical.1 = Some(height);
90 self
91 }
92
93 pub fn width(mut self, width: f32) -> Self {
95 self.horizontal.1 = Some(width);
96 self
97 }
98
99 pub fn span(mut self, start: f32, length: f32) -> Self {
102 self.vertical = (start, Some(length));
103 self
104 }
105
106 pub fn h_span(mut self, start: f32, length: f32) -> Self {
109 self.horizontal = (start, Some(length));
110 self
111 }
112}
113
114impl From<Point<Pixels>> for CrossLine {
115 fn from(value: Point<Pixels>) -> Self {
116 Self::new(value)
117 }
118}
119
120impl CrossLine {
121 fn line(&self, vertical: bool, cx: &App) -> Div {
125 let color = if self.dashed {
126 cx.theme().border.mix(cx.theme().foreground, 0.8)
127 } else {
128 cx.theme().foreground.opacity(0.08)
129 };
130 let thickness = if self.dashed { px(0.) } else { self.thickness };
132 let (start, length) = if vertical {
135 self.vertical
136 } else {
137 self.horizontal
138 };
139
140 let el = div().absolute();
141 let el = if vertical {
142 el.left(self.point.x - thickness * 0.5)
143 .w(thickness)
144 .top(px(start))
145 .map(|el| match length {
146 Some(length) => el.h(px(length)),
147 None => el.h_full(),
148 })
149 } else {
150 el.top(self.point.y - thickness * 0.5)
151 .h(thickness)
152 .left(px(start))
153 .map(|el| match length {
154 Some(length) => el.w(px(length)),
155 None => el.w_full(),
156 })
157 };
158
159 if self.dashed {
160 let el = if vertical {
161 el.border_l_1()
162 } else {
163 el.border_t_1()
164 };
165 el.border_dashed().border_color(color)
166 } else {
167 el.bg(color)
168 }
169 }
170}
171
172impl RenderOnce for CrossLine {
173 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
174 let vertical = self.direction.show_vertical().then(|| self.line(true, cx));
175 let horizontal = self
176 .direction
177 .show_horizontal()
178 .then(|| self.line(false, cx));
179
180 div()
181 .size_full()
182 .absolute()
183 .top_0()
184 .left_0()
185 .children(vertical)
186 .children(horizontal)
187 }
188}
189
190#[derive(IntoElement)]
191pub struct Dot {
192 point: Point<Pixels>,
193 size: Pixels,
194 stroke: Hsla,
195 fill: Hsla,
196 halo: Option<Pixels>,
198}
199
200impl Dot {
201 pub fn new(point: Point<Pixels>) -> Self {
202 Self {
203 point,
204 size: px(6.),
205 stroke: gpui::transparent_black(),
206 fill: gpui::transparent_black(),
207 halo: None,
208 }
209 }
210
211 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
213 self.size = size.into();
214 self
215 }
216
217 pub fn halo(mut self, size: impl Into<Pixels>) -> Self {
224 self.halo = Some(size.into());
225 self
226 }
227
228 pub fn stroke(mut self, stroke: Hsla) -> Self {
230 self.stroke = stroke;
231 self
232 }
233
234 pub fn fill(mut self, fill: Hsla) -> Self {
236 self.fill = fill;
237 self
238 }
239}
240
241impl RenderOnce for Dot {
242 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
243 let border_width = px(1.);
244 let offset = self.size / 2. - border_width / 2.;
245
246 let dot = div()
247 .absolute()
248 .w(self.size)
249 .h(self.size)
250 .rounded_full()
251 .border(border_width)
252 .border_color(self.stroke)
253 .bg(self.fill)
254 .left(self.point.x - offset)
255 .top(self.point.y - offset);
256
257 let halo = self.halo.map(|halo| {
260 div()
261 .absolute()
262 .size(halo)
263 .rounded_full()
264 .bg(self.fill.opacity(0.2))
265 .left(self.point.x - halo / 2.)
266 .top(self.point.y - halo / 2.)
267 });
268
269 div().absolute().top_0().left_0().children(halo).child(dot)
270 }
271}
272
273struct TooltipRow {
275 color: Option<Hsla>,
276 label: SharedString,
277 value: SharedString,
278 value_color: Option<Hsla>,
279}
280
281#[derive(IntoElement)]
282pub struct Tooltip {
283 base: Div,
284 gap: Pixels,
285 cross_line: Option<CrossLine>,
286 dots: Option<Vec<Dot>>,
287 appearance: bool,
288 title: Option<SharedString>,
289 rows: Vec<TooltipRow>,
290 cursor: Point<Pixels>,
292 within: Size<Pixels>,
295 progress: Option<f32>,
297 glide: bool,
299}
300
301impl Tooltip {
302 pub fn new(cursor: Point<Pixels>, within: Size<Pixels>) -> Self {
304 Self {
305 base: v_flex().gap_y_1(),
309 gap: px(0.),
310 cross_line: None,
311 dots: None,
312 appearance: true,
313 title: None,
314 rows: Vec::new(),
315 cursor,
316 within,
317 progress: None,
318 glide: true,
319 }
320 }
321
322 pub fn glide(mut self, glide: bool) -> Self {
333 self.glide = glide;
334 self
335 }
336
337 pub fn progress(mut self, progress: f32) -> Self {
344 self.progress = Some(progress.clamp(0., 1.));
345 self
346 }
347
348 #[deprecated(since = "0.7.0", note = "use `progress`")]
349 pub fn focus(self, focus: f32) -> Self {
350 self.progress(focus)
351 }
352
353 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
355 self.title = Some(title.into());
356 self
357 }
358
359 pub fn row(
361 mut self,
362 color: impl Into<Hsla>,
363 label: impl Into<SharedString>,
364 value: impl Into<SharedString>,
365 ) -> Self {
366 self.rows.push(TooltipRow {
367 color: Some(color.into()),
368 label: label.into(),
369 value: value.into(),
370 value_color: None,
371 });
372 self
373 }
374
375 pub fn plain_row(
381 mut self,
382 label: impl Into<SharedString>,
383 value: impl Into<SharedString>,
384 ) -> Self {
385 self.rows.push(TooltipRow {
386 color: None,
387 label: label.into(),
388 value: value.into(),
389 value_color: None,
390 });
391 self
392 }
393
394 pub fn value_color(mut self, color: impl Into<Hsla>) -> Self {
400 if let Some(row) = self.rows.last_mut() {
401 row.value_color = Some(color.into());
402 }
403 self
404 }
405
406 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
408 self.gap = gap.into();
409 self
410 }
411
412 pub fn cross_line(mut self, cross_line: CrossLine) -> Self {
414 self.cross_line = Some(cross_line);
415 self
416 }
417
418 pub fn dots(mut self, dots: impl IntoIterator<Item = Dot>) -> Self {
420 self.dots = Some(dots.into_iter().collect());
421 self
422 }
423
424 pub fn appearance(mut self, appearance: bool) -> Self {
426 self.appearance = appearance;
427 self
428 }
429}
430
431impl Styled for Tooltip {
432 fn style(&mut self) -> &mut StyleRefinement {
433 self.base.style()
434 }
435}
436
437impl ParentElement for Tooltip {
438 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
439 self.base.extend(elements);
440 }
441}
442
443fn has_swatches(rows: &[TooltipRow]) -> bool {
446 rows.iter().any(|row| row.color.is_some())
447}
448
449#[cfg(test)]
450impl Tooltip {
451 pub(crate) fn title_for_test(&self) -> Option<&SharedString> {
452 self.title.as_ref()
453 }
454
455 pub(crate) fn rows_for_test(&self) -> Vec<(SharedString, Option<Hsla>)> {
456 self.rows
457 .iter()
458 .map(|row| (row.value.clone(), row.value_color))
459 .collect()
460 }
461}
462
463impl RenderOnce for Tooltip {
464 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
465 let tracked_progress = hover_progress(window, cx);
468 let entering = is_hover_entering(window, cx);
469 let Tooltip {
470 base,
471 gap,
472 mut cross_line,
473 mut dots,
474 appearance,
475 title,
476 rows,
477 cursor,
478 within,
479 progress,
480 glide,
481 } = self;
482 let progress = progress.unwrap_or(tracked_progress);
483
484 if glide {
485 let policy = pointer_spring(cx).with_travel(!entering);
486 if let Some(line) = cross_line.as_mut() {
487 if line.direction.show_vertical() {
488 line.point.x = spring((GLIDE, "x"), line.point.x, policy, window, cx);
489 }
490 if line.direction.show_horizontal() {
491 line.point.y = spring((GLIDE, "y"), line.point.y, policy, window, cx);
492 }
493 }
494 for (i, dot) in dots.iter_mut().flatten().enumerate() {
495 dot.point = point(
496 spring(
497 ElementId::named_usize("__plot-hover-dot-x", i),
498 dot.point.x,
499 policy,
500 window,
501 cx,
502 ),
503 spring(
504 ElementId::named_usize("__plot-hover-dot-y", i),
505 dot.point.y,
506 policy,
507 window,
508 cx,
509 ),
510 );
511 }
512 }
513 for dot in dots.iter_mut().flatten() {
515 dot.halo = dot.halo.map(|halo| halo * progress);
516 }
517
518 let content = if title.is_some() || !rows.is_empty() {
520 let swatched = has_swatches(&rows);
521 v_flex()
522 .gap_1()
523 .when_some(title, |this, title| {
524 this.child(div().font_semibold().child(title))
525 })
526 .children(rows.into_iter().map(|row| {
527 h_flex()
528 .items_center()
529 .justify_between()
530 .gap_3()
531 .child(
532 h_flex()
533 .items_center()
534 .gap_1p5()
535 .when(swatched, |this| {
536 this.child(
537 div()
538 .size_2()
539 .rounded(cx.theme().radius.half())
540 .when_some(row.color, |this, color| this.bg(color)),
541 )
542 })
543 .child(
544 div()
545 .text_color(cx.theme().muted_foreground)
546 .child(row.label),
547 ),
548 )
549 .child(
550 div()
551 .when_some(row.value_color, |this, color| this.text_color(color))
552 .child(row.value),
553 )
554 }))
555 } else {
556 base
557 };
558 let content = content.text_xs();
563
564 div()
565 .size_full()
566 .absolute()
567 .top_0()
568 .left_0()
569 .opacity(progress)
570 .when_some(cross_line, |this, cross_line| this.child(cross_line))
571 .when_some(dots, |this, dots| this.children(dots))
572 .child(deferred(content.map(|mut this| {
577 if !appearance {
578 return this.size_full().relative().opacity(progress);
579 }
580
581 let min_w_unset = this.style().min_size.width.is_none();
584
585 this.absolute()
588 .opacity(progress)
589 .when(min_w_unset, |c| c.min_w(px(150.)))
590 .popover_style(cx)
591 .p_2()
592 .map(|c| {
593 if cursor.x < within.width * 0.5 {
594 c.left(cursor.x + gap)
595 } else {
596 c.right(within.width - cursor.x + gap)
597 }
598 })
599 .map(|c| {
600 if cursor.y < within.height * 0.5 {
601 c.top(cursor.y + gap)
602 } else {
603 c.bottom(within.height - cursor.y + gap)
604 }
605 })
606 })))
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use gpui::{point, px};
613
614 use super::*;
615
616 #[test]
617 fn a_value_color_colors_only_the_row_added_last() {
618 let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)))
619 .value_color(gpui::red())
620 .row(gpui::blue(), "Open", "1")
621 .row(gpui::blue(), "Close", "2")
622 .value_color(gpui::green());
623 let colors: Vec<_> = tooltip.rows.iter().map(|row| row.value_color).collect();
624 assert_eq!(colors, vec![None, Some(gpui::green())]);
625 }
626
627 #[test]
628 fn a_plain_row_has_no_swatch_and_takes_a_value_color() {
629 let tooltip = Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)))
630 .row(gpui::blue(), "Call", "1")
631 .plain_row("Total", "3")
632 .value_color(gpui::red());
633 let rows: Vec<_> = tooltip
634 .rows
635 .iter()
636 .map(|row| (row.color, row.value_color))
637 .collect();
638 assert_eq!(
639 rows,
640 vec![(Some(gpui::blue()), None), (None, Some(gpui::red()))]
641 );
642 }
643
644 #[test]
645 fn plain_rows_keep_a_swatch_slot_only_beside_series_rows() {
646 let tooltip = || Tooltip::new(point(px(0.), px(0.)), gpui::size(px(100.), px(100.)));
647 let mixed = tooltip()
648 .row(gpui::blue(), "Call", "1")
649 .plain_row("Total", "3");
650 let plain = tooltip().plain_row("Total", "3").plain_row("Ratio", "0.5");
651 assert!(has_swatches(&mixed.rows));
652 assert!(!has_swatches(&plain.rows));
653 }
654}