1use super::Chart;
4use crate::chart::coords::ChartMapping;
5use crate::data::inbox::Inbox;
6use crate::localization as l10n;
7use crate::model::{Direction, Signal, SignalShape, Timeframe};
8use crate::services::{Listener, SignalEvent, Signals};
9use crate::theme::ThemeExt;
10use egui::{FontId, Pos2, Rect, Sense, Stroke, Vec2};
11use std::sync::Arc;
12
13pub(super) struct SignalSource {
14 source: Arc<dyn Signals>,
15 inbox: Arc<Inbox>,
16 listener: Option<Listener>,
17 scope: Option<(String, Timeframe)>,
18 signals: Vec<Signal>,
19}
20
21impl SignalSource {
22 fn pump(&mut self, ctx: &egui::Context, scope: &(String, Timeframe)) {
23 if self.scope.as_ref() != Some(scope)
24 || self.listener.as_ref().is_none_or(|l| !l.is_active())
25 {
26 self.listener = None;
27 self.signals.clear();
28 self.inbox = Arc::default();
29 let repaint = ctx.clone();
30 let viewport = ctx.viewport_id();
31 self.inbox
32 .set_waker(move || repaint.request_repaint_of(viewport));
33 let (listener, events) = Listener::new(&self.inbox, 0);
34 self.listener = Some(listener);
35 self.scope = Some(scope.clone());
36 self.source.subscribe(&scope.0, scope.1, events);
37 }
38 if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
39 self.signals.clear();
40 ctx.request_repaint();
41 return;
42 }
43 for (_, event) in self.inbox.drain().answers {
44 match *event.downcast::<SignalEvent>().expect("signal event") {
45 SignalEvent::Upsert(signal) => {
46 if let Some(index) = self.signals.iter().position(|old| old.id == signal.id) {
47 self.signals.remove(index);
48 }
49 if valid(&signal.shape) {
50 self.signals.push(signal);
51 }
52 }
53 SignalEvent::Remove(id) => self.signals.retain(|signal| signal.id != id),
54 SignalEvent::Clear => self.signals.clear(),
55 }
56 }
57 }
58}
59
60impl Chart {
61 pub fn set_signal_source(&mut self, source: Option<Arc<dyn Signals>>) {
63 self.set_signal_sources(source.into_iter().collect());
64 }
65
66 pub fn set_signal_sources(&mut self, sources: Vec<Arc<dyn Signals>>) {
70 self.signal_sources = sources
71 .into_iter()
72 .map(|source| SignalSource {
73 source,
74 inbox: Arc::default(),
75 listener: None,
76 scope: None,
77 signals: Vec::new(),
78 })
79 .collect();
80 }
81
82 pub fn set_signal_category_visible(&mut self, category: &str, visible: bool) {
84 if visible {
85 self.hidden_signal_categories.remove(category);
86 } else {
87 self.hidden_signal_categories.insert(category.into());
88 }
89 }
90
91 pub(super) fn clear_source_signals(&mut self) {
92 for source in &mut self.signal_sources {
93 source.listener = None;
94 source.scope = None;
95 source.signals.clear();
96 source.inbox = Arc::default();
97 }
98 }
99
100 pub(super) fn pump_signals(&mut self, ctx: &egui::Context) {
101 let scope = (self.symbol.clone(), self.timeframe.unwrap_or_default());
102 for source in &mut self.signal_sources {
103 source.pump(ctx, &scope);
104 }
105 }
106
107 pub(super) fn paint_signals(&self, ui: &mut egui::Ui, coords: &ChartMapping) {
108 if self
109 .signal_sources
110 .iter()
111 .all(|source| source.signals.is_empty())
112 {
113 return;
114 }
115 let theme = ui.active_theme();
116 let painter = ui.painter().with_clip_rect(coords.rect);
117 let bars = self.state.shown();
118 let x = |time| {
119 crate::chart::rendering::candles::price_based::element_index_at(
120 &self.state,
121 self.chart_type,
122 &self.chart_type_params,
123 self.older_bars_drawn,
124 time,
125 )
126 .map(|index| index as f64)
127 .or_else(|| self.time_to_index(time))
128 .map(|index| coords.idx_to_x(0) + index as f32 * coords.bar_spacing)
129 };
130 let mut occupied = Vec::new();
131 let half_width = (coords.bar_spacing * self.config.candle_width)
132 .max(self.config.wick_width)
133 .max(1.0)
134 / 2.0;
135 if self.main_series_visible {
136 let end = coords
137 .x_to_idx(coords.rect.right())
138 .saturating_add(1)
139 .min(bars.len());
140 let mut reserve = |left, right, bar: &crate::model::Bar| {
141 if !bar.is_whitespace() {
142 occupied.push(Rect::from_two_pos(
143 Pos2::new(left, coords.price.price_to_y(bar.high)),
144 Pos2::new(right, coords.price.price_to_y(bar.low)),
145 ));
146 }
147 };
148 let factor = self.merge_groups.factor();
149 if factor > 1 {
150 for index in self.merge_groups.covering(coords.start_idx..end) {
151 let first = index * factor;
152 let last = (first + factor).min(bars.len()) - 1;
153 let left = coords.idx_to_x(first);
154 let right = coords.idx_to_x(last);
155 let center = (left + right) / 2.0;
156 let half_group = ((right - left).abs() / 2.0 + half_width)
157 .max(coords.bar_spacing * factor as f32 * self.config.candle_width / 2.0);
158 reserve(
159 center - half_group,
160 center + half_group,
161 &self.merge_groups.groups[index].bar,
162 );
163 }
164 } else {
165 for (index, bar) in bars.iter().enumerate().take(end).skip(coords.start_idx) {
166 let x = coords.idx_to_x(index);
167 reserve(x - half_width, x + half_width, bar);
168 }
169 }
170 }
171 let price_obstacles = occupied.len();
172 let mut visible = Vec::new();
173 let signals = [false, true].into_iter().flat_map(|markers| {
175 self.signal_sources
176 .iter()
177 .enumerate()
178 .flat_map(move |(index, source)| {
179 source
180 .signals
181 .iter()
182 .filter(move |signal| {
183 matches!(signal.shape, SignalShape::Marker { .. }) == markers
184 })
185 .map(move |signal| (index, source, signal))
186 })
187 });
188 for (index, source, signal) in signals {
189 if self.hidden_signal_categories.contains(&signal.category) {
190 continue;
191 }
192 let neutral = theme.ui.accent;
193 let (hit, label, color) = match signal.shape {
194 SignalShape::Marker {
195 time,
196 price,
197 direction,
198 } => {
199 let Some(index) =
200 crate::chart::rendering::candles::price_based::element_index_at(
201 &self.state,
202 self.chart_type,
203 &self.chart_type_params,
204 self.older_bars_drawn,
205 time,
206 )
207 .map(|index| index as f64)
208 .or_else(|| self.time_to_index(time).map(f64::floor))
209 else {
210 continue;
211 };
212 let px = coords.idx_to_x(0) + index as f32 * coords.bar_spacing;
213 let color = match direction {
214 Direction::Up => theme.chart.candle_up,
215 Direction::Down => theme.chart.candle_down,
216 Direction::Neutral => neutral,
217 };
218 let value = if let Some(price) = price {
219 price
220 } else {
221 if index < 0.0 {
222 continue;
223 }
224 let Some(bar) = bars.get(index as usize) else {
225 continue;
226 };
227 if direction == Direction::Up {
228 bar.low
229 } else {
230 bar.high
231 }
232 };
233 if !value.is_finite() {
234 continue;
235 }
236 let y = coords.price.price_to_y(value)
237 + if price.is_some() {
238 0.0
239 } else if direction == Direction::Up {
240 12.0
241 } else {
242 -12.0
243 };
244 let point = Pos2::new(px, y);
245 if !coords.rect.expand(8.0).contains(point) {
246 continue;
247 }
248 let sign = if direction == Direction::Up {
249 1.0
250 } else {
251 -1.0
252 };
253 if direction == Direction::Neutral {
254 painter.circle(point, 4.0, color, Stroke::new(1.0, theme.chart.bg));
255 } else {
256 painter.add(egui::Shape::convex_polygon(
257 vec![
258 point + Vec2::new(0.0, -5.0 * sign),
259 point + Vec2::new(-5.0, 4.0 * sign),
260 point + Vec2::new(5.0, 4.0 * sign),
261 ],
262 color,
263 Stroke::new(1.0, theme.chart.bg),
264 ));
265 }
266 (
267 Rect::from_center_size(point, Vec2::splat(14.0)),
268 point + Vec2::new(9.0, 0.0),
269 color,
270 )
271 }
272 SignalShape::Level { price, from } => {
273 let left = match from {
274 Some(time) => {
275 let Some(x) = x(time) else {
276 continue;
277 };
278 x.max(coords.rect.left())
279 }
280 None => coords.rect.left(),
281 };
282 let y = coords.price.price_to_y(price);
283 if left > coords.rect.right() || !coords.rect.y_range().contains(y) {
284 continue;
285 }
286 painter.line_segment(
287 [Pos2::new(left, y), Pos2::new(coords.rect.right(), y)],
288 Stroke::new(1.0, neutral),
289 );
290 (
291 Rect::from_min_max(
292 Pos2::new(left, y - 4.0),
293 Pos2::new(coords.rect.right(), y + 4.0),
294 ),
295 Pos2::new(left + 6.0, y - 11.0),
296 neutral,
297 )
298 }
299 SignalShape::PriceZone {
300 from,
301 to,
302 price,
303 direction,
304 } => {
305 let Some(left) = x(from) else {
306 continue;
307 };
308 let right = match to {
309 Some(time) => {
310 let Some(right) = x(time) else {
311 continue;
312 };
313 right
314 }
315 None => coords.rect.right(),
316 };
317 let y = coords.price.price_to_y(price);
318 if right < coords.rect.left()
319 || left > coords.rect.right()
320 || !coords.rect.y_range().contains(y)
321 {
322 continue;
323 }
324 let color = match direction {
325 Direction::Up => theme.chart.candle_up,
326 Direction::Down => theme.chart.candle_down,
327 Direction::Neutral => neutral,
328 };
329 let left = left.max(coords.rect.left());
330 let right = right.max(left + 1.0).min(coords.rect.right());
331 painter.line_segment(
332 [Pos2::new(left, y), Pos2::new(right, y)],
333 Stroke::new(2.0, color),
334 );
335 (
336 Rect::from_min_max(Pos2::new(left, y - 4.0), Pos2::new(right, y + 4.0)),
337 Pos2::new(left + 6.0, y - 11.0),
338 color,
339 )
340 }
341 SignalShape::Zone {
342 from,
343 to,
344 low,
345 high,
346 } => {
347 let Some(left) = x(from) else {
348 continue;
349 };
350 let right = match to {
351 Some(time) => {
352 let Some(x) = x(time) else {
353 continue;
354 };
355 x
356 }
357 None => coords.rect.right(),
358 };
359 let rect = Rect::from_two_pos(
360 Pos2::new(left, coords.price.price_to_y(high)),
361 Pos2::new(right, coords.price.price_to_y(low)),
362 )
363 .intersect(coords.rect);
364 if !rect.is_positive() {
365 continue;
366 }
367 painter.rect_filled(rect, 0.0, neutral.linear_multiply(0.12));
368 painter.rect_stroke(
369 rect,
370 0.0,
371 Stroke::new(1.0, neutral),
372 egui::StrokeKind::Inside,
373 );
374 (rect, rect.left_top() + Vec2::new(6.0, 13.0), neutral)
375 }
376 };
377 match signal.shape {
378 SignalShape::Marker { .. } => occupied.push(hit.shrink(2.0)),
379 SignalShape::Level { .. } | SignalShape::PriceZone { .. } => {
380 occupied.push(Rect::from_center_size(
381 hit.center(),
382 Vec2::new(hit.width(), 2.0),
383 ));
384 }
385 SignalShape::Zone { .. } => {
386 for [a, b] in [
387 [hit.left_top(), hit.right_top()],
388 [hit.right_top(), hit.right_bottom()],
389 [hit.right_bottom(), hit.left_bottom()],
390 [hit.left_bottom(), hit.left_top()],
391 ] {
392 occupied.push(Rect::from_two_pos(a, b).expand(0.5));
393 }
394 }
395 }
396 visible.push((index, source, signal, hit, label, color, None));
397 }
398 for markers in [true, false] {
400 for (_, _, signal, hit, anchor, color, caption) in visible.iter_mut().rev() {
401 if matches!(signal.shape, SignalShape::Marker { .. }) != markers {
402 continue;
403 }
404 let Some(text) = &signal.label else { continue };
405 let galley = painter.layout_no_wrap(
406 l10n::text(ui.ctx(), text),
407 FontId::proportional(11.0),
408 *color,
409 );
410 let pos = *anchor - Vec2::new(0.0, galley.size().y / 2.0);
411 let bounds = galley.mesh_bounds.translate(pos.to_vec2());
412 let beside_body = if markers
413 && occupied[..price_obstacles]
414 .iter()
415 .any(|obstacle| obstacle.intersects(bounds.expand(2.0)))
416 {
417 (half_width - 5.0).max(0.0)
418 } else {
419 0.0
420 };
421 for offset in [0.0, beside_body] {
422 let pos = pos + Vec2::new(offset, 0.0);
423 let bounds = bounds.translate(Vec2::new(offset, 0.0));
424 let padded = bounds.expand(2.0);
425 if coords.rect.contains_rect(padded)
426 && !occupied.iter().any(|obstacle| obstacle.intersects(padded))
427 {
428 occupied.push(padded);
429 *hit = hit.union(bounds);
430 *caption = Some((pos, galley));
431 break;
432 }
433 }
434 }
435 }
436 let pointer_layer = ui
437 .input(|input| input.pointer.hover_pos())
438 .and_then(|point| ui.ctx().layer_id_at(point));
439 for (index, source, signal, hit, anchor, color, caption) in &visible {
440 if let Some((pos, galley)) = caption {
441 painter.galley(*pos, galley.clone(), *color);
442 }
443 let response = ui.interact(
444 hit.intersect(coords.rect),
445 ui.scope_id().with(("host_signal", *index, &signal.id.0)),
446 Sense::click(),
447 );
448 if response.clicked() {
449 source.source.clicked(&signal.id);
450 }
451 let inside_tooltip = response.is_tooltip_open()
453 && pointer_layer
454 == Some(egui::LayerId::new(
455 egui::Order::Tooltip,
456 egui::Tooltip::next_tooltip_id(ui.ctx(), response.id),
457 ));
458 if response.enabled()
459 && (inside_tooltip || egui::Tooltip::should_show_tooltip(&response, true))
460 {
461 let marker = matches!(signal.shape, SignalShape::Marker { .. });
462 let compact =
463 Rect::from_center_size(*anchor - Vec2::new(9.0, 0.0), Vec2::splat(14.0));
464 let details: Vec<_> = visible
465 .iter()
466 .filter(|(_, _, other, other_hit, ..)| {
467 if marker {
468 other_hit.intersects(compact)
469 } else {
470 std::ptr::eq(*other, *signal)
471 }
472 })
473 .flat_map(|(_, _, other, ..)| {
474 other
475 .label
476 .iter()
477 .chain(other.tooltip.iter())
478 .map(|text| l10n::text(ui.ctx(), text))
479 })
480 .collect();
481 if !details.is_empty() {
482 egui::Tooltip::for_widget(&response).show(|ui| {
483 egui::ScrollArea::vertical()
484 .max_height(320.0)
485 .show(ui, |ui| {
486 for text in &details {
487 ui.label(text);
488 }
489 });
490 });
491 }
492 }
493 }
494 }
495
496 pub(super) fn show_signal_menu(&mut self, ui: &mut egui::Ui, rect: Rect) {
497 let categories: std::collections::BTreeSet<_> = self
498 .signal_sources
499 .iter()
500 .flat_map(|source| &source.signals)
501 .map(|signal| signal.category.clone())
502 .collect();
503 if !categories.is_empty() {
504 ui.scope_builder(
505 egui::UiBuilder::new().max_rect(Rect::from_min_size(
506 rect.right_top() + Vec2::new(-85.0, 8.0),
507 Vec2::new(80.0, 24.0),
508 )),
509 |ui| {
510 ui.menu_button(l10n::text(ui.ctx(), "Signals"), |ui| {
511 for category in categories {
512 let mut visible = !self.hidden_signal_categories.contains(&category);
513 if ui
514 .checkbox(&mut visible, l10n::text(ui.ctx(), &category))
515 .changed()
516 {
517 self.set_signal_category_visible(&category, visible);
518 }
519 }
520 });
521 },
522 );
523 }
524 }
525}
526
527fn valid(shape: &SignalShape) -> bool {
528 match *shape {
529 SignalShape::Marker { price, .. } => price.is_none_or(f64::is_finite),
530 SignalShape::Level { price, .. } => price.is_finite(),
531 SignalShape::PriceZone {
532 from, to, price, ..
533 } => price.is_finite() && to.is_none_or(|to| to >= from),
534 SignalShape::Zone {
535 from,
536 to,
537 low,
538 high,
539 } => low.is_finite() && high.is_finite() && low <= high && to.is_none_or(|to| to >= from),
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::model::SignalId;
547 use crate::services::Events;
548 use crate::widget::test_frames;
549 use std::sync::Mutex;
550
551 #[derive(Default)]
552 struct Host {
553 streams: Mutex<Vec<(String, Timeframe, Events<SignalEvent>)>>,
554 clicks: Mutex<Vec<SignalId>>,
555 }
556 impl Signals for Host {
557 fn subscribe(&self, symbol: &str, timeframe: Timeframe, events: Events<SignalEvent>) {
558 self.streams
559 .lock()
560 .unwrap()
561 .push((symbol.into(), timeframe, events));
562 }
563 fn clicked(&self, id: &SignalId) {
564 self.clicks.lock().unwrap().push(id.clone());
565 }
566 }
567
568 #[test]
569 fn dense_captions_leave_prices_and_signals_clear_without_losing_hover_details() {
570 for size in [Vec2::new(800.0, 600.0), Vec2::new(1440.0, 900.0)] {
571 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
572 for visible in [390, 12] {
573 let ctx = egui::Context::default();
574 let mut chart = test_frames::chart(&[40.0], &[60.0]);
575 chart.set_symbol("A");
576 chart.set_timeframe(Timeframe::Day1);
577 let first = chart.state.shown()[0].clone();
578 let bars: Vec<_> = (0..390)
579 .map(|i| {
580 let mut bar = first.clone();
581 bar.time += chrono::TimeDelta::days(i);
582 bar.open = 45.0;
583 bar.close = 55.0;
584 bar
585 })
586 .collect();
587 chart.set_data(crate::widget::SeriesId::MAIN, bars.clone());
588 let hosts = [Arc::new(Host::default()), Arc::new(Host::default())];
589 chart.set_signal_sources(hosts.iter().map(|host| host.clone() as _).collect());
590 chart.pump_signals(&ctx);
591 for (i, bar) in bars.iter().enumerate() {
592 for (host, name, price) in [
593 (&hosts[0], "First", 82.0),
594 (&hosts[1], "Second", 82.0),
595 (&hosts[0], "Body", 50.0),
596 (&hosts[0], "Border", 72.0),
597 ] {
598 host.streams.lock().unwrap()[0]
599 .2
600 .push(SignalEvent::Upsert(Signal {
601 id: SignalId(format!("{name}-{i}")),
602 shape: SignalShape::Marker {
603 time: bar.time,
604 price: Some(price),
605 direction: Direction::Neutral,
606 },
607 category: "Executions".into(),
608 label: Some(format!("{name} {i}")),
609 tooltip: (name != "Second")
610 .then(|| format!("{name} details {i}: 500 lots")),
611 }));
612 }
613 }
614 for (label, price) in [("1", 96.0), ("2", 97.0)] {
615 hosts[0].streams.lock().unwrap()[0]
616 .2
617 .push(SignalEvent::Upsert(Signal {
618 id: SignalId(label.into()),
619 shape: SignalShape::Marker {
620 time: bars[380].time,
621 price: Some(price),
622 direction: Direction::Neutral,
623 },
624 category: "Executions".into(),
625 label: Some(label.into()),
626 tooltip: None,
627 }));
628 }
629 hosts[0].streams.lock().unwrap()[0]
630 .2
631 .push(SignalEvent::Upsert(Signal {
632 id: SignalId("short-body-caption".into()),
633 shape: SignalShape::Marker {
634 time: bars[382].time,
635 price: Some(50.0),
636 direction: Direction::Neutral,
637 },
638 category: "Executions".into(),
639 label: Some("5".into()),
640 tooltip: None,
641 }));
642 hosts[0].streams.lock().unwrap()[0]
644 .2
645 .push(SignalEvent::Upsert(Signal {
646 id: SignalId("zone".into()),
647 shape: SignalShape::Zone {
648 from: bars[0].time,
649 to: None,
650 low: 72.0,
651 high: 90.0,
652 },
653 category: "Zones".into(),
654 label: Some("Zone caption".into()),
655 tooltip: Some("Zone details".into()),
656 }));
657 chart.pump_signals(&ctx);
658 let rect = Rect::from_min_size(Pos2::ZERO, size);
659 let coords = ChartMapping::new(
660 rect,
661 size.x / visible as f32,
662 390 - visible,
663 389,
664 0.0,
665 crate::scales::PriceMap::new(
666 crate::scales::PriceTransform::default(),
667 crate::scales::PriceRange::new(0.0, 100.0),
668 0.0,
669 size.y,
670 false,
671 ),
672 );
673 let candle_width = chart.config.candle_width;
674 let mut frame = |time, events| {
675 let mut output = ctx.run_ui(
676 egui::RawInput {
677 screen_rect: Some(rect),
678 time: Some(time),
679 events,
680 ..Default::default()
681 },
682 |ui| {
683 chart.set_theme(ui.ctx(), &theme);
684 chart.paint_signals(ui, &coords);
685 },
686 );
687 let shapes = std::mem::take(&mut output.shapes);
688 output.drop_without_applying_deltas();
689 shapes
690 };
691 let shapes = frame(0.0, vec![]);
692 let captions: Vec<_> = shapes
693 .iter()
694 .filter_map(|clipped| match &clipped.shape {
695 egui::Shape::Text(text) => {
696 Some((text.galley.text(), text.visual_bounding_rect()))
697 }
698 _ => None,
699 })
700 .collect();
701 assert!(captions.iter().any(|(text, _)| *text == "2"));
702 assert!(
703 !captions.iter().any(|(text, _)| *text == "1"),
704 "the recent caption has priority"
705 );
706 assert!(
707 captions.len() < visible * 4,
708 "dense captions are suppressed"
709 );
710 assert_eq!(
711 captions.iter().any(|(text, _)| *text == "5"),
712 visible == 12,
713 "zooming in restores a caption beside the candle body"
714 );
715 let markers = shapes
716 .iter()
717 .filter(|clipped| matches!(clipped.shape, egui::Shape::Circle(_)))
718 .count();
719 assert_eq!(markers, visible * 4 + 3, "every compact marker remains");
720 for (i, (name, bounds)) in captions.iter().enumerate() {
721 assert!(rect.contains_rect(*bounds), "clipped {name}");
722 assert!(!name.starts_with("Border"), "obstructed {name}");
723 for (_, other) in &captions[..i] {
724 assert!(
725 !bounds.expand(2.0).intersects(*other),
726 "overlapping captions"
727 );
728 }
729 for index in 390 - visible..390 {
731 let x = coords.idx_to_x(index);
732 let half_body = size.x / visible as f32 * candle_width / 2.0;
733 assert!(!bounds.intersects(Rect::from_min_max(
734 Pos2::new(x - half_body, size.y * 0.4),
735 Pos2::new(x + half_body, size.y * 0.6)
736 )));
737 }
738 for y in [size.y * 0.1, size.y * 0.28] {
739 assert!(!(bounds.top() <= y && bounds.bottom() >= y));
740 }
741 }
742 let point = Pos2::new(coords.idx_to_x(388), size.y * 0.18);
743 frame(0.1, vec![egui::Event::PointerMoved(point)]);
744 frame(1.0, vec![]);
745 let hovered = frame(1.1, vec![]);
746 let details = hovered
747 .iter()
748 .filter_map(|clipped| match &clipped.shape {
749 egui::Shape::Text(text) => Some(text.galley.text()),
750 _ => None,
751 })
752 .collect::<Vec<_>>()
753 .join("\n");
754 assert!(details.contains("First details 388: 500 lots"));
755 if size.x == 800.0 && visible == 390 {
756 let inside = hovered
757 .iter()
758 .find_map(|clipped| match &clipped.shape {
759 egui::Shape::Text(text) if text.galley.text() == "Zone details" => {
760 Some(text.visual_bounding_rect().center())
761 }
762 _ => None,
763 })
764 .expect("tooltip entry");
765 frame(1.2, vec![egui::Event::PointerMoved(inside)]);
766 frame(
767 1.3,
768 vec![egui::Event::MouseWheel {
769 unit: egui::MouseWheelUnit::Point,
770 delta: Vec2::new(0.0, -1000.0),
771 phase: egui::TouchPhase::Move,
772 source: egui::MouseWheelSource::Unknown,
773 modifiers: Default::default(),
774 }],
775 );
776 let scrolled = frame(1.4, vec![]);
777 assert!(
778 scrolled.iter().any(|clipped| matches!(&clipped.shape,
779 egui::Shape::Text(text) if text.galley.text() == "Second 388")),
780 "moving into the tooltip keeps its events available to scroll: inside={inside:?}, texts={:?}",
781 scrolled
782 .iter()
783 .filter_map(|clipped| match &clipped.shape {
784 egui::Shape::Text(text) => Some(text.galley.text()),
785 _ => None,
786 })
787 .collect::<Vec<_>>()
788 );
789 } else {
790 assert!(
791 details.contains("Second 388"),
792 "a caption without a tooltip remains discoverable"
793 );
794 }
795 }
796 }
797 }
798 }
799
800 #[test]
801 fn host_signals_update_render_and_clear_with_the_subscription() {
802 let ctx = egui::Context::default();
803 let mut chart = test_frames::chart(&[95.0], &[105.0]);
804 chart.config_mut().show_volume = false;
805 chart.set_main_series_visible(false);
806 chart.price_scale_mut().set_range(94.0, 106.0);
807 chart.set_timeframe(Timeframe::Day1);
808 chart.set_symbol("A");
809 let host = Arc::new(Host::default());
810 chart.set_signal_source(Some(host.clone()));
811 test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
812 let (symbol, timeframe, stream) = host.streams.lock().unwrap().pop().unwrap();
813 assert_eq!((symbol.as_str(), timeframe), ("A", Timeframe::Day1));
814 let time = chart.state.shown()[70].time;
815 let signal = |shape, name: &str| Signal {
816 id: SignalId(name.into()),
817 shape,
818 category: name.into(),
819 label: Some(name.into()),
820 tooltip: Some("Host details".into()),
821 };
822 let marker = signal(
823 SignalShape::Marker {
824 time,
825 price: Some(100.0),
826 direction: Direction::Up,
827 },
828 "Marker",
829 );
830 for item in [
831 marker.clone(),
832 signal(
833 SignalShape::Level {
834 price: 104.0,
835 from: None,
836 },
837 "Level",
838 ),
839 signal(
840 SignalShape::Zone {
841 from: time,
842 to: None,
843 low: 96.0,
844 high: 98.0,
845 },
846 "Zone",
847 ),
848 ] {
849 stream.push(SignalEvent::Upsert(item));
850 }
851 let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
852 let label_at = |shapes: &[egui::Shape], label: &str| {
853 shapes.iter().find_map(|shape| match shape {
854 egui::Shape::Text(text) if text.galley.text() == label => Some(text.pos),
855 _ => None,
856 })
857 };
858 for label in ["Marker", "Level", "Zone"] {
859 assert!(label_at(&shapes, label).is_some(), "missing {label}");
860 }
861 let expected_x = chart.index_to_x(70.0).unwrap();
862 assert!((label_at(&shapes, "Marker").unwrap().x - expected_x - 9.0).abs() < 0.01);
863 let point = Pos2::new(expected_x, chart.last_price_map.price_to_y(100.0));
864 test_frames::frame(
865 &ctx,
866 &mut chart,
867 0.2,
868 vec![egui::Event::PointerMoved(point)],
869 );
870 for pressed in [true, false] {
871 test_frames::frame(
872 &ctx,
873 &mut chart,
874 0.3 + f64::from(!pressed) * 0.1,
875 vec![egui::Event::PointerButton {
876 pos: point,
877 button: egui::PointerButton::Primary,
878 pressed,
879 modifiers: Default::default(),
880 }],
881 );
882 }
883 assert_eq!(
884 host.clicks.lock().unwrap().as_slice(),
885 std::slice::from_ref(&marker.id)
886 );
887 chart.set_signal_category_visible("Marker", false);
888 let shapes = test_frames::shapes(&ctx, &mut chart, 0.5, vec![]);
889 assert!(label_at(&shapes, "Marker").is_none());
890 chart.set_signal_category_visible("Marker", true);
891 let mut replacement = marker.clone();
892 replacement.label = Some("Updated".into());
893 stream.push(SignalEvent::Upsert(replacement));
894 let shapes = test_frames::shapes(&ctx, &mut chart, 0.6, vec![]);
895 assert!(label_at(&shapes, "Marker").is_none());
896 assert!(label_at(&shapes, "Updated").is_some());
897 let future = signal(
898 SignalShape::Marker {
899 time: chart.state.shown().last().unwrap().time + chrono::TimeDelta::days(5),
900 price: Some(100.0),
901 direction: Direction::Neutral,
902 },
903 "Future",
904 );
905 chart.set_visible_logical_range(crate::model::LogicalRange::new(40.0, 120.0));
906 stream.push(SignalEvent::Upsert(future.clone()));
907 let shapes = test_frames::shapes(&ctx, &mut chart, 0.61, vec![]);
908 assert!(
909 (label_at(&shapes, "Future")
910 .expect("priced marker in future whitespace")
911 .x
912 - chart.index_to_x(104.0).unwrap()
913 - 9.0)
914 .abs()
915 < 0.01
916 );
917 stream.push(SignalEvent::Remove(future.id));
918 let mut bars = chart.state.shown().to_vec();
919 let first = bars[0].clone();
920 for days in 1..=10 {
921 let mut older = first.clone();
922 older.time -= chrono::TimeDelta::days(days);
923 bars.push(older);
924 }
925 chart.set_data(crate::widget::SeriesId::MAIN, bars);
926 let shapes = test_frames::shapes(&ctx, &mut chart, 0.7, vec![]);
927 assert!(
928 (label_at(&shapes, "Updated").unwrap().x - chart.index_to_x(80.0).unwrap() - 9.0).abs()
929 < 0.01
930 );
931 stream.push(SignalEvent::Remove(marker.id.clone()));
932 test_frames::frame(&ctx, &mut chart, 0.8, vec![]);
933 assert_eq!(chart.signal_sources[0].signals.len(), 2);
934 stream.push(SignalEvent::Clear);
935 test_frames::frame(&ctx, &mut chart, 0.9, vec![]);
936 assert!(chart.signal_sources[0].signals.is_empty());
937 chart.set_timeframe(Timeframe::Hour1);
938 assert!(!stream.is_active());
939 test_frames::frame(&ctx, &mut chart, 1.0, vec![]);
940 let (_, interval, new) = host.streams.lock().unwrap().pop().unwrap();
941 assert_eq!(interval, Timeframe::Hour1);
942 stream.push(SignalEvent::Upsert(marker.clone()));
943 new.push(SignalEvent::Upsert(marker.clone()));
944 test_frames::frame(&ctx, &mut chart, 1.1, vec![]);
945 assert_eq!(chart.signal_sources[0].signals.len(), 1);
946 chart.set_symbol("B");
947 assert!(!new.is_active());
948 test_frames::frame(&ctx, &mut chart, 1.2, vec![]);
949 let (symbol, _, stream) = host.streams.lock().unwrap().pop().unwrap();
950 assert_eq!(symbol, "B");
951 assert!(chart.signal_sources[0].signals.is_empty());
952 for _ in 0..10_001 {
953 stream.push(SignalEvent::Upsert(marker.clone()));
954 }
955 test_frames::frame(&ctx, &mut chart, 1.3, vec![]);
956 assert!(!stream.is_active());
957 assert!(chart.signal_sources[0].signals.is_empty());
958 assert_eq!(
959 host.streams.lock().unwrap().len(),
960 1,
961 "lag starts a new stream"
962 );
963 chart.set_signal_source(None);
964 assert!(chart.signal_sources.is_empty());
965 }
966
967 #[test]
968 fn a_clear_from_one_source_leaves_the_signals_of_the_others() {
969 let ctx = egui::Context::default();
970 let mut chart = test_frames::chart(&[95.0], &[105.0]);
971 chart.config_mut().show_volume = false;
972 chart.set_main_series_visible(false);
973 chart.price_scale_mut().set_range(94.0, 106.0);
974 chart.set_timeframe(Timeframe::Day1);
975 chart.set_symbol("A");
976 let hosts = [Arc::new(Host::default()), Arc::new(Host::default())];
977 chart.set_signal_sources(hosts.iter().map(|host| host.clone() as _).collect());
978 test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
979 for (i, (host, label)) in hosts.iter().zip(["From A", "From B"]).enumerate() {
981 host.streams.lock().unwrap()[0]
982 .2
983 .push(SignalEvent::Upsert(Signal {
984 id: SignalId("level".into()),
985 shape: SignalShape::Level {
986 price: 100.0 + i as f64 * 2.0,
987 from: None,
988 },
989 category: "Levels".into(),
990 label: Some(label.into()),
991 tooltip: None,
992 }));
993 }
994 let labels = |chart: &mut Chart, time| {
995 test_frames::shapes(&ctx, chart, time, vec![])
996 .into_iter()
997 .filter_map(|shape| match shape {
998 egui::Shape::Text(text) if text.galley.text().starts_with("From") => {
999 Some(text.galley.text().to_owned())
1000 }
1001 _ => None,
1002 })
1003 .collect::<Vec<_>>()
1004 };
1005 assert_eq!(labels(&mut chart, 0.1), ["From A", "From B"]);
1006 hosts[0].streams.lock().unwrap()[0]
1007 .2
1008 .push(SignalEvent::Clear);
1009 assert_eq!(labels(&mut chart, 0.2), ["From B"]);
1010 }
1011 #[test]
1012 fn one_price_zones_render_with_their_side_even_with_no_duration() {
1013 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1014 for (direction, color) in [
1015 (Direction::Up, theme.chart.candle_up),
1016 (Direction::Down, theme.chart.candle_down),
1017 ] {
1018 let ctx = egui::Context::default();
1019 let mut chart = test_frames::chart(&[95.0], &[105.0]);
1020 chart.set_theme_override(Some(theme.clone()));
1021 chart.set_symbol("A");
1022 let host = Arc::new(Host::default());
1023 chart.set_signal_source(Some(host.clone()));
1024 test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
1025 let stream = host.streams.lock().unwrap()[0].2.clone();
1026 let time = chart.state.shown()[70].time;
1027 let zone = Signal {
1028 id: SignalId("price-zone".into()),
1029 shape: SignalShape::PriceZone {
1030 from: time,
1031 to: Some(time),
1032 price: 100.0,
1033 direction,
1034 },
1035 category: "Depth".into(),
1036 label: Some("Held level".into()),
1037 tooltip: Some("Absorbed 10".into()),
1038 };
1039 stream.push(SignalEvent::Upsert(zone.clone()));
1040 let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
1041 let x = chart.index_to_x(70.0).unwrap();
1042 let y = chart.last_price_map.price_to_y(100.0);
1043 let zone_visible = |shapes: &[egui::Shape]| {
1044 shapes.iter().any(|shape| matches!(shape,
1045 egui::Shape::LineSegment { points, stroke }
1046 if (points[0].x - x).abs() < 0.01 && (points[0].y - y).abs() < 0.01
1047 && (points[1].x - points[0].x - 1.0).abs() < 0.01 && stroke.color == color && stroke.width == 2.0))
1048 };
1049 assert!(zone_visible(&shapes));
1050 stream.push(SignalEvent::Upsert(Signal {
1051 id: SignalId("wall".into()),
1052 shape: SignalShape::Marker {
1053 time,
1054 price: Some(100.0),
1055 direction,
1056 },
1057 category: "Depth".into(),
1058 label: None,
1059 tooltip: Some("Wall executions".into()),
1060 }));
1061 stream.push(SignalEvent::Upsert(zone));
1063 test_frames::frame(
1064 &ctx,
1065 &mut chart,
1066 0.2,
1067 vec![egui::Event::PointerMoved(Pos2::new(x, y))],
1068 );
1069 test_frames::frame(&ctx, &mut chart, 1.0, vec![]);
1070 let hovered = test_frames::shapes(&ctx, &mut chart, 1.1, vec![]);
1071 assert!(hovered.iter().any(|shape| matches!(shape, egui::Shape::Path(marker)
1072 if marker.points.len() == 3 && marker.fill == color
1073 && marker.stroke == egui::epaint::PathStroke::from(Stroke::new(1.0, theme.chart.bg)))));
1074 assert!(
1075 hovered
1076 .iter()
1077 .any(|shape| matches!(shape, egui::Shape::Text(text)
1078 if text.galley.text() == "Wall executions"))
1079 );
1080 chart.set_signal_category_visible("Depth", false);
1081 let hidden = test_frames::shapes(&ctx, &mut chart, 1.2, vec![]);
1082 assert!(!zone_visible(&hidden));
1083 assert!(!valid(&SignalShape::PriceZone {
1084 from: time,
1085 to: Some(time - chrono::TimeDelta::seconds(1)),
1086 price: 100.0,
1087 direction
1088 }));
1089 assert!(!valid(&SignalShape::PriceZone {
1090 from: time,
1091 to: None,
1092 price: f64::NAN,
1093 direction
1094 }));
1095 }
1096 }
1097 }
1098}