Skip to main content

egui_cha_ds/molecules/
error_console.rs

1//! ErrorConsole molecule - Collects and displays errors
2
3use crate::atoms::icons;
4use crate::Theme;
5use egui::{Color32, FontFamily, RichText, Ui};
6use egui_cha::{Severity, ViewCtx};
7use std::collections::VecDeque;
8use std::time::{Duration, Instant};
9
10/// An error entry with timestamp
11#[derive(Clone, Debug)]
12pub struct ErrorEntry {
13    pub message: String,
14    pub timestamp: Instant,
15    pub level: ErrorLevel,
16}
17
18/// Error severity level for display in ErrorConsole
19///
20/// Ordered from least to most severe for filtering purposes.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum ErrorLevel {
23    /// Debug information (shown only in debug mode)
24    Debug,
25    /// Informational message
26    Info,
27    /// Warning (operation continues)
28    Warning,
29    /// Error (recoverable)
30    #[default]
31    Error,
32    /// Critical error (may require restart)
33    Critical,
34}
35
36impl From<Severity> for ErrorLevel {
37    fn from(severity: Severity) -> Self {
38        match severity {
39            Severity::Debug => ErrorLevel::Debug,
40            Severity::Info => ErrorLevel::Info,
41            Severity::Warn => ErrorLevel::Warning,
42            Severity::Error => ErrorLevel::Error,
43            Severity::Critical => ErrorLevel::Critical,
44        }
45    }
46}
47
48impl ErrorLevel {
49    /// Check if this level should be shown in production
50    pub fn is_production_visible(self) -> bool {
51        self >= ErrorLevel::Info
52    }
53}
54
55/// State for ErrorConsole (owned by parent)
56pub struct ErrorConsoleState {
57    errors: VecDeque<ErrorEntry>,
58    max_entries: usize,
59    auto_dismiss: Option<Duration>,
60}
61
62impl Default for ErrorConsoleState {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl ErrorConsoleState {
69    pub fn new() -> Self {
70        Self {
71            errors: VecDeque::new(),
72            max_entries: 10,
73            auto_dismiss: Some(Duration::from_secs(10)),
74        }
75    }
76
77    /// Set maximum number of errors to keep
78    pub fn with_max_entries(mut self, max: usize) -> Self {
79        self.max_entries = max;
80        self
81    }
82
83    /// Set auto-dismiss duration (None to disable)
84    pub fn with_auto_dismiss(mut self, duration: Option<Duration>) -> Self {
85        self.auto_dismiss = duration;
86        self
87    }
88
89    /// Push a new error
90    pub fn push(&mut self, message: impl Into<String>) {
91        self.push_with_level(message, ErrorLevel::Error);
92    }
93
94    /// Push a warning
95    pub fn push_warning(&mut self, message: impl Into<String>) {
96        self.push_with_level(message, ErrorLevel::Warning);
97    }
98
99    /// Push an info message
100    pub fn push_info(&mut self, message: impl Into<String>) {
101        self.push_with_level(message, ErrorLevel::Info);
102    }
103
104    /// Push with specific level
105    pub fn push_with_level(&mut self, message: impl Into<String>, level: ErrorLevel) {
106        self.errors.push_back(ErrorEntry {
107            message: message.into(),
108            timestamp: Instant::now(),
109            level,
110        });
111
112        // Trim to max entries
113        while self.errors.len() > self.max_entries {
114            self.errors.pop_front();
115        }
116    }
117
118    /// Remove expired errors (call this in update)
119    pub fn cleanup(&mut self) {
120        if let Some(duration) = self.auto_dismiss {
121            let now = Instant::now();
122            self.errors
123                .retain(|e| now.duration_since(e.timestamp) < duration);
124        }
125    }
126
127    /// Clear all errors
128    pub fn clear(&mut self) {
129        self.errors.clear();
130    }
131
132    /// Dismiss a specific error by index
133    pub fn dismiss(&mut self, index: usize) {
134        if index < self.errors.len() {
135            self.errors.remove(index);
136        }
137    }
138
139    /// Check if there are any errors
140    pub fn is_empty(&self) -> bool {
141        self.errors.is_empty()
142    }
143
144    /// Get error count
145    pub fn len(&self) -> usize {
146        self.errors.len()
147    }
148
149    /// Iterate over errors
150    pub fn iter(&self) -> impl Iterator<Item = &ErrorEntry> {
151        self.errors.iter()
152    }
153
154    /// Drain all errors (useful for batch processing)
155    pub fn drain(&mut self) -> impl Iterator<Item = ErrorEntry> + '_ {
156        self.errors.drain(..)
157    }
158}
159
160/// Messages for ErrorConsole
161#[derive(Clone, Debug)]
162pub enum ErrorConsoleMsg {
163    Dismiss(usize),
164    DismissAll,
165}
166
167/// ErrorConsole component
168pub struct ErrorConsole;
169
170impl ErrorConsole {
171    /// Get colors for error level from Theme
172    /// Returns (background_color, text_color, icon)
173    fn level_colors(level: ErrorLevel, theme: &Theme) -> (Color32, Color32, &'static str) {
174        let (log_color, icon) = match level {
175            ErrorLevel::Debug => (theme.log_debug, icons::WRENCH),
176            ErrorLevel::Info => (theme.log_info, icons::INFO),
177            ErrorLevel::Warning => (theme.log_warn, icons::WARNING),
178            ErrorLevel::Error => (theme.log_error, icons::X_CIRCLE),
179            ErrorLevel::Critical => (theme.log_critical, icons::FIRE),
180        };
181
182        // Background: semi-transparent version of the log color
183        let bg_color = Color32::from_rgba_unmultiplied(
184            log_color.r(),
185            log_color.g(),
186            log_color.b(),
187            30, // Low alpha for subtle background
188        );
189
190        (bg_color, log_color, icon)
191    }
192
193    /// Get header color from theme
194    fn header_color(theme: &Theme) -> Color32 {
195        theme.log_error
196    }
197
198    /// Show the error console (ViewCtx version)
199    pub fn show<Msg>(
200        ctx: &mut ViewCtx<'_, Msg>,
201        state: &ErrorConsoleState,
202        map_msg: impl Fn(ErrorConsoleMsg) -> Msg + Clone,
203    ) {
204        if state.is_empty() {
205            return;
206        }
207
208        let theme = Theme::current(ctx.ui.ctx());
209
210        // Collect dismiss clicks first
211        let mut dismiss_index: Option<usize> = None;
212        let mut clear_all = false;
213
214        ctx.ui.vertical(|ui| {
215            // Header with clear all button
216            ui.horizontal(|ui| {
217                ui.label(
218                    RichText::new(format!("Errors ({})", state.len()))
219                        .strong()
220                        .color(Self::header_color(&theme)),
221                );
222                ui.add_space(8.0);
223                if ui.small_button("Clear All").clicked() {
224                    clear_all = true;
225                }
226            });
227
228            ui.add_space(4.0);
229
230            // Error list
231            for (index, entry) in state.iter().enumerate() {
232                let (bg_color, text_color, icon) = Self::level_colors(entry.level, &theme);
233
234                egui::Frame::new()
235                    .fill(bg_color)
236                    .corner_radius(4.0)
237                    .inner_margin(egui::Margin::symmetric(8, 4))
238                    .show(ui, |ui| {
239                        ui.horizontal(|ui| {
240                            ui.label(
241                                RichText::new(icon)
242                                    .family(FontFamily::Name("icons".into()))
243                                    .color(text_color),
244                            );
245                            ui.label(RichText::new(&entry.message).color(text_color));
246                            ui.with_layout(
247                                egui::Layout::right_to_left(egui::Align::Center),
248                                |ui| {
249                                    if ui.small_button("×").clicked() {
250                                        dismiss_index = Some(index);
251                                    }
252                                },
253                            );
254                        });
255                    });
256
257                ui.add_space(2.0);
258            }
259        });
260
261        // Emit messages after UI rendering
262        if clear_all {
263            ctx.emit(map_msg(ErrorConsoleMsg::DismissAll));
264        } else if let Some(index) = dismiss_index {
265            ctx.emit(map_msg(ErrorConsoleMsg::Dismiss(index)));
266        }
267    }
268
269    /// Show without ViewCtx (basic Ui version)
270    pub fn show_ui(ui: &mut Ui, state: &ErrorConsoleState) -> Option<ErrorConsoleMsg> {
271        if state.is_empty() {
272            return None;
273        }
274
275        let theme = Theme::current(ui.ctx());
276        let mut result = None;
277
278        ui.vertical(|ui| {
279            ui.horizontal(|ui| {
280                ui.label(
281                    RichText::new(format!("Errors ({})", state.len()))
282                        .strong()
283                        .color(Self::header_color(&theme)),
284                );
285                ui.add_space(8.0);
286                if ui.small_button("Clear All").clicked() {
287                    result = Some(ErrorConsoleMsg::DismissAll);
288                }
289            });
290
291            ui.add_space(4.0);
292
293            for (index, entry) in state.iter().enumerate() {
294                let (bg_color, text_color, icon) = Self::level_colors(entry.level, &theme);
295
296                egui::Frame::new()
297                    .fill(bg_color)
298                    .corner_radius(4.0)
299                    .inner_margin(egui::Margin::symmetric(8, 4))
300                    .show(ui, |ui| {
301                        ui.horizontal(|ui| {
302                            ui.label(
303                                RichText::new(icon)
304                                    .family(FontFamily::Name("icons".into()))
305                                    .color(text_color),
306                            );
307                            ui.label(RichText::new(&entry.message).color(text_color));
308                            ui.with_layout(
309                                egui::Layout::right_to_left(egui::Align::Center),
310                                |ui| {
311                                    if ui.small_button("×").clicked() && result.is_none() {
312                                        result = Some(ErrorConsoleMsg::Dismiss(index));
313                                    }
314                                },
315                            );
316                        });
317                    });
318
319                ui.add_space(2.0);
320            }
321        });
322
323        result
324    }
325}