Skip to main content

egui_charts/drawings/manager/
paint.rs

1//! The painted-geometry cache, painting and hit testing of the drawings.
2
3use super::{DrawingManager, PaintedDrawing};
4use crate::drawings::domain::{ChartPoint, Drawing, DrawingToolType};
5use crate::drawings::rendering::ChartView;
6use crate::drawings::services::{DrawingInteraction, HandleService, in_z_order};
7use egui::epaint::{ClippedShape, Mesh, TessellationOptions, Tessellator, emath::TSTransform};
8use egui::{Color32, Pos2, Rect, Shape};
9use std::borrow::Cow;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13/// Shapes tessellated at a scale and with options, as what to paint in place of each shape: a
14/// run of geometry becomes one mesh in place of its first shape and nothing in place of the rest,
15/// and text, circles and textured shapes, which point into texture atlases that can change, stay
16/// as they are (`None`).
17pub(super) struct Tessellated {
18    pixels_per_point: f32,
19    options: TessellationOptions,
20    shapes: Vec<Option<Shape>>,
21}
22
23impl Tessellated {
24    fn new(shapes: &[ClippedShape], pixels_per_point: f32, options: TessellationOptions) -> Self {
25        // Neither the font atlas size nor its prepared discs are used without text and circles.
26        let mut tessellator = Tessellator::new(pixels_per_point, options, [1, 1], Vec::new());
27        let mut out: Vec<Option<Shape>> = vec![None; shapes.len()];
28        let mut run: Option<(usize, Mesh)> = None;
29        for (i, clipped) in shapes.iter().enumerate() {
30            let plain = is_plain(&clipped.shape)
31                && clipped.transform_after_tessellation == TSTransform::IDENTITY;
32            if (!plain
33                || run
34                    .as_ref()
35                    .is_some_and(|(first, _)| shapes[*first].clip_rect != clipped.clip_rect))
36                && let Some((first, mesh)) = run.take()
37            {
38                out[first] = Some(Shape::mesh(mesh));
39            }
40            if !plain {
41                continue;
42            }
43            let (_, mesh) = run.get_or_insert_with(|| (i, Mesh::default()));
44            tessellator.set_clip_rect(clipped.clip_rect);
45            tessellator.tessellate_shape(clipped.shape.clone(), mesh);
46            out[i] = Some(Shape::Noop);
47        }
48        if let Some((first, mesh)) = run {
49            out[first] = Some(Shape::mesh(mesh));
50        }
51        Self {
52            pixels_per_point,
53            options,
54            shapes: out,
55        }
56    }
57}
58
59/// Whether `a` tessellates as `b` does: the same plain shapes clipped alike, and the other shapes,
60/// which paint as they are, in the same places.
61fn same_geometry(a: &[ClippedShape], b: &[ClippedShape]) -> bool {
62    a.len() == b.len()
63        && a.iter()
64            .zip(b)
65            .all(|(a, b)| match (is_plain(&a.shape), is_plain(&b.shape)) {
66                (true, true) => a == b,
67                (plain_a, plain_b) => !plain_a && !plain_b,
68            })
69}
70
71/// Whether the tessellation of `shape` depends only on the shape, the scale and the options.
72fn is_plain(shape: &Shape) -> bool {
73    match shape {
74        Shape::Vec(shapes) => shapes.iter().all(is_plain),
75        Shape::Rect(rect) => rect.fill_texture_id() == egui::TextureId::default(),
76        Shape::Noop
77        | Shape::Ellipse(_)
78        | Shape::LineSegment { .. }
79        | Shape::Path(_)
80        | Shape::Band(_)
81        | Shape::QuadraticBezier(_)
82        | Shape::CubicBezier(_) => true,
83        Shape::Circle(_) | Shape::Text(_) | Shape::Mesh(_) | Shape::Callback(_) => false,
84    }
85}
86
87impl DrawingManager {
88    /// Uses the fitted endpoints kept with the last paint for data-driven handles.
89    pub(super) fn handle_drawing<'a>(&self, drawing: &'a Drawing) -> Cow<'a, Drawing> {
90        if drawing.tool_type != DrawingToolType::RegressionTrend || drawing.custom_tool.is_some() {
91            return Cow::Borrowed(drawing);
92        }
93        let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
94        let ends = painted
95            .iter()
96            .find(|p| p.id == drawing.id && p.points == drawing.points)
97            .and_then(|p| p.regression_ends);
98        let mut projected = drawing.clone();
99        projected.points = ends.map(Vec::from).unwrap_or_default();
100        Cow::Owned(projected)
101    }
102
103    /// Renders selection handles for a drawing, highlighting the currently
104    /// dragged handle (if any).
105    pub fn render_handles(&self, painter: &egui::Painter, drawing: &Drawing) {
106        let dragging = self
107            .dragging_handle
108            .filter(|(id, _)| *id == drawing.id)
109            .map(|(_, h)| h);
110        HandleService::render_handles(
111            painter,
112            &self.handle_drawing(drawing),
113            &self.options.handle_config,
114            dragging,
115        );
116    }
117
118    // === Hit testing ===
119
120    /// Hit-tests a screen point against all visible drawings (back-to-front).
121    ///
122    /// Returns the ID of the topmost drawing at `point`, or `None` if no
123    /// drawing was hit. Delegates to [`DrawingInteraction::hit_test`], the single
124    /// source of truth for per-segment / per-level geometry hit testing, so that
125    /// channels, Fibonacci levels, fans, and pitchforks are selectable along
126    /// their drawn geometry rather than only near a control point. Honors the
127    /// active timeframe so drawings hidden on the current timeframe are not hit.
128    /// After [`Self::render_all`], uses the actual clipped paint geometry, including text and
129    /// data-driven bodies. Before the first paint, falls back to the tool's geometric outline.
130    pub fn hit_test(&self, point: Pos2) -> Option<usize> {
131        let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
132        in_z_order(&self.drawings)
133            .into_iter()
134            .rev()
135            .filter(|d| d.selectable && d.is_visible_on_timeframe(&self.curr_timeframe))
136            .filter(|d| {
137                self.pane_clips.is_empty()
138                    || self
139                        .pane_clips
140                        .get(&d.pane)
141                        .is_some_and(|rect| rect.contains(point))
142            })
143            .find(|drawing| {
144                if let Some(id) = &drawing.custom_tool {
145                    return self
146                        .registry
147                        .get(id)
148                        .is_some_and(|tool| tool.implementation.hit_test(drawing, point, 6.0));
149                }
150                let paint = painted
151                    .iter()
152                    .find(|p| p.id == drawing.id && p.points == drawing.points);
153                if paint.is_some_and(|p| !p.shapes.iter().any(|s| s.clip_rect.contains(point))) {
154                    return false;
155                }
156                paint.is_some_and(|p| DrawingInteraction::hit_test_painted(point, &p.shapes))
157                    || (drawing.tool_type != DrawingToolType::RegressionTrend
158                        && DrawingInteraction::new()
159                            .hit_test(point, std::slice::from_ref(*drawing), &self.curr_timeframe)
160                            .is_some())
161            })
162            .map(|d| d.id)
163    }
164
165    /// Position and layout of the first upright text shape painting exactly `text` for `id`.
166    pub(crate) fn painted_text(&self, id: usize, text: &str) -> Option<(Pos2, Arc<egui::Galley>)> {
167        fn find(shape: &Shape, text: &str) -> Option<(Pos2, Arc<egui::Galley>)> {
168            match shape {
169                Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, text)),
170                Shape::Text(t) if t.angle == 0.0 && t.galley.text() == text => {
171                    Some((t.pos, Arc::clone(&t.galley)))
172                }
173                _ => None,
174            }
175        }
176        let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
177        painted
178            .iter()
179            .find(|p| p.id == id)?
180            .shapes
181            .iter()
182            .find_map(|s| find(&s.shape, text))
183    }
184
185    fn render_drawing(
186        &self,
187        drawing: &Drawing,
188        painter: &egui::Painter,
189        rect: Rect,
190        view: &ChartView,
191    ) {
192        if let Some(id) = &drawing.custom_tool {
193            if let Some(tool) = self.registry.get(id) {
194                tool.implementation.render(drawing, painter, rect, view);
195            }
196        } else {
197            drawing.render_with_selection(
198                painter,
199                rect,
200                view,
201                self.selection.is_selected(drawing.id),
202            );
203        }
204    }
205
206    /// Render all drawings against the chart's bars and coordinate mapping
207    pub fn render_all(&self, painter: &egui::Painter, price_rect: Rect, view: &ChartView) {
208        let painter = &painter.with_clip_rect(price_rect);
209        let mut painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
210        // The paints of the other panes' drawings stay; a set keeps this linear in the drawings.
211        let others: HashSet<usize> = (self.drawings.iter())
212            .filter(|d| !self.owns_pane(d))
213            .map(|d| d.id)
214            .collect();
215        let mut last: HashMap<usize, PaintedDrawing> = HashMap::new();
216        for paint in std::mem::take(&mut *painted) {
217            if others.contains(&paint.id) {
218                painted.push(paint);
219            } else {
220                last.insert(paint.id, paint);
221            }
222        }
223        let pixels_per_point = painter.pixels_per_point();
224        let options = painter.ctx().tessellation_options(|o| *o);
225        // A transformed layer, such as an `egui::Scene`, has its shapes transformed before they
226        // are tessellated, so a kept mesh would scale its feathering and rounding with the zoom.
227        let untransformed = (painter.ctx())
228            .layer_transform_to_global(painter.layer_id())
229            .is_none();
230        // Render completed drawings, bottom layer first
231        for drawing in in_z_order(&self.drawings) {
232            let preview = self
233                .text_editor
234                .as_ref()
235                .filter(|editor| editor.inline && editor.id == drawing.id)
236                .map(|editor| editor.preview(drawing, painter.ctx()));
237            let drawing = preview.as_ref().unwrap_or(drawing);
238            let off_pane = drawing.custom_tool.is_none()
239                && drawing
240                    .paint_bounds()
241                    .is_some_and(|bounds| !bounds.intersects(price_rect));
242            if self.owns_pane(drawing)
243                && drawing.is_visible_on_timeframe(&self.curr_timeframe)
244                && !off_pane
245            {
246                let start = painter.ctx().graphics(|g| {
247                    g.get(painter.layer_id())
248                        .map_or(0, |list| list.next_idx().0)
249                });
250                self.render_drawing(drawing, painter, price_rect, view);
251                let shapes: Vec<ClippedShape> = painter.ctx().graphics(|g| {
252                    g.get(painter.layer_id())
253                        .map(|list| list.all_entries().skip(start).cloned().collect())
254                        .unwrap_or_default()
255                });
256                // The same shapes as last frame paint last frame's tessellation, made on the
257                // second frame so that a drawing changing every frame is not tessellated twice.
258                let tessellated = last
259                    .remove(&drawing.id)
260                    .filter(|paint| untransformed && same_geometry(&paint.shapes, &shapes))
261                    .map(|paint| {
262                        paint
263                            .tessellated
264                            .filter(|t| {
265                                t.pixels_per_point == pixels_per_point && t.options == options
266                            })
267                            .unwrap_or_else(|| Tessellated::new(&shapes, pixels_per_point, options))
268                    });
269                if let Some(tessellated) = &tessellated {
270                    painter.ctx().graphics_mut(|g| {
271                        let list = g.entry(painter.layer_id());
272                        for (i, shape) in tessellated.shapes.iter().enumerate() {
273                            if let Some(shape) = shape {
274                                list.set(
275                                    egui::layers::ShapeIdx(start + i),
276                                    shapes[i].clip_rect,
277                                    shape.clone(),
278                                );
279                            }
280                        }
281                    });
282                }
283                painted.push(PaintedDrawing {
284                    id: drawing.id,
285                    points: drawing.points.clone(),
286                    regression_ends: (drawing.tool_type == DrawingToolType::RegressionTrend)
287                        .then(|| drawing.regression_ends(view))
288                        .flatten(),
289                    shapes,
290                    tessellated,
291                });
292            }
293        }
294
295        drop(painted);
296
297        // Render current drawing being created
298        if let Some(ref drawing) = self.curr_drawing
299            && self.owns_pane(drawing)
300        {
301            self.render_drawing(drawing, painter, price_rect, view);
302            if drawing.tool_type == DrawingToolType::Rect {
303                drawing.render_rect_guides(painter, view.price_formatter);
304                self.render_handles(painter, drawing);
305            }
306        }
307
308        if let Some(drawing) = &self.measurement
309            && self.owns_pane(drawing)
310        {
311            self.render_drawing(drawing, painter, price_rect, view);
312        }
313
314        // Render handles for every visible selected drawing.
315        for drawing in self.drawings.iter().filter(|d| {
316            d.selectable
317                && self.selection.is_selected(d.id)
318                && self.owns_pane(d)
319                && d.is_visible_on_timeframe(&self.curr_timeframe)
320        }) {
321            if drawing.tool_type == DrawingToolType::Rect {
322                drawing.render_rect_guides(painter, view.price_formatter);
323            }
324            self.render_handles(painter, drawing);
325        }
326    }
327}
328
329/// A tag a drawing puts on an axis of its pane.
330#[derive(Clone, Copy)]
331pub(crate) enum DrawingAxisTag {
332    /// `price` on the price axis, on a `color` tag.
333    Price { price: f64, color: Color32 },
334    /// The time of `anchor` on the time axis at screen `x`, on an opaque `color` tag.
335    Time {
336        x: f32,
337        anchor: ChartPoint,
338        color: Color32,
339    },
340}
341
342impl DrawingManager {
343    /// The axis tags of visible drawings of the pane last handled, in drawing order: enabled
344    /// price and time labels of horizontal, vertical and cross lines reaching `plot`, and
345    /// position levels, whose entry takes `entry_color`. Call after the pane handles input so
346    /// tags follow a drag in the same frame. Line tags use opaque RGB so their text stays legible.
347    pub(crate) fn axis_tags(
348        &self,
349        plot: Rect,
350        entry_color: Color32,
351    ) -> impl Iterator<Item = DrawingAxisTag> + '_ {
352        self.drawings
353            .iter()
354            .filter(|drawing| {
355                self.owns_pane(drawing) && drawing.is_visible_on_timeframe(&self.curr_timeframe)
356            })
357            .flat_map(move |drawing| {
358                let (price, time) = Drawing::axis_labels_of(drawing.tool_type);
359                let (price, time) = (
360                    price && drawing.price_axis_label,
361                    time && drawing.time_axis_label,
362                );
363                let [r, g, b, _] = drawing.color;
364                let color = Color32::from_rgb(r, g, b);
365                let at = drawing.chart_points.first().zip(drawing.points.first());
366                // A ray starting right of the plot draws nothing in it.
367                let price = at
368                    .filter(|(_, point)| {
369                        price
370                            && (drawing.tool_type != DrawingToolType::HorizontalRay
371                                || point.x <= plot.right())
372                    })
373                    .map(|(anchor, _)| DrawingAxisTag::Price {
374                        price: anchor.price,
375                        color,
376                    });
377                let time = at
378                    .filter(|(_, point)| time && plot.x_range().contains(point.x))
379                    .map(|(anchor, point)| DrawingAxisTag::Time {
380                        x: point.x,
381                        anchor: *anchor,
382                        color,
383                    });
384                drawing
385                    .position_price_tags(entry_color)
386                    .into_iter()
387                    .map(|(price, color)| DrawingAxisTag::Price { price, color })
388                    .chain([price, time].into_iter().flatten())
389            })
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::drawings::domain::ChartPoint;
397
398    /// Handles follow y = 11 + x for closes 10, 14, 12, regardless of clicked prices,
399    /// anchor order or extension. Moving the range to closes 14, 12 gives y = 14 - 2x.
400    #[test]
401    fn regression_handles_follow_the_fit_and_refit_after_dragging() {
402        use crate::drawings::{
403            HandlePos,
404            persistence::{drawing_to_stored, stored_to_drawing},
405        };
406        use crate::model::Bar;
407        let bars: Vec<_> = [10.0, 14.0, 12.0]
408            .into_iter()
409            .map(|close| Bar::from_value(chrono::DateTime::UNIX_EPOCH, close))
410            .collect();
411        let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 500.0));
412        let ctx = egui::Context::default();
413        for (start, reverse) in [(0.0, false), (0.0, true), (-1.0, false), (0.4, true)] {
414            let mut manager = DrawingManager::new();
415            let mut drawing = Drawing::new(7, DrawingToolType::RegressionTrend);
416            drawing.chart_points = vec![ChartPoint::new(start, 30.0), ChartPoint::new(2.0, 5.0)];
417            if reverse {
418                drawing.chart_points.reverse();
419            }
420            let saved = drawing_to_stored(&drawing, |b| 1_700_000_000_000 + (b * 60_000.0) as i64);
421            let mut drawing =
422                stored_to_drawing(&saved, |p| (p - 1_700_000_000_000) as f32 / 60_000.0, 7);
423            assert_eq!(
424                drawing
425                    .chart_points
426                    .iter()
427                    .map(|p| p.bar_idx)
428                    .collect::<Vec<_>>(),
429                if reverse {
430                    vec![2.0, start]
431                } else {
432                    vec![start, 2.0]
433                }
434            );
435            drawing.extend_right = true;
436            manager.drawings.push(drawing);
437            manager.select(7);
438            let render = |manager: &mut DrawingManager, bars: &[Bar]| {
439                manager
440                    .update_all_screen_coords(|b| 100.0 + b * 100.0, |p| 500.0 - p as f32 * 10.0);
441                ctx.run_ui(
442                    egui::RawInput {
443                        screen_rect: Some(pane),
444                        ..Default::default()
445                    },
446                    |ui| {
447                        manager.render_all(
448                            ui.painter(),
449                            pane,
450                            &ChartView {
451                                price_formatter: None,
452                                time_formatter: None,
453                                bar_seconds: 0,
454                                instrument: None,
455                                bars,
456                                revision: 0,
457                                bar_to_x: &|b| 100.0 + b * 100.0,
458                                price_to_y: &|p| 500.0 - p as f32 * 10.0,
459                                y_to_price: &|y| f64::from((500.0 - y) / 10.0),
460                                time_axis: true,
461                                executions: None,
462                                timeframe: None,
463                                finer: &[],
464                            },
465                        );
466                    },
467                )
468                .drop_without_applying_deltas();
469            };
470            render(&mut manager, &bars);
471            let (first, last) = if reverse { (1, 0) } else { (0, 1) };
472            let handles = manager.get_handles(&manager.drawings[0]);
473            assert_eq!(handles.len(), 2);
474            for point in &manager.drawings[0].points {
475                assert_eq!(manager.hit_test_handle(&manager.drawings[0], *point), None);
476                assert_eq!(manager.hit_test(*point), None);
477            }
478            for (index, point) in [
479                (first, Pos2::new(100.0, 390.0)),
480                (last, Pos2::new(300.0, 370.0)),
481            ] {
482                assert!(handles.contains(&(HandlePos::Point(index), point)));
483                assert_eq!(
484                    manager.hit_test_handle(&manager.drawings[0], point),
485                    Some(HandlePos::Point(index))
486                );
487            }
488            let before = manager.drawings[0].chart_points.clone();
489            manager.start_drag_handle(7, HandlePos::Point(first));
490            manager.update_drag_handle(
491                Pos2::new(100.0, 50.0),
492                |x| (x - 100.0) / 100.0,
493                |y| f64::from(500.0 - y) / 10.0,
494                |p, from, to| p - f64::from(to - from) / 10.0,
495            );
496            assert_eq!(
497                manager.drawings[0].chart_points, before,
498                "vertical drag is a no-op"
499            );
500            manager.update_drag_handle(
501                Pos2::new(200.0, 50.0),
502                |x| (x - 100.0) / 100.0,
503                |y| f64::from(500.0 - y) / 10.0,
504                |p, from, to| p - f64::from(to - from) / 10.0,
505            );
506            manager.end_drag_handle();
507            render(&mut manager, &bars);
508            let handles = manager.get_handles(&manager.drawings[0]);
509            assert!(handles.contains(&(HandlePos::Point(first), Pos2::new(200.0, 360.0))));
510            assert!(handles.contains(&(HandlePos::Point(last), Pos2::new(300.0, 380.0))));
511            assert!(
512                manager.drawings[0]
513                    .chart_points
514                    .iter()
515                    .zip(before)
516                    .all(|(p, old)| p.price == old.price)
517            );
518            assert!(manager.undo());
519            render(&mut manager, &bars);
520            assert_eq!(manager.get_handles(&manager.drawings[0]).len(), 2);
521            let mut live = bars.clone();
522            live[2].close = 18.0;
523            render(&mut manager, &live);
524            // Perfect line 10, 14, 18 after a tick: slope 4, intercept 10.
525            assert!(
526                manager
527                    .get_handles(&manager.drawings[0])
528                    .contains(&(HandlePos::Point(last), Pos2::new(300.0, 320.0)))
529            );
530            render(&mut manager, &[]);
531            assert!(manager.get_handles(&manager.drawings[0]).is_empty());
532        }
533    }
534
535    /// Text extents, position zones, Fibonacci rings and repeating cycle lines are
536    /// selectable away from every anchor, using their actual paint geometry.
537    #[test]
538    fn painted_bodies_are_hit_away_from_anchors() {
539        use egui::{Context, RawInput};
540        let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 400.0));
541        for (tool, anchors, hit, miss) in [
542            (
543                DrawingToolType::Sector,
544                vec![(100., 100.), (200., 100.), (100., 150.)],
545                (170.71068, 170.71068),
546                (220., 220.),
547            ),
548            (
549                DrawingToolType::Sector,
550                vec![(100., 100.), (200., 100.), (100., 50.)],
551                (150., 50.),
552                (50., 150.),
553            ),
554            (
555                DrawingToolType::ArrowMarkLeft,
556                vec![(100., 100.)],
557                (110., 100.),
558                (80., 100.),
559            ),
560            (
561                DrawingToolType::ArrowMarkRight,
562                vec![(100., 100.)],
563                (90., 100.),
564                (120., 100.),
565            ),
566            (
567                DrawingToolType::TrendLine,
568                vec![(50., 100.), (550., 100.)],
569                (200., 100.),
570                (510., 100.),
571            ),
572            (
573                DrawingToolType::TextLabel,
574                vec![(50., 100.)],
575                (180., 105.),
576                (180., 160.),
577            ),
578            (
579                DrawingToolType::LongPos,
580                vec![(50., 150.), (250., 50.), (250., 250.)],
581                (180., 100.),
582                (350., 300.),
583            ),
584            (
585                DrawingToolType::FibonacciCircles,
586                vec![(100., 100.), (200., 200.)],
587                (150., 200.),
588                (580., 390.),
589            ),
590            (
591                DrawingToolType::CyclicLines,
592                vec![(50., 100.), (150., 100.)],
593                (250., 300.),
594                (480., 390.),
595            ),
596        ] {
597            let ctx = Context::default();
598            let mut manager = DrawingManager::new();
599            let mut drawing = Drawing::new(7, tool);
600            drawing.points = anchors.into_iter().map(|(x, y)| Pos2::new(x, y)).collect();
601            drawing.chart_points = drawing
602                .points
603                .iter()
604                .map(|p| ChartPoint::new(p.x / 10.0, 500.0 - p.y as f64))
605                .collect();
606            if tool == DrawingToolType::Sector {
607                drawing.fill_color = Some([30, 150, 180, 60]);
608            }
609            drawing.text = Some("A long text annotation".into());
610            manager.drawings.push(drawing);
611            let render = |manager: &DrawingManager| {
612                ctx.run_ui(RawInput::default(), |ui| {
613                    let view = ChartView {
614                        price_formatter: None,
615                        time_formatter: None,
616                        bar_seconds: 0,
617                        instrument: None,
618                        bars: &[],
619                        revision: 0,
620                        bar_to_x: &|b| b * 10.0,
621                        price_to_y: &|p| 500.0 - p as f32,
622                        y_to_price: &|y| 500.0 - f64::from(y),
623                        time_axis: true,
624                        executions: None,
625                        timeframe: None,
626                        finer: &[],
627                    };
628                    manager.render_all(ui.painter(), pane, &view);
629                })
630                .drop_without_applying_deltas();
631            };
632            render(&manager);
633            assert_eq!(
634                manager.hit_test(Pos2::new(hit.0, hit.1)),
635                Some(7),
636                "{tool:?}"
637            );
638            assert_eq!(
639                manager.hit_test(Pos2::new(miss.0, miss.1)),
640                None,
641                "{tool:?}"
642            );
643            if tool == DrawingToolType::TextLabel {
644                manager.edit_drawing(7, |d| d.text = Some("x".into()));
645                assert_eq!(manager.hit_test(Pos2::new(hit.0, hit.1)), None);
646            }
647            manager.drawings[0].visible = false;
648            assert_eq!(manager.hit_test(Pos2::new(hit.0, hit.1)), None);
649            manager.delete_drawing(7);
650            render(&manager);
651            assert!(
652                manager.painted.lock().unwrap().is_empty(),
653                "deleted geometry is released"
654            );
655        }
656    }
657
658    /// Guards the tessellation cache: a drawing that paints the same shapes on consecutive frames
659    /// paints last frame's tessellation, which must come out as the triangles of its shapes, and a
660    /// moved drawing, or one in a layer that is then zoomed, must paint its shapes rather than the
661    /// old mesh.
662    #[test]
663    fn cached_tessellation_matches_the_painted_shapes() {
664        use egui::{Context, RawInput, epaint::Primitive};
665        let ctx = Context::default();
666        let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 400.0));
667        let mut manager = DrawingManager::new();
668        let mut drawing = Drawing::new(7, DrawingToolType::ElliottImpulse);
669        drawing.points = [
670            (50., 300.),
671            (100., 200.),
672            (150., 250.),
673            (250., 80.),
674            (300., 150.),
675            (350., 60.),
676        ]
677        .map(|(x, y)| Pos2::new(x, y))
678        .to_vec();
679        manager.drawings.push(drawing);
680        let meshes = |shapes: Vec<ClippedShape>| -> Vec<(Rect, Mesh)> {
681            (ctx.tessellate(shapes, 1.0).into_iter())
682                .filter_map(|p| match p.primitive {
683                    Primitive::Mesh(mesh) => Some((p.clip_rect, mesh)),
684                    Primitive::Callback(_) => None,
685                })
686                .collect()
687        };
688        for frame in 0..5 {
689            if frame == 3 {
690                manager.drawings[0].points[3].y = 40.0;
691            }
692            let layer = if frame == 4 {
693                TSTransform::from_scaling(2.0)
694            } else {
695                TSTransform::IDENTITY
696            };
697            let mut output = ctx.run_ui(RawInput::default(), |ui| {
698                ctx.set_transform_layer(ui.layer_id(), layer);
699                let view = ChartView {
700                    price_formatter: None,
701                    time_formatter: None,
702                    bar_seconds: 0,
703                    instrument: None,
704                    bars: &[],
705                    revision: 0,
706                    bar_to_x: &|b| b * 10.0,
707                    price_to_y: &|p| 500.0 - p as f32,
708                    y_to_price: &|y| 500.0 - f64::from(y),
709                    time_axis: true,
710                    executions: None,
711                    timeframe: None,
712                    finer: &[],
713                };
714                manager.render_all(ui.painter(), pane, &view);
715            });
716            let shapes = std::mem::take(&mut output.shapes);
717            output.drop_without_applying_deltas();
718            if frame == 2 {
719                assert!(
720                    (shapes.iter()).any(|s| matches!(s.shape, Shape::Mesh(_))),
721                    "an unchanged drawing paints its kept tessellation"
722                );
723            }
724            let mut painted = manager.painted.lock().unwrap()[0].shapes.clone();
725            painted.iter_mut().for_each(|s| s.transform(layer));
726            assert!(meshes(shapes) == meshes(painted), "frame {frame}");
727        }
728    }
729}