Skip to main content

egui_charts/drawings/rendering/
mod.rs

1//! Painting drawings against bars and a coordinate mapping.
2//!
3//! [`ChartView`] supplies data and mappings to [`Drawing::render`]; [`traits`]
4//! provides handle drawing and hit-testing helpers for custom
5//! renderers. A chart invokes drawing rendering automatically.
6
7mod annotations;
8mod channels;
9mod cycles;
10mod elliott;
11mod fibonacci;
12mod gann;
13mod lines;
14mod measurements;
15mod media;
16mod outline;
17mod patterns;
18mod pitchfork;
19mod shapes;
20mod style;
21mod trading;
22pub mod traits;
23// The painter the drawing renderers share with the chart.
24pub(crate) mod utils {
25    pub(crate) use crate::painting::dashed_line::draw_dashed_line;
26}
27
28pub(crate) use outline::Outline;
29pub use traits::DrawingRenderer;
30
31use crate::drawings::domain::{Drawing, DrawingToolType};
32use crate::model::Bar;
33use crate::painting::span_cache::SpanCache;
34use egui::Rect;
35use std::rc::Rc;
36
37/// The bars and coordinate mapping that drawings are rendered against.
38///
39/// Drawing anchors index into `bars`. The anchored VWAP, both volume profiles,
40/// the regression trend, the position and forecast results and the bars
41/// pattern are computed from these bars.
42pub struct ChartView<'a> {
43    /// Instrument metadata supplied by the host or feed, for tick and pip measurements.
44    pub instrument: Option<&'a crate::model::Instrument>,
45    /// The host's raw-price formatter for the series the drawings belong to, from
46    /// [`Chart::set_series_formatter`](crate::Chart::set_series_formatter) or else
47    /// [`Chart::set_price_formatter`](crate::Chart::set_price_formatter). Drawing prices and
48    /// price changes use its `format`; `None` keeps their built-in decimals.
49    pub price_formatter: Option<&'a dyn crate::scales::PriceFormatter>,
50    /// The chart's time formatter, which writes times in its display time zone with its
51    /// locale, date format, clock visibility and the host's
52    /// [`TimeFormatter`](crate::scales::TimeFormatter). Drawings write the dates they print with
53    /// its [`format_crosshair`](crate::scales::TimeFormatter::format_crosshair) and
54    /// [`bar_seconds`](Self::bar_seconds), as the crosshair time label does; `None` keeps their
55    /// built-in UTC dates. Custom renderers can use it the same way.
56    pub time_formatter: Option<&'a dyn crate::scales::TimeFormatter>,
57    /// The bar duration in seconds that the chart passes to `time_formatter`'s
58    /// `format_crosshair` for the crosshair time label, which sets how much of the clock a
59    /// date shows. Unused without `time_formatter`.
60    pub bar_seconds: i64,
61    /// The series that drawing anchors index into.
62    pub bars: &'a [Bar],
63    /// [`ChartState::revision`](crate::studies::ChartState::revision) of `bars`.
64    /// Results computed from the bars are cached under it, so it must change
65    /// whenever a bar before the last one changes.
66    pub revision: u64,
67    /// Screen x of a (fractional) bar index.
68    pub bar_to_x: &'a dyn Fn(f32) -> f32,
69    /// Screen y of a price.
70    pub price_to_y: &'a dyn Fn(f64) -> f32,
71    /// Price at a screen y, using the same scale and orientation as `price_to_y`.
72    pub y_to_price: &'a dyn Fn(f32) -> f64,
73    /// Whether the bar times are times, so measurements show the time between anchors; `false`
74    /// on a [horizontal scale](crate::scales::HorizontalScale) other than time.
75    pub time_axis: bool,
76    /// The executions behind `bars`, aggregated on the bars' clock. The volume profile drawings
77    /// then show the executed volume at each price, split by aggressor side, instead of
78    /// spreading bar volume over bar ranges; `None` estimates from the bars.
79    pub executions: Option<&'a crate::data::Footprint>,
80    /// Interval of `bars`, or `None` for price-based chart types, trade-based intervals and
81    /// horizontal scales other than time. Volume profiles read finer bars only with an interval.
82    pub timeframe: Option<crate::model::Timeframe>,
83    /// Histories of this symbol at the intervals that
84    /// [`Chart::study_data_requests`](crate::Chart::study_data_requests) lists for its drawings.
85    /// Without executions in their span, profile drawings use the interval
86    /// [`Instrument::profile_resolutions`](crate::model::Instrument::profile_resolutions)
87    /// suggests, replacing covered chart bars with finer bars; `&[]` estimates from `bars`.
88    pub finer: &'a [crate::data::StudyData<'a>],
89}
90
91/// Index of the first bar and the bars from anchor `a` to anchor `b`
92/// (inclusive, either order), clamped to the series; `None` when no bar lies
93/// between them.
94pub(crate) fn bars_between(bars: &[Bar], a: f32, b: f32) -> Option<(usize, &[Bar])> {
95    let last = bars.len().checked_sub(1)?;
96    let (first, end) = (a.min(b).round(), a.max(b).round());
97    if end < 0.0 {
98        return None;
99    }
100    // Clamp as indices: past 2^24 bars an f32 no longer holds every index.
101    let (first, end) = (first.max(0.0) as usize, (end as usize).min(last));
102    (first <= end).then(|| (first, &bars[first..=end]))
103}
104
105impl ChartView<'_> {
106    /// `build` of `bars`, the span that starts at bar `first`, cached in
107    /// `cache`. A span that ends on the last bar, which may be forming, is
108    /// extended one bar at a time with `extend` as the series streams.
109    pub(crate) fn cached<T, P: Copy + PartialEq>(
110        &self,
111        cache: &mut SpanCache<T, P>,
112        param: P,
113        first: usize,
114        bars: &[Bar],
115        build: impl Fn(&[Bar]) -> T,
116        extend: impl Fn(&T, &Bar) -> Option<T>,
117    ) -> Rc<T> {
118        if first + bars.len() == self.bars.len() {
119            cache.get_extended(param, first, bars, self.revision, build, extend)
120        } else {
121            cache.get(param, first, bars, self.revision, build)
122        }
123    }
124}
125
126/// Results kept per kind of drawing, more than the drawings of one kind on screen.
127pub(crate) const MAX_CACHED_SPANS: usize = 32;
128
129impl Drawing {
130    /// Renders the drawing as unselected.
131    pub fn render_original(&self, painter: &egui::Painter, rect: Rect, view: &ChartView) {
132        self.render_with_selection(painter, rect, view, false);
133    }
134
135    /// Main rendering dispatcher - routes to category-specific render methods
136    ///
137    /// This method handles rendering for every drawing tool type,
138    /// delegating to the appropriate category-specific implementation. `selected`
139    /// lets a tool mark its selection, as a position's statistics block does.
140    pub(crate) fn render_with_selection(
141        &self,
142        painter: &egui::Painter,
143        rect: Rect,
144        view: &ChartView,
145        selected: bool,
146    ) {
147        if self.points.is_empty() {
148            return;
149        }
150
151        let painter = &painter.with_clip_rect(rect);
152        let start = painter.ctx().graphics(|g| {
153            g.get(painter.layer_id())
154                .map_or(0, |list| list.next_idx().0)
155        });
156        let color = self.color32();
157        let stroke = self.stroke();
158
159        match self.tool_type {
160            // Simple Lines (9 tools)
161            DrawingToolType::TrendLine => self.render_trend_line(painter, rect, color, stroke),
162            DrawingToolType::Ray | DrawingToolType::ExtendedLine => {
163                self.render_trend_line(painter, rect, color, stroke)
164            }
165            DrawingToolType::HorizontalLine => self.render_horizontal_line(painter, rect, stroke),
166            DrawingToolType::HorizontalRay => self.render_horizontal_ray(painter, rect, stroke),
167            DrawingToolType::VerticalLine => self.render_vertical_line(painter, rect, stroke),
168            DrawingToolType::CrossLine => self.render_cross_line(painter, rect, stroke),
169            DrawingToolType::TrendAngle => {
170                return self.render_trend_angle(painter, rect, color, stroke);
171            }
172            DrawingToolType::InfoLine => self.render_info_line(painter, view),
173
174            // Shapes (8 tools)
175            DrawingToolType::Rect => self.render_rect(painter, rect, color, stroke),
176            DrawingToolType::RotatedRect => self.render_rotated_rect(painter),
177            DrawingToolType::Circle => self.render_circle(painter, color, stroke),
178            DrawingToolType::Ellipse => self.render_ellipse(painter, color, stroke),
179            DrawingToolType::Triangle => self.render_triangle(painter, color, stroke),
180            DrawingToolType::Arc => self.render_arc(painter, color, stroke),
181            DrawingToolType::Polyline => self.render_polyline(painter, color, stroke),
182            DrawingToolType::DoubleCurve => self.render_double_curve(painter),
183
184            // Fibonacci (10 tools)
185            DrawingToolType::FibonacciRetracement | DrawingToolType::FibonacciExtension => {
186                self.render_fibonacci(painter, rect, view)
187            }
188            DrawingToolType::FibonacciSpeedResistanceArcs => self.render_fibonacci_arc(painter),
189            DrawingToolType::FibonacciTimeZones => self.render_fibonacci_time_zones(painter, rect),
190            DrawingToolType::FibonacciChannel => self.render_fibonacci_channel(painter, rect, view),
191            DrawingToolType::FibonacciCircles => self.render_fibonacci_circles(painter),
192            DrawingToolType::FibonacciSpeedFan => self.render_fibonacci_speed_fan(painter, rect),
193            DrawingToolType::FibonacciSpiral => self.render_fibonacci_spiral(painter, rect),
194            DrawingToolType::FibonacciWedge => self.render_fibonacci_wedge(painter, rect),
195            DrawingToolType::TrendBasedFibTime => self.render_fibonacci_time_zones(painter, rect),
196
197            // Gann (4 tools)
198            DrawingToolType::GannFan => self.render_gann_fan(painter, rect),
199            DrawingToolType::GannSquare | DrawingToolType::GannFixed => {
200                self.render_gann_square(painter, view)
201            }
202            DrawingToolType::GannBox => self.render_gann_box(painter),
203
204            // Pitchforks (4 tools)
205            DrawingToolType::Pitchfork => self.render_pitchfork(painter, rect),
206            DrawingToolType::SchiffPitchfork => self.render_pitchfork(painter, rect),
207            DrawingToolType::ModifiedSchiffPitchfork => {
208                self.render_modified_schiff_pitchfork(painter, rect)
209            }
210            DrawingToolType::InsidePitchfork => self.render_inside_pitchfork(painter, rect),
211
212            // Channels (4 tools)
213            DrawingToolType::ParallelChannel
214            | DrawingToolType::FlatTopBottom
215            | DrawingToolType::DisjointChannel => self.render_channel(painter, rect),
216            DrawingToolType::RegressionTrend => self.render_regression_trend(painter, view),
217
218            // Patterns (6 tools)
219            DrawingToolType::XABCDPattern => self.render_xabcd_pattern(painter),
220            DrawingToolType::CypherPattern => self.render_cypher_pattern(painter),
221            DrawingToolType::HeadAndShoulders => self.render_head_and_shoulders(painter),
222            DrawingToolType::ABCDPattern => self.render_abcd_pattern(painter),
223            DrawingToolType::TrianglePattern => self.render_triangle_pattern(painter),
224            DrawingToolType::ThreeDrivesPattern => self.render_three_drives_pattern(painter),
225
226            // Elliott Wave (5 tools)
227            DrawingToolType::ElliottImpulse
228            | DrawingToolType::ElliottCorrection
229            | DrawingToolType::ElliottTriangle
230            | DrawingToolType::ElliottDoubleCombo
231            | DrawingToolType::ElliottTripleCombo => self.render_elliott_waves(painter),
232
233            // Cycles (3 tools)
234            DrawingToolType::CyclicLines => self.render_cyclic_lines(painter, rect),
235            DrawingToolType::TimeCycles => self.render_time_cycles(painter, rect),
236            DrawingToolType::SineLine => self.render_sine_line(painter, rect),
237
238            // Projection (6 tools)
239            DrawingToolType::LongPos => self.render_pos_tool(painter, view, true, selected),
240            DrawingToolType::ShortPos => self.render_pos_tool(painter, view, false, selected),
241            DrawingToolType::Forecast => self.render_forecast(painter, view),
242            DrawingToolType::BarsPattern => self.render_bars_pattern(painter, view),
243            // Candle colours and the neutral path are independent of the common drawing style.
244            DrawingToolType::GhostFeed => return self.render_ghost_feed(painter, view),
245            DrawingToolType::ProjectionTool => self.render_projection_tool(painter, rect, view),
246
247            // Volume (3 tools)
248            DrawingToolType::AnchoredVWAP => self.render_anchored_vwap(painter, view),
249            DrawingToolType::FixedRangeVolumeProfile => {
250                self.render_fixed_range_volume_profile(painter, view)
251            }
252            DrawingToolType::AnchoredVolumeProfile => {
253                self.render_anchored_volume_profile(painter, view)
254            }
255
256            // Measurers (4 tools)
257            DrawingToolType::Measure => self.render_measure(painter, view),
258            DrawingToolType::PriceRange
259            | DrawingToolType::DateRange
260            | DrawingToolType::DateAndPriceRange => self.render_range(painter, rect, stroke, view),
261
262            // Annotations (7 tools)
263            DrawingToolType::TextLabel | DrawingToolType::AnchoredText => {
264                self.render_text_label(painter)
265            }
266            DrawingToolType::Note => self.render_note(painter),
267            DrawingToolType::AnchoredNote => self.render_anchored_note(painter, selected),
268            DrawingToolType::Callout => self.render_callout(painter),
269            DrawingToolType::PriceLabel => self.render_price_label(painter, view),
270            DrawingToolType::FlagNote => self.render_flag_note(painter),
271
272            // Brushes (2 tools)
273            DrawingToolType::Brush | DrawingToolType::Highlighter => self.render_brush(painter),
274
275            // Arrows (6 tools)
276            DrawingToolType::Arrow => self.render_arrow(painter, color, stroke),
277            DrawingToolType::ArrowMarker => self.render_arrow_marker(painter, color),
278            DrawingToolType::ArrowMarkUp => self.render_arrow_mark(painter, egui::Vec2::UP),
279            DrawingToolType::ArrowMarkDown => self.render_arrow_mark(painter, egui::Vec2::DOWN),
280            DrawingToolType::ArrowMarkLeft => self.render_arrow_mark(painter, egui::Vec2::LEFT),
281            DrawingToolType::ArrowMarkRight => self.render_arrow_mark(painter, egui::Vec2::RIGHT),
282
283            // Additional tools
284            DrawingToolType::Pitchfan => self.render_pitchfork(painter, rect),
285            DrawingToolType::Sector => self.render_sector(painter),
286            DrawingToolType::Path => self.render_path(painter),
287            DrawingToolType::Curve => self.render_curve(painter),
288            DrawingToolType::PriceNote => self.render_price_note(painter, view),
289            DrawingToolType::Table => self.render_table(painter),
290            DrawingToolType::Comment => self.render_comment(painter),
291            DrawingToolType::Signpost => self.render_signpost(painter),
292            DrawingToolType::Image => self.render_image(painter),
293            DrawingToolType::Tweet => self.render_post(painter),
294            DrawingToolType::Idea => self.render_idea(painter),
295            DrawingToolType::FontIcon | DrawingToolType::Sticker => self.render_font_icon(painter),
296
297            // Cursor tools (4 - not rendered as drawings)
298            DrawingToolType::CrossCursor
299            | DrawingToolType::DotCursor
300            | DrawingToolType::ArrowCursor
301            | DrawingToolType::Eraser => {}
302        }
303        self.finish_style(painter, start, rect);
304        if self.tool_type == DrawingToolType::Rect {
305            self.render_rect_middle_line(painter, rect);
306        }
307    }
308
309    /// Render this drawing using the modular render system
310    pub fn render(&self, painter: &egui::Painter, rect: Rect, view: &ChartView) {
311        self.render_original(painter, rect, view);
312    }
313
314    /// The screen area this drawing paints in, for tools that paint within three times the
315    /// diagonal of their anchors plus [`PAINT_MARGIN`] and the room its text needs around them, so
316    /// a drawing outside the pane can be skipped. Tools that also paint at prices taken from the
317    /// bars, such as positions and volume profiles, or run a leg on a fixed time past its anchor,
318    /// which a steep leg carries to any price, are bounded in time only. `None` for tools that can
319    /// reach the pane's edges, as lines extended left or right do.
320    pub(crate) fn paint_bounds(&self) -> Option<Rect> {
321        use DrawingToolType as T;
322        let bounded = matches!(
323            self.tool_type,
324            T::TrendLine
325                | T::InfoLine
326                | T::TrendAngle
327                | T::Rect
328                | T::RotatedRect
329                | T::Circle
330                | T::Ellipse
331                | T::Triangle
332                | T::Arc
333                | T::Polyline
334                | T::DoubleCurve
335                | T::Path
336                | T::Curve
337                | T::Brush
338                | T::Highlighter
339                | T::Sector
340                | T::FibonacciSpeedResistanceArcs
341                | T::FibonacciCircles
342                | T::GannSquare
343                | T::GannBox
344                | T::GannFixed
345                | T::XABCDPattern
346                | T::CypherPattern
347                | T::HeadAndShoulders
348                | T::ABCDPattern
349                | T::TrianglePattern
350                | T::ElliottImpulse
351                | T::ElliottCorrection
352                | T::ElliottTriangle
353                | T::ElliottDoubleCombo
354                | T::ElliottTripleCombo
355                | T::LongPos
356                | T::ShortPos
357                | T::Forecast
358                | T::BarsPattern
359                | T::FixedRangeVolumeProfile
360                | T::Measure
361                | T::PriceRange
362                | T::DateRange
363                | T::DateAndPriceRange
364                | T::Arrow
365                | T::ArrowMarker
366                | T::ArrowMarkUp
367                | T::ArrowMarkDown
368                | T::ArrowMarkLeft
369                | T::ArrowMarkRight
370                | T::FontIcon
371                | T::Sticker
372                | T::Image
373        );
374        if !bounded || self.extend_left || self.extend_right {
375            return None;
376        }
377        if matches!(
378            self.tool_type,
379            T::FibonacciCircles | T::FibonacciSpeedResistanceArcs
380        ) {
381            let &[start, end, ..] = self.points.as_slice() else {
382                return None;
383            };
384            let ratio = self
385                .fibonacci_config()
386                .visible_levels()
387                .map(|l| l.value)
388                .filter(|v| v.is_finite() && *v >= 0.0)
389                .fold(0.0_f32, f32::max);
390            let (center, size) = if self.tool_type == T::FibonacciCircles {
391                (start.lerp(end, 0.5), (end - start).abs() * ratio)
392            } else {
393                (start, egui::Vec2::splat(start.distance(end) * ratio * 2.0))
394            };
395            return size.is_finite().then(|| {
396                Rect::from_center_size(center, size)
397                    .union(Rect::from_two_pos(start, end))
398                    .expand(PAINT_MARGIN)
399            });
400        }
401        if self.tool_type == T::GannFixed {
402            let &[first, second, ..] = self.points.as_slice() else {
403                return None;
404            };
405            // Five times the anchors' span can pass the reach below when the span is steep.
406            let corner = self.gann_square_corner(first, second);
407            return Some(Rect::from_two_pos(first, corner).expand(PAINT_MARGIN));
408        }
409        if self.tool_type == T::Image {
410            return self.image_frame().map(|frame| frame.expand(PAINT_MARGIN));
411        }
412        let hull = self.bounding_rect()?;
413        // Room for the drawing's text: one and a half ems per character covers the widest glyph
414        // advance and the row of a line break.
415        let text = self.text.as_deref().map_or(0.0, |t| {
416            1.5 * crate::styles::typography::font_size(self.font_size) * t.chars().count() as f32
417        });
418        let reach = 3.0 * hull.size().length() + PAINT_MARGIN + text;
419        let bounds = hull.expand(reach);
420        let time_only = matches!(
421            self.tool_type,
422            T::LongPos
423                | T::ShortPos
424                | T::Forecast
425                | T::BarsPattern
426                | T::FixedRangeVolumeProfile
427                | T::HeadAndShoulders
428                | T::TrianglePattern
429        );
430        Some(if time_only {
431            Rect::from_x_y_ranges(bounds.x_range(), egui::Rangef::EVERYTHING)
432        } else {
433            bounds
434        })
435    }
436}
437
438/// Room beyond the anchors for the labels, markers and fixed-size extensions of a drawing with
439/// [`Drawing::paint_bounds`].
440const PAINT_MARGIN: f32 = 200.0;
441
442/// Bars the data-driven drawing tests share; their reference values were
443/// computed independently from these OHLCV rows. The last bar has no range.
444#[cfg(test)]
445pub(crate) fn sample_bars() -> Vec<Bar> {
446    [
447        (10.0, 12.0, 9.0, 11.0, 100.0),
448        (11.0, 13.0, 10.0, 12.0, 200.0),
449        (12.0, 12.5, 11.0, 11.0, 150.0),
450        (11.5, 14.0, 11.0, 13.5, 300.0),
451        (13.5, 15.0, 13.0, 14.0, 250.0),
452        (14.0, 14.5, 12.0, 12.5, 400.0),
453        (13.0, 13.0, 13.0, 13.0, 50.0),
454    ]
455    .into_iter()
456    .enumerate()
457    .map(|(i, (o, h, l, c, v))| {
458        let time = chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap();
459        Bar::new(time, o, h, l, c, v)
460    })
461    .collect()
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::drawings::DrawingManager;
468    use egui::{Color32, Context, Pos2, RawInput, Shape};
469
470    /// A non-default style reaches geometry in every renderer family, while fills keep
471    /// their configured colour. Dashes in a 200 px horizontal edge cannot be one solid edge.
472    #[test]
473    fn common_stroke_and_fill_styles_reach_tool_families() {
474        use crate::model::LineStyle;
475        for tool in [
476            DrawingToolType::Rect,
477            DrawingToolType::Circle,
478            DrawingToolType::Triangle,
479            DrawingToolType::Curve,
480            DrawingToolType::GannFan,
481            DrawingToolType::CyclicLines,
482            DrawingToolType::TimeCycles,
483            DrawingToolType::XABCDPattern,
484            DrawingToolType::PriceRange,
485            DrawingToolType::DateRange,
486            DrawingToolType::DateAndPriceRange,
487        ] {
488            let mut drawing = Drawing::new(7, tool);
489            drawing.points = [
490                (100., 100.),
491                (300., 300.),
492                (200., 250.),
493                (350., 150.),
494                (400., 200.),
495            ]
496            .map(|(x, y)| Pos2::new(x, y))
497            .to_vec();
498            drawing.chart_points = drawing
499                .points
500                .iter()
501                .map(|p| crate::drawings::ChartPoint::new(p.x / 10.0, 500.0 - p.y as f64))
502                .collect();
503            drawing.line_style = LineStyle::Dotted;
504            drawing.stroke_width = 5.0;
505            drawing.fill_color = Some([12, 34, 56, 80]);
506            let shapes = paint(&drawing);
507            let mut lines = Vec::new();
508            fn collect<'a>(
509                shapes: &'a [Shape],
510                lines: &mut Vec<(&'a [Pos2; 2], &'a egui::Stroke)>,
511            ) {
512                for s in shapes {
513                    match s {
514                        Shape::Vec(v) => collect(v, lines),
515                        Shape::LineSegment { points, stroke } => lines.push((points, stroke)),
516                        _ => {}
517                    }
518                }
519            }
520            collect(&shapes, &mut lines);
521            assert!(
522                lines
523                    .iter()
524                    .any(|(p, s)| p[0].distance(p[1]) <= 3.0 && s.width == 5.0),
525                "{tool:?} has no styled dash"
526            );
527            assert!(
528                !lines.iter().any(|(p, _)| p[0].distance(p[1]) > 10.0),
529                "{tool:?} retained a solid line"
530            );
531            assert!(
532                !lines
533                    .iter()
534                    .any(|(_, s)| s.color == egui::Color32::WHITE && s.width > 2.0),
535                "{tool:?} restyled anchor decorations"
536            );
537            if matches!(
538                tool,
539                DrawingToolType::Rect
540                    | DrawingToolType::Circle
541                    | DrawingToolType::Triangle
542                    | DrawingToolType::TimeCycles
543                    | DrawingToolType::XABCDPattern
544                    | DrawingToolType::PriceRange
545                    | DrawingToolType::DateRange
546                    | DrawingToolType::DateAndPriceRange
547            ) {
548                fn has_fill(shapes: &[Shape], color: egui::Color32) -> bool {
549                    shapes.iter().any(|s| match s {
550                        Shape::Vec(v) => has_fill(v, color),
551                        Shape::Path(p) => p.fill == color,
552                        Shape::Rect(r) => r.fill == color,
553                        Shape::Circle(c) => c.fill == color,
554                        _ => false,
555                    })
556                }
557                assert!(
558                    has_fill(&shapes, drawing.fill_color32().unwrap()),
559                    "{tool:?} ignored fill"
560                );
561            }
562        }
563    }
564
565    /// For the box (100, 100) to (300, 300), the arrows end at the second anchor's
566    /// coordinate on each axis. Cards sit beyond that end in the tooltip colours.
567    #[test]
568    fn measurer_arrows_point_to_the_second_anchor_and_stats_sit_on_cards() {
569        use crate::drawings::ChartPoint;
570        use DrawingToolType as T;
571        let chart = crate::theme::Theme::dark().chart;
572        for tool in [
573            T::Measure,
574            T::PriceRange,
575            T::DateRange,
576            T::DateAndPriceRange,
577            T::InfoLine,
578        ] {
579            for (first, second) in [
580                ((300., 100.), (100., 300.)),
581                ((100., 300.), (300., 100.)),
582                ((100., 100.), (300., 300.)),
583                ((300., 300.), (100., 100.)),
584                ((100., 200.), (300., 200.)),
585                ((200., 100.), (200., 300.)),
586                ((200., 200.), (200., 200.)),
587            ] {
588                let mut drawing = Drawing::new(1, tool);
589                drawing.points = [first, second].map(|(x, y)| Pos2::new(x, y)).to_vec();
590                drawing.chart_points = drawing
591                    .points
592                    .iter()
593                    .map(|p| ChartPoint::new(p.x / 10.0, 500.0 - f64::from(p.y)))
594                    .collect();
595                let shapes = paint(&drawing);
596                let bounds = Rect::from_two_pos(drawing.points[0], drawing.points[1]);
597                let card = shapes
598                    .iter()
599                    .find_map(|s| match s {
600                        Shape::Rect(r) if r.fill == chart.bg_tooltip => Some(r.rect),
601                        _ => None,
602                    })
603                    .unwrap_or_else(|| panic!("{tool:?}: no tooltip card"));
604                assert!(
605                    shapes.iter().any(|s| matches!(s, Shape::Text(t)
606                    if t.galley.job.sections.iter().all(|s| s.format.color == chart.axis_text
607                        && s.format.font_id.size == crate::styles::typography::SM_MD))),
608                    "{tool:?}: card text"
609                );
610                if tool == T::InfoLine {
611                    assert!(card.min.x >= bounds.center().x && card.min.y >= bounds.center().y);
612                    assert_eq!(
613                        shapes
614                            .iter()
615                            .filter(|s| matches!(s, Shape::LineSegment { .. }))
616                            .count(),
617                        1,
618                        "info line has no connector"
619                    );
620                    continue;
621                }
622                if second.1 <= first.1 {
623                    assert!(card.max.y < bounds.min.y, "{tool:?}: card above");
624                } else {
625                    assert!(card.min.y > bounds.max.y, "{tool:?}: card below");
626                }
627                let mut tips = Vec::new();
628                if tool != T::DateRange && first.1 != second.1 {
629                    tips.push(Pos2::new(bounds.center().x, second.1));
630                }
631                if tool != T::PriceRange && first.0 != second.0 {
632                    tips.push(Pos2::new(second.0, bounds.center().y));
633                }
634                let heads: Vec<_> = shapes
635                    .iter()
636                    .filter_map(|s| match s {
637                        Shape::Path(p) if p.closed && p.fill.a() > 0 => Some(p),
638                        _ => None,
639                    })
640                    .collect();
641                assert_eq!(
642                    heads.len(),
643                    tips.len(),
644                    "{tool:?}: heads for {first:?} to {second:?}"
645                );
646                for tip in tips {
647                    let head = heads
648                        .iter()
649                        .find(|p| p.points[0] == tip)
650                        .unwrap_or_else(|| panic!("{tool:?}: no arrow at {tip:?}"));
651                    // Both base corners lie behind the tip along the drag direction.
652                    let direction = if tip.y == bounds.center().y {
653                        egui::vec2(second.0 - first.0, 0.0)
654                    } else {
655                        egui::vec2(0.0, second.1 - first.1)
656                    };
657                    assert!(
658                        head.points[1..]
659                            .iter()
660                            .all(|p| (*p - tip).dot(direction) < 0.0)
661                    );
662                }
663            }
664        }
665    }
666
667    /// The settings the properties dialog offers for a tool are the ones its renderer uses: a
668    /// fill colour reaches a filled area, extension moves geometry (each flag flipped from the
669    /// tool's default, since an extended line starts extended), the text is painted, each line
670    /// end style draws a marker and the middle line flag adds a line.
671    /// Icons keep their glyph in the text field, which the glyph picker edits.
672    #[test]
673    fn tool_capabilities_match_their_renderers() {
674        use crate::drawings::ArrowStyle;
675        fn found(shapes: &[Shape], hit: &impl Fn(&Shape) -> bool) -> bool {
676            shapes.iter().any(|s| match s {
677                Shape::Vec(v) => found(v, hit),
678                s => hit(s),
679            })
680        }
681        for &tool in DrawingToolType::all() {
682            let mut drawing = Drawing::new(7, tool);
683            drawing.points = [
684                (100., 100.),
685                (300., 300.),
686                (200., 250.),
687                (350., 150.),
688                (400., 200.),
689                (450., 180.),
690                (500., 250.),
691            ]
692            .map(|(x, y)| Pos2::new(x, y))
693            .to_vec();
694            drawing.chart_points = drawing
695                .points
696                .iter()
697                .map(|p| crate::drawings::ChartPoint::new(p.x / 10.0, 500.0 - p.y as f64))
698                .collect();
699            if tool == DrawingToolType::RegressionTrend {
700                drawing.chart_points[0].bar_idx = 0.0;
701                drawing.chart_points[1].bar_idx = 6.0;
702            }
703            let mut filled = drawing.clone();
704            filled.fill_color = Some([12, 34, 56, 80]);
705            let fill = filled.fill_color32().unwrap();
706            let fill = found(&paint(&filled), &|s| match s {
707                Shape::Path(p) => p.fill == fill,
708                Shape::Rect(r) => r.fill == fill,
709                Shape::Circle(c) => c.fill == fill,
710                Shape::Mesh(m) => m.vertices.iter().any(|v| v.color == fill),
711                _ => false,
712            });
713            assert_eq!(tool.supports_fill(), fill, "{tool:?} fill");
714            let mut extended = drawing.clone();
715            (extended.extend_left, extended.extend_right) =
716                (!drawing.extend_left, !drawing.extend_right);
717            let extend = format!("{:?}", paint(&extended)) != format!("{:?}", paint(&drawing));
718            assert_eq!(tool.supports_extend(), extend, "{tool:?} extend");
719            let mut labelled = drawing.clone();
720            labelled.text = Some("Edited text".into());
721            let text = found(
722                &paint_with(&labelled, true),
723                &|s| matches!(s, Shape::Text(t) if t.galley.text().contains("Edited text")),
724            );
725            let icon = matches!(tool, DrawingToolType::FontIcon | DrawingToolType::Sticker);
726            assert_eq!(tool.supports_text(), text && !icon, "{tool:?} text");
727            let mut plain = drawing.clone();
728            plain.arrow_start_style = ArrowStyle::None;
729            plain.arrow_end_style = ArrowStyle::None;
730            let changes = |edit: &dyn Fn(&mut Drawing)| {
731                let mut edited = plain.clone();
732                edit(&mut edited);
733                format!("{:?}", paint(&edited)) != format!("{:?}", paint(&plain))
734            };
735            for (end, styled) in [
736                (
737                    "start",
738                    changes(&|d| d.arrow_start_style = ArrowStyle::Circle),
739                ),
740                ("end", changes(&|d| d.arrow_end_style = ArrowStyle::Circle)),
741            ] {
742                assert_eq!(tool.supports_line_ends(), styled, "{tool:?} line {end}");
743            }
744            let middle = changes(&|d| d.channel_midline = !d.channel_midline);
745            assert_eq!(tool.supports_middle_line(), middle, "{tool:?} middle line");
746        }
747    }
748
749    /// Guards culling: a drawing with paint bounds paints nothing outside them, for spread,
750    /// near-vertical and tiny anchors, so skipping it when the bounds miss the pane hides nothing
751    /// in view. A tool added to the bounded list whose paint reaches further, such as a line to a
752    /// pane edge, fails it.
753    #[test]
754    fn drawings_paint_within_their_bounds() {
755        let layouts: [&[(f32, f32)]; 3] = [
756            &[
757                (100., 100.),
758                (300., 300.),
759                (200., 250.),
760                (350., 150.),
761                (400., 200.),
762                (250., 120.),
763                (450., 280.),
764                (150., 180.),
765            ],
766            &[
767                (200., 100.),
768                (200.5, 300.),
769                (201., 150.),
770                (201.5, 280.),
771                (202., 120.),
772                (202.5, 260.),
773                (203., 140.),
774                (203.5, 300.),
775            ],
776            &[
777                (200., 200.),
778                (201., 202.),
779                (202., 199.),
780                (203., 201.),
781                (204., 200.),
782                (205., 202.),
783                (206., 198.),
784                (207., 201.),
785            ],
786        ];
787        let bars = sample_bars();
788        // Larger than all bounds, so paint that reaches a pane edge lands outside them.
789        let pane = Rect::from_min_max(Pos2::new(-2000., -2000.), Pos2::new(2500., 2500.));
790        let mut wrong = Vec::new();
791        for &tool in DrawingToolType::all() {
792            for (i, layout) in layouts.into_iter().enumerate() {
793                let mut drawing = Drawing::new(7, tool);
794                let count = tool.required_points().unwrap_or(layout.len());
795                drawing.points = layout
796                    .iter()
797                    .take(count)
798                    .map(|&(x, y)| Pos2::new(x, y))
799                    .collect();
800                drawing.chart_points = (drawing.points.iter())
801                    .map(|p| crate::drawings::ChartPoint::new(p.x / 40.0, 500.0 - p.y as f64))
802                    .collect();
803                // A label too long for the margin must grow the bounds with it, on the tiny anchors
804                // where the margin is nearly all the reach there is.
805                drawing.text = Some(if i == 2 {
806                    "Sample label ".repeat(8)
807                } else {
808                    "Sample".into()
809                });
810                if tool == DrawingToolType::Image {
811                    drawing.image = Some(crate::drawings::ImageConfig {
812                        size: Some([1500.0, 800.0]),
813                        ..Default::default()
814                    });
815                }
816                let Some(bounds) = drawing.paint_bounds() else {
817                    continue;
818                };
819                let ctx = Context::default();
820                let output = ctx.run_ui(RawInput::default(), |ui| {
821                    let view = ChartView {
822                        price_formatter: None,
823                        time_formatter: None,
824                        bar_seconds: 0,
825                        instrument: None,
826                        bars: &bars,
827                        revision: 0,
828                        bar_to_x: &|b| b * 40.0,
829                        price_to_y: &|p| 500.0 - p as f32,
830                        y_to_price: &|y| 500.0 - f64::from(y),
831                        time_axis: true,
832                        executions: None,
833                        timeframe: None,
834                        finer: &[],
835                    };
836                    drawing.render(ui.painter(), pane, &view);
837                });
838                for shape in &output.shapes {
839                    let paint = shape.shape.visual_bounding_rect();
840                    if !paint.is_negative() && !bounds.expand(0.5).contains_rect(paint) {
841                        wrong.push(format!("{tool:?} paints {paint:?} outside {bounds:?}"));
842                    }
843                }
844                output.drop_without_applying_deltas();
845            }
846        }
847        assert!(wrong.is_empty(), "{wrong:#?}");
848    }
849
850    /// Text size changes the laid-out box; bold adds weight without requiring host fonts.
851    #[test]
852    fn common_annotation_fonts_are_laid_out_at_the_configured_size() {
853        use crate::drawings::FontWeight;
854        for (tool, every_label) in [
855            (DrawingToolType::TextLabel, false),
856            (DrawingToolType::AnchoredText, false),
857            (DrawingToolType::Note, false),
858            (DrawingToolType::AnchoredNote, false),
859            (DrawingToolType::Callout, false),
860            (DrawingToolType::PriceLabel, false),
861            (DrawingToolType::FlagNote, false),
862            (DrawingToolType::PriceNote, false),
863            (DrawingToolType::Comment, false),
864            (DrawingToolType::Signpost, false),
865            (DrawingToolType::Table, false),
866            (DrawingToolType::FontIcon, false),
867            (DrawingToolType::XABCDPattern, true),
868            (DrawingToolType::CypherPattern, true),
869            (DrawingToolType::HeadAndShoulders, true),
870            (DrawingToolType::ABCDPattern, true),
871            (DrawingToolType::TrianglePattern, true),
872            (DrawingToolType::ThreeDrivesPattern, true),
873            (DrawingToolType::ElliottImpulse, true),
874            (DrawingToolType::ElliottCorrection, true),
875            (DrawingToolType::ElliottTriangle, true),
876            (DrawingToolType::ElliottDoubleCombo, true),
877            (DrawingToolType::ElliottTripleCombo, true),
878        ] {
879            let mut drawing = Drawing::new(7, tool);
880            drawing.points = (0..tool.required_points().unwrap_or(2).max(2))
881                .map(|i| {
882                    Pos2::new(
883                        50.0 + i as f32 * 60.0,
884                        if i % 2 == 0 { 100.0 } else { 150.0 },
885                    )
886                })
887                .collect();
888            // Three drives labels only its ratios, which are measured in price.
889            if tool == DrawingToolType::ThreeDrivesPattern {
890                drawing.chart_points = (drawing.points.iter())
891                    .map(|p| crate::drawings::ChartPoint::new(p.x / 10.0, 500.0 - p.y as f64))
892                    .collect();
893            }
894            drawing.text = Some("Sample".into());
895            drawing.font_size = 48.0;
896            drawing.font_weight = FontWeight::Bold;
897            let shapes = paint_with(&drawing, true);
898            let mut texts = Vec::new();
899            fn collect<'a>(shapes: &'a [Shape], texts: &mut Vec<&'a egui::epaint::TextShape>) {
900                for s in shapes {
901                    match s {
902                        Shape::Vec(v) => collect(v, texts),
903                        Shape::Text(t) => texts.push(t),
904                        _ => {}
905                    }
906                }
907            }
908            collect(&shapes, &mut texts);
909            if matches!(tool, DrawingToolType::Table | DrawingToolType::Note) {
910                let table = shapes
911                    .iter()
912                    .find_map(|s| match s {
913                        Shape::Rect(r) => Some(r.rect),
914                        _ => None,
915                    })
916                    .unwrap();
917                assert!(
918                    texts
919                        .iter()
920                        .all(|t| table.contains_rect(t.visual_bounding_rect()))
921                );
922            }
923            let matching = texts
924                .iter()
925                .filter(|t| {
926                    t.galley
927                        .job
928                        .sections
929                        .iter()
930                        .all(|s| s.format.font_id.size == 48.0)
931                })
932                .count();
933            assert!(
934                matching > 0 && (!every_label || matching == texts.len()),
935                "{tool:?} ignored font size"
936            );
937            let bold_text: Vec<_> = texts.iter().map(|t| (*t).clone()).collect();
938            drawing.font_weight = FontWeight::Normal;
939            let normal = paint(&drawing);
940            let mut normal_text = Vec::new();
941            collect(&normal, &mut normal_text);
942            assert_ne!(
943                bold_text,
944                normal_text.into_iter().cloned().collect::<Vec<_>>(),
945                "{tool:?} ignored font weight"
946            );
947        }
948    }
949
950    /// Endpoint symbols retain the clicked direction even when the anchors run right to left.
951    #[test]
952    fn common_endpoint_markers_follow_styles_and_anchor_direction() {
953        use crate::drawings::ArrowStyle;
954        for &style in ArrowStyle::all() {
955            let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
956            drawing.points = vec![Pos2::new(300., 100.), Pos2::new(100., 100.)];
957            drawing.arrow_start_style = ArrowStyle::Circle;
958            drawing.arrow_end_style = style;
959            let shapes = paint(&drawing);
960            assert!(
961                shapes
962                    .iter()
963                    .any(|s| matches!(s,Shape::Circle(c) if c.center == drawing.points[0])),
964                "start circle missing"
965            );
966            let tip = drawing.points[1];
967            let present = match style {
968                ArrowStyle::None => !shapes.iter().any(|s| matches!(s,Shape::Path(_))),
969                ArrowStyle::Arrow => shapes.iter().any(|s| matches!(s,Shape::Path(p) if p.points == [tip, Pos2::new(108.,96.), Pos2::new(108.,104.)])),
970                ArrowStyle::OpenArrow => shapes.iter().any(|s| matches!(s,Shape::Path(p) if p.points.get(1) == Some(&tip) && p.fill == egui::Color32::TRANSPARENT)),
971                ArrowStyle::Circle => shapes.iter().any(|s| matches!(s,Shape::Circle(c) if c.center == tip)),
972                ArrowStyle::Square => shapes.iter().any(|s| matches!(s,Shape::Rect(r) if r.rect.center() == tip)),
973                ArrowStyle::Diamond => shapes.iter().any(|s| matches!(s,Shape::Path(p) if p.points.len() == 4 && p.points[0].x < tip.x && p.points[2].x > tip.x)),
974            };
975            assert!(present, "{style:?}");
976        }
977        let mut path = Drawing::new(7, DrawingToolType::Path);
978        path.points = vec![
979            Pos2::new(100., 100.),
980            Pos2::new(200., 100.),
981            Pos2::new(200., 200.),
982        ];
983        assert!(
984            !paint(&path)
985                .iter()
986                .any(|s| matches!(s, Shape::Circle(c) if c.center == path.points[2]))
987        );
988        // Final segment points down: the arrow base is eight pixels above the tip, four either side.
989        assert!(paint(&path).iter().any(|s| matches!(s, Shape::Path(p) if p.points == [Pos2::new(200.,200.), Pos2::new(196.,192.), Pos2::new(204.,192.)])));
990        for tool in [
991            DrawingToolType::InfoLine,
992            DrawingToolType::TrendAngle,
993            DrawingToolType::Arrow,
994        ] {
995            let mut drawing = Drawing::new(7, tool);
996            drawing.points = vec![Pos2::new(100., 100.), Pos2::new(300., 100.)];
997            drawing.line_style = crate::model::LineStyle::Dashed;
998            drawing.extend_left = true;
999            drawing.extend_right = true;
1000            fn extent(shapes: &[Shape]) -> (f32, f32) {
1001                shapes
1002                    .iter()
1003                    .fold((f32::INFINITY, f32::NEG_INFINITY), |(min, max), s| {
1004                        let (a, b) = match s {
1005                            Shape::Vec(v) => extent(v),
1006                            Shape::LineSegment { points, .. } => {
1007                                (points[0].x.min(points[1].x), points[0].x.max(points[1].x))
1008                            }
1009                            _ => (min, max),
1010                        };
1011                        (min.min(a), max.max(b))
1012                    })
1013            }
1014            let (min, max) = extent(&paint(&drawing));
1015            assert!(
1016                min <= 0.0 && max >= 499.0,
1017                "{tool:?}: extended dashed line spans {min}..{max}"
1018            );
1019        }
1020        for tool in [DrawingToolType::TrendLine, DrawingToolType::ExtendedLine] {
1021            let mut drawing = Drawing::new(7, tool);
1022            drawing.points = vec![Pos2::new(100., 300.), Pos2::new(100., 100.)];
1023            drawing.arrow_start_style = ArrowStyle::Circle;
1024            drawing.arrow_end_style = ArrowStyle::Square;
1025            let shapes = paint(&drawing);
1026            let (start, end) = if tool == DrawingToolType::ExtendedLine {
1027                (Pos2::new(100., 400.), Pos2::new(100., 0.))
1028            } else {
1029                (drawing.points[0], drawing.points[1])
1030            };
1031            assert!(
1032                shapes
1033                    .iter()
1034                    .any(|s| matches!(s,Shape::Circle(c) if c.center == start)),
1035                "{tool:?}: vertical start"
1036            );
1037            assert!(
1038                shapes
1039                    .iter()
1040                    .any(|s| matches!(s,Shape::Rect(r) if r.rect.center() == end)),
1041                "{tool:?}: vertical end"
1042            );
1043        }
1044    }
1045
1046    /// Marks are 14 px triangles with their tip on the anchor and an 8.4 px half-base.
1047    /// The 200 px marker has a 70 by 80 px head and a shaft widening from 16 to 32 px:
1048    /// area 70 * 80 / 2 + 130 * (16 + 32) / 2 = 5920 px², in every direction.
1049    /// Saved line ends, extension, fill and line styles must not alter these solid shapes.
1050    #[test]
1051    fn arrow_tools_paint_one_solid_shape_in_the_drawing_colour() {
1052        use crate::drawings::ArrowStyle;
1053        use DrawingToolType as T;
1054        use egui::epaint::{Mesh, TessellationOptions, Tessellator};
1055        let p = Pos2::new;
1056        for (tool, anchors, want) in [
1057            (
1058                T::ArrowMarkUp,
1059                vec![p(200., 200.)],
1060                vec![p(200., 200.), p(208.4, 214.), p(191.6, 214.)],
1061            ),
1062            (
1063                T::ArrowMarkDown,
1064                vec![p(200., 200.)],
1065                vec![p(200., 200.), p(191.6, 186.), p(208.4, 186.)],
1066            ),
1067            (
1068                T::ArrowMarkLeft,
1069                vec![p(200., 200.)],
1070                vec![p(200., 200.), p(214., 191.6), p(214., 208.4)],
1071            ),
1072            (
1073                T::ArrowMarkRight,
1074                vec![p(200., 200.)],
1075                vec![p(200., 200.), p(186., 208.4), p(186., 191.6)],
1076            ),
1077            (
1078                T::ArrowMarker,
1079                vec![p(100., 200.), p(300., 200.)],
1080                vec![
1081                    p(300., 200.),
1082                    p(230., 240.),
1083                    p(230., 216.),
1084                    p(100., 208.),
1085                    p(100., 192.),
1086                    p(230., 184.),
1087                    p(230., 160.),
1088                ],
1089            ),
1090            (
1091                T::ArrowMarker,
1092                vec![p(300., 200.), p(100., 200.)],
1093                vec![
1094                    p(100., 200.),
1095                    p(170., 160.),
1096                    p(170., 184.),
1097                    p(300., 192.),
1098                    p(300., 208.),
1099                    p(170., 216.),
1100                    p(170., 240.),
1101                ],
1102            ),
1103            (
1104                T::ArrowMarker,
1105                vec![p(100., 300.), p(100., 100.)],
1106                vec![
1107                    p(100., 100.),
1108                    p(140., 170.),
1109                    p(116., 170.),
1110                    p(108., 300.),
1111                    p(92., 300.),
1112                    p(84., 170.),
1113                    p(60., 170.),
1114                ],
1115            ),
1116        ] {
1117            let mut drawing = Drawing::with_color(7, tool, [12, 34, 56, 255]);
1118            drawing.points = anchors;
1119            drawing.arrow_start_style = ArrowStyle::Circle;
1120            drawing.arrow_end_style = ArrowStyle::Arrow;
1121            (drawing.extend_left, drawing.extend_right) = (true, true);
1122            let shapes = paint(&drawing);
1123            let paths: Vec<_> = shapes
1124                .iter()
1125                .filter_map(|s| match s {
1126                    Shape::Path(path) => Some(path),
1127                    _ => None,
1128                })
1129                .collect();
1130            assert!(
1131                !shapes
1132                    .iter()
1133                    .any(|s| matches!(s, Shape::LineSegment { .. } | Shape::Circle(_))),
1134                "{tool:?} line or end"
1135            );
1136            assert_eq!(paths.len(), 1, "{tool:?}");
1137            let path = paths[0];
1138            assert_eq!(path.fill, drawing.color32(), "{tool:?} colour");
1139            assert!(path.closed && path.stroke.is_empty(), "{tool:?}");
1140            assert_eq!(path.points.len(), want.len(), "{tool:?}");
1141            for (got, want) in path.points.iter().zip(&want) {
1142                assert!(
1143                    got.distance(*want) < 1e-3,
1144                    "{tool:?}: {:?}, want {want:?}",
1145                    path.points
1146                );
1147            }
1148            let mut styled = drawing.clone();
1149            styled.fill_color = Some([200, 100, 50, 80]);
1150            styled.line_style = crate::model::LineStyle::Dashed;
1151            styled.stroke_width = 8.0;
1152            assert_eq!(
1153                format!("{:?}", paint(&styled)),
1154                format!("{shapes:?}"),
1155                "{tool:?} style"
1156            );
1157            if tool == T::ArrowMarker {
1158                let mut mesh = Mesh::default();
1159                Tessellator::new(1.0, TessellationOptions::default(), [1, 1], Vec::new())
1160                    .tessellate_shape(Shape::Path(path.clone()), &mut mesh);
1161                let mut area = 0.0;
1162                for tri in mesh.indices.as_chunks::<3>().0 {
1163                    let vertices = tri.map(|i| mesh.vertices[i as usize]);
1164                    if vertices.iter().any(|v| v.color.a() != 255) {
1165                        continue;
1166                    }
1167                    let [a, b, c] = vertices.map(|v| v.pos);
1168                    let centre = a + ((b - a) + (c - a)) / 3.0;
1169                    let mut inside = false;
1170                    for i in 0..want.len() {
1171                        let (a, b) = (want[i], want[(i + 1) % want.len()]);
1172                        if (a.y > centre.y) != (b.y > centre.y)
1173                            && centre.x < (b.x - a.x) * (centre.y - a.y) / (b.y - a.y) + a.x
1174                        {
1175                            inside = !inside;
1176                        }
1177                    }
1178                    assert!(inside, "triangle outside the arrow: {a:?}, {b:?}, {c:?}");
1179                    area += ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() / 2.0;
1180                }
1181                // Feathering removes at most 10% of the opaque area.
1182                assert!((5328.0..=5920.0).contains(&area), "opaque area {area}");
1183            }
1184        }
1185    }
1186
1187    /// These anchors form a semicircle of radius sqrt(5000), length 222.144 px.
1188    /// An 8 px dash / 4 px gap paints about two thirds, plus at most one dash.
1189    #[test]
1190    fn dash_spacing_continues_across_segmented_curves() {
1191        let mut drawing = Drawing::new(7, DrawingToolType::Arc);
1192        drawing.points = vec![
1193            Pos2::new(100., 100.),
1194            Pos2::new(200., 200.),
1195            Pos2::new(200., 100.),
1196        ];
1197        drawing.line_style = crate::model::LineStyle::Dashed;
1198        fn length(shapes: &[Shape]) -> f32 {
1199            shapes
1200                .iter()
1201                .map(|s| match s {
1202                    Shape::Vec(v) => length(v),
1203                    Shape::LineSegment { points, .. } => points[0].distance(points[1]),
1204                    _ => 0.0,
1205                })
1206                .sum()
1207        }
1208        let painted = length(&paint(&drawing));
1209        assert!(
1210            painted > 140.0 && painted < 157.0,
1211            "painted {painted} px of a 222.144 px arc"
1212        );
1213    }
1214
1215    /// Bundled glyphs must use vector primitives even when the host has no emoji font.
1216    #[test]
1217    fn bundled_glyphs_use_vectors_at_their_independent_size() {
1218        for id in [
1219            "icon:star",
1220            "icon:target",
1221            "sticker:bull",
1222            "sticker:bear",
1223            "sticker:rocket",
1224            "sticker:idea",
1225        ] {
1226            for size in [24.0, 48.0] {
1227                for width in [2.0, 6.0] {
1228                    let mut drawing = Drawing::new(7, DrawingToolType::FontIcon);
1229                    drawing.text = Some(id.into());
1230                    drawing.font_size = size;
1231                    drawing.stroke_width = width;
1232                    drawing.points = vec![Pos2::new(100., 100.)];
1233                    let shapes = paint(&drawing);
1234                    assert!(!shapes.is_empty());
1235                    assert!(
1236                        !shapes.iter().any(|s| matches!(s, Shape::Text(_))),
1237                        "{id} used a host font"
1238                    );
1239                    let bounds = Rect::from_center_size(drawing.points[0], egui::Vec2::splat(size))
1240                        .expand(width);
1241                    let painted = shapes.iter().fold(Rect::NOTHING, |bounds, shape| {
1242                        bounds.union(shape.visual_bounding_rect())
1243                    });
1244                    assert!(
1245                        painted.width() > size * 0.6 && painted.height() > size * 0.6,
1246                        "{id}: {painted:?} at {size}"
1247                    );
1248                    for shape in &shapes {
1249                        assert!(
1250                            bounds.contains_rect(shape.visual_bounding_rect()),
1251                            "{id}: {shape:?}"
1252                        );
1253                    }
1254                }
1255            }
1256        }
1257    }
1258
1259    /// y = 250 - x/2 and either y = 150 or its reflection y = 50 + x/2.
1260    /// Both cross at (200, 150); fills must cover both lobes without stroked ends.
1261    #[test]
1262    fn wedge_channels_share_clean_paint_fill_and_extended_hit_geometry() {
1263        let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 400.0));
1264        for tool in [
1265            DrawingToolType::FlatTopBottom,
1266            DrawingToolType::DisjointChannel,
1267        ] {
1268            for reverse in [false, true] {
1269                for (left, right, xs) in [
1270                    (false, false, [100.0, 300.0]),
1271                    (true, false, [0.0, 300.0]),
1272                    (false, true, [100.0, 500.0]),
1273                    (true, true, [0.0, 500.0]),
1274                ] {
1275                    for filled in [false, true] {
1276                        let mut drawing = Drawing::new(1, tool);
1277                        drawing.chart_points = vec![
1278                            crate::drawings::ChartPoint::new(10.0, 300.0),
1279                            crate::drawings::ChartPoint::new(30.0, 400.0),
1280                            crate::drawings::ChartPoint::new(20.0, 350.0),
1281                        ];
1282                        if reverse {
1283                            drawing.chart_points.swap(0, 1);
1284                        }
1285                        drawing.extend_left = left;
1286                        drawing.extend_right = right;
1287                        drawing.fill_color = filled.then_some([12, 34, 56, 80]);
1288                        drawing.update_screen_coords(|b| b * 10.0, |p| 500.0 - p as f32);
1289                        let other_y = |x| {
1290                            if tool == DrawingToolType::FlatTopBottom {
1291                                150.0
1292                            } else {
1293                                50.0 + x / 2.0
1294                            }
1295                        };
1296                        let lines = vec![
1297                            xs.map(|x| Pos2::new(x, 250.0 - x / 2.0)),
1298                            xs.map(|x| Pos2::new(x, other_y(x))),
1299                        ];
1300                        let outline = drawing.outline(rect).unwrap();
1301                        assert_eq!(outline.lines, lines, "{tool:?}");
1302                        assert_eq!(outline.areas.len(), if filled { 2 } else { 0 });
1303                        let shapes = paint(&drawing);
1304                        let mut painted_lines = Vec::new();
1305                        let mut areas = Vec::new();
1306                        for shape in shapes {
1307                            match shape {
1308                                Shape::LineSegment { points, .. } => painted_lines.push(points),
1309                                Shape::Path(path) if path.fill.a() > 0 => {
1310                                    assert!(path.stroke.is_empty());
1311                                    assert_eq!(path.fill, drawing.fill_color32().unwrap());
1312                                    areas.push(path.points);
1313                                }
1314                                Shape::Noop => {}
1315                                other => panic!("unexpected channel decoration: {other:?}"),
1316                            }
1317                        }
1318                        assert_eq!(painted_lines, lines);
1319                        assert_eq!(areas, outline.areas);
1320                        let hit = crate::drawings::services::DrawingInteraction::new();
1321                        for x in [125.0, 275.0] {
1322                            for y in [250.0 - x / 2.0, other_y(x)] {
1323                                assert_eq!(
1324                                    hit.hit_test(Pos2::new(x, y), &[drawing.clone()], "1m"),
1325                                    Some(1)
1326                                );
1327                            }
1328                            let y = (250.0 - x / 2.0 + other_y(x)) / 2.0;
1329                            assert_eq!(
1330                                hit.hit_test(Pos2::new(x, y), &[drawing.clone()], "1m"),
1331                                filled.then_some(1)
1332                            );
1333                        }
1334                    }
1335                }
1336            }
1337        }
1338    }
1339
1340    /// Even-odd fills and their hit areas agree for concave and crossing paths and curve lobes.
1341    #[test]
1342    fn filled_shapes_cover_the_area_their_line_encloses() {
1343        use crate::drawings::services::DrawingInteraction;
1344        use DrawingToolType as T;
1345        let concave = vec![
1346            (50., 50.),
1347            (200., 50.),
1348            (200., 200.),
1349            (125., 100.),
1350            (50., 200.),
1351        ];
1352        // Analytic areas: a radius-100 semicircle, the quadratic's 300 * 90 * 2/3,
1353        // and the cubic's two lobes, 2 * 300 * 450 / 32. Curved fills allow the
1354        // curved boundary length, bounded by control polygons for Beziers, times
1355        // a quarter point of boundary error.
1356        for (tool, points, closed, area, area_tolerance, hits, misses) in [
1357            (
1358                T::Polyline,
1359                concave.clone(),
1360                true,
1361                15000.,
1362                2.,
1363                vec![(125., 75.), (50., 125.)],
1364                vec![(125., 150.)],
1365            ),
1366            (
1367                T::Polyline,
1368                concave,
1369                false,
1370                0.,
1371                2.,
1372                vec![],
1373                vec![(125., 75.), (50., 125.)],
1374            ),
1375            (
1376                T::Polyline,
1377                vec![(50., 50.), (250., 250.), (250., 50.), (50., 250.)],
1378                false,
1379                20000.,
1380                2.,
1381                vec![(70., 150.)],
1382                vec![(150., 70.)],
1383            ),
1384            (
1385                T::Arc,
1386                vec![(100., 200.), (300., 200.), (200., 100.)],
1387                false,
1388                std::f32::consts::PI * 5000.,
1389                80.,
1390                vec![(200., 150.)],
1391                vec![(200., 250.)],
1392            ),
1393            (
1394                T::Curve,
1395                vec![(100., 300.), (250., 120.), (400., 300.)],
1396                false,
1397                18000.,
1398                120.,
1399                vec![(250., 260.)],
1400                vec![(250., 190.)],
1401            ),
1402            (
1403                T::DoubleCurve,
1404                vec![(100., 200.), (200., 350.), (300., 50.), (400., 200.)],
1405                false,
1406                8437.5,
1407                170.,
1408                vec![(163.4, 230.), (336.6, 170.)],
1409                vec![(163.4, 170.), (250., 240.)],
1410            ),
1411        ] {
1412            let mut drawing = Drawing::new(1, tool);
1413            drawing.points = points.into_iter().map(Pos2::from).collect();
1414            drawing.completed = true;
1415            drawing.closed = closed;
1416            drawing.fill_color = (area > 0.).then_some([12, 34, 56, 80]);
1417            let painted = paint(&drawing);
1418            let fill = Color32::from_rgba_unmultiplied(12, 34, 56, 80);
1419            let painted_area: f32 = painted
1420                .iter()
1421                .filter_map(|shape| match shape {
1422                    Shape::Mesh(mesh) => {
1423                        assert!(mesh.vertices.iter().all(|v| v.color == fill));
1424                        Some(
1425                            mesh.indices
1426                                .as_chunks::<3>()
1427                                .0
1428                                .iter()
1429                                .map(|tri| {
1430                                    let a = mesh.vertices[tri[0] as usize].pos;
1431                                    let b = mesh.vertices[tri[1] as usize].pos;
1432                                    let c = mesh.vertices[tri[2] as usize].pos;
1433                                    ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs()
1434                                        / 2.
1435                                })
1436                                .sum::<f32>(),
1437                        )
1438                    }
1439                    _ => None,
1440                })
1441                .sum();
1442            assert!(
1443                (painted_area - area).abs() < area_tolerance,
1444                "{tool:?}: {painted_area} != {area}"
1445            );
1446            let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(500., 400.));
1447            let clipped: Vec<_> = painted
1448                .into_iter()
1449                .map(|shape| egui::epaint::ClippedShape::new(rect, shape))
1450                .collect();
1451            for (points, want) in [(hits, true), (misses, false)] {
1452                for point in points.into_iter().map(Pos2::from) {
1453                    assert_eq!(
1454                        DrawingInteraction::new().hit_test(point, &[drawing.clone()], "1m"),
1455                        want.then_some(1),
1456                        "{tool:?} outline at {point:?}"
1457                    );
1458                    assert_eq!(
1459                        DrawingInteraction::hit_test_painted(point, &clipped),
1460                        want,
1461                        "{tool:?} paint at {point:?}"
1462                    );
1463                }
1464            }
1465        }
1466        for points in [
1467            vec![],
1468            vec![Pos2::ZERO; 2],
1469            vec![Pos2::ZERO, Pos2::new(f32::NAN, 1.), Pos2::new(1., 2.)],
1470        ] {
1471            assert!(shapes::even_odd_trapezoids(&points).is_empty());
1472        }
1473    }
1474
1475    pub(super) fn paint(drawing: &Drawing) -> Vec<Shape> {
1476        paint_with(drawing, false)
1477    }
1478
1479    fn paint_with(drawing: &Drawing, selected: bool) -> Vec<Shape> {
1480        let ctx = Context::default();
1481        let bars = sample_bars();
1482        let output = ctx.run_ui(RawInput::default(), |ui| {
1483            let view = ChartView {
1484                price_formatter: None,
1485                time_formatter: None,
1486                bar_seconds: 0,
1487                instrument: None,
1488                bars: if drawing.tool_type == DrawingToolType::RegressionTrend {
1489                    &bars
1490                } else {
1491                    &[]
1492                },
1493                revision: 0,
1494                bar_to_x: &|b| b * 10.0,
1495                price_to_y: &|p| 500.0 - p as f32,
1496                y_to_price: &|y| 500.0 - f64::from(y),
1497                time_axis: true,
1498                executions: None,
1499                timeframe: None,
1500                finer: &[],
1501            };
1502            drawing.render_with_selection(
1503                ui.painter(),
1504                Rect::from_min_max(Pos2::ZERO, Pos2::new(500., 400.)),
1505                &view,
1506                selected,
1507            );
1508        });
1509        let shapes = output.shapes.iter().map(|s| s.shape.clone()).collect();
1510        output.drop_without_applying_deltas();
1511        shapes
1512    }
1513
1514    /// Each tool with an anchor and a label draws the label where its anchors say, joined to the
1515    /// anchor by one thin line from the side that faces it, and a pin shows its text only while
1516    /// selected.
1517    #[test]
1518    fn anchored_labels_join_their_anchor() {
1519        #[derive(Default)]
1520        struct Painted {
1521            rects: Vec<egui::epaint::RectShape>,
1522            segments: Vec<([Pos2; 2], egui::Stroke)>,
1523            circles: Vec<egui::epaint::CircleShape>,
1524            paths: Vec<egui::epaint::PathShape>,
1525            texts: Vec<egui::epaint::TextShape>,
1526        }
1527        fn collect(shapes: &[Shape], painted: &mut Painted) {
1528            for shape in shapes {
1529                match shape {
1530                    Shape::Vec(shapes) => collect(shapes, painted),
1531                    Shape::Rect(r) => painted.rects.push(r.clone()),
1532                    Shape::LineSegment { points, stroke } => {
1533                        painted.segments.push((*points, *stroke))
1534                    }
1535                    Shape::Circle(c) => painted.circles.push(*c),
1536                    Shape::Path(p) => painted.paths.push(p.clone()),
1537                    Shape::Text(t) => painted.texts.push(t.clone()),
1538                    _ => {}
1539                }
1540            }
1541        }
1542        let text_of = |t: &egui::epaint::TextShape| t.galley.job.text.clone();
1543        let pos = |x, y| Pos2::new(x, y);
1544        // Bars and prices come from the mapping `paint` renders with.
1545        let draw = |tool, points: &[(f32, f32)], text: &str, selected| {
1546            let mut drawing = Drawing::new(7, tool);
1547            // White on this red has a contrast of 1.05 / (0.2195 + 0.05) = 3.90.
1548            drawing.color = [0xF2, 0x36, 0x45, 0xFF];
1549            drawing.points = points.iter().map(|&(x, y)| pos(x, y)).collect();
1550            drawing.chart_points = points
1551                .iter()
1552                .map(|&(x, y)| crate::drawings::ChartPoint::new(x / 10.0, 500.0 - f64::from(y)))
1553                .collect();
1554            drawing.text = Some(text.into());
1555            let mut painted = Painted::default();
1556            collect(&paint_with(&drawing, selected), &mut painted);
1557            painted
1558        };
1559
1560        let note = draw(
1561            DrawingToolType::Note,
1562            &[(130., 400.), (100., 100.)],
1563            "Hi",
1564            false,
1565        );
1566        let [card] = &note.rects[..] else {
1567            panic!("one label");
1568        };
1569        assert_eq!(card.rect.min, pos(100.0, 100.0));
1570        assert_eq!(card.stroke.width, 1.0);
1571        // The anchor lies far below and at most 30 px to the side, so the bottom faces it.
1572        assert_eq!(
1573            note.segments,
1574            [(
1575                [
1576                    pos(card.rect.center().x, card.rect.max.y),
1577                    pos(130.0, 400.0)
1578                ],
1579                egui::Stroke::new(1.0, Color32::from_rgb(0xF2, 0x36, 0x45)),
1580            )]
1581        );
1582        let [dot] = &note.circles[..] else {
1583            panic!("one dot");
1584        };
1585        assert_eq!((dot.center, dot.radius), (pos(130.0, 400.0), 3.0));
1586        assert_eq!(note.texts.iter().map(text_of).collect::<Vec<_>>(), ["Hi"]);
1587
1588        let saved = draw(DrawingToolType::Note, &[(100., 100.)], "", false);
1589        assert_eq!(saved.rects[0].rect.min, pos(100.0, 100.0));
1590        assert!(saved.segments.is_empty());
1591        assert_eq!(
1592            saved.texts.iter().map(text_of).collect::<Vec<_>>(),
1593            ["Text"]
1594        );
1595
1596        let price_note = draw(
1597            DrawingToolType::PriceNote,
1598            &[(130., 400.), (100., 100.)],
1599            "Entry",
1600            false,
1601        );
1602        assert_eq!(
1603            price_note.texts.iter().map(text_of).collect::<Vec<_>>(),
1604            ["100.00 Entry"]
1605        );
1606        assert_eq!(price_note.segments.len(), 1, "no line across the pane");
1607        assert_eq!(
1608            price_note.rects[0].fill,
1609            Color32::from_rgb(0xF2, 0x36, 0x45)
1610        );
1611        assert_eq!(
1612            price_note.texts[0].galley.job.sections[0].format.color,
1613            Color32::WHITE
1614        );
1615
1616        let pin = draw(
1617            DrawingToolType::AnchoredNote,
1618            &[(200., 300.)],
1619            "Fill",
1620            false,
1621        );
1622        assert!(pin.texts.is_empty());
1623        let [marker] = &pin.paths[..] else {
1624            panic!("one marker");
1625        };
1626        let bounds = Rect::from_points(&marker.points);
1627        assert!(marker.closed && marker.points.contains(&pos(200.0, 300.0)));
1628        assert!(
1629            Rect::from_min_max(pos(194.0, 278.0), pos(206.0, 300.0))
1630                .expand(0.01)
1631                .contains_rect(bounds)
1632                && bounds.height() > 20.0,
1633            "{bounds:?}"
1634        );
1635        let selected = draw(DrawingToolType::AnchoredNote, &[(200., 300.)], "Fill", true);
1636        assert_eq!(
1637            selected.texts.iter().map(text_of).collect::<Vec<_>>(),
1638            ["Fill"]
1639        );
1640        assert!(selected.texts[0].visual_bounding_rect().max.y <= 274.0);
1641
1642        let comment = draw(DrawingToolType::Comment, &[(100., 300.)], "Hi", false);
1643        let [bubble] = &comment.rects[..] else {
1644            panic!("one bubble");
1645        };
1646        assert_eq!((bubble.rect.min.x, bubble.rect.max.y), (100.0, 300.0));
1647        assert_eq!(
1648            bubble.corner_radius,
1649            egui::CornerRadius {
1650                nw: 8,
1651                ne: 8,
1652                sw: 0,
1653                se: 8
1654            }
1655        );
1656        assert!(comment.circles.is_empty());
1657        assert!(
1658            bubble
1659                .rect
1660                .contains_rect(comment.texts[0].visual_bounding_rect())
1661        );
1662
1663        // Black on this yellow has the better contrast: white has only 1.05 / 0.860 = 1.22.
1664        let mut pale = Drawing::new(7, DrawingToolType::Comment);
1665        pale.points = vec![pos(100.0, 300.0)];
1666        pale.fill_color = Some([255, 235, 59, 255]);
1667        let mut painted = Painted::default();
1668        collect(&paint(&pale), &mut painted);
1669        assert_eq!(
1670            painted.texts[0].galley.job.sections[0].format.color,
1671            Color32::BLACK
1672        );
1673
1674        let callout = draw(
1675            DrawingToolType::Callout,
1676            &[(100., 300.), (200., 100.)],
1677            "Hi",
1678            false,
1679        );
1680        assert!(callout.rects.is_empty());
1681        let [outline] = &callout.paths[..] else {
1682            panic!("box and pointer are one shape");
1683        };
1684        assert!(outline.closed && outline.points.contains(&pos(100.0, 300.0)));
1685        assert_eq!(outline.stroke.width, 1.0);
1686        let inside = draw(
1687            DrawingToolType::Callout,
1688            &[(205., 105.), (200., 100.)],
1689            "Hi",
1690            false,
1691        );
1692        assert!(inside.paths.is_empty());
1693        assert_eq!(inside.rects.len(), 1);
1694
1695        let label = draw(
1696            DrawingToolType::PriceLabel,
1697            &[(100., 300.)],
1698            "Ignored",
1699            false,
1700        );
1701        assert_eq!(
1702            label.texts.iter().map(text_of).collect::<Vec<_>>(),
1703            ["200.00"]
1704        );
1705        let [badge] = &label.paths[..] else {
1706            panic!("badge and pointer are one shape");
1707        };
1708        assert!(badge.closed && badge.points.contains(&pos(100.0, 300.0)));
1709        let text = label.texts[0].visual_bounding_rect();
1710        assert!(text.min.x > 108.0 && text.max.y < 292.0, "{text:?}");
1711
1712        // A second anchor on another bar still gets a vertical pole; one anchor shows the label
1713        // 40 px above the bar.
1714        for (points, centre) in [
1715            (&[(100., 300.), (160., 200.)][..], pos(100.0, 200.0)),
1716            (&[(100., 300.)][..], pos(100.0, 260.0)),
1717        ] {
1718            let signpost = draw(DrawingToolType::Signpost, points, "Earnings", false);
1719            let [plate] = &signpost.rects[..] else {
1720                panic!("one plate");
1721            };
1722            assert_eq!(plate.rect.center(), centre);
1723            let [(pole, stroke)] = signpost.segments[..] else {
1724                panic!("one pole");
1725            };
1726            assert_eq!(pole, [pos(100.0, 300.0), pos(100.0, plate.rect.max.y)]);
1727            assert_eq!(stroke.width, 1.0);
1728            assert_eq!(
1729                signpost.texts.iter().map(text_of).collect::<Vec<_>>(),
1730                ["Earnings"]
1731            );
1732        }
1733    }
1734
1735    /// A pattern's labels and selection handles can cross the pane edge;
1736    /// their paint commands retain the pane's clip, intersected with the caller's clip.
1737    #[test]
1738    fn drawings_and_handles_keep_the_pane_clip() {
1739        let ctx = Context::default();
1740        let pane = Rect::from_min_max(Pos2::new(30.0, 30.0), Pos2::new(300.0, 250.0));
1741        let parent = Rect::from_min_max(Pos2::new(50.0, 10.0), Pos2::new(400.0, 400.0));
1742        let mut manager = DrawingManager::new();
1743        let mut pattern = Drawing::new(7, DrawingToolType::HeadAndShoulders);
1744        pattern.points = [
1745            (40., 200.),
1746            (80., 150.),
1747            (120., 200.),
1748            (160., 100.),
1749            (200., 200.),
1750            (240., 150.),
1751            (280., 260.),
1752        ]
1753        .map(|(x, y)| Pos2::new(x, y))
1754        .to_vec();
1755        manager.drawings.push(pattern);
1756        manager.select(7);
1757        let output = ctx.run_ui(RawInput::default(), |ui| {
1758            let view = ChartView {
1759                price_formatter: None,
1760                time_formatter: None,
1761                bar_seconds: 0,
1762                instrument: None,
1763                bars: &[],
1764                revision: 0,
1765                bar_to_x: &|x| x,
1766                price_to_y: &|p| p as f32,
1767                y_to_price: &f64::from,
1768                time_axis: true,
1769                executions: None,
1770                timeframe: None,
1771                finer: &[],
1772            };
1773            manager.render_all(&ui.painter().with_clip_rect(parent), pane, &view);
1774        });
1775        let shapes = output.shapes.clone();
1776        output.drop_without_applying_deltas();
1777        let painted: Vec<_> = shapes
1778            .iter()
1779            .filter(|s| !matches!(s.shape, Shape::Noop))
1780            .collect();
1781        assert!(!painted.is_empty());
1782        assert!(
1783            painted
1784                .iter()
1785                .all(|s| s.clip_rect == pane.intersect(parent)),
1786            "drawing or handle escaped its pane"
1787        );
1788    }
1789}