egui_charts/chart/rendering/
alert_levels.rs1use egui::{Color32, FontId, Rect, Sense, Stroke, Ui, pos2, vec2};
4
5use crate::chart::coords::ChartMapping;
6use crate::model::{Alert, AlertCondition, AlertSource, AlertStatus};
7
8pub 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
146pub 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}