Skip to main content

egui_cha_ds/molecules/
log_stream.rs

1//! LogStream - Real-time log viewer component
2//!
3//! A scrollable log viewer with filtering and auto-scroll capabilities.
4//!
5//! # Example
6//!
7//! ```ignore
8//! // State initialization (in Model)
9//! let mut log_state = LogStreamState::new();
10//!
11//! // Pushing logs
12//! log_state.push_info("Brain #0", "Starting inference task");
13//! log_state.push_warn("Manager", "Queue depth > 100");
14//! log_state.push_error("Worker #42", "Connection timeout");
15//!
16//! // Rendering
17//! LogStream::new(&log_state)
18//!     .height(300.0)
19//!     .show_toolbar(true)
20//!     .show(ui);
21//! ```
22
23use crate::atoms::{Button, Input};
24use crate::semantics::LogSeverity;
25use crate::Theme;
26use egui::{FontFamily, Response, RichText, ScrollArea, Ui};
27use std::collections::VecDeque;
28use std::time::Instant;
29
30/// A single log entry
31#[derive(Clone, Debug)]
32pub struct LogEntry {
33    /// Timestamp when the log was created
34    pub timestamp: Instant,
35    /// Severity level
36    pub severity: LogSeverity,
37    /// Source identifier (e.g., "Brain #0", "Worker #42")
38    pub source: Option<String>,
39    /// Log message
40    pub message: String,
41}
42
43impl LogEntry {
44    /// Create a new log entry
45    pub fn new(severity: LogSeverity, message: impl Into<String>) -> Self {
46        Self {
47            timestamp: Instant::now(),
48            severity,
49            source: None,
50            message: message.into(),
51        }
52    }
53
54    /// Create with source
55    pub fn with_source(mut self, source: impl Into<String>) -> Self {
56        self.source = Some(source.into());
57        self
58    }
59}
60
61/// Filter settings for log display
62#[derive(Clone, Debug, Default)]
63pub struct LogFilter {
64    /// Minimum severity to show (None = show all)
65    pub min_severity: Option<LogSeverity>,
66    /// Source filter (None = show all)
67    pub source: Option<String>,
68    /// Text search query
69    pub search: String,
70}
71
72impl LogFilter {
73    /// Check if an entry passes the filter
74    pub fn matches(&self, entry: &LogEntry) -> bool {
75        // Severity filter
76        if let Some(min) = self.min_severity {
77            if entry.severity < min {
78                return false;
79            }
80        }
81
82        // Source filter
83        if let Some(ref src) = self.source {
84            if let Some(ref entry_src) = entry.source {
85                if !entry_src.contains(src) {
86                    return false;
87                }
88            } else {
89                return false;
90            }
91        }
92
93        // Search filter
94        if !self.search.is_empty() {
95            let query = self.search.to_lowercase();
96            let msg_match = entry.message.to_lowercase().contains(&query);
97            let src_match = entry
98                .source
99                .as_ref()
100                .map(|s| s.to_lowercase().contains(&query))
101                .unwrap_or(false);
102            if !msg_match && !src_match {
103                return false;
104            }
105        }
106
107        true
108    }
109}
110
111/// State for LogStream (owned by parent)
112pub struct LogStreamState {
113    entries: VecDeque<LogEntry>,
114    max_entries: usize,
115    /// Auto-scroll to bottom on new entries
116    pub auto_scroll: bool,
117    /// Current filter settings
118    pub filter: LogFilter,
119    /// Track if new entries were added (for auto-scroll)
120    scroll_to_bottom: bool,
121}
122
123impl Default for LogStreamState {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl LogStreamState {
130    /// Create a new empty state
131    pub fn new() -> Self {
132        Self {
133            entries: VecDeque::new(),
134            max_entries: 1000,
135            auto_scroll: true,
136            filter: LogFilter::default(),
137            scroll_to_bottom: false,
138        }
139    }
140
141    /// Set maximum entries to keep
142    pub fn with_max_entries(mut self, max: usize) -> Self {
143        self.max_entries = max;
144        self
145    }
146
147    /// Push a log entry
148    pub fn push(&mut self, entry: LogEntry) {
149        self.entries.push_back(entry);
150
151        // Trim old entries
152        while self.entries.len() > self.max_entries {
153            self.entries.pop_front();
154        }
155
156        if self.auto_scroll {
157            self.scroll_to_bottom = true;
158        }
159    }
160
161    /// Push a debug log
162    pub fn push_debug(&mut self, source: &str, message: impl Into<String>) {
163        self.push(LogEntry::new(LogSeverity::Debug, message).with_source(source));
164    }
165
166    /// Push an info log
167    pub fn push_info(&mut self, source: &str, message: impl Into<String>) {
168        self.push(LogEntry::new(LogSeverity::Info, message).with_source(source));
169    }
170
171    /// Push a warning log
172    pub fn push_warn(&mut self, source: &str, message: impl Into<String>) {
173        self.push(LogEntry::new(LogSeverity::Warn, message).with_source(source));
174    }
175
176    /// Push an error log
177    pub fn push_error(&mut self, source: &str, message: impl Into<String>) {
178        self.push(LogEntry::new(LogSeverity::Error, message).with_source(source));
179    }
180
181    /// Push a critical log
182    pub fn push_critical(&mut self, source: &str, message: impl Into<String>) {
183        self.push(LogEntry::new(LogSeverity::Critical, message).with_source(source));
184    }
185
186    /// Clear all entries
187    pub fn clear(&mut self) {
188        self.entries.clear();
189    }
190
191    /// Get total entry count
192    pub fn len(&self) -> usize {
193        self.entries.len()
194    }
195
196    /// Check if empty
197    pub fn is_empty(&self) -> bool {
198        self.entries.is_empty()
199    }
200
201    /// Get filtered entries
202    pub fn filtered_entries(&self) -> impl Iterator<Item = &LogEntry> {
203        self.entries.iter().filter(|e| self.filter.matches(e))
204    }
205
206    /// Get filtered entry count
207    pub fn filtered_len(&self) -> usize {
208        self.filtered_entries().count()
209    }
210
211    /// Check and consume scroll flag
212    fn take_scroll_flag(&mut self) -> bool {
213        std::mem::take(&mut self.scroll_to_bottom)
214    }
215}
216
217/// Timestamp display format
218#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
219pub enum TimestampFormat {
220    /// Don't show timestamp
221    None,
222    /// Show time only (HH:MM:SS)
223    #[default]
224    TimeOnly,
225    /// Show relative time (e.g., "2s ago")
226    Relative,
227}
228
229/// LogStream component - displays a scrollable log view
230pub struct LogStream<'a> {
231    state: &'a mut LogStreamState,
232    height: Option<f32>,
233    show_timestamp: bool,
234    show_source: bool,
235    show_toolbar: bool,
236    monospace: bool,
237    timestamp_format: TimestampFormat,
238}
239
240impl<'a> LogStream<'a> {
241    /// Create a new LogStream viewer
242    pub fn new(state: &'a mut LogStreamState) -> Self {
243        Self {
244            state,
245            height: None,
246            show_timestamp: true,
247            show_source: true,
248            show_toolbar: true,
249            monospace: true,
250            timestamp_format: TimestampFormat::TimeOnly,
251        }
252    }
253
254    /// Set fixed height (None = fill available space)
255    pub fn height(mut self, h: f32) -> Self {
256        self.height = Some(h);
257        self
258    }
259
260    /// Show/hide timestamp column
261    pub fn show_timestamp(mut self, show: bool) -> Self {
262        self.show_timestamp = show;
263        self
264    }
265
266    /// Show/hide source column
267    pub fn show_source(mut self, show: bool) -> Self {
268        self.show_source = show;
269        self
270    }
271
272    /// Show/hide toolbar (filter controls)
273    pub fn show_toolbar(mut self, show: bool) -> Self {
274        self.show_toolbar = show;
275        self
276    }
277
278    /// Use monospace font for log messages
279    pub fn monospace(mut self, mono: bool) -> Self {
280        self.monospace = mono;
281        self
282    }
283
284    /// Set timestamp format
285    pub fn timestamp_format(mut self, format: TimestampFormat) -> Self {
286        self.timestamp_format = format;
287        self
288    }
289
290    /// Show the log stream
291    pub fn show(mut self, ui: &mut Ui) -> Response {
292        let theme = Theme::current(ui.ctx());
293
294        let response = ui
295            .vertical(|ui| {
296                // Toolbar
297                if self.show_toolbar {
298                    self.render_toolbar(ui, &theme);
299                    ui.add_space(theme.spacing_sm);
300                }
301
302                // Log entries
303                self.render_entries(ui, &theme);
304            })
305            .response;
306
307        response
308    }
309
310    fn render_toolbar(&mut self, ui: &mut Ui, theme: &Theme) {
311        ui.horizontal(|ui| {
312            // Severity filter dropdown
313            let severity_options: [(_, Option<LogSeverity>); 6] = [
314                ("All", None),
315                ("Debug+", Some(LogSeverity::Debug)),
316                ("Info+", Some(LogSeverity::Info)),
317                ("Warn+", Some(LogSeverity::Warn)),
318                ("Error+", Some(LogSeverity::Error)),
319                ("Critical", Some(LogSeverity::Critical)),
320            ];
321
322            let current_label = severity_options
323                .iter()
324                .find(|(_, v)| *v == self.state.filter.min_severity)
325                .map(|(l, _)| *l)
326                .unwrap_or("All");
327
328            egui::ComboBox::from_id_salt("log_severity_filter")
329                .selected_text(current_label)
330                .width(80.0)
331                .show_ui(ui, |ui| {
332                    for (label, severity) in &severity_options {
333                        if ui
334                            .selectable_label(self.state.filter.min_severity == *severity, *label)
335                            .clicked()
336                        {
337                            self.state.filter.min_severity = *severity;
338                        }
339                    }
340                });
341
342            // Search input
343            ui.add_space(theme.spacing_sm);
344            Input::new()
345                .placeholder("Search...")
346                .desired_width(150.0)
347                .show(ui, &mut self.state.filter.search);
348
349            // Clear button
350            ui.add_space(theme.spacing_sm);
351            if Button::ghost("Clear").show(ui) {
352                self.state.clear();
353            }
354
355            // Auto-scroll toggle
356            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
357                ui.checkbox(&mut self.state.auto_scroll, "Auto-scroll");
358            });
359        });
360    }
361
362    fn render_entries(&mut self, ui: &mut Ui, theme: &Theme) {
363        let scroll_to_bottom = self.state.take_scroll_flag();
364
365        let scroll_area = if let Some(h) = self.height {
366            ScrollArea::vertical().max_height(h)
367        } else {
368            ScrollArea::vertical()
369        };
370
371        scroll_area
372            .auto_shrink([false, false])
373            .stick_to_bottom(scroll_to_bottom)
374            .show(ui, |ui| {
375                let now = Instant::now();
376
377                // Collect filtered entries (to avoid borrow issues)
378                let entries: Vec<_> = self
379                    .state
380                    .entries
381                    .iter()
382                    .filter(|e| self.state.filter.matches(e))
383                    .collect();
384
385                if entries.is_empty() {
386                    ui.label(
387                        RichText::new("No log entries")
388                            .italics()
389                            .color(theme.text_muted),
390                    );
391                } else {
392                    for entry in entries {
393                        self.render_entry(ui, entry, theme, now);
394                    }
395                }
396            });
397    }
398
399    fn render_entry(&self, ui: &mut Ui, entry: &LogEntry, theme: &Theme, now: Instant) {
400        let severity_color = entry.severity.color(theme);
401
402        ui.horizontal(|ui| {
403            // Timestamp
404            if self.show_timestamp && self.timestamp_format != TimestampFormat::None {
405                let ts_text = match self.timestamp_format {
406                    TimestampFormat::None => String::new(),
407                    TimestampFormat::TimeOnly => {
408                        let elapsed = now.duration_since(entry.timestamp);
409                        let secs = elapsed.as_secs();
410                        let mins = secs / 60;
411                        let hours = mins / 60;
412                        format!("{:02}:{:02}:{:02}", hours % 24, mins % 60, secs % 60)
413                    }
414                    TimestampFormat::Relative => {
415                        let elapsed = now.duration_since(entry.timestamp);
416                        if elapsed.as_secs() < 60 {
417                            format!("{}s ago", elapsed.as_secs())
418                        } else if elapsed.as_secs() < 3600 {
419                            format!("{}m ago", elapsed.as_secs() / 60)
420                        } else {
421                            format!("{}h ago", elapsed.as_secs() / 3600)
422                        }
423                    }
424                };
425                ui.label(RichText::new(ts_text).color(theme.text_muted).monospace());
426            }
427
428            // Severity icon
429            ui.label(
430                RichText::new(entry.severity.icon())
431                    .family(FontFamily::Name("icons".into()))
432                    .color(severity_color),
433            );
434
435            // Severity label
436            ui.label(
437                RichText::new(format!("[{}]", entry.severity.label()))
438                    .color(severity_color)
439                    .strong(),
440            );
441
442            // Source
443            if self.show_source {
444                if let Some(ref source) = entry.source {
445                    ui.label(RichText::new(source).color(theme.text_secondary));
446                }
447            }
448
449            // Message
450            let msg = if self.monospace {
451                RichText::new(&entry.message).monospace()
452            } else {
453                RichText::new(&entry.message)
454            };
455            ui.label(msg);
456        });
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn test_log_entry_creation() {
466        let entry = LogEntry::new(LogSeverity::Info, "Test message").with_source("TestSource");
467
468        assert_eq!(entry.severity, LogSeverity::Info);
469        assert_eq!(entry.message, "Test message");
470        assert_eq!(entry.source, Some("TestSource".to_string()));
471    }
472
473    #[test]
474    fn test_log_stream_state_push() {
475        let mut state = LogStreamState::new().with_max_entries(5);
476
477        for i in 0..10 {
478            state.push_info("test", format!("Message {}", i));
479        }
480
481        // Should only keep last 5
482        assert_eq!(state.len(), 5);
483    }
484
485    #[test]
486    fn test_log_filter_severity() {
487        let filter = LogFilter {
488            min_severity: Some(LogSeverity::Warn),
489            ..Default::default()
490        };
491
492        let debug = LogEntry::new(LogSeverity::Debug, "debug");
493        let info = LogEntry::new(LogSeverity::Info, "info");
494        let warn = LogEntry::new(LogSeverity::Warn, "warn");
495        let error = LogEntry::new(LogSeverity::Error, "error");
496
497        assert!(!filter.matches(&debug));
498        assert!(!filter.matches(&info));
499        assert!(filter.matches(&warn));
500        assert!(filter.matches(&error));
501    }
502
503    #[test]
504    fn test_log_filter_search() {
505        let filter = LogFilter {
506            search: "error".to_string(),
507            ..Default::default()
508        };
509
510        let entry1 = LogEntry::new(LogSeverity::Info, "An error occurred");
511        let entry2 = LogEntry::new(LogSeverity::Info, "All good");
512        let entry3 = LogEntry::new(LogSeverity::Info, "ok").with_source("ErrorHandler");
513
514        assert!(filter.matches(&entry1));
515        assert!(!filter.matches(&entry2));
516        assert!(filter.matches(&entry3)); // matches source
517    }
518
519    #[test]
520    fn test_filtered_entries() {
521        let mut state = LogStreamState::new();
522        state.push_debug("src", "debug msg");
523        state.push_info("src", "info msg");
524        state.push_warn("src", "warn msg");
525        state.push_error("src", "error msg");
526
527        state.filter.min_severity = Some(LogSeverity::Warn);
528
529        assert_eq!(state.filtered_len(), 2);
530    }
531}