Skip to main content

egui_charts/chart/rendering/axes/
mod.rs

1//! Price and time axis rendering
2
3mod 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
18/// Gap between the plot's right edge and the start of the price axis labels.
19pub(crate) const PRICE_LABEL_GAP: f32 = 8.0;
20
21/// Padding between an axis tag's text and its edge.
22const TAG_PADDING: Vec2 = Vec2::new(6.0, 1.5);
23
24/// The default font for axis labels and tags: the theme's label text.
25pub(crate) fn axis_font(ctx: &egui::Context) -> FontId {
26    FontId::proportional(ctx.active_theme().text.label)
27}
28
29/// Paints `text` on a `fill` tag in the axis font and returns the tag's rect.
30///
31/// The text sits where an axis label placed at `anchor` with `align` would, and the tag pads it.
32/// A tag that would end past `max_x` moves left to end there, but stays inside the painter's left edge.
33pub(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
73/// `label` led by `name` when the axis shows names, so equal values of different series stay
74/// apart.
75pub(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
83/// The axis ending at the plot's left edge uses right-aligned tags.
84fn 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
98/// Moves the tag centres `ys`, given top to bottom, apart where they are closer than a tag is
99/// high, so the tags stack in the order of their prices. Each stack of tags that touch is
100/// centred on where its tags wanted to be; tags that are far apart stay where they are.
101pub(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    // Stacks of (tag count, centre of the stack's first tag).
108    let mut stacks: Vec<(usize, f32)> = Vec::new();
109    for &y in ys {
110        let (mut count, mut first) = (1, y);
111        // Merge while the stack touches the one above it. The first centres of the two are
112        // averaged after lining their tags up in one stack.
113        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
130/// Paints a later value beside the nearest free vertical slot, preserving earlier tags.
131/// Tags that cannot fit inside the pane are omitted.
132pub(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
173/// Last-bar values of visible study lines, in the same units and colours as their plot.
174pub(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    // The lines of one study stack in the order of their prices.
231    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
250/// Renders `line` from `start_x`, clamped to the plot's left edge, to its right edge and, with
251/// `show_axis` and the line's `axis_label`, its tag in the price axis, which ends at `axis_right`.
252/// Pass the plot's left edge as `axis_right` to place tags in the left axis.
253/// Returns the tag's rect, or `None` when no tag is drawn.
254pub 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    // In scale units: a flat range maps every price, NaN included, to the pane's middle.
269    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
309/// The text colour and fill of `line`'s axis tag.
310pub(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
319/// The decimals of an automatic price tag at `y`: the scale's precision, else the nearest tick
320/// label's, with at least two.
321pub(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
326// Keep white text on financial colours; pale custom fills still need dark text.
327pub(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
335/// Paints a compact hover tag within its axis bounds.
336pub(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
384/// Width of the current price labels and tags, including their plot gap and right clearance.
385/// Include formatted session and volume labels in `tag_labels` when allocating their column,
386/// and the host `formatter` the labels are written with.
387pub 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
409/// Paints independent current close, session and volume readouts, returning their rectangles
410/// in that order. Pass all three rectangles to tick occlusion and pointer routing.
411///
412/// Session lines stay at their data price. Tags move apart only to keep the rows readable;
413/// volume is a value readout at the bottom of the pane, never a price coordinate. The caller supplies
414/// the current values and colours independently of the historical bar under the pointer.
415/// Off-scale prices do not hide other rows. A short pane keeps close, session, then volume.
416pub 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
431/// The chart's current-value stack, optionally including the supplied quote's bar countdown.
432/// A non-empty `close_name` leads the current close's tag.
433pub(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    // Keep paint order stable even when the price coordinates reverse the row order.
576    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/// Paints a 26 by 20 point circled-plus button joined to the left of `tag`.
593/// A click returns `price` for the host's alert, order or price-line menu. The tag itself
594/// remains available for price-axis drags. Uses the theme's tag fill and `foreground` ink;
595/// call before handling price-axis drags.
596#[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
612/// The joined button's target, shared by painting and chart gesture handling.
613pub(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/// Keeps the public action's signature while the chart supplies its configured tag fill.
621#[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        // Translation must run outside the context lock held by widget_info.
660        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/// A 24-point scale-settings target in the price/time-axis corner. The caller opens its
678/// existing scale menu when the returned response is clicked, before handling scale drags.
679#[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        // Translation must run outside the context lock held by widget_info.
710        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        // ln(100) is midway between ln(10) and ln(1000); +25% is 3/4 up -50%..50%.
735        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    /// Price lines within one label height stack on the scale in price order.
884    #[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    /// Current values stay independent and ordered when price coordinates coincide.
917    #[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    /// Add and settings cells emit their own clicks without a chart drag.
1053    #[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    /// Study tags keep their optional names and choose readable text for the plot fill.
1095    #[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        // White contrast is 1.63:1 on gold, 1.22:1 on yellow and 2.78:1 on green.
1109        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}