1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Size,
5 Window, point, px,
6};
7use gpui_component_macros::IntoPlot;
8
9use crate::{
10 ActiveTheme,
11 plot::{
12 AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis,
13 scale::{PlotValue, Scale, ScaleLinear, ScalePoint},
14 shape::Line,
15 tooltip::{CrossLine, Dot, Tooltip, TooltipState},
16 },
17};
18
19use super::{
20 AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent,
21 axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask,
22 point_range, point_value_scale,
23};
24
25#[derive(IntoPlot)]
26pub struct LineChart<T, X, Y>
27where
28 T: 'static,
29 X: PartialEq + Into<SharedString> + 'static,
30 Y: PlotValue,
31{
32 data: Vec<T>,
33 x: Option<Rc<dyn Fn(&T) -> X>>,
34 y: Option<Rc<dyn Fn(&T) -> Y>>,
35 stroke: Option<Hsla>,
36 curve: Curve,
37 dot: bool,
38 tick_margin: usize,
39 x_axis: bool,
40 grid: bool,
41 y_domain: Option<(Y, Y)>,
42 point_count: Option<usize>,
43 axes: PointAxes,
44 id: ElementId,
45 interactive: bool,
46 name: Option<SharedString>,
47 tooltip_content: TooltipContent<T>,
48}
49
50impl<T, X, Y> LineChart<T, X, Y>
51where
52 X: PartialEq + Into<SharedString> + 'static,
53 Y: PlotValue,
54{
55 #[track_caller]
56 pub fn new<I>(data: I) -> Self
57 where
58 I: IntoIterator<Item = T>,
59 {
60 Self {
61 data: data.into_iter().collect(),
62 stroke: None,
63 curve: Default::default(),
64 dot: false,
65 x: None,
66 y: None,
67 tick_margin: 1,
68 x_axis: true,
69 grid: true,
70 y_domain: None,
71 point_count: None,
72 axes: PointAxes::default(),
73 id: caller_id(),
74 interactive: true,
75 name: None,
76 tooltip_content: TooltipContent::default(),
77 }
78 }
79
80 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
87 self.id = id.into();
88 self
89 }
90
91 pub fn interactive(mut self, interactive: bool) -> Self {
100 self.interactive = interactive;
101 self
102 }
103
104 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
106 self.name = Some(name.into());
107 self
108 }
109
110 pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
112 self.tooltip_content.set_title(title);
113 self
114 }
115
116 pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
120 self.tooltip_content.set_value(move |d, _, v| value(d, v));
121 self
122 }
123
124 pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
130 where
131 H: Into<Hsla>,
132 {
133 self.tooltip_content
134 .set_value_color(move |d, _, value| color(d, value));
135 self
136 }
137
138 pub fn tooltip_content<E>(
145 mut self,
146 content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
147 ) -> Self
148 where
149 E: IntoElement,
150 {
151 self.tooltip_content.set_content(content);
152 self
153 }
154
155 pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
156 self.x = Some(Rc::new(x));
157 self
158 }
159
160 pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
161 self.y = Some(Rc::new(y));
162 self
163 }
164
165 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
166 self.stroke = Some(stroke.into());
167 self
168 }
169
170 pub fn natural(mut self) -> Self {
171 self.curve = Curve::Natural;
172 self
173 }
174
175 pub fn linear(mut self) -> Self {
176 self.curve = Curve::Linear;
177 self
178 }
179
180 pub fn step_after(mut self) -> Self {
181 self.curve = Curve::StepAfter;
182 self
183 }
184
185 pub fn dot(mut self) -> Self {
186 self.dot = true;
187 self
188 }
189
190 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
191 self.tick_margin = tick_margin;
192 self
193 }
194
195 pub fn x_axis(mut self, x_axis: bool) -> Self {
199 self.x_axis = x_axis;
200 self
201 }
202
203 pub fn grid(mut self, grid: bool) -> Self {
204 self.grid = grid;
205 self
206 }
207
208 pub fn y_domain(mut self, min: Y, max: Y) -> Self {
216 self.y_domain = Some((min, max));
217 self
218 }
219
220 pub fn point_count(mut self, count: usize) -> Self {
229 self.point_count = Some(count);
230 self
231 }
232
233 pub fn y_axis(mut self, y_axis: bool) -> Self {
237 self.axes.y_axis = y_axis;
238 self
239 }
240
241 pub fn y_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
246 self.axes.y_axis_label_placement = placement;
247 self
248 }
249
250 pub fn y_tick_count(mut self, count: usize) -> Self {
259 self.axes.y_tick_count = count.max(2);
260 self
261 }
262
263 pub fn y_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
265 where
266 S: Into<SharedString> + 'static,
267 {
268 self.axes.y_tick_format = Some(Rc::new(move |value| format(value).into()));
269 self
270 }
271
272 pub fn x_tick_count(mut self, count: usize) -> Self {
279 self.axes.x_tick_count = Some(count);
280 self
281 }
282
283 pub fn grid_columns(mut self, count: usize) -> Self {
288 self.axes.grid_columns = count;
289 self
290 }
291
292 pub fn grid_dashed(mut self, dashed: bool) -> Self {
296 self.axes.grid_dashed = dashed;
297 self
298 }
299
300 pub fn reference_line(mut self, value: Y) -> Self {
304 if let Some(value) = value.to_f64() {
305 self.axes.reference_lines.push(value);
306 }
307 self
308 }
309
310 pub fn y_padding(mut self, top: f32, bottom: f32) -> Self {
315 self.axes.y_padding = (top, bottom);
316 self
317 }
318
319 fn scales(
324 &self,
325 bounds: Bounds<Pixels>,
326 ) -> Option<(ScalePoint<X>, ScaleLinear<Y>, ValueExtent)> {
327 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
328
329 let width = bounds.size.width.as_f32();
330 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
331 let height = bounds.size.height.as_f32() - axis_gap;
332
333 let len = self.data.len();
334 let x = ScalePoint::new(
335 self.data.iter().map(|v| x_fn(v)),
336 point_range(
337 self.axes.plot_left(),
338 width - self.axes.plot_left(),
339 len,
340 axis_point_count(self.point_count, len),
341 ),
342 );
343 let (y, extent) = point_value_scale(
344 self.data.iter().map(|v| y_fn(v)),
345 self.y_domain,
346 height,
347 self.axes.y_padding,
348 );
349
350 Some((x, y, extent))
351 }
352}
353
354impl<T, X, Y> Plot for LineChart<T, X, Y>
355where
356 X: PartialEq + Into<SharedString> + 'static,
357 Y: PlotValue,
358{
359 fn prepaint(
360 &mut self,
361 bounds: Bounds<Pixels>,
362 window: &mut Window,
363 _cx: &mut App,
364 ) -> Vec<AnyElement> {
365 if let Some((_, _, extent)) = self.scales(bounds) {
367 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
368 let height = bounds.size.height.as_f32() - axis_gap;
369 self.axes.measure_y_labels(extent, height, window);
370 }
371 vec![]
372 }
373
374 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
375 let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
376 return;
377 };
378 let Some((x, y, extent)) = self.scales(bounds) else {
379 return;
380 };
381
382 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
383 let height = bounds.size.height.as_f32() - axis_gap;
384
385 let left = self.axes.plot_left();
389 let axis_bounds = Bounds {
390 origin: bounds.origin + point(px(left), px(0.)),
391 size: Size::new(bounds.size.width - px(left), bounds.size.height),
392 };
393 let mut axis = PlotAxis::new().stroke(cx.theme().border);
394 if self.x_axis {
395 let labeled = labeled_items(
396 axis_point_count(self.point_count, self.data.len()),
397 self.axes.x_tick_count,
398 self.tick_margin,
399 );
400 let labels = build_point_x_labels(
401 &self.data,
402 x_fn.as_ref(),
403 &x,
404 axis_point_count(self.point_count, self.data.len()),
405 &labeled,
406 cx.theme().muted_foreground,
407 )
408 .into_iter()
409 .map(|mut label| {
410 label.tick -= px(left);
411 label
412 });
413 axis = axis.x(height).x_label(labels);
414 }
415 axis.paint(&axis_bounds, window, cx);
416
417 if self.grid {
418 self.axes.paint_grid(bounds, height, window, cx);
419 }
420
421 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
423 let y_fn = y_fn.clone();
426 let mut line = Line::new()
427 .data(self.data.iter().enumerate())
428 .x(move |(i, _)| x.tick_at(*i))
429 .y(move |(_, d)| y.tick(&y_fn(d)))
430 .stroke(stroke)
431 .curve(self.curve)
432 .stroke_width(2.);
433
434 if self.dot {
435 line = line.dot().dot_size(8.).dot_fill(stroke);
436 }
437
438 let mask = self
439 .y_domain
440 .is_some()
441 .then(|| pinned_plot_mask(bounds, height));
442 window.with_content_mask(mask, |window| {
443 if self.interactive {
447 let caches = PathCaches::for_paint("line", window, cx);
448 caches.update(cx, |caches, _| {
449 line.paint_cached(&bounds, caches.slot(0), window);
450 });
451 } else {
452 line.paint(&bounds, window);
453 }
454 });
455
456 self.axes
457 .paint_reference_lines(extent, bounds, height, window, cx);
458 self.axes.paint_y_labels(extent, bounds, height, window, cx);
459 }
460
461 fn id(&self) -> Option<ElementId> {
462 self.interactive.then(|| self.id.clone())
463 }
464
465 fn tooltip_state(
466 &self,
467 position: Point<Pixels>,
468 bounds: Bounds<Pixels>,
469 _cx: &App,
470 ) -> Option<TooltipState> {
471 let y_fn = self.y.as_ref()?;
472 let (x, y, _) = self.scales(bounds)?;
473
474 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
476 if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
477 || position.x.as_f32() < self.axes.plot_left()
478 {
479 return None;
480 }
481
482 let index = x.nearest_index(position.x.as_f32());
483 let d = self.data.get(index)?;
484 let x_tick = x.tick_at(index)?;
485 let y_tick = y.tick(&y_fn(d))?;
486
487 Some(TooltipState::new(
488 index,
489 point(px(x_tick), position.y),
490 vec![point(px(x_tick), px(y_tick))],
491 ))
492 }
493
494 fn tooltip(
495 &self,
496 state: &TooltipState,
497 cursor: Point<Pixels>,
498 bounds: Bounds<Pixels>,
499 window: &mut Window,
500 cx: &mut App,
501 ) -> Option<AnyElement> {
502 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
503 let d = self.data.get(state.index)?;
504 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
505 let name = self.name.clone().unwrap_or_default();
506 let dot = *state.dots.first()?;
507
508 let tooltip = Tooltip::new(cursor, bounds.size)
510 .gap(px(8.))
511 .cross_line(
513 CrossLine::new(state.cross_line)
514 .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
515 )
516 .dots(Some(
517 Dot::new(dot)
518 .size(HOVER_DOT_SIZE)
519 .halo(HOVER_HALO_SIZE)
520 .stroke(cx.theme().background)
521 .fill(stroke),
522 ));
523
524 let tooltip = self.tooltip_content.apply(
525 tooltip,
526 d,
527 || Some(x_fn(d).into()),
528 || Some([(stroke, name, y_fn(d).to_f64()?)]),
529 window,
530 cx,
531 )?;
532
533 Some(tooltip.into_any_element())
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use gpui::{Bounds, point, px, size};
540
541 use super::LineChart;
542 use crate::plot::scale::Scale;
543
544 #[test]
545 fn test_f32_values_scale_like_f64() {
546 let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)));
547 let chart: LineChart<(usize, f32), String, f32> =
548 LineChart::new([2f32, 4.].into_iter().enumerate())
549 .x(|(i, _)| i.to_string())
550 .y(|(_, v)| *v)
551 .x_axis(false);
552 let (_, y, _) = chart.scales(bounds).unwrap();
553 let y64 = LineChart::new([2f64, 4.].into_iter().enumerate())
554 .x(|(i, _): &(usize, f64)| i.to_string())
555 .y(|(_, v)| *v)
556 .x_axis(false)
557 .scales(bounds)
558 .unwrap()
559 .1;
560 assert_eq!(y.tick(&4.), y64.tick(&4.));
561 assert_eq!(y.tick(&0.), y64.tick(&0.));
562 }
563}