1mod currency;
4mod labels;
5mod study_scale;
6
7use crate::model::PriceLine;
8use crate::painting::dashed_line::draw_dashed_line;
9use crate::scales::PriceMap;
10use crate::theme::{ThemeExt, contrast};
11pub use currency::render_axis_currency;
12use egui::{Align2, Color32, CornerRadius, FontId, Painter, Pos2, Rect, Stroke, Vec2};
13pub(super) use labels::semibold;
14pub(crate) use labels::{price_marks, render_time_grid, tick_decimals, time_axis_marks};
15pub use labels::{render_price_labels, render_price_labels_at_pos, render_time_labels};
16pub(crate) use study_scale::{render_study_axis, study_mapping};
17
18pub(crate) const PRICE_LABEL_GAP: f32 = 8.0;
20
21const TAG_PADDING: Vec2 = Vec2::new(6.0, 1.5);
23
24pub(crate) fn axis_font(ctx: &egui::Context) -> FontId {
26 FontId::proportional(ctx.active_theme().text.label)
27}
28
29pub(crate) fn paint_axis_tag(
34 painter: &Painter,
35 anchor: Pos2,
36 align: Align2,
37 max_x: f32,
38 text: String,
39 text_color: Color32,
40 fill: Color32,
41) -> Rect {
42 let max_x = if align == Align2::LEFT_CENTER {
43 max_x - 6.0
44 } else {
45 max_x
46 };
47 let theme = painter.ctx().active_theme();
48 let font = axis_font(painter.ctx());
49 let galley = painter.layout_no_wrap(text, font, text_color);
50 let mut tag = align
51 .anchor_size(anchor, galley.size())
52 .expand2(TAG_PADDING);
53 tag = tag.translate(Vec2::new((max_x - tag.max.x).min(0.0), 0.0));
54 tag = tag.translate(Vec2::new(
55 (painter.clip_rect().left() - tag.min.x).max(0.0),
56 0.0,
57 ));
58 let text_pos = tag.min + TAG_PADDING;
59 painter.rect_filled(
60 tag,
61 CornerRadius {
62 nw: 0,
63 sw: 0,
64 ne: theme.radius.xs as u8,
65 se: theme.radius.xs as u8,
66 },
67 fill,
68 );
69 painter.galley(text_pos, galley, text_color);
70 tag
71}
72
73pub(crate) fn named_label(show_name: bool, name: &str, label: String) -> String {
76 if show_name && !name.is_empty() {
77 format!("{name} {label}")
78 } else {
79 label
80 }
81}
82
83fn price_tag_anchor(plot: Rect, axis_right: f32, y: f32) -> (Pos2, Align2) {
85 if axis_right <= plot.left() {
86 (
87 Pos2::new(plot.left() - 5.0 - TAG_PADDING.x, y),
88 Align2::RIGHT_CENTER,
89 )
90 } else {
91 (
92 Pos2::new(plot.right() + 5.0 + TAG_PADDING.x, y),
93 Align2::LEFT_CENTER,
94 )
95 }
96}
97
98pub(crate) fn spread_axis_ys(painter: &Painter, ys: &[f32]) -> Vec<f32> {
102 let height = painter
103 .layout_no_wrap("0".into(), axis_font(painter.ctx()), Color32::WHITE)
104 .size()
105 .y
106 + 2.0 * TAG_PADDING.y;
107 let mut stacks: Vec<(usize, f32)> = Vec::new();
109 for &y in ys {
110 let (mut count, mut first) = (1, y);
111 while let Some(&(above_count, above_first)) = stacks.last() {
114 if first >= above_first + above_count as f32 * height {
115 break;
116 }
117 let (a, b) = (above_count as f32, count as f32);
118 first = (a * above_first + b * first - a * b * height) / (a + b);
119 count += above_count;
120 stacks.pop();
121 }
122 stacks.push((count, first));
123 }
124 stacks
125 .into_iter()
126 .flat_map(|(count, first)| (0..count).map(move |at| first + at as f32 * height))
127 .collect()
128}
129
130pub(crate) fn render_axis_value(
133 painter: &Painter,
134 plot: Rect,
135 axis_right: f32,
136 y: f32,
137 text: String,
138 colors: (Color32, Color32),
139 occupied: &[Option<Rect>],
140 allow_overlap: bool,
141) -> Option<Rect> {
142 let (text_color, fill) = colors;
143 let height = painter
144 .layout_no_wrap(text.clone(), axis_font(painter.ctx()), Color32::WHITE)
145 .size()
146 .y
147 + 2.0 * TAG_PADDING.y;
148 if !y.is_finite() || height > plot.height() {
149 return None;
150 }
151 let clamp = |y: f32| y.clamp(plot.top() + height / 2.0, plot.bottom() - height / 2.0);
152 let free = |y: f32| {
153 allow_overlap
154 || occupied.iter().flatten().all(|rect| {
155 y + height / 2.0 <= rect.top() + 0.001 || y - height / 2.0 >= rect.bottom() - 0.001
156 })
157 };
158 let centre = std::iter::once(clamp(y))
159 .chain(occupied.iter().flatten().flat_map(|rect| {
160 [
161 clamp(rect.top() - height / 2.0),
162 clamp(rect.bottom() + height / 2.0),
163 ]
164 }))
165 .filter(|y| free(*y))
166 .min_by(|a, b| (a - y).abs().total_cmp(&(b - y).abs()))?;
167 let (anchor, align) = price_tag_anchor(plot, axis_right, centre);
168 Some(paint_axis_tag(
169 painter, anchor, align, axis_right, text, text_color, fill,
170 ))
171}
172
173pub(crate) fn render_study_tags(
175 painter: &Painter,
176 plot: Rect,
177 map: &PriceMap,
178 study: &dyn crate::studies::Indicator,
179 axis_right: f32,
180 occupied: &mut Vec<Option<Rect>>,
181 formatter: Option<&dyn crate::scales::PriceFormatter>,
182 axis: &crate::config::PriceAxisOptions,
183) {
184 if !axis.show_study_values
185 || !study.is_visible()
186 || study.scale() == crate::studies::StudyScale::None
187 {
188 return;
189 }
190 let values = study.values();
191 let Some(bar) = values.len().checked_sub(1) else {
192 return;
193 };
194 let transform = map.transform();
195 let mut tags = Vec::new();
196 for line in 0..study.line_count() {
197 let plot_style = study.plot_style(line);
198 let Some(value) = study.plot_value(line, bar) else {
199 continue;
200 };
201 if !plot_style.visible
202 || !study.plot_tagged(line)
203 || matches!(
204 plot_style.plot_type,
205 crate::studies::PlotType::BarColor | crate::studies::PlotType::BackgroundColor
206 )
207 || plot_style.width <= 0.0
208 || !map.range().contains(transform.to_scale(value))
209 {
210 continue;
211 }
212 let label = if study.value_format() == crate::scales::StudyFormat::Price {
213 map.label(
214 transform.to_scale(value),
215 study
216 .precision()
217 .unwrap_or_else(|| tag_decimals(map, map.price_to_y(value))),
218 formatter,
219 )
220 } else {
221 study.format_plot_value(line, value)
222 };
223 let color = axis.study_value_color.unwrap_or(plot_style.color);
224 tags.push((
225 map.price_to_y(value),
226 named_label(axis.show_study_names, study.name(), label),
227 (tag_text_color(color), color),
228 ));
229 }
230 tags.sort_by(|a, b| a.0.total_cmp(&b.0));
232 let mut ys: Vec<_> = tags.iter().map(|tag| tag.0).collect();
233 if !axis.allow_label_overlap {
234 ys = spread_axis_ys(painter, &ys);
235 }
236 for ((_, text, colors), y) in tags.into_iter().zip(ys) {
237 occupied.push(render_axis_value(
238 painter,
239 plot,
240 axis_right,
241 y,
242 text,
243 colors,
244 occupied,
245 axis.allow_label_overlap,
246 ));
247 }
248}
249
250pub fn render_price_line(
255 painter: &Painter,
256 price_rect: Rect,
257 price_map: &PriceMap,
258 line: &PriceLine,
259 start_x: f32,
260 show_axis: bool,
261 axis_right: f32,
262) -> Option<Rect> {
263 let x0 = start_x.max(price_rect.min.x);
264 if x0 >= price_rect.max.x {
265 return None;
266 }
267 let transform = price_map.transform();
268 if !price_map.range().contains(transform.to_scale(line.price)) {
270 return None;
271 }
272 let y = price_map.price_to_y(line.price);
273
274 let start = Pos2::new(x0, y);
275 let end = Pos2::new(price_rect.max.x, y);
276 let stroke = Stroke::new(line.width, line.color);
277 if line.width > 0.0 {
278 if let Some((dash, gap)) = line.style.hairline_pattern() {
279 draw_dashed_line(painter, start, end, stroke, dash, gap);
280 } else {
281 painter.line_segment([start, end], stroke);
282 }
283 }
284 if let Some(title) = &line.title {
285 painter.with_clip_rect(price_rect).text(
286 start,
287 Align2::LEFT_BOTTOM,
288 title,
289 axis_font(painter.ctx()),
290 line.title_color.unwrap_or(line.color),
291 );
292 }
293
294 if !show_axis || !line.axis_label {
295 return None;
296 }
297 let label = price_map.label(
298 transform.to_scale(line.price),
299 tag_decimals(price_map, y),
300 None,
301 );
302 let (text_color, fill) = axis_label_colors(line);
303 let (anchor, align) = price_tag_anchor(price_rect, axis_right, y);
304 Some(paint_axis_tag(
305 painter, anchor, align, axis_right, label, text_color, fill,
306 ))
307}
308
309pub(crate) fn axis_label_colors(line: &PriceLine) -> (Color32, Color32) {
311 let fill = line.axis_label_color.unwrap_or(line.color);
312 (
313 line.axis_label_text_color
314 .unwrap_or_else(|| tag_text_color(fill)),
315 fill,
316 )
317}
318
319pub(crate) fn tag_decimals(map: &PriceMap, y: f32) -> usize {
322 map.price_precision()
323 .unwrap_or_else(|| tick_decimals(map, y).max(2))
324}
325
326pub(crate) fn tag_text_color(fill: Color32) -> Color32 {
328 if contrast(Color32::WHITE, fill) >= 2.0 {
329 Color32::WHITE
330 } else {
331 Color32::BLACK
332 }
333}
334
335pub(crate) fn paint_crosshair_tag(
337 painter: &Painter,
338 anchor: Pos2,
339 align: Align2,
340 bounds: std::ops::RangeInclusive<f32>,
341 text: String,
342 colors: (Color32, Color32),
343 font: FontId,
344 height: f32,
345 covered: Option<Rect>,
346) -> Rect {
347 let (text_color, fill) = colors;
348 let padding = 8.0;
349 let available = (bounds.end() - bounds.start()).max(0.0);
350 let mut job = egui::text::LayoutJob::simple_singleline(text, font, text_color);
351 job.wrap = egui::text::TextWrapping::truncate_at_width((available - padding).max(0.0));
352 let galley = painter.layout_job(job);
353 let size = Vec2::new(
354 (galley.size().x + padding).min(available),
355 height.max(galley.size().y),
356 );
357 let mut tag = align.anchor_size(anchor, size);
358 tag = tag.translate(Vec2::new((bounds.end() - tag.right()).min(0.0), 0.0));
359 tag = tag.translate(Vec2::new((bounds.start() - tag.left()).max(0.0), 0.0));
360 tag = tag.translate(Vec2::new(
361 0.0,
362 (painter.clip_rect().bottom() - tag.bottom()).min(0.0),
363 ));
364 tag = tag.translate(Vec2::new(
365 0.0,
366 (painter.clip_rect().top() - tag.top()).max(0.0),
367 ));
368 if let Some(covered) = covered.filter(|covered| covered.intersects(tag)) {
369 let cover = covered.intersect(Rect::from_x_y_ranges(
370 *bounds.start()..=*bounds.end(),
371 painter.clip_rect().y_range(),
372 ));
373 painter.rect_filled(cover, 0.0, painter.ctx().active_theme().chart.bg);
374 }
375 tag.min.x = tag.min.x.max(*bounds.start());
376 tag.max.x = tag.max.x.min(*bounds.end());
377 let painter = painter.with_clip_rect(tag.intersect(painter.clip_rect()));
378 painter.rect_filled(tag, 1.0, fill);
379 let text_pos = Pos2::new(tag.left() + 4.0, tag.center().y - galley.size().y / 2.0);
380 painter.galley(text_pos, galley, text_color);
381 tag
382}
383
384pub fn price_axis_width(
388 painter: &Painter,
389 price_map: &PriceMap,
390 tag_labels: &[&str],
391 formatter: Option<&dyn crate::scales::PriceFormatter>,
392) -> f32 {
393 price_marks(price_map, formatter)
394 .into_iter()
395 .map(|mark| mark.label)
396 .chain(tag_labels.iter().map(|label| (*label).to_owned()))
397 .map(|label| {
398 painter
399 .layout_no_wrap(label, axis_font(painter.ctx()), Color32::WHITE)
400 .size()
401 .x
402 })
403 .fold(0.0, f32::max)
404 + 5.0
405 + TAG_PADDING.x * 2.0
406 + 6.0
407}
408
409pub fn render_quote_tags(
417 painter: &Painter,
418 price_rect: Rect,
419 price_map: &PriceMap,
420 close: &PriceLine,
421 session: Option<(&crate::model::quote::SessionQuote, Color32)>,
422 volume: Option<(f64, Color32)>,
423 axis_right: f32,
424) -> [Option<Rect>; 3] {
425 render_quote_tags_with_countdown(
426 painter, price_rect, price_map, close, session, volume, axis_right, None, None, 0.0, None,
427 None, false, "",
428 )
429}
430
431pub(crate) fn render_quote_tags_with_countdown(
434 painter: &Painter,
435 price_rect: Rect,
436 price_map: &PriceMap,
437 close: &PriceLine,
438 session: Option<(&crate::model::quote::SessionQuote, Color32)>,
439 volume: Option<(f64, Color32)>,
440 axis_right: f32,
441 countdown: Option<&str>,
442 volume_precision: Option<u8>,
443 reserved_top: f32,
444 formatter: Option<&dyn crate::scales::PriceFormatter>,
445 volume_formatter: Option<&dyn crate::scales::PriceFormatter>,
446 allow_overlap: bool,
447 close_name: &str,
448) -> [Option<Rect>; 3] {
449 use crate::scales::{PriceFormatter, VolumeFormatter};
450 let mut tags = [None; 3];
451 let transform = price_map.transform();
452 let height = painter
453 .layout_no_wrap("0".into(), axis_font(painter.ctx()), Color32::WHITE)
454 .size()
455 .y
456 + TAG_PADDING.y * 2.0;
457 let visible = |price| price_map.range().contains(transform.to_scale(price));
458 let close_y = price_map.price_to_y(close.price);
459 let mut rows = Vec::with_capacity(3);
460 let mut line = close.clone();
461 line.axis_label = false;
462 render_price_line(
463 painter,
464 price_rect,
465 price_map,
466 &line,
467 price_rect.left(),
468 false,
469 axis_right,
470 );
471 if visible(close.price) && close.axis_label {
472 rows.push((
473 0,
474 close_y,
475 {
476 let price = named_label(
477 true,
478 close_name,
479 price_map.label(
480 transform.to_scale(close.price),
481 tag_decimals(price_map, close_y),
482 formatter,
483 ),
484 );
485 countdown.map_or_else(|| price.clone(), |time| format!("{price}\n{time}"))
486 },
487 close.color,
488 ));
489 }
490 if let Some((quote, color)) = session.filter(|(quote, _)| visible(quote.price)) {
491 let line = PriceLine::new(quote.price, color)
492 .with_style(crate::model::LineStyle::Dotted)
493 .with_axis_label(false)
494 .with_width(0.7);
495 render_price_line(
496 painter,
497 price_rect,
498 price_map,
499 &line,
500 price_rect.left(),
501 false,
502 axis_right,
503 );
504 let mut y = price_map.price_to_y(quote.price);
505 let separation = height
506 + if countdown.is_some() {
507 (height - TAG_PADDING.y * 2.0) / 2.0
508 } else {
509 0.0
510 };
511 if !allow_overlap && !rows.is_empty() && (y - close_y).abs() < separation {
512 y = close_y - separation;
513 }
514 let label = price_map.label(
515 transform.to_scale(quote.price),
516 tag_decimals(price_map, price_map.price_to_y(quote.price)),
517 formatter,
518 );
519 rows.push((1, y, format!("{} {label}", quote.qualifier), color));
520 }
521 if let Some((value, color)) = volume.filter(|(value, _)| value.is_finite() && *value >= 0.0) {
522 rows.push((
523 2,
524 price_rect.bottom() - height / 2.0,
525 volume_formatter.map_or_else(
526 || {
527 VolumeFormatter {
528 precision: usize::from(volume_precision.unwrap_or(2)),
529 }
530 .format(value)
531 },
532 |formatter| formatter.format(value),
533 ),
534 color,
535 ));
536 }
537 let row_height = |text: &str| {
538 painter
539 .layout_no_wrap(text.to_owned(), axis_font(painter.ctx()), Color32::WHITE)
540 .size()
541 .y
542 + TAG_PADDING.y * 2.0
543 };
544 if !allow_overlap {
545 while rows.iter().map(|row| row_height(&row.2)).sum::<f32>()
546 > price_rect.height() - reserved_top
547 {
548 if rows.pop().is_none() {
549 break;
550 }
551 }
552 rows.sort_by(|a, b| a.1.total_cmp(&b.1));
553 let mut next = price_rect.top() + reserved_top;
554 for row in &mut rows {
555 let height = row_height(&row.2);
556 row.1 = row.1.max(next + height / 2.0);
557 next = row.1 + height / 2.0;
558 }
559 next = price_rect.bottom();
560 for row in rows.iter_mut().rev() {
561 let height = row_height(&row.2);
562 row.1 = row.1.min(next - height / 2.0);
563 next = row.1 - height / 2.0;
564 }
565 } else {
566 rows.retain(|row| row_height(&row.2) <= price_rect.height() - reserved_top);
567 for row in &mut rows {
568 let height = row_height(&row.2);
569 row.1 = row.1.clamp(
570 price_rect.top() + reserved_top + height / 2.0,
571 price_rect.bottom() - height / 2.0,
572 );
573 }
574 }
575 rows.sort_by_key(|row| row.0);
577 for (slot, y, text, fill) in rows {
578 let (anchor, align) = price_tag_anchor(price_rect, axis_right, y);
579 tags[slot] = Some(paint_axis_tag(
580 painter,
581 anchor,
582 align,
583 axis_right,
584 text,
585 tag_text_color(fill),
586 fill,
587 ));
588 }
589 tags
590}
591
592#[cfg(feature = "icons")]
597#[cfg_attr(docsrs, doc(cfg(feature = "icons")))]
598pub fn crosshair_price_action(
599 ui: &mut egui::Ui,
600 tag: Rect,
601 price: f64,
602 foreground: Color32,
603) -> Option<f64> {
604 crosshair_price_action_with_colors(
605 ui,
606 tag,
607 price,
608 (foreground, ui.ctx().active_theme().chart.crosshair_label_bg),
609 )
610}
611
612pub(crate) fn crosshair_price_button_rect(tag: Rect) -> Rect {
614 Rect::from_center_size(
615 tag.left_center() - Vec2::new(13.0, 0.0),
616 Vec2::new(26.0, 20.0),
617 )
618}
619
620#[cfg(feature = "icons")]
622pub(crate) fn crosshair_price_action_with_colors(
623 ui: &mut egui::Ui,
624 tag: Rect,
625 price: f64,
626 colors: (Color32, Color32),
627) -> Option<f64> {
628 let (foreground, fill) = colors;
629 let button = crosshair_price_button_rect(tag);
630 let response = ui.interact(
631 button,
632 ui.scope_id().with("crosshair_price_action"),
633 egui::Sense::click(),
634 );
635 ui.painter().rect_filled(button, 1.0, fill);
636 if response.hovered() {
637 ui.painter()
638 .rect_filled(button, 1.0, foreground.gamma_multiply(0.18));
639 }
640 let center = button.center();
641 let stroke = Stroke::new(1.0, foreground);
642 ui.painter().circle_stroke(center, 6.0, stroke);
643 for offset in [Vec2::new(3.0, 0.0), Vec2::new(0.0, 3.0)] {
644 ui.painter()
645 .line_segment([center - offset, center + offset], stroke);
646 }
647 let needs_info = response.clicked()
648 || response.double_clicked()
649 || response.triple_clicked()
650 || response.gained_focus()
651 || response.changed()
652 || ui
653 .ctx()
654 .accesskit_node_builder(response.id, |_| ())
655 .is_some();
656 #[cfg(debug_assertions)]
657 let needs_info = needs_info || ui.ctx().global_style().debug.show_interactive_widgets;
658 if needs_info {
659 let label = crate::localization::lookup(ui.ctx(), "Add at price");
661 response.widget_info(|| {
662 egui::WidgetInfo::labeled(
663 egui::Role::Button,
664 true,
665 label.as_deref().unwrap_or("Add at price"),
666 )
667 });
668 }
669 response
670 .on_hover_ui(|ui| {
671 ui.label(crate::localization::text(ui.ctx(), "Add at price"));
672 })
673 .clicked()
674 .then_some(price)
675}
676
677#[cfg(feature = "icons")]
680#[cfg_attr(docsrs, doc(cfg(feature = "icons")))]
681pub fn axis_settings_button(ui: &mut egui::Ui, corner: Rect) -> egui::Response {
682 let target = Rect::from_center_size(corner.center(), Vec2::splat(24.0)).intersect(corner);
683 let response = ui.interact(
684 target,
685 ui.scope_id().with("axis_settings"),
686 egui::Sense::click(),
687 );
688 crate::icons::icons::SETTINGS
689 .as_image_tinted(
690 Vec2::splat(16.0),
691 ui.ctx().active_theme().chart.axis_text_secondary,
692 )
693 .paint_at(
694 ui,
695 Rect::from_center_size(target.center(), Vec2::splat(16.0)),
696 );
697 let needs_info = response.clicked()
698 || response.double_clicked()
699 || response.triple_clicked()
700 || response.gained_focus()
701 || response.changed()
702 || ui
703 .ctx()
704 .accesskit_node_builder(response.id, |_| ())
705 .is_some();
706 #[cfg(debug_assertions)]
707 let needs_info = needs_info || ui.ctx().global_style().debug.show_interactive_widgets;
708 if needs_info {
709 let label = crate::localization::lookup(ui.ctx(), "Scale settings");
711 response.widget_info(|| {
712 egui::WidgetInfo::labeled(
713 egui::Role::Button,
714 true,
715 label.as_deref().unwrap_or("Scale settings"),
716 )
717 });
718 }
719 response.on_hover_ui(|ui| {
720 ui.label(crate::localization::text(ui.ctx(), "Scale settings"));
721 })
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use crate::scales::{PriceRange, PriceScaleMode, PriceTransform};
728 use egui::{Context, RawInput, Shape};
729
730 #[test]
731 fn price_line_follows_the_scale() {
732 use PriceScaleMode::{Logarithmic, Normal, Percentage};
733
734 let cases = [
736 (
737 "logarithmic",
738 Logarithmic,
739 0.0,
740 (10_f64.ln(), 1000_f64.ln()),
741 100.0,
742 true,
743 Some((50.0, Some("100.00"))),
744 ),
745 (
746 "percentage",
747 Percentage,
748 100.0,
749 (-50.0, 50.0),
750 125.0,
751 true,
752 Some((25.0, Some("+25.00%"))),
753 ),
754 (
755 "off the scale",
756 Normal,
757 0.0,
758 (100.0, 200.0),
759 250.0,
760 true,
761 None,
762 ),
763 (
764 "not finite",
765 Normal,
766 0.0,
767 (150.0, 150.0),
768 f64::NAN,
769 true,
770 None,
771 ),
772 ("flat range", Normal, 0.0, (150.0, 150.0), 250.0, true, None),
773 (
774 "hidden label",
775 Normal,
776 0.0,
777 (100.0, 200.0),
778 150.0,
779 false,
780 Some((50.0, None)),
781 ),
782 ];
783 let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(100.0, 100.0));
784 let mut failures = Vec::new();
785 for (name, mode, base, (min, max), price, axis_label, expected) in cases {
786 let ctx = Context::default();
787 let map = PriceMap::new(
788 PriceTransform { mode, base },
789 PriceRange::new(min, max),
790 0.0,
791 100.0,
792 false,
793 );
794 let line = PriceLine::new(price, Color32::RED).with_axis_label(axis_label);
795 let output = ctx.run_ui(RawInput::default(), |ui| {
796 render_price_line(ui.painter(), pane, &map, &line, pane.min.x, true, 200.0);
797 });
798 let segments: Vec<_> = output
799 .shapes
800 .iter()
801 .filter_map(|shape| {
802 if let Shape::LineSegment { points, .. } = &shape.shape {
803 Some(*points)
804 } else {
805 None
806 }
807 })
808 .collect();
809 let labels: Vec<_> = output
810 .shapes
811 .iter()
812 .filter_map(|shape| {
813 if let Shape::Text(text) = &shape.shape {
814 Some(text.galley.text().to_owned())
815 } else {
816 None
817 }
818 })
819 .collect();
820 let matches = match expected {
821 None => output.shapes.is_empty(),
822 Some((y, label)) => {
823 !segments.is_empty()
824 && segments.iter().flatten().all(|p| (p.y - y).abs() < 1e-3)
825 && labels.iter().map(String::as_str).eq(label)
826 }
827 };
828 if !matches {
829 failures.push(format!("{name}: expected {expected:?}, segments {segments:?}, labels {labels:?}, {} shapes", output.shapes.len()));
830 }
831 output.drop_without_applying_deltas();
832 }
833 assert!(failures.is_empty(), "{}", failures.join("\n"));
834
835 for (fill, expected) in [
836 (Color32::from_rgb(241, 54, 69), Color32::WHITE),
837 (Color32::WHITE, Color32::BLACK),
838 ] {
839 let ctx = Context::default();
840 let output = ctx.run_ui(RawInput::default(), |ui| {
841 let map = PriceMap::new(
842 PriceTransform::default(),
843 PriceRange::new(700.0, 710.0),
844 0.0,
845 100.0,
846 false,
847 )
848 .with_price_precision(Some(3));
849 render_price_line(
850 ui.painter(),
851 pane,
852 &map,
853 &PriceLine::new(704.125, fill),
854 0.0,
855 true,
856 200.0,
857 );
858 });
859 let text = output
860 .shapes
861 .iter()
862 .find_map(|shape| match &shape.shape {
863 Shape::Text(text) => Some(text),
864 _ => None,
865 })
866 .unwrap();
867 assert_eq!(text.galley.text(), "704.125");
868 assert_eq!(text.fallback_color, expected);
869 let tag = output
870 .shapes
871 .iter()
872 .find_map(|shape| match &shape.shape {
873 Shape::Rect(rect) => Some(rect),
874 _ => None,
875 })
876 .unwrap();
877 assert_eq!(tag.corner_radius.nw, 0);
878 assert_eq!(tag.corner_radius.sw, 0);
879 output.drop_without_applying_deltas();
880 }
881 }
882
883 #[test]
885 fn price_line_tags_stack_in_price_order() {
886 use crate::widget::test_frames::{chart, shapes};
887 let ctx = Context::default();
888 let mut chart = chart(&[100.0], &[120.0]);
889 chart.config.show_volume = false;
890 for price in [110.0, 110.2, 110.1, 109.9] {
891 chart.add_price_line(
892 crate::widget::SeriesId::MAIN,
893 PriceLine::new(price, Color32::GOLD),
894 );
895 }
896 shapes(&ctx, &mut chart, 0.0, vec![]);
897 let mut tags: Vec<_> = shapes(&ctx, &mut chart, 1.0, vec![])
898 .into_iter()
899 .filter_map(|shape| match shape {
900 Shape::Text(text)
901 if ["110.00", "110.20", "110.10", "109.90"].contains(&text.galley.text()) =>
902 {
903 Some((text.visual_bounding_rect(), text.galley.text().to_owned()))
904 }
905 _ => None,
906 })
907 .collect();
908 tags.sort_by(|a, b| a.0.top().total_cmp(&b.0.top()));
909 let order: Vec<_> = tags.iter().map(|tag| tag.1.as_str()).collect();
910 assert_eq!(order, ["110.20", "110.10", "110.00", "109.90"]);
911 for pair in tags.windows(2) {
912 assert!(pair[0].0.bottom() <= pair[1].0.top() + 0.01, "{tags:?}");
913 }
914 }
915
916 #[test]
918 fn quote_tags_separate_colliding_prices_and_volume() {
919 use crate::model::quote::SessionQuote;
920 let host =
921 crate::scales::CustomPriceFormatter::new(|value| format!("{value:.0} contracts"));
922 for allow_overlap in [false, true] {
923 for host_volume in [false, true] {
924 let volume_formatter =
925 host_volume.then_some(&host as &dyn crate::scales::PriceFormatter);
926 for (close, session_price, font_size, pane_height, show_close, slots, countdown) in [
927 (704.61, 704.64, 12.0, 100.0, true, [true, true, true], None),
928 (709.99, 709.99, 12.0, 100.0, true, [true, true, true], None),
929 (700.01, 700.01, 12.0, 100.0, true, [true, true, true], None),
930 (704.61, 704.64, 24.0, 100.0, true, [true, true, true], None),
931 (709.99, 700.01, 12.0, 100.0, true, [true, true, true], None),
932 (720.0, 704.64, 12.0, 100.0, true, [false, true, true], None),
933 (
934 704.61,
935 704.64,
936 12.0,
937 100.0,
938 false,
939 [false, true, true],
940 None,
941 ),
942 (704.61, 704.64, 12.0, 25.0, true, [true, false, false], None),
943 (
944 704.61,
945 704.64,
946 12.0,
947 100.0,
948 true,
949 [true, true, true],
950 Some("00:15"),
951 ),
952 ] {
953 let slots = if allow_overlap && pane_height == 25.0 {
954 [true, true, true]
955 } else {
956 slots
957 };
958 for mut theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
959 let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(100.0, pane_height));
960 let map = PriceMap::new(
961 PriceTransform::default(),
962 PriceRange::new(700.0, 710.0),
963 0.0,
964 pane_height,
965 false,
966 )
967 .with_price_precision(Some(2));
968 let session = SessionQuote::new(session_price, "EXT");
969 let ctx = Context::default();
970 theme.text.label = font_size;
971 crate::theme::apply_to_egui(&ctx, &theme);
972 let mut tags = [None; 3];
973 let mut width = 0.0;
974 let text = [
975 countdown.map_or_else(
976 || format!("{close:.2}"),
977 |time| format!("{close:.2}\n{time}"),
978 ),
979 format!("EXT {session_price:.2}"),
980 if host_volume {
981 "1590000 contracts".into()
982 } else {
983 "1.59M".into()
984 },
985 ];
986 let output = ctx.run_ui(RawInput::default(), |ui| {
987 width = price_axis_width(
988 ui.painter(),
989 &map,
990 &text.iter().map(String::as_str).collect::<Vec<_>>(),
991 None,
992 );
993 tags = render_quote_tags_with_countdown(
994 ui.painter(),
995 pane,
996 &map,
997 &PriceLine::new(close, Color32::RED).with_axis_label(show_close),
998 Some((&session, Color32::from_rgb(251, 142, 1))),
999 Some((1_590_000.0, Color32::RED)),
1000 pane.right() + width,
1001 countdown,
1002 None,
1003 0.0,
1004 None,
1005 volume_formatter,
1006 allow_overlap,
1007 "",
1008 );
1009 });
1010 let labels: Vec<_> = output
1011 .shapes
1012 .iter()
1013 .filter_map(|shape| match &shape.shape {
1014 Shape::Text(text) => Some(text.galley.text().to_owned()),
1015 _ => None,
1016 })
1017 .collect();
1018 output.drop_without_applying_deltas();
1019 assert_eq!(tags.map(|tag| tag.is_some()), slots);
1020 let rects: Vec<_> = tags.into_iter().flatten().collect();
1021 assert!(
1022 rects
1023 .iter()
1024 .all(|rect| rect.top() >= 0.0 && rect.bottom() <= pane_height)
1025 );
1026 assert!(
1027 rects
1028 .iter()
1029 .all(|rect| rect.left() >= pane.right()
1030 && rect.width() + 5.0 <= width)
1031 );
1032 assert!(
1033 allow_overlap
1034 || rects.iter().enumerate().all(|(i, a)| rects
1035 .iter()
1036 .skip(i + 1)
1037 .all(|b| a.bottom() <= b.top() || b.bottom() <= a.top())),
1038 "{rects:?}"
1039 );
1040 let expected: Vec<_> = text
1041 .into_iter()
1042 .zip(slots)
1043 .filter_map(|(text, shown)| shown.then_some(text))
1044 .collect();
1045 assert_eq!(labels, expected);
1046 }
1047 }
1048 }
1049 }
1050 }
1051
1052 #[cfg(feature = "icons")]
1054 #[test]
1055 fn axis_controls_emit_their_actions() {
1056 use egui::{Event, Modifiers, PointerButton};
1057 let ctx = Context::default();
1058 let tag = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(180.0, 121.0));
1059 let corner = Rect::from_min_max(Pos2::new(200.0, 200.0), Pos2::new(260.0, 228.0));
1060 let mut actions = Vec::new();
1061 for (pos, expected) in [
1062 (Pos2::new(87.0, 110.5), Some("add")),
1063 (tag.center(), None),
1064 (corner.center(), Some("settings")),
1065 ] {
1066 let before = actions.len();
1067 for pressed in [None, Some(true), Some(false)] {
1068 let mut input = RawInput::default();
1069 input.events.push(Event::PointerMoved(pos));
1070 if let Some(pressed) = pressed {
1071 input.events.push(Event::PointerButton {
1072 pos,
1073 button: PointerButton::Primary,
1074 pressed,
1075 modifiers: Modifiers::NONE,
1076 });
1077 }
1078 let output = ctx.run_ui(input, |ui| {
1079 if let Some(price) = crosshair_price_action(ui, tag, 704.64, Color32::WHITE) {
1080 assert_eq!(price, 704.64);
1081 actions.push("add");
1082 }
1083 if axis_settings_button(ui, corner).clicked() {
1084 actions.push("settings");
1085 }
1086 });
1087 output.drop_without_applying_deltas();
1088 }
1089 assert_eq!(actions[before..].first().copied(), expected);
1090 }
1091 assert_eq!(actions, ["add", "settings"]);
1092 }
1093
1094 #[test]
1096 fn study_value_tags_use_plot_contrast_and_optional_names() {
1097 use crate::studies::{CustomIndicator, Indicator, IndicatorValue};
1098 let map = PriceMap::new(
1099 PriceTransform::default(),
1100 PriceRange::new(0.0, 100.0),
1101 0.0,
1102 100.0,
1103 false,
1104 );
1105 let mut study = CustomIndicator::new("Mine", |_| vec![IndicatorValue::Single(50.0)]);
1106 study.calculate(&[]);
1107 let plot = Rect::from_min_max(Pos2::ZERO, Pos2::new(100.0, 100.0));
1108 for (show_study_names, fill, text_color, expected) in [
1110 (
1111 false,
1112 Color32::from_rgb(255, 193, 7),
1113 Color32::BLACK,
1114 "50.00",
1115 ),
1116 (
1117 true,
1118 Color32::from_rgb(255, 235, 59),
1119 Color32::BLACK,
1120 "Mine 50.00",
1121 ),
1122 (
1123 true,
1124 Color32::from_rgb(76, 175, 80),
1125 Color32::WHITE,
1126 "Mine 50.00",
1127 ),
1128 ] {
1129 study.set_colors(vec![fill]);
1130 let ctx = Context::default();
1131 let output = ctx.run_ui(RawInput::default(), |ui| {
1132 render_study_tags(
1133 ui.painter(),
1134 plot,
1135 &map,
1136 &study,
1137 120.0,
1138 &mut Vec::new(),
1139 None,
1140 &crate::config::PriceAxisOptions {
1141 show_study_names,
1142 ..Default::default()
1143 },
1144 );
1145 });
1146 let labels: Vec<_> = output
1147 .shapes
1148 .iter()
1149 .filter_map(|shape| match &shape.shape {
1150 Shape::Text(text) => Some((text.galley.text().to_owned(), text.fallback_color)),
1151 _ => None,
1152 })
1153 .collect();
1154 output.drop_without_applying_deltas();
1155 assert_eq!(labels, [(expected.to_owned(), text_color)], "{fill:?}");
1156 }
1157 }
1158}