Skip to main content

egui_charts/chart/rendering/overlays/
tooltip.rs

1//! Tooltip Renderers
2//!
3//! Provides three tooltip variants:
4//! - Floating: Classic tooltip that follows cursor position
5//! - Tracking: Fixed horizontal bar at top of chart showing OHLC data
6//! - Magnifier: Circular zoom lens for detailed data inspection
7
8use super::super::{ChartMapping, RenderContext, StyleColors};
9use crate::chart::rendering::axes::semibold;
10use crate::config::{TooltipMode, TooltipOptions};
11use crate::localization as l10n;
12use crate::model::{Bar, Instrument, percent_change};
13use crate::scales::price_decimals;
14use crate::scales::{
15    DefaultPriceFormatter, HorizontalScale, PriceFormatter, PriceMap, VolumeFormatter,
16    instrument_price_precision,
17};
18use crate::styles::typography::font_size;
19use crate::theme::ThemeExt;
20use crate::tokens::DESIGN_TOKENS;
21use egui::{FontId, Painter, Pos2, Rect, Stroke, Vec2, epaint::StrokeKind};
22
23/// Host number formatters shared by legends and tooltips. Missing entries retain the
24/// renderer's instrument precision and built-in formatting. Prices use instrument units;
25/// percentages use percentage points, so `5.0` means 5%.
26#[derive(Clone, Copy, Default)]
27pub struct ValueFormatters<'a> {
28    /// OHLC prices and absolute changes.
29    pub price: Option<&'a dyn PriceFormatter>,
30    /// Volume values.
31    pub volume: Option<&'a dyn PriceFormatter>,
32    /// Percentage changes.
33    pub percentage: Option<&'a dyn PriceFormatter>,
34}
35
36/// One visible study plot's value at the selected bar. Missing or warming-up values stay absent.
37#[derive(Clone, Debug, PartialEq)]
38pub struct StudyValue {
39    /// Host-formatted text, when supplied by the chart. `None` uses the units and precision.
40    pub formatted: Option<String>,
41    /// Study title.
42    pub study: String,
43    /// Plot title within the study.
44    pub plot: String,
45    /// Finite value at the selected index, or `None` for a gap.
46    pub value: Option<f64>,
47    /// Number of decimal places used by the study's legend.
48    pub precision: usize,
49    /// Value units used by this study.
50    pub format: crate::scales::StudyFormat,
51    /// Plot colour.
52    pub color: egui::Color32,
53}
54
55impl StudyValue {
56    /// Study and plot caption, omitting a repeated single-plot title.
57    pub fn label(&self) -> String {
58        if self.plot.is_empty() || self.plot == self.study {
59            self.study.clone()
60        } else {
61            format!("{}: {}", self.study, self.plot)
62        }
63    }
64
65    /// Value at the study's precision, or the missing-value marker.
66    pub fn formatted_value(&self) -> String {
67        if let Some(formatted) = &self.formatted {
68            return formatted.clone();
69        }
70        self.value.filter(|value| value.is_finite()).map_or_else(
71            || "∅".to_owned(),
72            |value| self.format.format(value, Some(self.precision)),
73        )
74    }
75}
76
77/// Renders tooltip based on configured mode
78pub fn render_tooltip_with_options(
79    context: &RenderContext,
80    hover_pos: Pos2,
81    candle: &Bar,
82    previous_close: Option<f64>,
83    options: &TooltipOptions,
84    colors: &StyleColors,
85    price_scale: &PriceMap,
86    coords: &ChartMapping,
87    visible_data: &[Bar],
88    scale: &dyn HorizontalScale,
89    bar_seconds: i64,
90    studies: &[StudyValue],
91    instrument: Option<&Instrument>,
92    formatters: ValueFormatters<'_>,
93) {
94    match options.mode {
95        TooltipMode::Floating => {
96            // The held crosshair's vertical line snaps to the bar's centre, and the card follows it.
97            let hover_pos = coords
98                .local_idx_at_x(hover_pos.x, visible_data.len())
99                .filter(|_| options.on_long_press)
100                .map_or(hover_pos, |local| {
101                    Pos2::new(coords.idx_to_x(coords.start_idx + local), hover_pos.y)
102                });
103            render_floating_tooltip(
104                context.painter,
105                hover_pos,
106                context.rect,
107                coords.bar_spacing,
108                candle,
109                previous_close,
110                options,
111                colors.bar_color(candle.close >= candle.open),
112                scale,
113                bar_seconds,
114                studies,
115                instrument,
116                formatters,
117            );
118        }
119        TooltipMode::Tracking => {
120            render_tracking_tooltip(
121                context.painter,
122                context.rect,
123                candle,
124                options,
125                scale,
126                bar_seconds,
127                studies,
128                instrument,
129                formatters,
130            );
131        }
132        TooltipMode::Magnifier => {
133            render_magnifier_tooltip(
134                context,
135                hover_pos,
136                candle,
137                options,
138                price_scale,
139                coords,
140                visible_data,
141            );
142        }
143    }
144}
145
146/// Renders the floating tooltip: a label and value grid in the top corner of `price_rect`
147/// on the side away from the pointer, so it never covers the hovered bar.
148/// The `instrument`'s symbol heads the grid.
149/// Held values use time, OHLC and total volume instead of the hover change row;
150/// optional study rows follow these values. Their card sits beside the bar centred at
151/// `hover_pos.x`, 15 px from that line or clear of the `bar_spacing` slot when it is wider, on
152/// the right unless only the left has room, with its top at the pointer and values in semibold.
153pub fn render_floating_tooltip(
154    painter: &Painter,
155    hover_pos: Pos2,
156    price_rect: Rect,
157    bar_spacing: f32,
158    candle: &Bar,
159    previous_close: Option<f64>,
160    options: &TooltipOptions,
161    value_color: egui::Color32,
162    scale: &dyn HorizontalScale,
163    bar_seconds: i64,
164    studies: &[StudyValue],
165    instrument: Option<&Instrument>,
166    formatters: ValueFormatters<'_>,
167) {
168    let options = &options.resolved(painter.ctx());
169    let price = |price: f64| {
170        let decimals = instrument.and_then(instrument_price_precision).map_or_else(
171            || price_decimals(price).min(options.price_precision),
172            usize::from,
173        );
174        formatters.price.map_or_else(
175            || DefaultPriceFormatter::with_precision(decimals).format(price),
176            |formatter| formatter.format(price),
177        )
178    };
179    let mut rows = Vec::new();
180
181    if options.show_time || options.on_long_press {
182        rows.push(("Time", scale.format_crosshair(candle.time, bar_seconds)));
183    }
184
185    if options.show_ohlc {
186        rows.push(("Open", price(candle.open)));
187        rows.push(("High", price(candle.high)));
188        rows.push(("Low", price(candle.low)));
189        rows.push(("Close", price(candle.close)));
190    }
191
192    if options.show_change && !options.on_long_press {
193        let reference = previous_close.unwrap_or(candle.open);
194        let change = candle.close - reference;
195        let change_pct = percent_change(reference, candle.close);
196        let absolute = formatters.price.map_or_else(
197            || {
198                format!(
199                    "{change:+.*}",
200                    instrument
201                        .and_then(instrument_price_precision)
202                        .map_or(2, usize::from)
203                )
204            },
205            |formatter| formatter.format(change),
206        );
207        let percent = formatters.percentage.map_or_else(
208            || format!("{change_pct:+.2}%"),
209            |formatter| formatter.format(change_pct),
210        );
211        rows.push(("Change", format!("{absolute} ({percent})")));
212    }
213    if options.show_volume {
214        let volume = VolumeFormatter {
215            precision: instrument
216                .and_then(|instrument| instrument.volume_decimals)
217                .map_or(2, usize::from),
218        }
219        .format(candle.volume);
220        rows.push((
221            "Vol",
222            formatters
223                .volume
224                .map_or(volume, |formatter| formatter.format(candle.volume)),
225        ));
226    }
227
228    let mut rows: Vec<_> = rows
229        .into_iter()
230        .map(|(label, value)| (l10n::text(painter.ctx(), label), value, value_color))
231        .collect();
232    rows.extend(studies.iter().map(|study| {
233        (
234            study.label(),
235            study.formatted_value(),
236            // A fully transparent colour holds no RGB, so it has no opaque form.
237            if study.color.a() == 0 {
238                options.text_color
239            } else {
240                study.color.to_opaque()
241            },
242        )
243    }));
244
245    if rows.is_empty() {
246        return;
247    }
248    if let Some(instrument) = instrument {
249        rows.insert(0, (instrument.symbol.clone(), String::new(), value_color));
250    }
251
252    let theme = painter.ctx().active_theme();
253    let font_id = FontId::proportional(font_size(options.font_size));
254    let rows: Vec<_> = rows
255        .into_iter()
256        .map(|(label, value, color)| {
257            (
258                painter.layout_no_wrap(label, font_id.clone(), options.text_color),
259                if options.on_long_press {
260                    painter.layout_job(egui::text::LayoutJob::single_section(
261                        value,
262                        semibold(font_id.clone(), color),
263                    ))
264                } else {
265                    painter.layout_no_wrap(value, font_id.clone(), color)
266                },
267            )
268        })
269        .collect();
270    let label_width = rows
271        .iter()
272        .map(|(label, _)| label.size().x)
273        .fold(0.0, f32::max);
274    let value_width = rows
275        .iter()
276        .map(|(_, value)| value.size().x)
277        .fold(0.0, f32::max);
278    let row_gap = if options.on_long_press {
279        6.0
280    } else {
281        theme.spacing.xs
282    };
283    let line_height = rows[0].0.size().y + row_gap;
284    let padding = if options.on_long_press {
285        14.0
286    } else {
287        theme.spacing.lg
288    };
289    let inset = theme.spacing.lg;
290    let mut bounds = price_rect.shrink(inset);
291    let (on_left, clearance) = if options.on_long_press {
292        let clearance = (bar_spacing / 2.0).max(15.0);
293        let width = label_width + theme.spacing.lg + value_width + 2.0 * padding;
294        let room = bounds.right() - hover_pos.x - clearance;
295        (
296            room < width && hover_pos.x - clearance - bounds.left() > room,
297            clearance,
298        )
299    } else {
300        (hover_pos.x > price_rect.center().x, inset)
301    };
302    if on_left {
303        bounds.max.x = bounds.max.x.min(hover_pos.x - clearance);
304    } else {
305        bounds.min.x = bounds.min.x.max(hover_pos.x + clearance);
306    }
307    if !bounds.is_positive() {
308        return;
309    }
310    let padding = padding.min(bounds.width() / 8.0).min(bounds.height() / 8.0);
311    let gap = theme.spacing.lg.min(bounds.width() / 8.0);
312    let content_width = (bounds.width() - 2.0 * padding - gap).max(0.0);
313    let value_width = value_width.min(content_width * 0.65);
314    let label_width = label_width.min(content_width - value_width);
315    let fit = |galley: std::sync::Arc<egui::Galley>, width| {
316        let mut job = (*galley.job).clone();
317        job.wrap = egui::text::TextWrapping::truncate_at_width(width);
318        painter.layout_job(job)
319    };
320    let rows: Vec<_> = rows
321        .into_iter()
322        .map(|(label, value)| (fit(label, label_width), fit(value, value_width)))
323        .collect();
324    let size = Vec2::new(
325        label_width + gap + value_width + 2.0 * padding,
326        (rows.len() as f32 * line_height - row_gap + 2.0 * padding).min(bounds.height()),
327    );
328    // The held card keeps to the line, the hover card to the far corner.
329    let x = if on_left != options.on_long_press {
330        bounds.left()
331    } else {
332        bounds.right() - size.x
333    };
334    let y = if options.on_long_press {
335        hover_pos.y.min(bounds.bottom() - size.y).max(bounds.top())
336    } else {
337        bounds.top()
338    };
339    let tooltip_rect = Rect::from_min_size(Pos2::new(x, y), size);
340    if options.on_long_press {
341        painter.add(
342            egui::Shadow {
343                offset: [0, 2],
344                blur: 8,
345                spread: 0,
346                color: egui::Color32::from_black_alpha(40),
347            }
348            .as_shape(tooltip_rect, theme.radius.sm),
349        );
350    }
351
352    let border_color = if candle.is_bullish() {
353        options.border_color_bullish
354    } else {
355        options.border_color_bearish
356    };
357    painter.rect_filled(tooltip_rect, theme.radius.sm, options.background_color);
358    painter.rect_stroke(
359        tooltip_rect,
360        theme.radius.sm,
361        Stroke::new(1.0, border_color),
362        StrokeKind::Inside,
363    );
364
365    // Labels left-aligned, values right-aligned. Overflow stays within the readout.
366    let painter =
367        painter.with_clip_rect(tooltip_rect.shrink(padding).intersect(painter.clip_rect()));
368    let values_right = tooltip_rect.max.x - padding;
369    for (i, (label, value)) in rows.into_iter().enumerate() {
370        let y = tooltip_rect.min.y + padding + i as f32 * line_height;
371        if y + label.size().y.max(value.size().y) > tooltip_rect.bottom() - padding {
372            break;
373        }
374        if !label.text().is_empty() {
375            painter.galley(
376                Pos2::new(tooltip_rect.min.x + padding, y),
377                label,
378                options.text_color,
379            );
380        }
381        if !value.text().is_empty() {
382            painter.galley(
383                Pos2::new(values_right - value.size().x, y),
384                value,
385                options.text_color,
386            );
387        }
388    }
389}
390
391/// Renders tracking tooltip as horizontal bar at top of chart
392/// Shows: SYMBOL  O: xxx  H: xxx  L: xxx  C: xxx  Vol: xxx  (+x.xx%)
393pub fn render_tracking_tooltip(
394    painter: &Painter,
395    chart_rect: Rect,
396    candle: &Bar,
397    options: &TooltipOptions,
398    scale: &dyn HorizontalScale,
399    bar_seconds: i64,
400    studies: &[StudyValue],
401    instrument: Option<&Instrument>,
402    formatters: ValueFormatters<'_>,
403) {
404    let options = &options.resolved(painter.ctx());
405    let bar_height = options.tracking_bar_height;
406    let bar_rect = Rect::from_min_size(chart_rect.min, Vec2::new(chart_rect.width(), bar_height));
407
408    let painter = painter.with_clip_rect(
409        bar_rect
410            .intersect(chart_rect)
411            .intersect(painter.clip_rect()),
412    );
413    // Draw bar background
414    painter.rect_filled(bar_rect, 0.0, options.tracking_bar_background);
415
416    // Build tracking text
417    let precision = instrument
418        .and_then(instrument_price_precision)
419        .map_or(options.price_precision.min(4), usize::from);
420    let mut parts: Vec<String> = instrument
421        .map(|instrument| instrument.symbol.clone())
422        .into_iter()
423        .collect();
424
425    if options.show_time {
426        parts.push(scale.format_crosshair(candle.time, bar_seconds));
427    }
428
429    if options.show_ohlc {
430        for (label, value) in [
431            ("O: {value}", candle.open),
432            ("H: {value}", candle.high),
433            ("L: {value}", candle.low),
434            ("C: {value}", candle.close),
435        ] {
436            let value = formatters.price.map_or_else(
437                || format!("{value:.precision$}"),
438                |formatter| formatter.format(value),
439            );
440            parts.push(l10n::format_text(
441                painter.ctx(),
442                label,
443                &[("value", value.as_str())],
444            ));
445        }
446    }
447
448    if options.show_volume {
449        let volume = formatters.volume.map_or_else(
450            || {
451                VolumeFormatter {
452                    precision: instrument
453                        .and_then(|instrument| instrument.volume_decimals)
454                        .map_or(2, usize::from),
455                }
456                .format(candle.volume)
457            },
458            |formatter| formatter.format(candle.volume),
459        );
460        parts.push(l10n::format_text(
461            painter.ctx(),
462            "Vol: {volume}",
463            &[("volume", volume.as_str())],
464        ));
465    }
466
467    parts.extend(
468        studies
469            .iter()
470            .map(|study| format!("{}: {}", study.label(), study.formatted_value())),
471    );
472
473    if options.show_change {
474        let change_pct = percent_change(candle.open, candle.close);
475        let sign = if change_pct >= 0.0 { "+" } else { "" };
476        let chart = painter.ctx().active_theme().chart;
477        let change_color = if change_pct >= 0.0 {
478            chart.candle_up
479        } else {
480            chart.candle_down
481        };
482
483        // Draw change separately with color
484        let main_text = parts.join("   ");
485        let change_text = formatters.percentage.map_or_else(
486            || format!("({sign}{change_pct:.2}%)"),
487            |formatter| format!("({})", formatter.format(change_pct)),
488        );
489
490        let font_id = FontId::proportional(font_size(options.font_size));
491        let text_y = bar_rect.center().y;
492
493        // Draw main text
494        let main_right = if main_text.is_empty() {
495            bar_rect.min.x + 10.0
496        } else {
497            painter
498                .text(
499                    Pos2::new(bar_rect.min.x + 10.0, text_y),
500                    egui::Align2::LEFT_CENTER,
501                    &main_text,
502                    font_id.clone(),
503                    options.text_color,
504                )
505                .max
506                .x
507        };
508
509        // Draw change text with color
510        painter.text(
511            Pos2::new(main_right + 15.0, text_y),
512            egui::Align2::LEFT_CENTER,
513            &change_text,
514            font_id,
515            change_color,
516        );
517
518        return;
519    }
520
521    // If no change display, just show combined text
522    let text = parts.join("   ");
523    if text.is_empty() {
524        return;
525    }
526    let font_id = FontId::proportional(font_size(options.font_size));
527
528    painter.text(
529        Pos2::new(bar_rect.min.x + 10.0, bar_rect.center().y),
530        egui::Align2::LEFT_CENTER,
531        &text,
532        font_id,
533        options.text_color,
534    );
535}
536
537/// Clips a wick or body edge to the circle using its nearest point on the line.
538fn lens_line(
539    painter: &Painter,
540    center: Pos2,
541    radius: f32,
542    [start, end]: [Pos2; 2],
543    stroke: Stroke,
544) {
545    let direction = end - start;
546    let length_sq = direction.length_sq();
547    if length_sq == 0.0 {
548        return;
549    }
550    let nearest = (center - start).dot(direction) / length_sq;
551    let distance_sq = (start + direction * nearest).distance_sq(center);
552    if distance_sq > radius * radius {
553        return;
554    }
555    let span = ((radius * radius - distance_sq) / length_sq).sqrt();
556    let from = (nearest - span).max(0.0);
557    let to = (nearest + span).min(1.0);
558    if from < to {
559        painter.line_segment([start + direction * from, start + direction * to], stroke);
560    }
561}
562
563/// Intersects the lens polygon with the four sides of a filled candle body.
564fn lens_body(circle: &[Pos2], body: Rect) -> Vec<Pos2> {
565    let mut points = circle.to_vec();
566    for (axis, edge, sign) in [
567        (0, body.left(), 1.0),
568        (0, body.right(), -1.0),
569        (1, body.top(), 1.0),
570        (1, body.bottom(), -1.0),
571    ] {
572        let input = std::mem::take(&mut points);
573        let Some(mut previous) = input.last().copied() else {
574            break;
575        };
576        for current in input {
577            let inside = (current[axis] - edge) * sign >= 0.0;
578            let was_inside = (previous[axis] - edge) * sign >= 0.0;
579            if inside != was_inside {
580                let t = (edge - previous[axis]) / (current[axis] - previous[axis]);
581                points.push(previous + (current - previous) * t);
582            }
583            if inside {
584                points.push(current);
585            }
586            previous = current;
587        }
588    }
589    points
590}
591
592/// Renders magnifier tooltip as circular zoom lens
593/// Shows enlarged view of candles around cursor position
594pub fn render_magnifier_tooltip(
595    context: &RenderContext,
596    hover_pos: Pos2,
597    _candle: &Bar,
598    options: &TooltipOptions,
599    price_scale: &PriceMap,
600    coords: &ChartMapping,
601    visible_data: &[Bar],
602) {
603    let chart = context.painter.ctx().active_theme().chart;
604    // A zoom or size of 0, NaN or infinity would make the lens span unboundedly many bars or none.
605    let zoom = if options.magnifier_zoom.is_nan() {
606        0.1
607    } else {
608        options.magnifier_zoom.clamp(0.1, 100.0)
609    };
610    let size = options
611        .magnifier_size
612        .max(0.0)
613        .min(context.rect.width().min(context.rect.height()));
614    let radius = size / 2.0;
615
616    // Pos magnifier (centered on cursor, but kept in bounds)
617    let mut center = hover_pos;
618    if center.x - radius < context.rect.min.x {
619        center.x = context.rect.min.x + radius;
620    }
621    if center.x + radius > context.rect.max.x {
622        center.x = context.rect.max.x - radius;
623    }
624    if center.y - radius < context.rect.min.y {
625        center.y = context.rect.min.y + radius;
626    }
627    if center.y + radius > context.rect.max.y {
628        center.y = context.rect.max.y - radius;
629    }
630
631    // Draw magnifier background (dark circle)
632    context
633        .painter
634        .circle_filled(center, radius, chart.bg_tooltip);
635
636    // Calculate visible range in the magnifier
637    // The magnifier shows a zoomed view centered on the hover position
638    let src_width = size / zoom;
639
640    // Enlarges the chart about the cursor, so prices land where the chart's own scale puts them.
641    let price_to_mag_y =
642        |price: f64| center.y + (price_scale.price_to_y(price) - hover_pos.y) * zoom;
643
644    // The painter clips rectangles only; clip candle geometry to the lens as well.
645    let clip_rect = Rect::from_center_size(center, Vec2::splat(size));
646    let painter = context.painter.with_clip_rect(clip_rect);
647    let candle_radius = (radius - DESIGN_TOKENS.stroke.hairline / 2.0).max(0.0);
648    let circle: Vec<_> = (0..64)
649        .map(|i| {
650            let angle = std::f32::consts::TAU * i as f32 / 64.0;
651            center + Vec2::angled(angle) * candle_radius
652        })
653        .collect();
654
655    // Draw zoomed candles
656    let zoomed_bar_spacing = coords.bar_spacing * zoom;
657    let candle_width = zoomed_bar_spacing * 0.7;
658
659    let first = coords
660        .x_to_idx(hover_pos.x - src_width / 2.0 - coords.bar_spacing)
661        .saturating_sub(coords.start_idx)
662        .min(visible_data.len());
663    let end = coords
664        .x_to_idx(hover_pos.x + src_width / 2.0 + coords.bar_spacing)
665        .saturating_add(1)
666        .saturating_sub(coords.start_idx)
667        .min(visible_data.len());
668    for (local_idx, bar) in visible_data.iter().enumerate().take(end).skip(first) {
669        let bar_x = center.x + (coords.idx_to_x(coords.start_idx + local_idx) - hover_pos.x) * zoom;
670
671        // Skip if outside magnifier bounds
672        if bar_x < clip_rect.min.x - candle_width || bar_x > clip_rect.max.x + candle_width {
673            continue;
674        }
675
676        let open_y = price_to_mag_y(bar.open);
677        let close_y = price_to_mag_y(bar.close);
678        let high_y = price_to_mag_y(bar.high);
679        let low_y = price_to_mag_y(bar.low);
680
681        let is_bullish = bar.close >= bar.open;
682        let candle_color = if is_bullish {
683            chart.candle_up
684        } else {
685            chart.candle_down
686        };
687
688        // Draw wick
689        lens_line(
690            &painter,
691            center,
692            candle_radius,
693            [Pos2::new(bar_x, high_y), Pos2::new(bar_x, low_y)],
694            Stroke::new(DESIGN_TOKENS.stroke.hairline, candle_color),
695        );
696
697        // Draw body
698        let body_top = open_y.min(close_y);
699        let body_bottom = open_y.max(close_y);
700        let body_height = (body_bottom - body_top).max(1.0);
701
702        let body_rect = Rect::from_min_size(
703            Pos2::new(bar_x - candle_width / 2.0, body_top),
704            Vec2::new(candle_width, body_height),
705        );
706
707        if is_bullish {
708            let corners = [
709                body_rect.left_top(),
710                body_rect.right_top(),
711                body_rect.right_bottom(),
712                body_rect.left_bottom(),
713            ];
714            for i in 0..4 {
715                lens_line(
716                    &painter,
717                    center,
718                    candle_radius,
719                    [corners[i], corners[(i + 1) % 4]],
720                    Stroke::new(DESIGN_TOKENS.stroke.hairline, candle_color),
721                );
722            }
723        } else {
724            let points = lens_body(&circle, body_rect);
725            if points.len() >= 3 {
726                painter.add(egui::Shape::convex_polygon(
727                    points,
728                    candle_color,
729                    Stroke::NONE,
730                ));
731            }
732        }
733    }
734
735    context.painter.circle_stroke(
736        center,
737        radius,
738        Stroke::new(DESIGN_TOKENS.stroke.thick, chart.axis_text_secondary),
739    );
740
741    // Draw crosshair in center of magnifier
742    let crosshair_color = chart.crosshair_line;
743    context.painter.line_segment(
744        [
745            Pos2::new(center.x, center.y - radius * 0.3),
746            Pos2::new(center.x, center.y + radius * 0.3),
747        ],
748        Stroke::new(DESIGN_TOKENS.stroke.hairline, crosshair_color),
749    );
750    context.painter.line_segment(
751        [
752            Pos2::new(center.x - radius * 0.3, center.y),
753            Pos2::new(center.x + radius * 0.3, center.y),
754        ],
755        Stroke::new(DESIGN_TOKENS.stroke.hairline, crosshair_color),
756    );
757
758    // Draw zoom indicator
759    let zoom_text = format!("{zoom:.1}x");
760    context.painter.text(
761        Pos2::new(center.x, center.y + radius - 12.0),
762        egui::Align2::CENTER_BOTTOM,
763        &zoom_text,
764        FontId::proportional(context.painter.ctx().active_theme().text.tiny),
765        chart.axis_text_secondary,
766    );
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use crate::scales::{PriceRange, PriceScaleMode, PriceTransform};
773    use chrono::{TimeZone, Utc};
774
775    const HOVER: Pos2 = Pos2::new(400.0, 250.0);
776
777    /// Empty titles and optional readouts never submit text with non-finite bounds.
778    #[test]
779    fn chart_readouts_have_finite_shape_bounds() {
780        fn check(shape: &egui::Shape) {
781            match shape {
782                egui::Shape::Vec(shapes) => shapes.iter().for_each(check),
783                egui::Shape::Noop => {}
784                // egui gives invisible control frames no visual bounds.
785                egui::Shape::Rect(rect)
786                    if shape.visual_bounding_rect() == Rect::NOTHING
787                        && rect.fill == egui::Color32::TRANSPARENT
788                        && rect.stroke.is_empty() =>
789                {
790                    assert!(rect.rect.is_finite());
791                }
792                _ => assert!(
793                    shape.visual_bounding_rect().is_finite(),
794                    "non-finite bounds: {shape:?}"
795                ),
796            }
797        }
798
799        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
800            for symbol in [None, Some("SAMPLE")] {
801                for (mode, change_only) in [
802                    (TooltipMode::Floating, false),
803                    (TooltipMode::Tracking, false),
804                    (TooltipMode::Tracking, true),
805                ] {
806                    let ctx = egui::Context::default();
807                    crate::theme::apply_to_egui(&ctx, &theme);
808                    let bars = (0..500)
809                        .map(|i| {
810                            Bar::new(
811                                chrono::DateTime::from_timestamp(1_700_000_000 + i * 60, 0)
812                                    .unwrap(),
813                                100.0,
814                                110.0,
815                                90.0,
816                                105.0,
817                                1000.0,
818                            )
819                        })
820                        .collect();
821                    let mut chart =
822                        crate::widget::Chart::new(crate::model::BarData::from_bars(bars));
823                    if let Some(symbol) = symbol {
824                        chart.set_symbol(symbol);
825                    }
826                    let mut options = chart.chart_options().clone();
827                    options.tooltip.mode = mode;
828                    if mode == TooltipMode::Tracking {
829                        // The volume study's rows would keep the tracking text from being empty.
830                        chart.config_mut().show_volume = false;
831                        options.tooltip.show_ohlc = false;
832                        options.tooltip.show_time = false;
833                        options.tooltip.show_volume = false;
834                        options.tooltip.show_indicators = false;
835                        options.tooltip.show_change = change_only;
836                    }
837                    chart.set_chart_options(options);
838                    for hover in [None, Some(HOVER)] {
839                        let mut output = ctx.run_ui(
840                            egui::RawInput {
841                                screen_rect: Some(Rect::from_min_size(
842                                    Pos2::ZERO,
843                                    Vec2::new(800.0, 600.0),
844                                )),
845                                events: hover.map_or_else(
846                                    || vec![egui::Event::PointerGone],
847                                    |pos| vec![egui::Event::PointerMoved(pos)],
848                                ),
849                                ..Default::default()
850                            },
851                            |ui| {
852                                chart.show(ui);
853                            },
854                        );
855                        let shapes = std::mem::take(&mut output.shapes);
856                        output.drop_without_applying_deltas();
857                        assert!(shapes.iter().any(|shape| matches!(&shape.shape,
858                            egui::Shape::Text(text) if text.galley.text().starts_with("O "))));
859                        if hover.is_some() && mode == TooltipMode::Floating {
860                            assert!(shapes.iter().any(|shape| matches!(&shape.shape,
861                                egui::Shape::Text(text) if text.galley.text() == "Open")));
862                        }
863                        shapes.iter().for_each(|shape| check(&shape.shape));
864                    }
865                }
866            }
867        }
868    }
869
870    /// Both tooltip modes translate value labels instead of looking up finished English.
871    #[test]
872    fn readouts_reach_the_translator() {
873        let bar = Bar::new(chrono::DateTime::UNIX_EPOCH, 10.0, 11.0, 9.0, 10.5, 100.0);
874        let scale = crate::scales::TimeFormatterBuilder::new().build();
875        let plot = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
876        let options = TooltipOptions {
877            show_time: false,
878            ..Default::default()
879        };
880        for tracking in [true, false] {
881            let ctx = egui::Context::default();
882            crate::localization::mark_keys(&ctx);
883            let output = ctx.run_ui(Default::default(), |ui| {
884                if tracking {
885                    render_tracking_tooltip(
886                        ui.painter(),
887                        plot,
888                        &bar,
889                        &options,
890                        &scale,
891                        60,
892                        &[],
893                        None,
894                        ValueFormatters::default(),
895                    );
896                } else {
897                    render_floating_tooltip(
898                        ui.painter(),
899                        HOVER,
900                        plot,
901                        10.0,
902                        &bar,
903                        None,
904                        &options,
905                        egui::Color32::GREEN,
906                        &scale,
907                        60,
908                        &[],
909                        None,
910                        ValueFormatters::default(),
911                    );
912                }
913            });
914            let texts: Vec<_> = output
915                .shapes
916                .iter()
917                .filter_map(|shape| match &shape.shape {
918                    egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
919                    _ => None,
920                })
921                .collect();
922            output.drop_without_applying_deltas();
923            assert!(!texts.is_empty());
924            for text in &texts {
925                assert!(
926                    !text.chars().any(|c| c.is_ascii_alphabetic()),
927                    "tracking {tracking}: {text}"
928                );
929            }
930            if !tracking {
931                assert_eq!(texts.iter().filter(|text| text.as_str() == "~").count(), 6);
932            }
933        }
934    }
935
936    /// Readout text keeps the plot's colour while removing its rendering opacity, and a plot
937    /// without any colour falls back to the theme's text colour.
938    #[test]
939    fn study_readouts_paint_translucent_plot_colors_opaque() {
940        let options = TooltipOptions {
941            text_color: egui::Color32::from_rgb(10, 20, 30),
942            ..Default::default()
943        };
944        for (plot, expected) in [
945            (
946                egui::Color32::from_rgba_unmultiplied(255, 0, 0, 0x99),
947                egui::Color32::from_rgb(255, 0, 0),
948            ),
949            (egui::Color32::TRANSPARENT, options.text_color),
950        ] {
951            let ctx = egui::Context::default();
952            crate::theme::apply_to_egui(&ctx, &crate::theme::Theme::dark());
953            let study = StudyValue {
954                formatted: None,
955                study: "Study".into(),
956                plot: "Plot".into(),
957                value: Some(12.5),
958                precision: 2,
959                format: crate::scales::StudyFormat::Fixed(2),
960                color: plot,
961            };
962            let mut output = ctx.run_ui(Default::default(), |ui| {
963                render_floating_tooltip(
964                    ui.painter(),
965                    HOVER,
966                    Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)),
967                    10.0,
968                    &Bar::new(chrono::DateTime::UNIX_EPOCH, 10.0, 11.0, 9.0, 10.5, 100.0),
969                    None,
970                    &options,
971                    egui::Color32::GREEN,
972                    &crate::scales::TimeFormatterBuilder::new().build(),
973                    60,
974                    std::slice::from_ref(&study),
975                    None,
976                    ValueFormatters::default(),
977                );
978            });
979            let shapes = std::mem::take(&mut output.shapes);
980            output.drop_without_applying_deltas();
981            let color = shapes.iter().find_map(|shape| match &shape.shape {
982                egui::Shape::Text(text) if text.galley.text() == "12.50" => {
983                    Some(text.galley.job.sections[0].format.color)
984                }
985                _ => None,
986            });
987            assert_eq!(color, Some(expected), "plot colour {plot:?}");
988        }
989    }
990
991    /// The floating, held and tracking readouts lead with the instrument's symbol, and the
992    /// symbol follows a change of instrument.
993    #[test]
994    fn readouts_lead_with_the_instrument_symbol() {
995        let plot = Rect::from_min_size(Pos2::new(20.0, 20.0), Vec2::new(800.0, 300.0));
996        let bar = Bar::new(chrono::DateTime::UNIX_EPOCH, 10.0, 11.0, 9.0, 10.5, 100.0);
997        let scale = crate::scales::TimeFormatterBuilder::new().build();
998        for (instrument, held, tracking, expected) in [
999            (Some("SAMPLE"), false, false, Some("SAMPLE")),
1000            (Some("OTHER"), false, false, Some("OTHER")),
1001            (Some("SAMPLE"), true, false, Some("SAMPLE")),
1002            (Some("SAMPLE"), false, true, Some("SAMPLE")),
1003            (None, false, false, None),
1004        ] {
1005            let instrument = instrument.map(Instrument::new);
1006            let ctx = egui::Context::default();
1007            let output = ctx.run_ui(Default::default(), |ui| {
1008                if tracking {
1009                    render_tracking_tooltip(
1010                        ui.painter(),
1011                        plot,
1012                        &bar,
1013                        &TooltipOptions::default(),
1014                        &scale,
1015                        3600,
1016                        &[],
1017                        instrument.as_ref(),
1018                        ValueFormatters::default(),
1019                    );
1020                } else {
1021                    render_floating_tooltip(
1022                        ui.painter(),
1023                        Pos2::new(plot.left() + 100.0, plot.center().y),
1024                        plot,
1025                        10.0,
1026                        &bar,
1027                        None,
1028                        &TooltipOptions {
1029                            on_long_press: held,
1030                            ..Default::default()
1031                        },
1032                        egui::Color32::GREEN,
1033                        &scale,
1034                        3600,
1035                        &[],
1036                        instrument.as_ref(),
1037                        ValueFormatters::default(),
1038                    );
1039                }
1040            });
1041            let first = output.shapes.iter().find_map(|shape| match &shape.shape {
1042                egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
1043                _ => None,
1044            });
1045            output.drop_without_applying_deltas();
1046            let first = first.unwrap_or_default();
1047            match expected {
1048                Some(symbol) => assert!(first.starts_with(symbol), "{first:?}"),
1049                None => assert!(!first.contains("SAMPLE"), "{first:?}"),
1050            }
1051        }
1052    }
1053
1054    #[test]
1055    fn study_readouts_fit_narrow_plots() {
1056        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1057            for width in [160.0, 320.0, 800.0] {
1058                for (fraction, held, tracking) in [
1059                    (0.25, false, false),
1060                    (0.75, false, false),
1061                    (0.25, true, false),
1062                    (0.75, true, false),
1063                    (0.5, false, true),
1064                ] {
1065                    let ctx = egui::Context::default();
1066                    crate::theme::apply_to_egui(&ctx, &theme);
1067                    let plot = Rect::from_min_size(Pos2::new(20.0, 20.0), Vec2::new(width, 300.0));
1068                    let pointer = Pos2::new(plot.left() + width * fraction, plot.center().y);
1069                    let bar = Bar::new(chrono::DateTime::UNIX_EPOCH, 10.0, 11.0, 9.0, 10.5, 100.0);
1070                    let studies = vec![
1071                        StudyValue {
1072                            formatted: None,
1073                            format: crate::scales::StudyFormat::Fixed(2),
1074                            study: "A very long study caption with several input parameters".into(),
1075                            plot: "Signal".into(),
1076                            value: Some(1234.567),
1077                            precision: 3,
1078                            color: egui::Color32::RED,
1079                        };
1080                        12
1081                    ];
1082                    let mut output = ctx.run_ui(Default::default(), |ui| {
1083                        if tracking {
1084                            render_tracking_tooltip(
1085                                ui.painter(),
1086                                plot,
1087                                &bar,
1088                                &TooltipOptions::default(),
1089                                &crate::scales::TimeFormatterBuilder::new().build(),
1090                                3600,
1091                                &studies,
1092                                None,
1093                                ValueFormatters::default(),
1094                            );
1095                            return;
1096                        }
1097                        render_floating_tooltip(
1098                            ui.painter(),
1099                            pointer,
1100                            plot,
1101                            10.0,
1102                            &bar,
1103                            None,
1104                            &TooltipOptions {
1105                                on_long_press: held,
1106                                show_time: !held,
1107                                ..Default::default()
1108                            },
1109                            egui::Color32::GREEN,
1110                            &crate::scales::TimeFormatterBuilder::new().build(),
1111                            3600,
1112                            &studies,
1113                            None,
1114                            ValueFormatters::default(),
1115                        );
1116                    });
1117                    let shapes = std::mem::take(&mut output.shapes);
1118                    output.drop_without_applying_deltas();
1119                    if held {
1120                        let labels: Vec<_> = shapes
1121                            .iter()
1122                            .filter_map(|shape| match &shape.shape {
1123                                egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
1124                                _ => None,
1125                            })
1126                            .step_by(2)
1127                            .take(6)
1128                            .collect();
1129                        assert_eq!(labels, ["Time", "Open", "High", "Low", "Close", "Vol"]);
1130                    }
1131                    let mut labels = 0;
1132                    for shape in shapes {
1133                        match shape.shape {
1134                            egui::Shape::Text(text) => {
1135                                let visible = Rect::from_min_size(text.pos, text.galley.size())
1136                                    .intersect(shape.clip_rect);
1137                                if visible.is_positive() {
1138                                    assert!(
1139                                        plot.contains_rect(visible),
1140                                        "text escapes plot at {width}: {visible:?}"
1141                                    );
1142                                    labels += 1;
1143                                }
1144                            }
1145                            egui::Shape::Rect(panel) if panel.stroke.width == 1.0 => {
1146                                assert!(
1147                                    plot.contains_rect(panel.rect),
1148                                    "tooltip escapes plot at {width}: {:?}",
1149                                    panel.rect
1150                                );
1151                                assert!(
1152                                    !panel.rect.x_range().contains(pointer.x),
1153                                    "tooltip covers hovered bar"
1154                                );
1155                            }
1156                            _ => {}
1157                        }
1158                    }
1159                    assert!(labels > 0);
1160                }
1161            }
1162        }
1163    }
1164
1165    /// The held card follows the vertical line at the bar's centre, not the pointer: 15 px to its
1166    /// right, to its left when the right has no room, and past a wide bar. Its top is the pointer's
1167    /// unless that leaves the plot. Values are semibold and labels regular.
1168    #[test]
1169    fn held_values_sit_beside_the_vertical_line() {
1170        let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 500.0));
1171        let map = PriceMap::new(
1172            PriceTransform {
1173                mode: PriceScaleMode::Normal,
1174                base: 0.0,
1175            },
1176            PriceRange::new(0.0, 20.0),
1177            0.0,
1178            500.0,
1179            false,
1180        );
1181        let colors = StyleColors {
1182            bullish: egui::Color32::GREEN,
1183            bearish: egui::Color32::RED,
1184            text: egui::Color32::WHITE,
1185            bullish_border: None,
1186            bearish_border: None,
1187            bullish_wick: None,
1188            bearish_wick: None,
1189            candle_border_width: 0.0,
1190        };
1191        let options = TooltipOptions {
1192            mode: TooltipMode::Floating,
1193            on_long_press: true,
1194            ..Default::default()
1195        };
1196        let scale = crate::scales::TimeFormatterBuilder::new().build();
1197        // A bar's centre is at 800 - (last - index + 0.5) * spacing - 1 px.
1198        for (spacing, last, pointer, left, right, top) in [
1199            // Bar 40 at 404.
1200            (10.0, 79, (402.0, 250.0), Some(419.0), None, Some(250.0)),
1201            // Bar 0 at 4.
1202            (10.0, 79, (5.0, 250.0), Some(19.0), None, Some(250.0)),
1203            // Bar 78 at 784, with no room on the right.
1204            (10.0, 79, (785.0, 250.0), None, Some(769.0), Some(250.0)),
1205            // Bar 6 at 409, half of its 60 px slot being wider than 15 px.
1206            (60.0, 12, (400.0, 250.0), Some(439.0), None, Some(250.0)),
1207            // Bar 12 at 769, likewise on the left.
1208            (60.0, 12, (770.0, 250.0), None, Some(739.0), Some(250.0)),
1209            // A pointer low in the plot keeps the card above the 8 px inset.
1210            (10.0, 79, (402.0, 490.0), Some(419.0), None, None),
1211        ] {
1212            let bar = Bar::new(chrono::DateTime::UNIX_EPOCH, 10.0, 11.0, 9.0, 10.5, 100.0);
1213            let bars = vec![bar; last + 1];
1214            let coords = ChartMapping::new(rect, spacing, 0, last, 0.0, map);
1215            let mut output = egui::Context::default().run_ui(Default::default(), |ui| {
1216                render_tooltip_with_options(
1217                    &RenderContext::new(ui.painter(), rect),
1218                    Pos2::new(pointer.0, pointer.1),
1219                    &bars[0],
1220                    None,
1221                    &options,
1222                    &colors,
1223                    &map,
1224                    &coords,
1225                    &bars,
1226                    &scale,
1227                    60,
1228                    &[],
1229                    None,
1230                    ValueFormatters::default(),
1231                );
1232            });
1233            let shapes = std::mem::take(&mut output.shapes);
1234            output.drop_without_applying_deltas();
1235            let card = shapes
1236                .iter()
1237                .find_map(|shape| match &shape.shape {
1238                    egui::Shape::Rect(card) if card.stroke.width == 1.0 => Some(card.rect),
1239                    _ => None,
1240                })
1241                .expect("a held card");
1242            let case = format!("{spacing} {pointer:?}: {card:?}");
1243            let close = |a: f32, b: f32| (a - b).abs() < 0.01;
1244            assert!(left.is_none_or(|left| close(card.left(), left)), "{case}");
1245            assert!(
1246                right.is_none_or(|right| close(card.right(), right)),
1247                "{case}"
1248            );
1249            assert!(
1250                top.map_or(close(card.bottom(), 492.0), |top| close(card.top(), top)),
1251                "{case}"
1252            );
1253            let weights: Vec<_> = shapes
1254                .iter()
1255                .filter_map(|shape| match &shape.shape {
1256                    egui::Shape::Text(text) => Some(
1257                        !text.galley.job.sections[0]
1258                            .format
1259                            .coords
1260                            .as_ref()
1261                            .is_empty(),
1262                    ),
1263                    _ => None,
1264                })
1265                .collect();
1266            assert_eq!(weights.len(), 12, "{case}");
1267            for row in weights.chunks(2) {
1268                assert_eq!(row, [false, true], "{case}");
1269            }
1270        }
1271    }
1272
1273    /// The shapes the lens paints over 80 bars on a logarithmic scale, cursor at `HOVER`.
1274    fn lens_shapes(options: &TooltipOptions) -> Vec<egui::epaint::ClippedShape> {
1275        let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 500.0));
1276        let transform = PriceTransform {
1277            mode: PriceScaleMode::Logarithmic,
1278            base: 0.0,
1279        };
1280        let range = PriceRange::new(10f64.ln(), 10_000f64.ln());
1281        let map = PriceMap::new(transform, range, 0.0, 500.0, false);
1282        let p = map.y_to_price(HOVER.y);
1283        let t = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
1284        let bars = (0..80)
1285            .map(|i| {
1286                let (open, close) = if i % 2 == 0 {
1287                    (p, p)
1288                } else {
1289                    (p * 1.8, p / 1.8)
1290                };
1291                Bar::new(t, open, p * 2.0, p / 2.0, close, 1.0)
1292            })
1293            .collect::<Vec<_>>();
1294        let coords = ChartMapping::new(rect, 10.0, 0, 79, 0.0, map);
1295        let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
1296            let painter = Painter::new(ui.ctx().clone(), egui::LayerId::background(), rect);
1297            render_magnifier_tooltip(
1298                &RenderContext::new(&painter, rect),
1299                HOVER,
1300                &bars[0],
1301                options,
1302                &map,
1303                &coords,
1304                &bars,
1305            );
1306        });
1307        let shapes = output.shapes.clone();
1308        output.drop_without_applying_deltas();
1309        shapes
1310    }
1311
1312    /// The lens magnifies the chart about the cursor: on a logarithmic scale a wick
1313    /// from half to twice the price under the cursor is centred on the lens, and
1314    /// every wick, body edge and fill stays inside its circular boundary.
1315    #[test]
1316    fn magnifier_centres_and_clips_candles_on_a_log_scale() {
1317        for (size, zoom) in [(120.0, 2.0), (240.0, 4.0)] {
1318            let shapes = lens_shapes(&TooltipOptions {
1319                magnifier_size: size,
1320                magnifier_zoom: zoom,
1321                ..Default::default()
1322            });
1323            assert!(
1324                shapes.iter().any(|shape| match &shape.shape {
1325                    egui::Shape::Rect(body) => body.fill != egui::Color32::TRANSPARENT,
1326                    egui::Shape::Path(body) => body.fill != egui::Color32::TRANSPARENT,
1327                    _ => false,
1328                }),
1329                "a filled candle body in the lens"
1330            );
1331            // Bar 40 is at 800 - (79 - 40 + 0.5) * 10 - 1 = 404.
1332            let wick_x = HOVER.x + 4.0 * zoom;
1333            let wick = shapes
1334                .iter()
1335                .find_map(|c| match &c.shape {
1336                    egui::Shape::LineSegment { points, .. }
1337                        if points[0].x == wick_x
1338                            && points[1].x == wick_x
1339                            && (points[0].y - points[1].y).abs() > 50.0 =>
1340                    {
1341                        Some(*points)
1342                    }
1343                    _ => None,
1344                })
1345                .expect("a wick in the lens");
1346            let mid = (wick[0].y + wick[1].y) / 2.0;
1347            assert!((mid - HOVER.y).abs() < 0.5, "wick {wick:?}");
1348            // log(2) / log(1000) * 500 * zoom exceeds either lens radius.
1349            // The circle equation gives the wick endpoints at its offset from the cursor.
1350            let radius = size / 2.0 - DESIGN_TOKENS.stroke.hairline / 2.0;
1351            let extent = (radius * radius - (4.0 * zoom).powi(2)).sqrt();
1352            assert!((wick[0].y - (HOVER.y - extent)).abs() < 0.01);
1353            assert!((wick[1].y - (HOVER.y + extent)).abs() < 0.01);
1354            for shape in &shapes {
1355                let points: &[Pos2] = match &shape.shape {
1356                    egui::Shape::LineSegment { points, .. } => points,
1357                    egui::Shape::Path(path) => &path.points,
1358                    egui::Shape::Rect(body) => {
1359                        for corner in [body.rect.left_top(), body.rect.right_bottom()] {
1360                            assert!(corner.distance(HOVER) <= size / 2.0 + 0.01, "body {body:?}");
1361                        }
1362                        continue;
1363                    }
1364                    _ => continue,
1365                };
1366                assert!(
1367                    points
1368                        .iter()
1369                        .all(|point| point.distance(HOVER) <= size / 2.0 + 0.01),
1370                    "size {size}, zoom {zoom}: {points:?}"
1371                );
1372            }
1373        }
1374    }
1375
1376    /// A zoom of 0 or NaN neither hangs the frame nor paints non-finite geometry.
1377    #[test]
1378    fn degenerate_magnifier_zoom_draws_finite_lens() {
1379        for zoom in [f32::NAN, 0.0] {
1380            for c in lens_shapes(&TooltipOptions::magnifier_with_zoom(zoom)) {
1381                if let egui::Shape::LineSegment { points, .. } = &c.shape {
1382                    assert!(
1383                        points.iter().all(|p| p.is_finite()),
1384                        "zoom {zoom}: {points:?}"
1385                    );
1386                }
1387            }
1388        }
1389    }
1390}