Skip to main content

envision/component/status_log/
mod.rs

1//! A component for displaying scrolling status messages.
2//!
3//! [`StatusLog`] provides a scrolling list of status messages with severity levels,
4//! commonly used to display application status, progress updates, or log entries.
5//! State is stored in [`StatusLogState`] and updated via [`StatusLogMessage`].
6//!
7//! See also [`LogViewer`](super::LogViewer) for a searchable log viewer with
8//! severity filtering.
9//!
10//! # Example
11//!
12//! ```rust
13//! use envision::component::{StatusLog, StatusLogState, StatusLogLevel, Component};
14//!
15//! let mut state = StatusLogState::new();
16//!
17//! // Add messages with convenience methods
18//! state.info("Starting process...");
19//! state.success("Process completed");
20//! state.warning("Low disk space");
21//! state.error("Connection failed");
22//!
23//! // Messages are displayed newest first
24//! assert_eq!(state.len(), 4);
25//! ```
26
27pub mod entry;
28
29pub use entry::{StatusLogEntry, StatusLogLevel};
30
31use ratatui::widgets::{Block, Borders, List, ListItem};
32
33use super::{Component, EventContext, RenderContext};
34use crate::input::{Event, Key};
35
36/// Messages that can be sent to a StatusLog component.
37#[derive(Clone, Debug, PartialEq)]
38pub enum StatusLogMessage {
39    /// Add a new log entry.
40    Push {
41        /// The message content.
42        message: String,
43        /// Severity level.
44        level: StatusLogLevel,
45        /// Optional timestamp.
46        timestamp: Option<String>,
47    },
48    /// Clear all entries.
49    Clear,
50    /// Remove a specific entry by ID.
51    Remove(u64),
52    /// Scroll up by one line.
53    ScrollUp,
54    /// Scroll down by one line.
55    ScrollDown,
56    /// Scroll to the top (newest).
57    ScrollToTop,
58    /// Scroll to the bottom (oldest visible).
59    ScrollToBottom,
60}
61
62/// Output messages from a StatusLog component.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum StatusLogOutput {
65    /// An entry was added (returns ID).
66    Added(u64),
67    /// An entry was removed.
68    Removed(u64),
69    /// All entries were cleared.
70    Cleared,
71    /// An old entry was evicted due to max_entries limit.
72    Evicted(u64),
73}
74
75/// State for a StatusLog component.
76///
77/// Contains log entries and display configuration.
78///
79/// # Example
80///
81/// ```rust
82/// use envision::component::StatusLogState;
83///
84/// let mut state = StatusLogState::new()
85///     .with_max_entries(100)
86///     .with_show_timestamps(true);
87///
88/// state.info("Application started");
89/// ```
90#[derive(Clone, Debug, PartialEq)]
91#[cfg_attr(
92    feature = "serialization",
93    derive(serde::Serialize, serde::Deserialize)
94)]
95pub struct StatusLogState {
96    /// All log entries (stored in insertion order, displayed newest first).
97    entries: Vec<StatusLogEntry>,
98    /// Counter for generating unique IDs.
99    next_id: u64,
100    /// Maximum number of entries to keep.
101    max_entries: usize,
102    /// Whether to show timestamps.
103    show_timestamps: bool,
104    /// Scroll offset for viewing older entries.
105    scroll_offset: usize,
106    /// Title for the block.
107    title: Option<String>,
108}
109
110impl Default for StatusLogState {
111    fn default() -> Self {
112        Self {
113            entries: Vec::new(),
114            next_id: 0,
115            max_entries: 50,
116            show_timestamps: false,
117            scroll_offset: 0,
118            title: None,
119        }
120    }
121}
122
123impl StatusLogState {
124    /// Creates a new empty StatusLog state.
125    ///
126    /// # Example
127    ///
128    /// ```rust
129    /// use envision::component::StatusLogState;
130    ///
131    /// let state = StatusLogState::new();
132    /// assert!(state.is_empty());
133    /// ```
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Sets the maximum number of entries to keep.
139    ///
140    /// When this limit is exceeded, the oldest entries are evicted.
141    ///
142    /// # Example
143    ///
144    /// ```rust
145    /// use envision::component::StatusLogState;
146    ///
147    /// let state = StatusLogState::new().with_max_entries(100);
148    /// assert_eq!(state.max_entries(), 100);
149    /// ```
150    pub fn with_max_entries(mut self, max: usize) -> Self {
151        self.max_entries = max;
152        self
153    }
154
155    /// Sets whether to show timestamps.
156    ///
157    /// # Example
158    ///
159    /// ```rust
160    /// use envision::component::StatusLogState;
161    ///
162    /// let state = StatusLogState::new().with_show_timestamps(true);
163    /// assert!(state.show_timestamps());
164    /// ```
165    pub fn with_show_timestamps(mut self, show: bool) -> Self {
166        self.show_timestamps = show;
167        self
168    }
169
170    /// Sets the title for the log block.
171    ///
172    /// # Example
173    ///
174    /// ```rust
175    /// use envision::component::StatusLogState;
176    ///
177    /// let state = StatusLogState::new().with_title("Events");
178    /// assert_eq!(state.title(), Some("Events"));
179    /// ```
180    pub fn with_title(mut self, title: impl Into<String>) -> Self {
181        self.title = Some(title.into());
182        self
183    }
184
185    /// Adds an info-level message.
186    ///
187    /// # Returns
188    ///
189    /// The ID of the new entry.
190    ///
191    /// # Example
192    ///
193    /// ```rust
194    /// use envision::component::StatusLogState;
195    ///
196    /// let mut state = StatusLogState::new();
197    /// let id = state.info("Processing...");
198    /// assert_eq!(state.len(), 1);
199    /// ```
200    pub fn info(&mut self, message: impl Into<String>) -> u64 {
201        self.push(message, StatusLogLevel::Info, None)
202    }
203
204    /// Adds a success-level message.
205    ///
206    /// # Example
207    ///
208    /// ```rust
209    /// use envision::component::{StatusLogState, StatusLogLevel};
210    ///
211    /// let mut state = StatusLogState::new();
212    /// state.success("Build complete");
213    /// assert_eq!(state.entries()[0].level(), StatusLogLevel::Success);
214    /// ```
215    pub fn success(&mut self, message: impl Into<String>) -> u64 {
216        self.push(message, StatusLogLevel::Success, None)
217    }
218
219    /// Adds a warning-level message.
220    ///
221    /// # Example
222    ///
223    /// ```rust
224    /// use envision::component::{StatusLogState, StatusLogLevel};
225    ///
226    /// let mut state = StatusLogState::new();
227    /// state.warning("Low memory");
228    /// assert_eq!(state.entries()[0].level(), StatusLogLevel::Warning);
229    /// ```
230    pub fn warning(&mut self, message: impl Into<String>) -> u64 {
231        self.push(message, StatusLogLevel::Warning, None)
232    }
233
234    /// Adds an error-level message.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use envision::component::{StatusLogState, StatusLogLevel};
240    ///
241    /// let mut state = StatusLogState::new();
242    /// state.error("Connection failed");
243    /// assert_eq!(state.entries()[0].level(), StatusLogLevel::Error);
244    /// ```
245    pub fn error(&mut self, message: impl Into<String>) -> u64 {
246        self.push(message, StatusLogLevel::Error, None)
247    }
248
249    /// Adds an info-level message with timestamp.
250    ///
251    /// # Example
252    ///
253    /// ```rust
254    /// use envision::component::StatusLogState;
255    ///
256    /// let mut state = StatusLogState::new();
257    /// state.info_with_timestamp("Starting", "09:00:00");
258    /// assert_eq!(state.entries()[0].timestamp(), Some("09:00:00"));
259    /// ```
260    pub fn info_with_timestamp(
261        &mut self,
262        message: impl Into<String>,
263        timestamp: impl Into<String>,
264    ) -> u64 {
265        self.push(message, StatusLogLevel::Info, Some(timestamp.into()))
266    }
267
268    /// Adds a success-level message with timestamp.
269    ///
270    /// # Example
271    ///
272    /// ```rust
273    /// use envision::component::StatusLogState;
274    ///
275    /// let mut state = StatusLogState::new();
276    /// state.success_with_timestamp("Done", "09:01:00");
277    /// assert_eq!(state.entries()[0].message(), "Done");
278    /// ```
279    pub fn success_with_timestamp(
280        &mut self,
281        message: impl Into<String>,
282        timestamp: impl Into<String>,
283    ) -> u64 {
284        self.push(message, StatusLogLevel::Success, Some(timestamp.into()))
285    }
286
287    /// Adds a warning-level message with timestamp.
288    ///
289    /// # Example
290    ///
291    /// ```rust
292    /// use envision::component::StatusLogState;
293    ///
294    /// let mut state = StatusLogState::new();
295    /// state.warning_with_timestamp("Slow", "09:02:00");
296    /// assert_eq!(state.entries()[0].timestamp(), Some("09:02:00"));
297    /// ```
298    pub fn warning_with_timestamp(
299        &mut self,
300        message: impl Into<String>,
301        timestamp: impl Into<String>,
302    ) -> u64 {
303        self.push(message, StatusLogLevel::Warning, Some(timestamp.into()))
304    }
305
306    /// Adds an error-level message with timestamp.
307    ///
308    /// # Example
309    ///
310    /// ```rust
311    /// use envision::component::StatusLogState;
312    ///
313    /// let mut state = StatusLogState::new();
314    /// state.error_with_timestamp("Crash", "09:03:00");
315    /// assert_eq!(state.entries()[0].message(), "Crash");
316    /// ```
317    pub fn error_with_timestamp(
318        &mut self,
319        message: impl Into<String>,
320        timestamp: impl Into<String>,
321    ) -> u64 {
322        self.push(message, StatusLogLevel::Error, Some(timestamp.into()))
323    }
324
325    /// Internal method to push an entry.
326    fn push(
327        &mut self,
328        message: impl Into<String>,
329        level: StatusLogLevel,
330        timestamp: Option<String>,
331    ) -> u64 {
332        let id = self.next_id;
333        self.next_id += 1;
334
335        let entry = if let Some(ts) = timestamp {
336            StatusLogEntry::with_timestamp(id, message, level, ts)
337        } else {
338            StatusLogEntry::new(id, message, level)
339        };
340
341        self.entries.push(entry);
342        id
343    }
344
345    /// Enforces max_entries limit and returns evicted ID if any.
346    fn enforce_limit(&mut self) -> Option<u64> {
347        if self.entries.len() > self.max_entries {
348            let evicted = self.entries.remove(0);
349            Some(evicted.id)
350        } else {
351            None
352        }
353    }
354
355    /// Returns all entries.
356    ///
357    /// # Example
358    ///
359    /// ```rust
360    /// use envision::component::StatusLogState;
361    ///
362    /// let mut state = StatusLogState::new();
363    /// state.info("First");
364    /// state.error("Second");
365    /// assert_eq!(state.entries().len(), 2);
366    /// assert_eq!(state.entries()[0].message(), "First");
367    /// ```
368    pub fn entries(&self) -> &[StatusLogEntry] {
369        &self.entries
370    }
371
372    /// Returns entries in display order (newest first).
373    ///
374    /// # Example
375    ///
376    /// ```rust
377    /// use envision::component::StatusLogState;
378    ///
379    /// let mut state = StatusLogState::new();
380    /// state.info("First");
381    /// state.info("Second");
382    /// let newest: Vec<_> = state.entries_newest_first().collect();
383    /// assert_eq!(newest[0].message(), "Second");
384    /// assert_eq!(newest[1].message(), "First");
385    /// ```
386    pub fn entries_newest_first(&self) -> impl Iterator<Item = &StatusLogEntry> {
387        self.entries.iter().rev()
388    }
389
390    /// Returns the number of entries.
391    ///
392    /// # Example
393    ///
394    /// ```rust
395    /// use envision::component::StatusLogState;
396    ///
397    /// let mut state = StatusLogState::new();
398    /// assert_eq!(state.len(), 0);
399    /// state.info("Hello");
400    /// assert_eq!(state.len(), 1);
401    /// ```
402    pub fn len(&self) -> usize {
403        self.entries.len()
404    }
405
406    /// Returns true if there are no entries.
407    ///
408    /// # Example
409    ///
410    /// ```rust
411    /// use envision::component::StatusLogState;
412    ///
413    /// let state = StatusLogState::new();
414    /// assert!(state.is_empty());
415    /// ```
416    pub fn is_empty(&self) -> bool {
417        self.entries.is_empty()
418    }
419
420    /// Returns the maximum number of entries.
421    ///
422    /// # Example
423    ///
424    /// ```rust
425    /// use envision::component::StatusLogState;
426    ///
427    /// let state = StatusLogState::new();
428    /// assert_eq!(state.max_entries(), 50); // default
429    /// ```
430    pub fn max_entries(&self) -> usize {
431        self.max_entries
432    }
433
434    /// Sets the maximum number of entries.
435    ///
436    /// If the current count exceeds the new maximum, the oldest entries are
437    /// removed to bring the count within the limit.
438    ///
439    /// # Example
440    ///
441    /// ```rust
442    /// use envision::component::StatusLogState;
443    ///
444    /// let mut state = StatusLogState::new();
445    /// state.set_max_entries(10);
446    /// assert_eq!(state.max_entries(), 10);
447    /// ```
448    pub fn set_max_entries(&mut self, max: usize) {
449        self.max_entries = max;
450        if self.entries.len() > max {
451            let excess = self.entries.len() - max;
452            self.entries.drain(..excess);
453            // Clamp scroll offset after eviction
454            if self.scroll_offset >= self.entries.len() {
455                self.scroll_offset = self.entries.len().saturating_sub(1);
456            }
457        }
458    }
459
460    /// Returns whether timestamps are shown.
461    ///
462    /// # Example
463    ///
464    /// ```rust
465    /// use envision::component::StatusLogState;
466    ///
467    /// let state = StatusLogState::new();
468    /// assert!(!state.show_timestamps()); // disabled by default
469    /// ```
470    pub fn show_timestamps(&self) -> bool {
471        self.show_timestamps
472    }
473
474    /// Sets whether to show timestamps.
475    ///
476    /// # Example
477    ///
478    /// ```rust
479    /// use envision::component::StatusLogState;
480    ///
481    /// let mut state = StatusLogState::new();
482    /// state.set_show_timestamps(true);
483    /// assert!(state.show_timestamps());
484    /// ```
485    pub fn set_show_timestamps(&mut self, show: bool) {
486        self.show_timestamps = show;
487    }
488
489    /// Returns the current scroll offset.
490    ///
491    /// # Example
492    ///
493    /// ```rust
494    /// use envision::component::StatusLogState;
495    ///
496    /// let state = StatusLogState::new();
497    /// assert_eq!(state.scroll_offset(), 0);
498    /// ```
499    pub fn scroll_offset(&self) -> usize {
500        self.scroll_offset
501    }
502
503    /// Sets the scroll offset.
504    ///
505    /// # Example
506    ///
507    /// ```rust
508    /// use envision::component::StatusLogState;
509    ///
510    /// let mut state = StatusLogState::new();
511    /// state.info("A");
512    /// state.info("B");
513    /// state.info("C");
514    /// state.set_scroll_offset(1);
515    /// assert_eq!(state.scroll_offset(), 1);
516    /// ```
517    pub fn set_scroll_offset(&mut self, offset: usize) {
518        self.scroll_offset = offset.min(self.entries.len().saturating_sub(1));
519    }
520
521    /// Removes an entry by ID.
522    ///
523    /// # Example
524    ///
525    /// ```rust
526    /// use envision::component::StatusLogState;
527    ///
528    /// let mut state = StatusLogState::new();
529    /// let id = state.info("Temporary");
530    /// assert_eq!(state.len(), 1);
531    /// assert!(state.remove(id));
532    /// assert_eq!(state.len(), 0);
533    /// assert!(!state.remove(id)); // Already removed
534    /// ```
535    pub fn remove(&mut self, id: u64) -> bool {
536        let len_before = self.entries.len();
537        self.entries.retain(|e| e.id != id);
538        self.entries.len() < len_before
539    }
540
541    /// Clears all entries.
542    ///
543    /// # Example
544    ///
545    /// ```rust
546    /// use envision::component::StatusLogState;
547    ///
548    /// let mut state = StatusLogState::new();
549    /// state.info("A");
550    /// state.info("B");
551    /// state.clear();
552    /// assert!(state.is_empty());
553    /// assert_eq!(state.scroll_offset(), 0);
554    /// ```
555    pub fn clear(&mut self) {
556        self.entries.clear();
557        self.scroll_offset = 0;
558    }
559
560    /// Returns the title.
561    ///
562    /// # Example
563    ///
564    /// ```rust
565    /// use envision::component::StatusLogState;
566    ///
567    /// let state = StatusLogState::new().with_title("Log");
568    /// assert_eq!(state.title(), Some("Log"));
569    /// ```
570    pub fn title(&self) -> Option<&str> {
571        self.title.as_deref()
572    }
573
574    /// Sets the title.
575    ///
576    /// # Example
577    ///
578    /// ```rust
579    /// use envision::component::StatusLogState;
580    ///
581    /// let mut state = StatusLogState::new();
582    /// state.set_title(Some("Events".to_string()));
583    /// assert_eq!(state.title(), Some("Events"));
584    /// ```
585    pub fn set_title(&mut self, title: Option<String>) {
586        self.title = title;
587    }
588
589    /// Updates the status log state with a message, returning any output.
590    ///
591    /// # Example
592    ///
593    /// ```rust
594    /// use envision::component::{
595    ///     StatusLogState, StatusLogMessage, StatusLogOutput, StatusLogLevel,
596    /// };
597    ///
598    /// let mut state = StatusLogState::new();
599    /// let output = state.update(StatusLogMessage::Push {
600    ///     message: "Hello".to_string(),
601    ///     level: StatusLogLevel::Info,
602    ///     timestamp: None,
603    /// });
604    /// assert!(matches!(output, Some(StatusLogOutput::Added(_))));
605    /// assert_eq!(state.len(), 1);
606    /// ```
607    pub fn update(&mut self, msg: StatusLogMessage) -> Option<StatusLogOutput> {
608        StatusLog::update(self, msg)
609    }
610}
611
612/// A component for displaying scrolling status messages.
613///
614/// `StatusLog` displays messages with severity levels (Info, Success, Warning, Error),
615/// with the newest messages shown first.
616///
617/// # Visual Format
618///
619/// ```text
620/// ┌─Status─────────────────┐
621/// │ ✗ Connection failed    │
622/// │ ⚠ Low disk space       │
623/// │ ✓ Process completed    │
624/// │ ℹ Starting process...  │
625/// └────────────────────────┘
626/// ```
627///
628/// # Example
629///
630/// ```rust
631/// use envision::component::{StatusLog, StatusLogState, StatusLogMessage, StatusLogLevel, Component};
632///
633/// let mut state = StatusLogState::new();
634///
635/// // Add via convenience methods
636/// state.info("Starting...");
637///
638/// // Or via update
639/// StatusLog::update(&mut state, StatusLogMessage::Push {
640///     message: "Done!".to_string(),
641///     level: StatusLogLevel::Success,
642///     timestamp: None,
643/// });
644/// ```
645pub struct StatusLog;
646
647impl Component for StatusLog {
648    type State = StatusLogState;
649    type Message = StatusLogMessage;
650    type Output = StatusLogOutput;
651
652    fn init() -> Self::State {
653        StatusLogState::default()
654    }
655
656    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
657        match msg {
658            StatusLogMessage::Push {
659                message,
660                level,
661                timestamp,
662            } => {
663                let id = state.push(message, level, timestamp);
664                if let Some(evicted_id) = state.enforce_limit() {
665                    // Return evicted output if we hit the limit
666                    return Some(StatusLogOutput::Evicted(evicted_id));
667                }
668                Some(StatusLogOutput::Added(id))
669            }
670            StatusLogMessage::Clear => {
671                if state.entries.is_empty() {
672                    None
673                } else {
674                    state.clear();
675                    Some(StatusLogOutput::Cleared)
676                }
677            }
678            StatusLogMessage::Remove(id) => {
679                if state.remove(id) {
680                    Some(StatusLogOutput::Removed(id))
681                } else {
682                    None
683                }
684            }
685            StatusLogMessage::ScrollUp => {
686                if state.scroll_offset > 0 {
687                    state.scroll_offset -= 1;
688                }
689                None
690            }
691            StatusLogMessage::ScrollDown => {
692                if state.scroll_offset < state.entries.len().saturating_sub(1) {
693                    state.scroll_offset += 1;
694                }
695                None
696            }
697            StatusLogMessage::ScrollToTop => {
698                state.scroll_offset = 0;
699                None
700            }
701            StatusLogMessage::ScrollToBottom => {
702                state.scroll_offset = state.entries.len().saturating_sub(1);
703                None
704            }
705        }
706    }
707
708    fn handle_event(
709        _state: &Self::State,
710        event: &Event,
711        ctx: &EventContext,
712    ) -> Option<Self::Message> {
713        if !ctx.focused || ctx.disabled {
714            return None;
715        }
716        if let Some(key) = event.as_key() {
717            match key.code {
718                Key::Up | Key::Char('k') => Some(StatusLogMessage::ScrollUp),
719                Key::Down | Key::Char('j') => Some(StatusLogMessage::ScrollDown),
720                Key::Home => Some(StatusLogMessage::ScrollToTop),
721                Key::End => Some(StatusLogMessage::ScrollToBottom),
722                _ => None,
723            }
724        } else {
725            None
726        }
727    }
728
729    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
730        if ctx.area.width == 0 || ctx.area.height == 0 {
731            return;
732        }
733
734        crate::annotation::with_registry(|reg| {
735            reg.register(
736                ctx.area,
737                crate::annotation::Annotation::new(crate::annotation::WidgetType::StatusLog)
738                    .with_id("status_log")
739                    .with_meta("entry_count", state.len().to_string()),
740            );
741        });
742
743        let block = if let Some(title) = &state.title {
744            Block::default().borders(Borders::ALL).title(title.as_str())
745        } else {
746            Block::default().borders(Borders::ALL)
747        };
748
749        let inner = block.inner(ctx.area);
750
751        // Build list items (newest first, with scroll offset)
752        let items: Vec<ListItem> = state
753            .entries_newest_first()
754            .skip(state.scroll_offset)
755            .take(inner.height as usize)
756            .map(|entry| {
757                let prefix = entry.level.prefix();
758                let style = if ctx.disabled {
759                    ctx.theme.disabled_style()
760                } else {
761                    match entry.level {
762                        StatusLogLevel::Info => ctx.theme.info_style(),
763                        StatusLogLevel::Success => ctx.theme.success_style(),
764                        StatusLogLevel::Warning => ctx.theme.warning_style(),
765                        StatusLogLevel::Error => ctx.theme.error_style(),
766                    }
767                };
768
769                let content = if state.show_timestamps {
770                    if let Some(ts) = &entry.timestamp {
771                        format!("{} [{}] {}", prefix, ts, entry.message)
772                    } else {
773                        format!("{} {}", prefix, entry.message)
774                    }
775                } else {
776                    format!("{} {}", prefix, entry.message)
777                };
778
779                ListItem::new(content).style(style)
780            })
781            .collect();
782
783        ctx.frame.render_widget(block, ctx.area);
784
785        if !items.is_empty() {
786            let list = List::new(items);
787            ctx.frame.render_widget(list, inner);
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests;