1mod 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;
23pub(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
37pub struct ChartView<'a> {
43 pub instrument: Option<&'a crate::model::Instrument>,
45 pub price_formatter: Option<&'a dyn crate::scales::PriceFormatter>,
50 pub time_formatter: Option<&'a dyn crate::scales::TimeFormatter>,
57 pub bar_seconds: i64,
61 pub bars: &'a [Bar],
63 pub revision: u64,
67 pub bar_to_x: &'a dyn Fn(f32) -> f32,
69 pub price_to_y: &'a dyn Fn(f64) -> f32,
71 pub y_to_price: &'a dyn Fn(f32) -> f64,
73 pub time_axis: bool,
76 pub executions: Option<&'a crate::data::Footprint>,
80 pub timeframe: Option<crate::model::Timeframe>,
83 pub finer: &'a [crate::data::StudyData<'a>],
89}
90
91pub(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 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 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
126pub(crate) const MAX_CACHED_SPANS: usize = 32;
128
129impl Drawing {
130 pub fn render_original(&self, painter: &egui::Painter, rect: Rect, view: &ChartView) {
132 self.render_with_selection(painter, rect, view, false);
133 }
134
135 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 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 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 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 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 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 DrawingToolType::ParallelChannel
214 | DrawingToolType::FlatTopBottom
215 | DrawingToolType::DisjointChannel => self.render_channel(painter, rect),
216 DrawingToolType::RegressionTrend => self.render_regression_trend(painter, view),
217
218 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 DrawingToolType::ElliottImpulse
228 | DrawingToolType::ElliottCorrection
229 | DrawingToolType::ElliottTriangle
230 | DrawingToolType::ElliottDoubleCombo
231 | DrawingToolType::ElliottTripleCombo => self.render_elliott_waves(painter),
232
233 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 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 DrawingToolType::GhostFeed => return self.render_ghost_feed(painter, view),
245 DrawingToolType::ProjectionTool => self.render_projection_tool(painter, rect, view),
246
247 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 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 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 DrawingToolType::Brush | DrawingToolType::Highlighter => self.render_brush(painter),
274
275 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 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 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 pub fn render(&self, painter: &egui::Painter, rect: Rect, view: &ChartView) {
311 self.render_original(painter, rect, view);
312 }
313
314 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 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 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
438const PAINT_MARGIN: f32 = 200.0;
441
442#[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 #[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 #[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 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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 assert!((5328.0..=5920.0).contains(&area), "opaque area {area}");
1183 }
1184 }
1185 }
1186
1187 #[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 #[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 #[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 #[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 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 #[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 let draw = |tool, points: &[(f32, f32)], text: &str, selected| {
1546 let mut drawing = Drawing::new(7, tool);
1547 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] = ¬e.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 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] = ¬e.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 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 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 #[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}