Skip to main content

egui_charts/chart/rendering/
alert_levels.rs

1//! Interactive alert levels. Changes are host intents; rendering never changes alert status.
2
3use egui::{Color32, FontId, Rect, Sense, Stroke, Ui, pos2, vec2};
4
5use crate::chart::coords::ChartMapping;
6use crate::model::{Alert, AlertCondition, AlertSource, AlertStatus};
7
8/// Draw price thresholds and return a replacement alert after a drag.
9/// Supply only alerts for this pane, resolving symbol or watchlist membership in the host.
10/// With `active_only` unset, triggered, disabled and expired alerts are drawn dimmed.
11/// Call after drawing the chart, then submit the result through the host's alerts service.
12/// Non-price sources require the host to resolve their own pane and coordinates.
13/// `format` supplies the instrument's precision and currency for labels.
14pub fn render_alert_levels<'a>(
15    ui: &mut Ui,
16    mapping: &ChartMapping,
17    alerts: impl IntoIterator<Item = &'a Alert>,
18    color: Color32,
19    labels: bool,
20    active_only: bool,
21    format: impl Fn(f64) -> String,
22) -> Option<Alert> {
23    let mut changed = None;
24    let painter = ui.painter().with_clip_rect(mapping.rect);
25    for alert in alerts
26        .into_iter()
27        .filter(|a| !active_only || a.status == AlertStatus::Active)
28    {
29        let Some(id) = &alert.id else { continue };
30        let active = alert.status == AlertStatus::Active;
31        let color = if active {
32            color
33        } else {
34            color.gamma_multiply(0.5)
35        };
36        let rules = std::iter::once((&alert.source, &alert.condition)).chain(
37            alert
38                .additional_conditions
39                .iter()
40                .map(|r| (&r.source, &r.condition)),
41        );
42        for (index, (source, condition)) in rules.enumerate() {
43            if *source != AlertSource::Price {
44                continue;
45            }
46            let levels = match condition {
47                AlertCondition::EnteringChannel { lower, upper }
48                | AlertCondition::ExitingChannel { lower, upper }
49                | AlertCondition::InsideChannel { lower, upper }
50                | AlertCondition::OutsideChannel { lower, upper } => vec![*lower, *upper],
51                _ => condition.level().into_iter().collect(),
52            };
53            for (edge, level) in levels
54                .into_iter()
55                .enumerate()
56                .filter(|(_, v)| v.is_finite())
57            {
58                let key = ui.scope_id().with(("alert_level", &id.0, index, edge));
59                let staged = ui
60                    .ctx()
61                    .data(|data| data.get_temp::<f64>(key))
62                    .unwrap_or(level);
63                let y = mapping.price_to_y(staged);
64                if !y.is_finite() || !mapping.is_y_visible(y) {
65                    continue;
66                }
67                let hit = Rect::from_min_max(
68                    pos2(mapping.rect.left(), y - 6.0),
69                    pos2(mapping.rect.right(), y + 6.0),
70                )
71                .intersect(mapping.rect);
72                let response = ui.interact(hit, key, Sense::drag()).on_hover_ui(|ui| {
73                    ui.label(crate::localization::text(ui.ctx(), "Drag alert level"));
74                });
75                let mut value = staged;
76                if response.dragged()
77                    && let Some(pointer) = response.interact_pointer_pos()
78                {
79                    value = mapping
80                        .y_to_price(pointer.y.clamp(mapping.rect.top(), mapping.rect.bottom()));
81                    if value.is_finite() {
82                        ui.ctx().data_mut(|data| data.insert_temp(key, value));
83                    }
84                }
85                if response.drag_stopped() {
86                    ui.ctx().data_mut(|data| data.remove::<f64>(key));
87                    if value.is_finite() && value != level {
88                        let mut updated = alert.clone();
89                        let condition = if index == 0 {
90                            &mut updated.condition
91                        } else {
92                            &mut updated.additional_conditions[index - 1].condition
93                        };
94                        match condition {
95                            AlertCondition::Crossing(v)
96                            | AlertCondition::CrossingUp(v)
97                            | AlertCondition::CrossingDown(v)
98                            | AlertCondition::Above(v)
99                            | AlertCondition::Below(v) => *v = value,
100                            AlertCondition::EnteringChannel { lower, upper }
101                            | AlertCondition::ExitingChannel { lower, upper }
102                            | AlertCondition::InsideChannel { lower, upper }
103                            | AlertCondition::OutsideChannel { lower, upper } => {
104                                if edge == 0 {
105                                    *lower = value;
106                                } else {
107                                    *upper = value;
108                                }
109                            }
110                            _ => continue,
111                        }
112                        if updated.is_valid() {
113                            changed = Some(updated);
114                        }
115                    }
116                }
117                let y = mapping.price_to_y(value);
118                painter.hline(mapping.rect.x_range(), y, Stroke::new(1.0, color));
119                if labels {
120                    let name = if alert.name.is_empty() {
121                        "Alert"
122                    } else {
123                        &alert.name
124                    };
125                    let text = format!("{name}  {}", format(value));
126                    let font = FontId::proportional(12.0);
127                    let text_color = if active {
128                        ui.visuals().text_color()
129                    } else {
130                        ui.visuals().weak_text_color()
131                    };
132                    let galley = painter.layout_no_wrap(text, font, text_color);
133                    let label = Rect::from_min_size(
134                        pos2(mapping.rect.left() + 8.0, y - galley.size().y - 4.0),
135                        galley.size() + vec2(8.0, 2.0),
136                    );
137                    painter.rect_filled(label, 2.0, ui.visuals().panel_fill);
138                    painter.galley(label.min + vec2(4.0, 1.0), galley, text_color);
139                }
140            }
141        }
142    }
143    changed
144}
145
146/// Resolve Alt+A at a hovered chart price without handling keys owned by a field or popup.
147/// The host decides whether to open a dialog or save a default alert at the returned price.
148pub fn alert_shortcut_price(ui: &mut Ui, mapping: &ChartMapping) -> Option<f64> {
149    let pointer = ui.ctx().pointer_hover_pos()?;
150    if !ui.rect_contains_pointer(mapping.rect)
151        || ui.ctx().egui_wants_keyboard_input()
152        || egui::Popup::is_any_open(ui.ctx())
153    {
154        return None;
155    }
156    if ui.input_mut(|input| input.consume_key(egui::Modifiers::ALT, egui::Key::A)) {
157        let price = mapping.y_to_price(pointer.y);
158        return price.is_finite().then_some(price);
159    }
160    None
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::model::AlertId;
167    use crate::scales::{PriceMap, PriceRange, PriceTransform};
168    use crate::theme::Theme;
169
170    #[test]
171    fn inactive_levels_follow_the_filter_and_stay_dimmed() {
172        for theme in [Theme::light(), Theme::dark()] {
173            let ctx = egui::Context::default();
174            crate::theme::apply_to_egui(&ctx, &theme);
175            for status in [
176                AlertStatus::Active,
177                AlertStatus::Triggered,
178                AlertStatus::Disabled,
179                AlertStatus::Expired,
180            ] {
181                let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(100.0));
182                alert.id = Some(AlertId("level".into()));
183                alert.status = status;
184                for active_only in [false, true] {
185                    let mut output = ctx.run_ui(Default::default(), |ui| {
186                        let rect = ui.max_rect();
187                        let map = ChartMapping::new(
188                            rect,
189                            6.0,
190                            0,
191                            0,
192                            0.0,
193                            PriceMap::new(
194                                PriceTransform::default(),
195                                PriceRange::new(90.0, 110.0),
196                                rect.top(),
197                                rect.bottom(),
198                                false,
199                            ),
200                        );
201                        render_alert_levels(
202                            ui,
203                            &map,
204                            [&alert],
205                            Color32::GOLD,
206                            true,
207                            active_only,
208                            |price| price.to_string(),
209                        );
210                    });
211                    let shapes = std::mem::take(&mut output.shapes);
212                    output.drop_without_applying_deltas();
213                    let visible = !active_only || status == AlertStatus::Active;
214                    let lines: Vec<_> = shapes
215                        .iter()
216                        .filter_map(|s| match s.shape {
217                            egui::Shape::LineSegment { stroke, .. } => Some(stroke.color),
218                            _ => None,
219                        })
220                        .collect();
221                    let expected = if status == AlertStatus::Active {
222                        Color32::GOLD
223                    } else {
224                        Color32::GOLD.gamma_multiply(0.5)
225                    };
226                    assert_eq!(
227                        lines,
228                        if visible { vec![expected] } else { vec![] },
229                        "{status:?}, active_only={active_only}"
230                    );
231                    let label = shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.text() == "Alert  100"));
232                    assert_eq!(label, visible);
233                }
234            }
235        }
236    }
237}