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