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;