dampen_dev/
overlay.rs

1//! Error overlay UI components for displaying parse errors
2//!
3//! This module provides UI widgets for displaying error overlays during
4//! hot-reload when XML parsing or validation fails.
5
6use dampen_core::parser::error::ParseError;
7use iced::{
8    Alignment, Color, Element, Length,
9    widget::{button, column, container, text},
10};
11use std::time::Instant;
12
13/// UI state for displaying parse errors during hot-reload
14#[derive(Debug, Clone)]
15pub struct ErrorOverlay {
16    /// Parse error details
17    pub error: Option<ParseError>,
18
19    /// Whether overlay is visible
20    pub visible: bool,
21
22    /// Timestamp when error occurred
23    pub timestamp: Instant,
24}
25
26impl ErrorOverlay {
27    /// Create a new error overlay (initially hidden)
28    pub fn new() -> Self {
29        Self {
30            error: None,
31            visible: false,
32            timestamp: Instant::now(),
33        }
34    }
35
36    /// Show the overlay with an error
37    ///
38    /// # Arguments
39    /// * `error` - The parse error to display
40    pub fn show(&mut self, error: ParseError) {
41        self.error = Some(error);
42        self.visible = true;
43        self.timestamp = Instant::now();
44    }
45
46    /// Hide the overlay
47    pub fn hide(&mut self) {
48        self.visible = false;
49    }
50
51    /// Check if the overlay is currently visible
52    pub fn is_visible(&self) -> bool {
53        self.visible
54    }
55
56    /// Render the error overlay as an Iced widget
57    ///
58    /// Returns a full-screen overlay with error details and a dismiss button.
59    /// If the overlay is not visible, returns an empty container.
60    ///
61    /// # Type Parameters
62    /// * `Message` - Application message type that must have a variant to dismiss the overlay
63    ///
64    /// # Arguments
65    /// * `on_dismiss` - Message to send when the dismiss button is clicked
66    ///
67    /// # Example
68    /// ```ignore
69    /// let overlay = ErrorOverlay::new();
70    /// let widget = overlay.render(Message::DismissError);
71    /// ```
72    pub fn render<'a, Message: Clone + 'a>(&'a self, on_dismiss: Message) -> Element<'a, Message> {
73        if !self.visible {
74            return container(text("")).into();
75        }
76
77        let error = match &self.error {
78            Some(e) => e,
79            None => return container(text("")).into(),
80        };
81
82        // Title
83        let title = text("Hot-Reload Error")
84            .size(24)
85            .style(|_theme| text::Style {
86                color: Some(Color::WHITE),
87            });
88
89        // Error message
90        let message = text(&error.message).size(16).style(|_theme| text::Style {
91            color: Some(Color::WHITE),
92        });
93
94        // Location info
95        let location = text(format!(
96            "at line {}, column {}",
97            error.span.line, error.span.column
98        ))
99        .size(14)
100        .style(|_theme| text::Style {
101            color: Some(Color::from_rgb(0.9, 0.9, 0.9)),
102        });
103
104        // Suggestion (if available)
105        let suggestion_widget = if let Some(ref suggestion) = error.suggestion {
106            let label = text(format!("💡 {}", suggestion))
107                .size(14)
108                .style(|_theme| text::Style {
109                    color: Some(Color::from_rgb(1.0, 1.0, 0.6)),
110                });
111            Some(label)
112        } else {
113            None
114        };
115
116        // Dismiss button
117        let dismiss_btn = button(text("Dismiss (Esc)").size(14).style(|_theme| text::Style {
118            color: Some(Color::BLACK),
119        }))
120        .on_press(on_dismiss)
121        .padding(10);
122
123        // Build content column
124        let mut content = column![title, message, location]
125            .spacing(12)
126            .align_x(Alignment::Start);
127
128        if let Some(suggestion) = suggestion_widget {
129            content = content.push(suggestion);
130        }
131
132        content = content.push(dismiss_btn);
133
134        // Wrap in red container with padding
135        container(content)
136            .width(Length::Fill)
137            .height(Length::Fill)
138            .padding(40)
139            .style(|_theme| container::Style {
140                background: Some(Color::from_rgb(0.8, 0.2, 0.2).into()),
141                text_color: Some(Color::WHITE),
142                ..Default::default()
143            })
144            .into()
145    }
146}
147
148impl Default for ErrorOverlay {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use dampen_core::ir::span::Span;
158    use dampen_core::parser::error::ParseErrorKind;
159
160    #[derive(Debug, Clone)]
161    enum TestMessage {
162        Dismiss,
163    }
164
165    #[test]
166    fn test_new_overlay_is_hidden() {
167        let overlay = ErrorOverlay::new();
168        assert!(!overlay.is_visible());
169        assert!(overlay.error.is_none());
170    }
171
172    #[test]
173    fn test_show_makes_overlay_visible() {
174        let mut overlay = ErrorOverlay::new();
175        let error = ParseError {
176            kind: ParseErrorKind::XmlSyntax,
177            message: "Test error".to_string(),
178            span: Span {
179                start: 0,
180                end: 5,
181                line: 1,
182                column: 5,
183            },
184            suggestion: None,
185        };
186
187        overlay.show(error.clone());
188        assert!(overlay.is_visible());
189        assert_eq!(overlay.error, Some(error));
190    }
191
192    #[test]
193    fn test_hide_makes_overlay_invisible() {
194        let mut overlay = ErrorOverlay::new();
195        let error = ParseError {
196            kind: ParseErrorKind::XmlSyntax,
197            message: "Test error".to_string(),
198            span: Span {
199                start: 0,
200                end: 5,
201                line: 1,
202                column: 5,
203            },
204            suggestion: None,
205        };
206
207        overlay.show(error);
208        overlay.hide();
209        assert!(!overlay.is_visible());
210        // Error is preserved even when hidden
211        assert!(overlay.error.is_some());
212    }
213
214    #[test]
215    fn test_render_returns_empty_when_hidden() {
216        let overlay = ErrorOverlay::new();
217        let element = overlay.render(TestMessage::Dismiss);
218        // We can't easily inspect the Element structure, but we can verify it compiles
219        // and returns successfully
220        drop(element);
221    }
222
223    #[test]
224    fn test_render_with_visible_error() {
225        let mut overlay = ErrorOverlay::new();
226        let error = ParseError {
227            kind: ParseErrorKind::UnknownWidget,
228            message: "Unknown widget 'foo'".to_string(),
229            span: Span {
230                start: 50,
231                end: 53,
232                line: 10,
233                column: 15,
234            },
235            suggestion: Some("Did you mean 'button'?".to_string()),
236        };
237
238        overlay.show(error);
239        let element = overlay.render(TestMessage::Dismiss);
240        // Verify the element is created successfully
241        drop(element);
242    }
243
244    #[test]
245    fn test_render_without_suggestion() {
246        let mut overlay = ErrorOverlay::new();
247        let error = ParseError {
248            kind: ParseErrorKind::InvalidValue,
249            message: "Invalid value".to_string(),
250            span: Span {
251                start: 100,
252                end: 105,
253                line: 5,
254                column: 20,
255            },
256            suggestion: None,
257        };
258
259        overlay.show(error);
260        let element = overlay.render(TestMessage::Dismiss);
261        // Verify the element is created successfully even without suggestion
262        drop(element);
263    }
264
265    #[test]
266    fn test_timestamp_updated_on_show() {
267        let mut overlay = ErrorOverlay::new();
268        let initial_timestamp = overlay.timestamp;
269
270        // Wait a tiny bit to ensure timestamp changes
271        std::thread::sleep(std::time::Duration::from_millis(1));
272
273        let error = ParseError {
274            kind: ParseErrorKind::XmlSyntax,
275            message: "Test".to_string(),
276            span: Span {
277                start: 0,
278                end: 1,
279                line: 1,
280                column: 1,
281            },
282            suggestion: None,
283        };
284
285        overlay.show(error);
286        assert!(overlay.timestamp > initial_timestamp);
287    }
288}