Skip to main content

jag_ui/elements/
alert.rs

1//! Alert notification banner element.
2
3use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// Severity level for an alert, controlling its color scheme.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum AlertSeverity {
17    Info,
18    Success,
19    Warning,
20    Error,
21}
22
23impl AlertSeverity {
24    /// Background color for this severity.
25    pub fn bg_color(self) -> ColorLinPremul {
26        match self {
27            Self::Info => ColorLinPremul::from_srgba_u8([219, 234, 254, 255]),
28            Self::Success => ColorLinPremul::from_srgba_u8([220, 252, 231, 255]),
29            Self::Warning => ColorLinPremul::from_srgba_u8([254, 249, 195, 255]),
30            Self::Error => ColorLinPremul::from_srgba_u8([254, 226, 226, 255]),
31        }
32    }
33
34    /// Text color for this severity.
35    pub fn text_color(self) -> ColorLinPremul {
36        match self {
37            Self::Info => ColorLinPremul::from_srgba_u8([30, 64, 175, 255]),
38            Self::Success => ColorLinPremul::from_srgba_u8([22, 101, 52, 255]),
39            Self::Warning => ColorLinPremul::from_srgba_u8([133, 77, 14, 255]),
40            Self::Error => ColorLinPremul::from_srgba_u8([153, 27, 27, 255]),
41        }
42    }
43
44    /// Border/accent color for this severity.
45    pub fn border_color(self) -> ColorLinPremul {
46        match self {
47            Self::Info => ColorLinPremul::from_srgba_u8([147, 197, 253, 255]),
48            Self::Success => ColorLinPremul::from_srgba_u8([134, 239, 172, 255]),
49            Self::Warning => ColorLinPremul::from_srgba_u8([253, 224, 71, 255]),
50            Self::Error => ColorLinPremul::from_srgba_u8([252, 165, 165, 255]),
51        }
52    }
53}
54
55/// A simple notification banner showing a message with severity-based
56/// coloring and an optional dismiss action.
57pub struct Alert {
58    pub rect: Rect,
59    /// Message text.
60    pub message: String,
61    /// Severity determines the color scheme.
62    pub severity: AlertSeverity,
63    /// Font size for the message.
64    pub font_size: f32,
65    /// Corner radius.
66    pub radius: f32,
67    /// Whether a dismiss ("X") button is shown.
68    pub dismissible: bool,
69    /// Whether the alert has been dismissed.
70    pub dismissed: bool,
71    /// Focus identifier.
72    pub focus_id: FocusId,
73}
74
75impl Alert {
76    /// Create an alert with the given message and severity.
77    pub fn new(message: impl Into<String>, severity: AlertSeverity) -> Self {
78        Self {
79            rect: Rect {
80                x: 0.0,
81                y: 0.0,
82                w: 400.0,
83                h: 48.0,
84            },
85            message: message.into(),
86            severity,
87            font_size: 14.0,
88            radius: 6.0,
89            dismissible: true,
90            dismissed: false,
91            focus_id: FocusId(0),
92        }
93    }
94
95    /// Dismiss button rectangle (right side of the alert).
96    fn dismiss_rect(&self) -> Rect {
97        let size = 24.0;
98        Rect {
99            x: self.rect.x + self.rect.w - size - 12.0,
100            y: self.rect.y + (self.rect.h - size) * 0.5,
101            w: size,
102            h: size,
103        }
104    }
105
106    /// Hit-test the dismiss button.
107    pub fn hit_test_dismiss(&self, x: f32, y: f32) -> bool {
108        if !self.dismissible {
109            return false;
110        }
111        let r = self.dismiss_rect();
112        x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
113    }
114
115    /// Hit-test the entire alert area.
116    pub fn hit_test(&self, x: f32, y: f32) -> bool {
117        x >= self.rect.x
118            && x <= self.rect.x + self.rect.w
119            && y >= self.rect.y
120            && y <= self.rect.y + self.rect.h
121    }
122}
123
124// ---------------------------------------------------------------------------
125// Element trait
126// ---------------------------------------------------------------------------
127
128impl Element for Alert {
129    fn rect(&self) -> Rect {
130        self.rect
131    }
132
133    fn set_rect(&mut self, rect: Rect) {
134        self.rect = rect;
135    }
136
137    fn render(&self, canvas: &mut Canvas, z: i32) {
138        if self.dismissed {
139            return;
140        }
141
142        let bg = self.severity.bg_color();
143        let border = self.severity.border_color();
144        let text_color = self.severity.text_color();
145
146        // Background + border
147        let rrect = RoundedRect {
148            rect: self.rect,
149            radii: RoundedRadii {
150                tl: self.radius,
151                tr: self.radius,
152                br: self.radius,
153                bl: self.radius,
154            },
155        };
156        jag_surface::shapes::draw_snapped_rounded_rectangle(
157            canvas,
158            rrect,
159            Some(Brush::Solid(bg)),
160            Some(1.0),
161            Some(Brush::Solid(border)),
162            z,
163        );
164
165        // Message text
166        let text_x = self.rect.x + 16.0;
167        let text_y = self.rect.y + self.rect.h * 0.5 + self.font_size * 0.35;
168        canvas.draw_text_run_weighted(
169            [text_x, text_y],
170            self.message.clone(),
171            self.font_size,
172            400.0,
173            text_color,
174            z + 1,
175        );
176
177        // Dismiss button
178        if self.dismissible {
179            let dr = self.dismiss_rect();
180            canvas.draw_text_run_weighted(
181                [dr.x + 5.0, dr.y + dr.h - 5.0],
182                "\u{2715}".to_string(),
183                14.0,
184                400.0,
185                text_color,
186                z + 2,
187            );
188        }
189    }
190
191    fn focus_id(&self) -> Option<FocusId> {
192        if self.dismissible {
193            Some(self.focus_id)
194        } else {
195            None
196        }
197    }
198}
199
200// ---------------------------------------------------------------------------
201// EventHandler trait
202// ---------------------------------------------------------------------------
203
204impl EventHandler for Alert {
205    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
206        if self.dismissed {
207            return EventResult::Ignored;
208        }
209        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
210            return EventResult::Ignored;
211        }
212        if self.hit_test_dismiss(event.x, event.y) {
213            self.dismissed = true;
214            EventResult::Handled
215        } else {
216            EventResult::Ignored
217        }
218    }
219
220    fn handle_keyboard(&mut self, _event: &KeyboardEvent) -> EventResult {
221        EventResult::Ignored
222    }
223
224    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
225        EventResult::Ignored
226    }
227
228    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
229        EventResult::Ignored
230    }
231
232    fn is_focused(&self) -> bool {
233        false
234    }
235
236    fn set_focused(&mut self, _focused: bool) {}
237
238    fn contains_point(&self, x: f32, y: f32) -> bool {
239        if self.dismissed {
240            return false;
241        }
242        self.hit_test(x, y)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Tests
248// ---------------------------------------------------------------------------
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn alert_severity_colors_differ() {
256        let info_bg = AlertSeverity::Info.bg_color();
257        let error_bg = AlertSeverity::Error.bg_color();
258        // They should not be equal.
259        assert_ne!(info_bg, error_bg);
260    }
261
262    #[test]
263    fn alert_defaults() {
264        let a = Alert::new("Something happened", AlertSeverity::Info);
265        assert_eq!(a.message, "Something happened");
266        assert_eq!(a.severity, AlertSeverity::Info);
267        assert!(a.dismissible);
268        assert!(!a.dismissed);
269    }
270
271    #[test]
272    fn alert_dismiss_click() {
273        let mut a = Alert::new("Msg", AlertSeverity::Warning);
274        a.rect = Rect {
275            x: 0.0,
276            y: 0.0,
277            w: 400.0,
278            h: 48.0,
279        };
280        let dr = a.dismiss_rect();
281        let evt = MouseClickEvent {
282            button: MouseButton::Left,
283            state: ElementState::Pressed,
284            x: dr.x + 5.0,
285            y: dr.y + 5.0,
286            click_count: 1,
287        };
288        assert_eq!(a.handle_mouse_click(&evt), EventResult::Handled);
289        assert!(a.dismissed);
290    }
291
292    #[test]
293    fn alert_not_hittable_when_dismissed() {
294        let mut a = Alert::new("Msg", AlertSeverity::Error);
295        a.rect = Rect {
296            x: 0.0,
297            y: 0.0,
298            w: 400.0,
299            h: 48.0,
300        };
301        a.dismissed = true;
302        assert!(!a.contains_point(200.0, 24.0));
303    }
304
305    #[test]
306    fn alert_hit_test() {
307        let a = Alert::new("Msg", AlertSeverity::Success);
308        assert!(a.hit_test(200.0, 24.0));
309        assert!(!a.hit_test(500.0, 24.0));
310    }
311}