1use 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#[derive(Clone, Debug)]
32pub struct LogEntry {
33 pub timestamp: Instant,
35 pub severity: LogSeverity,
37 pub source: Option<String>,
39 pub message: String,
41}
42
43impl LogEntry {
44 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 pub fn with_source(mut self, source: impl Into<String>) -> Self {
56 self.source = Some(source.into());
57 self
58 }
59}
60
61#[derive(Clone, Debug, Default)]
63pub struct LogFilter {
64 pub min_severity: Option<LogSeverity>,
66 pub source: Option<String>,
68 pub search: String,
70}
71
72impl LogFilter {
73 pub fn matches(&self, entry: &LogEntry) -> bool {
75 if let Some(min) = self.min_severity {
77 if entry.severity < min {
78 return false;
79 }
80 }
81
82 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 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
111pub struct LogStreamState {
113 entries: VecDeque<LogEntry>,
114 max_entries: usize,
115 pub auto_scroll: bool,
117 pub filter: LogFilter,
119 scroll_to_bottom: bool,
121}
122
123impl Default for LogStreamState {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl LogStreamState {
130 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 pub fn with_max_entries(mut self, max: usize) -> Self {
143 self.max_entries = max;
144 self
145 }
146
147 pub fn push(&mut self, entry: LogEntry) {
149 self.entries.push_back(entry);
150
151 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 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 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 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 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 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 pub fn clear(&mut self) {
188 self.entries.clear();
189 }
190
191 pub fn len(&self) -> usize {
193 self.entries.len()
194 }
195
196 pub fn is_empty(&self) -> bool {
198 self.entries.is_empty()
199 }
200
201 pub fn filtered_entries(&self) -> impl Iterator<Item = &LogEntry> {
203 self.entries.iter().filter(|e| self.filter.matches(e))
204 }
205
206 pub fn filtered_len(&self) -> usize {
208 self.filtered_entries().count()
209 }
210
211 fn take_scroll_flag(&mut self) -> bool {
213 std::mem::take(&mut self.scroll_to_bottom)
214 }
215}
216
217#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
219pub enum TimestampFormat {
220 None,
222 #[default]
224 TimeOnly,
225 Relative,
227}
228
229pub 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 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 pub fn height(mut self, h: f32) -> Self {
256 self.height = Some(h);
257 self
258 }
259
260 pub fn show_timestamp(mut self, show: bool) -> Self {
262 self.show_timestamp = show;
263 self
264 }
265
266 pub fn show_source(mut self, show: bool) -> Self {
268 self.show_source = show;
269 self
270 }
271
272 pub fn show_toolbar(mut self, show: bool) -> Self {
274 self.show_toolbar = show;
275 self
276 }
277
278 pub fn monospace(mut self, mono: bool) -> Self {
280 self.monospace = mono;
281 self
282 }
283
284 pub fn timestamp_format(mut self, format: TimestampFormat) -> Self {
286 self.timestamp_format = format;
287 self
288 }
289
290 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 if self.show_toolbar {
298 self.render_toolbar(ui, &theme);
299 ui.add_space(theme.spacing_sm);
300 }
301
302 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 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 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 ui.add_space(theme.spacing_sm);
351 if Button::ghost("Clear").show(ui) {
352 self.state.clear();
353 }
354
355 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 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 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 ui.label(
430 RichText::new(entry.severity.icon())
431 .family(FontFamily::Name("icons".into()))
432 .color(severity_color),
433 );
434
435 ui.label(
437 RichText::new(format!("[{}]", entry.severity.label()))
438 .color(severity_color)
439 .strong(),
440 );
441
442 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 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 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)); }
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}