1use gpui::{
2 AnyElement, App, Div, Half as _, Hsla, IntoElement, ParentElement, Pixels, Point, RenderOnce,
3 SharedString, Size, StyleRefinement, Styled, Window, deferred, div, prelude::FluentBuilder, px,
4};
5use gpui_base::motion::{Transition, transition};
6
7use crate::ThemeStyled as _;
8use crate::{ActiveTheme, Colorize, StyledExt, h_flex, v_flex};
9
10#[derive(Default)]
11pub enum CrossLineAxis {
12 #[default]
13 Vertical,
14 Horizontal,
15 Both,
16}
17
18impl CrossLineAxis {
19 #[inline]
21 pub fn show_vertical(&self) -> bool {
22 matches!(self, CrossLineAxis::Vertical | CrossLineAxis::Both)
23 }
24
25 #[inline]
27 pub fn show_horizontal(&self) -> bool {
28 matches!(self, CrossLineAxis::Horizontal | CrossLineAxis::Both)
29 }
30}
31
32#[derive(IntoElement)]
33pub struct CrossLine {
34 point: Point<Pixels>,
35 vertical: (f32, Option<f32>),
38 horizontal: (f32, Option<f32>),
41 thickness: Pixels,
43 dashed: bool,
45 direction: CrossLineAxis,
46}
47
48impl CrossLine {
49 pub fn new(point: Point<Pixels>) -> Self {
50 Self {
51 point,
52 vertical: (0., None),
53 horizontal: (0., None),
54 thickness: px(1.),
55 dashed: true,
56 direction: Default::default(),
57 }
58 }
59
60 pub fn band(mut self, thickness: impl Into<Pixels>) -> Self {
64 self.thickness = thickness.into();
65 self.dashed = false;
66 self
67 }
68
69 pub fn horizontal(mut self) -> Self {
71 self.direction = CrossLineAxis::Horizontal;
72 self
73 }
74
75 pub fn both(mut self) -> Self {
77 self.direction = CrossLineAxis::Both;
78 self
79 }
80
81 pub fn height(mut self, height: f32) -> Self {
83 self.vertical.1 = Some(height);
84 self
85 }
86
87 pub fn width(mut self, width: f32) -> Self {
89 self.horizontal.1 = Some(width);
90 self
91 }
92
93 pub fn span(mut self, start: f32, length: f32) -> Self {
96 self.vertical = (start, Some(length));
97 self
98 }
99
100 pub fn h_span(mut self, start: f32, length: f32) -> Self {
103 self.horizontal = (start, Some(length));
104 self
105 }
106}
107
108impl From<Point<Pixels>> for CrossLine {
109 fn from(value: Point<Pixels>) -> Self {
110 Self::new(value)
111 }
112}
113
114impl CrossLine {
115 fn line(&self, vertical: bool, cx: &App) -> Div {
119 let color = if self.dashed {
120 cx.theme().border.mix(cx.theme().foreground, 0.8)
121 } else {
122 cx.theme().foreground.opacity(0.08)
123 };
124 let thickness = if self.dashed { px(0.) } else { self.thickness };
126 let (start, length) = if vertical {
129 self.vertical
130 } else {
131 self.horizontal
132 };
133
134 let el = div().absolute();
135 let el = if vertical {
136 el.left(self.point.x - thickness * 0.5)
137 .w(thickness)
138 .top(px(start))
139 .map(|el| match length {
140 Some(length) => el.h(px(length)),
141 None => el.h_full(),
142 })
143 } else {
144 el.top(self.point.y - thickness * 0.5)
145 .h(thickness)
146 .left(px(start))
147 .map(|el| match length {
148 Some(length) => el.w(px(length)),
149 None => el.w_full(),
150 })
151 };
152
153 if self.dashed {
154 let el = if vertical {
155 el.border_l_1()
156 } else {
157 el.border_t_1()
158 };
159 el.border_dashed().border_color(color)
160 } else {
161 el.bg(color)
162 }
163 }
164}
165
166impl RenderOnce for CrossLine {
167 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
168 let vertical = self.direction.show_vertical().then(|| self.line(true, cx));
169 let horizontal = self
170 .direction
171 .show_horizontal()
172 .then(|| self.line(false, cx));
173
174 div()
175 .size_full()
176 .absolute()
177 .top_0()
178 .left_0()
179 .children(vertical)
180 .children(horizontal)
181 }
182}
183
184#[derive(IntoElement)]
185pub struct Dot {
186 point: Point<Pixels>,
187 size: Pixels,
188 stroke: Hsla,
189 fill: Hsla,
190 halo: Option<Pixels>,
192}
193
194impl Dot {
195 pub fn new(point: Point<Pixels>) -> Self {
196 Self {
197 point,
198 size: px(6.),
199 stroke: gpui::transparent_black(),
200 fill: gpui::transparent_black(),
201 halo: None,
202 }
203 }
204
205 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
207 self.size = size.into();
208 self
209 }
210
211 pub fn halo(mut self, size: impl Into<Pixels>) -> Self {
215 self.halo = Some(size.into());
216 self
217 }
218
219 pub fn stroke(mut self, stroke: Hsla) -> Self {
221 self.stroke = stroke;
222 self
223 }
224
225 pub fn fill(mut self, fill: Hsla) -> Self {
227 self.fill = fill;
228 self
229 }
230}
231
232impl RenderOnce for Dot {
233 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
234 let border_width = px(1.);
235 let offset = self.size / 2. - border_width / 2.;
236
237 let dot = div()
238 .absolute()
239 .w(self.size)
240 .h(self.size)
241 .rounded_full()
242 .border(border_width)
243 .border_color(self.stroke)
244 .bg(self.fill)
245 .left(self.point.x - offset)
246 .top(self.point.y - offset);
247
248 let halo = self.halo.map(|halo| {
251 div()
252 .absolute()
253 .size(halo)
254 .rounded_full()
255 .bg(self.fill.opacity(0.2))
256 .left(self.point.x - halo / 2.)
257 .top(self.point.y - halo / 2.)
258 });
259
260 div().absolute().top_0().left_0().children(halo).child(dot)
261 }
262}
263
264#[derive(Clone)]
265pub struct TooltipState {
266 pub index: usize,
267 pub cross_line: Point<Pixels>,
268 pub dots: Vec<Point<Pixels>>,
269}
270
271impl TooltipState {
272 pub fn new(index: usize, cross_line: Point<Pixels>, dots: Vec<Point<Pixels>>) -> Self {
273 Self {
274 index,
275 cross_line,
276 dots,
277 }
278 }
279}
280
281#[derive(Clone)]
288pub struct PlotHover {
289 state: TooltipState,
290 focus: f32,
291 hovered: bool,
292}
293
294impl PlotHover {
295 pub fn state(&self) -> &TooltipState {
298 &self.state
299 }
300
301 pub fn focus(&self) -> f32 {
307 self.focus
308 }
309
310 pub fn is_hovered(&self) -> bool {
313 self.hovered
314 }
315
316 pub fn is_entering(&self) -> bool {
320 self.hovered && self.focus == 0.
321 }
322}
323
324struct HoverMemory {
329 state: Option<TooltipState>,
330 cursor: Point<Pixels>,
331 focus: f32,
332}
333
334impl Default for HoverMemory {
335 fn default() -> Self {
336 Self {
337 state: None,
338 cursor: Point::default(),
339 focus: 1.,
341 }
342 }
343}
344
345const HOVER_MEMORY: &str = "__plot-hover";
347
348#[doc(hidden)]
357pub fn track_hover(
358 live: Option<TooltipState>,
359 cursor: Option<Point<Pixels>>,
360 window: &mut Window,
361 cx: &mut App,
362) -> Option<(PlotHover, Point<Pixels>)> {
363 let hovered = live.is_some();
364 let memory = window.use_keyed_state(HOVER_MEMORY, cx, |_, _| HoverMemory::default());
365
366 let motion = cx.theme().motion_tokens();
367 let easing = if hovered {
368 motion.easing_enter.clone()
369 } else {
370 motion.easing_exit.clone()
371 };
372 let focus = transition(
373 (HOVER_MEMORY, "focus"),
374 if hovered { 1. } else { 0. },
375 Transition::new(motion.duration_fast).easing(easing),
376 window,
377 cx,
378 );
379
380 memory.update(cx, |memory, _| {
381 if let (Some(live), Some(cursor)) = (live, cursor) {
382 memory.state = Some(live);
383 memory.cursor = cursor;
384 }
385 memory.focus = focus;
386 if !hovered && focus <= 0. {
387 memory.state = None;
388 }
389 });
390
391 let memory = memory.read(cx);
392 let state = memory.state.clone()?;
393 Some((
394 PlotHover {
395 state,
396 focus,
397 hovered,
398 },
399 memory.cursor,
400 ))
401}
402
403struct TooltipRow {
405 color: Hsla,
406 label: SharedString,
407 value: SharedString,
408}
409
410#[derive(IntoElement)]
411pub struct Tooltip {
412 base: Div,
413 gap: Pixels,
414 cross_line: Option<CrossLine>,
415 dots: Option<Vec<Dot>>,
416 appearance: bool,
417 title: Option<SharedString>,
418 rows: Vec<TooltipRow>,
419 cursor: Point<Pixels>,
421 within: Size<Pixels>,
424 focus: Option<f32>,
426}
427
428impl Tooltip {
429 pub fn new(cursor: Point<Pixels>, within: Size<Pixels>) -> Self {
431 Self {
432 base: v_flex(),
433 gap: px(0.),
434 cross_line: None,
435 dots: None,
436 appearance: true,
437 title: None,
438 rows: Vec::new(),
439 cursor,
440 within,
441 focus: None,
442 }
443 }
444
445 pub fn focus(mut self, focus: f32) -> Self {
452 self.focus = Some(focus.clamp(0., 1.));
453 self
454 }
455
456 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
458 self.title = Some(title.into());
459 self
460 }
461
462 pub fn row(
464 mut self,
465 color: impl Into<Hsla>,
466 label: impl Into<SharedString>,
467 value: impl Into<SharedString>,
468 ) -> Self {
469 self.rows.push(TooltipRow {
470 color: color.into(),
471 label: label.into(),
472 value: value.into(),
473 });
474 self
475 }
476
477 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
479 self.gap = gap.into();
480 self
481 }
482
483 pub fn cross_line(mut self, cross_line: CrossLine) -> Self {
485 self.cross_line = Some(cross_line);
486 self
487 }
488
489 pub fn dots(mut self, dots: impl IntoIterator<Item = Dot>) -> Self {
491 self.dots = Some(dots.into_iter().collect());
492 self
493 }
494
495 pub fn appearance(mut self, appearance: bool) -> Self {
497 self.appearance = appearance;
498 self
499 }
500}
501
502impl Styled for Tooltip {
503 fn style(&mut self) -> &mut StyleRefinement {
504 self.base.style()
505 }
506}
507
508impl ParentElement for Tooltip {
509 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
510 self.base.extend(elements);
511 }
512}
513
514impl RenderOnce for Tooltip {
515 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
516 let tracked_focus = window
519 .use_keyed_state(HOVER_MEMORY, cx, |_, _| HoverMemory::default())
520 .read(cx)
521 .focus;
522 let Tooltip {
523 base,
524 gap,
525 cross_line,
526 dots,
527 appearance,
528 title,
529 rows,
530 cursor,
531 within,
532 focus,
533 } = self;
534 let focus = focus.unwrap_or(tracked_focus);
535
536 let content = if title.is_some() || !rows.is_empty() {
538 v_flex()
539 .text_sm()
540 .gap_1()
541 .when_some(title, |this, title| {
542 this.child(div().font_semibold().child(title))
543 })
544 .children(rows.into_iter().map(|row| {
545 h_flex()
546 .items_center()
547 .justify_between()
548 .gap_3()
549 .child(
550 h_flex()
551 .items_center()
552 .gap_1p5()
553 .child(
554 div()
555 .size_2()
556 .rounded(cx.theme().radius.half())
557 .bg(row.color),
558 )
559 .child(
560 div()
561 .text_color(cx.theme().muted_foreground)
562 .child(row.label),
563 ),
564 )
565 .child(div().child(row.value))
566 }))
567 } else {
568 base
569 };
570
571 div()
572 .size_full()
573 .absolute()
574 .top_0()
575 .left_0()
576 .opacity(focus)
577 .when_some(cross_line, |this, cross_line| this.child(cross_line))
578 .when_some(dots, |this, dots| this.children(dots))
579 .child(deferred(content.map(|mut this| {
584 if !appearance {
585 return this.size_full().relative().opacity(focus);
586 }
587
588 let min_w_unset = this.style().min_size.width.is_none();
591
592 this.absolute()
595 .opacity(focus)
596 .when(min_w_unset, |c| c.min_w(px(150.)))
597 .popover_style(cx)
598 .p_2()
599 .map(|c| {
600 if cursor.x < within.width * 0.5 {
601 c.left(cursor.x + gap)
602 } else {
603 c.right(within.width - cursor.x + gap)
604 }
605 })
606 .map(|c| {
607 if cursor.y < within.height * 0.5 {
608 c.top(cursor.y + gap)
609 } else {
610 c.bottom(within.height - cursor.y + gap)
611 }
612 })
613 })))
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use gpui::{point, px};
620
621 use super::*;
622
623 #[test]
624 fn test_plot_hover_readers() {
625 let state = TooltipState::new(2, point(px(10.), px(20.)), vec![]);
626 let hover = PlotHover {
627 state,
628 focus: 1.,
629 hovered: true,
630 };
631 assert_eq!(hover.state().index, 2);
632 assert!(hover.is_hovered());
633 assert!(!hover.is_entering());
635
636 let entering = PlotHover {
638 focus: 0.,
639 ..hover.clone()
640 };
641 assert!(entering.is_entering());
642
643 let lingering = PlotHover {
645 focus: 0.4,
646 hovered: false,
647 ..hover
648 };
649 assert!(!lingering.is_hovered());
650 assert!(!lingering.is_entering());
651 }
652}