Skip to main content

gpui_macos/chart/
candlestick_chart.rs

1use std::{hash::Hash, rc::Rc};
2
3use gpui_kit::{
4    AnyElement, App, Bounds, ElementId, Hsla, IntoElement, PathBuilder, Pixels, Point,
5    SharedString, Window, fill, point, px,
6};
7
8use crate::{
9    ActiveTheme,
10    plot::{
11        Grid, Plot, PlotAxis, origin_point,
12        scale::{PlotValue, Scale, ScaleBand, ScaleLinear},
13        tooltip::{CrossLine, Tooltip, TooltipState},
14    },
15};
16
17use super::{
18    AXIS_GAP, MAX_BAND_WIDTH, TooltipContent, build_band_labels, caller_id, labeled_items,
19};
20
21pub struct CandlestickChart<T, X, Y>
22where
23    T: 'static,
24    X: Eq + Hash + Into<SharedString> + 'static,
25    Y: PlotValue,
26{
27    data: Vec<T>,
28    x: Option<Rc<dyn Fn(&T) -> X>>,
29    open: Option<Rc<dyn Fn(&T) -> Y>>,
30    high: Option<Rc<dyn Fn(&T) -> Y>>,
31    low: Option<Rc<dyn Fn(&T) -> Y>>,
32    close: Option<Rc<dyn Fn(&T) -> Y>>,
33    tick_margin: usize,
34    body_width_ratio: f32,
35    max_band_width: Pixels,
36    x_axis: bool,
37    grid: bool,
38    bullish: Option<Hsla>,
39    bearish: Option<Hsla>,
40    id: ElementId,
41    interactive: bool,
42    tooltip_content: TooltipContent<T>,
43}
44
45impl<T, X, Y> CandlestickChart<T, X, Y>
46where
47    X: Eq + Hash + Into<SharedString> + 'static,
48    Y: PlotValue,
49{
50    #[track_caller]
51    pub fn new<I>(data: I) -> Self
52    where
53        I: IntoIterator<Item = T>,
54    {
55        Self {
56            data: data.into_iter().collect(),
57            x: None,
58            open: None,
59            high: None,
60            low: None,
61            close: None,
62            tick_margin: 1,
63            body_width_ratio: 0.8,
64            max_band_width: px(MAX_BAND_WIDTH),
65            x_axis: true,
66            grid: true,
67            bullish: None,
68            bearish: None,
69            id: caller_id(),
70            interactive: true,
71            tooltip_content: TooltipContent::default(),
72        }
73    }
74
75    /// Name this chart's [`ElementId`], replacing the default taken from the
76    /// construction site.
77    ///
78    /// Pass one where a single construction site renders several of these
79    /// charts as siblings: they share the default id, and with it one hover
80    /// state and one path cache. The id must be unique among those siblings.
81    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
82        self.id = id.into();
83        self
84    }
85
86    /// Turn this chart's interactive layer on or off. On by default.
87    ///
88    /// The layer is the hitbox under the cursor and what it drives: a highlight
89    /// band marks the hovered candle, and a tooltip shows its open, high, low and
90    /// close. Turn it off for a chart that only decorates, or one an element
91    /// above it wants the cursor for: without a hitbox it neither answers the
92    /// mouse nor takes the hover from what sits over it. A chart that is off also
93    /// drops its path cache, which is keyed on the same id.
94    pub fn interactive(mut self, interactive: bool) -> Self {
95        self.interactive = interactive;
96        self
97    }
98
99    /// Set the hover tooltip's title for a datum, instead of its x value.
100    pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
101        self.tooltip_content.set_title(title);
102        self
103    }
104
105    /// Set the text of each tooltip row's value; the raw number by default.
106    ///
107    /// The closure receives the datum, the row's index (0 to 3 for open, high, low and close) and
108    /// the value the row reads.
109    pub fn tooltip_value(
110        mut self,
111        value: impl Fn(&T, usize, f64) -> SharedString + 'static,
112    ) -> Self {
113        self.tooltip_content.set_value(value);
114        self
115    }
116
117    /// Color each tooltip row's value, such as green or red by its sign; the
118    /// tooltip's text color by default.
119    ///
120    /// The closure receives the same arguments as
121    /// [`tooltip_value`](Self::tooltip_value).
122    pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, usize, f64) -> H + 'static) -> Self
123    where
124        H: Into<Hsla>,
125    {
126        self.tooltip_content.set_value_color(color);
127        self
128    }
129
130    /// Draw the tooltip box's content for a datum yourself, in place of the
131    /// title and rows, for a layout they cannot express such as a table.
132    ///
133    /// The highlight band and where the box sits stay the chart's, and
134    /// [`tooltip_title`](Self::tooltip_title), [`tooltip_value`](Self::tooltip_value)
135    /// and [`tooltip_value_color`](Self::tooltip_value_color) no longer apply.
136    pub fn tooltip_content<E>(
137        mut self,
138        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
139    ) -> Self
140    where
141        E: IntoElement,
142    {
143        self.tooltip_content.set_content(content);
144        self
145    }
146
147    pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
148        self.x = Some(Rc::new(x));
149        self
150    }
151
152    pub fn open(mut self, open: impl Fn(&T) -> Y + 'static) -> Self {
153        self.open = Some(Rc::new(open));
154        self
155    }
156
157    pub fn high(mut self, high: impl Fn(&T) -> Y + 'static) -> Self {
158        self.high = Some(Rc::new(high));
159        self
160    }
161
162    pub fn low(mut self, low: impl Fn(&T) -> Y + 'static) -> Self {
163        self.low = Some(Rc::new(low));
164        self
165    }
166
167    pub fn close(mut self, close: impl Fn(&T) -> Y + 'static) -> Self {
168        self.close = Some(Rc::new(close));
169        self
170    }
171
172    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
173        self.tick_margin = tick_margin;
174        self
175    }
176
177    pub fn body_width_ratio(mut self, ratio: f32) -> Self {
178        self.body_width_ratio = ratio;
179        self
180    }
181
182    /// Keep every candle's band at most `width` wide, so a few candles across
183    /// a wide chart stay narrow.
184    ///
185    /// Default is 30px.
186    pub fn max_band_width(mut self, width: impl Into<Pixels>) -> Self {
187        self.max_band_width = width.into();
188        self
189    }
190
191    /// Show or hide the x-axis line and labels.
192    ///
193    /// Default is true.
194    pub fn x_axis(mut self, x_axis: bool) -> Self {
195        self.x_axis = x_axis;
196        self
197    }
198
199    pub fn grid(mut self, grid: bool) -> Self {
200        self.grid = grid;
201        self
202    }
203
204    /// Set the color of a candle that closed above its open.
205    ///
206    /// Defaults to the theme's `chart.bullish` color. Markets that read a rise
207    /// as red set this and [`Self::bearish`] the other way round.
208    pub fn bullish(mut self, color: impl Into<Hsla>) -> Self {
209        self.bullish = Some(color.into());
210        self
211    }
212
213    /// Set the color of a candle that closed at or below its open.
214    ///
215    /// Defaults to the theme's `chart.bearish` color.
216    pub fn bearish(mut self, color: impl Into<Hsla>) -> Self {
217        self.bearish = Some(color.into());
218        self
219    }
220
221    /// The candle colors, `(bullish, bearish)`, set or from the theme.
222    fn candle_colors(&self, cx: &App) -> (Hsla, Hsla) {
223        (
224            self.bullish.unwrap_or(cx.macos().success),
225            self.bearish.unwrap_or(cx.macos().danger),
226        )
227    }
228
229    /// The band scale along the x axis. Shared by `paint` and `tooltip_state` so
230    /// the candles and the hover band stay aligned.
231    fn x_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<X>> {
232        let x_fn = self.x.as_ref()?;
233        Some(
234            ScaleBand::new(
235                self.data.iter().map(|v| x_fn(v)),
236                [0., bounds.size.width.as_f32()],
237            )
238            .max_band_width(self.max_band_width.as_f32())
239            .padding_inner(0.4)
240            .padding_outer(0.2),
241        )
242    }
243
244    /// The height of the plot area above the x-axis labels.
245    fn plot_height(&self, bounds: Bounds<Pixels>) -> f32 {
246        bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }
247    }
248}
249
250crate::impl_into_plot!(impl<T, X, Y> CandlestickChart<T, X, Y> where X: Eq + Hash + Into<SharedString> + 'static, Y: PlotValue);
251
252impl<T, X, Y> Plot for CandlestickChart<T, X, Y>
253where
254    X: Eq + Hash + Into<SharedString> + 'static,
255    Y: PlotValue,
256{
257    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
258        let (Some(x_fn), Some(open_fn), Some(high_fn), Some(low_fn), Some(close_fn)) = (
259            self.x.as_ref(),
260            self.open.as_ref(),
261            self.high.as_ref(),
262            self.low.as_ref(),
263            self.close.as_ref(),
264        ) else {
265            return;
266        };
267
268        let height = self.plot_height(bounds);
269
270        // X scale
271        let Some(x) = self.x_scale(bounds) else {
272            return;
273        };
274        let band_width = x.band_width();
275
276        // Y scale
277        let all_values: Vec<Y> = self
278            .data
279            .iter()
280            .flat_map(|d| vec![high_fn(d), low_fn(d), open_fn(d), close_fn(d)])
281            .collect();
282        let y = ScaleLinear::new(all_values, [height, 10.]);
283
284        // Draw X axis
285        let mut axis = PlotAxis::new().stroke(cx.macos().border);
286        if self.x_axis {
287            let labels = build_band_labels(
288                &self.data,
289                x_fn.as_ref(),
290                &x,
291                band_width,
292                &labeled_items(self.data.len(), None, self.tick_margin),
293                cx.macos().secondary,
294            );
295            axis = axis.x(height).x_label(labels);
296        }
297        axis.paint(&bounds, window, cx);
298
299        // Draw grid
300        if self.grid {
301            Grid::new()
302                .y((0..=3).map(|i| height * i as f32 / 4.0))
303                .stroke(cx.macos().divider())
304                .paint(&bounds, window);
305        }
306
307        // Draw candlesticks
308        let (bullish, bearish) = self.candle_colors(cx);
309        let origin = bounds.origin;
310        let x_fn = x_fn.clone();
311        let open_fn = open_fn.clone();
312        let high_fn = high_fn.clone();
313        let low_fn = low_fn.clone();
314        let close_fn = close_fn.clone();
315
316        for d in &self.data {
317            let x_tick = x.tick(&x_fn(d));
318            let Some(x_tick) = x_tick else {
319                continue;
320            };
321
322            // Get OHLC values for the current data point
323            let open = open_fn(d);
324            let high = high_fn(d);
325            let low = low_fn(d);
326            let close = close_fn(d);
327
328            // Convert values to pixel coordinates
329            let open_y = y.tick(&open);
330            let high_y = y.tick(&high);
331            let low_y = y.tick(&low);
332            let close_y = y.tick(&close);
333
334            let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) =
335                (open_y, high_y, low_y, close_y)
336            else {
337                continue;
338            };
339
340            // Determine if bullish (close > open) or bearish (close < open)
341            let is_bullish = close > open;
342            let color = if is_bullish { bullish } else { bearish };
343
344            // Calculate candlestick body dimensions
345            let center_x = x_tick + band_width / 2.;
346            let body_width = band_width * self.body_width_ratio;
347            let body_left = center_x - body_width / 2.;
348            let body_right = center_x + body_width / 2.;
349
350            // Draw wick (high to low line)
351            let mut wick_builder = PathBuilder::stroke(px(1.));
352            wick_builder.move_to(origin_point(px(center_x), px(high_y), origin));
353            wick_builder.line_to(origin_point(px(center_x), px(low_y), origin));
354
355            if let Ok(path) = wick_builder.build() {
356                window.paint_path(path, color);
357            }
358
359            // Draw body (open to close rectangle)
360            // For bullish: top is close, bottom is open
361            // For bearish: top is open, bottom is close
362            let (top, bottom) = if is_bullish {
363                (close_y, open_y)
364            } else {
365                (open_y, close_y)
366            };
367
368            let body_bounds = Bounds::from_corners(
369                origin_point(px(body_left), px(top), origin),
370                origin_point(px(body_right), px(bottom), origin),
371            );
372
373            window.paint_quad(fill(body_bounds, color));
374        }
375    }
376
377    fn id(&self) -> Option<ElementId> {
378        self.interactive.then(|| self.id.clone())
379    }
380
381    fn tooltip_state(
382        &self,
383        position: Point<Pixels>,
384        bounds: Bounds<Pixels>,
385        _cx: &App,
386    ) -> Option<TooltipState> {
387        let x_fn = self.x.as_ref()?;
388        let x = self.x_scale(bounds)?;
389
390        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
391        if position.y.as_f32() > self.plot_height(bounds) {
392            return None;
393        }
394
395        let index = x.nearest_index(position.x.as_f32());
396        let d = self.data.get(index)?;
397        let center = x.tick(&x_fn(d))? + x.band_width() / 2.;
398
399        Some(TooltipState::new(
400            index,
401            point(px(center), position.y),
402            vec![],
403        ))
404    }
405
406    fn tooltip(
407        &self,
408        state: &TooltipState,
409        cursor: Point<Pixels>,
410        bounds: Bounds<Pixels>,
411        window: &mut Window,
412        cx: &mut App,
413    ) -> Option<AnyElement> {
414        let (x_fn, open_fn, high_fn, low_fn, close_fn) = (
415            self.x.as_ref()?,
416            self.open.as_ref()?,
417            self.high.as_ref()?,
418            self.low.as_ref()?,
419            self.close.as_ref()?,
420        );
421        let d = self.data.get(state.index)?;
422        let (open, close) = (open_fn(d), close_fn(d));
423        let (bullish, bearish) = self.candle_colors(cx);
424        let color = if close > open { bullish } else { bearish };
425
426        // Highlight the hovered candle with a translucent band the width of its
427        // slot, which glides between candles, confined to the plot area above the
428        // axis labels.
429        let band_width = self.x_scale(bounds)?.band_width();
430        let cross_line = CrossLine::new(state.cross_line)
431            .span(0., self.plot_height(bounds))
432            .band(px(band_width));
433
434        let tooltip = Tooltip::new(cursor, bounds.size)
435            .gap(px(8.))
436            .cross_line(cross_line);
437        let tooltip = self.tooltip_content.apply(
438            tooltip,
439            d,
440            || Some(x_fn(d).into()),
441            || {
442                [
443                    ("Open", open),
444                    ("High", high_fn(d)),
445                    ("Low", low_fn(d)),
446                    ("Close", close),
447                ]
448                .into_iter()
449                .map(|(label, value)| Some((color, label.to_string().into(), value.to_f64()?)))
450                .collect::<Option<Vec<_>>>()
451            },
452            window,
453            cx,
454        )?;
455
456        Some(tooltip.into_any_element())
457    }
458}