Skip to main content

envision/component/log_viewer/
mod.rs

1//! A searchable log viewer with severity filtering.
2//!
3//! `LogViewer` composes a [`StatusLog`](super::StatusLog) with an
4//! [`InputField`](super::InputField) search bar and severity-level toggle
5//! filters. Press `/` to focus the search bar, `Escape` to clear and return
6//! to the log, and `1`-`4` to toggle Info/Success/Warning/Error filters.
7//!
8//! # Example
9//!
10//! ```rust
11//! use envision::component::{
12//!     Component, Focusable, LogViewer, LogViewerState,
13//!     LogViewerMessage, LogViewerOutput,
14//! };
15//!
16//! let mut state = LogViewerState::new();
17//! state.push_info("Application started");
18//! state.push_warning("Disk space low");
19//! state.push_error("Connection failed");
20//!
21//! assert_eq!(state.visible_entries().len(), 3);
22//!
23//! // Filter to errors only (toggle off Info, Success, Warning)
24//! LogViewer::update(&mut state, LogViewerMessage::ToggleInfo);
25//! LogViewer::update(&mut state, LogViewerMessage::ToggleSuccess);
26//! LogViewer::update(&mut state, LogViewerMessage::ToggleWarning);
27//! assert_eq!(state.visible_entries().len(), 1);
28//! ```
29
30use std::marker::PhantomData;
31
32use ratatui::prelude::*;
33use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
34
35use super::{
36    Component, Focusable, InputFieldMessage, InputFieldState, StatusLogEntry, StatusLogLevel,
37};
38use crate::input::{Event, KeyCode, KeyModifiers};
39use crate::theme::Theme;
40
41/// Internal focus target for the log viewer.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43#[cfg_attr(
44    feature = "serialization",
45    derive(serde::Serialize, serde::Deserialize)
46)]
47enum Focus {
48    /// The log list is focused.
49    #[default]
50    Log,
51    /// The search bar is focused.
52    Search,
53}
54
55/// Messages that can be sent to a LogViewer.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum LogViewerMessage {
58    /// Scroll the log up by one line.
59    ScrollUp,
60    /// Scroll the log down by one line.
61    ScrollDown,
62    /// Scroll to the top of the log.
63    ScrollToTop,
64    /// Scroll to the bottom of the log.
65    ScrollToBottom,
66    /// Focus the search bar.
67    FocusSearch,
68    /// Return focus to the log (and optionally clear search).
69    FocusLog,
70    /// Type a character in the search bar.
71    SearchInput(char),
72    /// Delete character before cursor in search bar.
73    SearchBackspace,
74    /// Delete character at cursor in search bar.
75    SearchDelete,
76    /// Move search cursor left.
77    SearchLeft,
78    /// Move search cursor right.
79    SearchRight,
80    /// Move search cursor to start.
81    SearchHome,
82    /// Move search cursor to end.
83    SearchEnd,
84    /// Clear the search text.
85    ClearSearch,
86    /// Toggle the Info level filter.
87    ToggleInfo,
88    /// Toggle the Success level filter.
89    ToggleSuccess,
90    /// Toggle the Warning level filter.
91    ToggleWarning,
92    /// Toggle the Error level filter.
93    ToggleError,
94    /// Add an entry to the log.
95    Push {
96        /// The message text.
97        message: String,
98        /// The severity level.
99        level: StatusLogLevel,
100        /// Optional timestamp.
101        timestamp: Option<String>,
102    },
103    /// Clear all log entries.
104    Clear,
105    /// Remove a specific entry by ID.
106    Remove(u64),
107}
108
109/// Output messages from a LogViewer.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub enum LogViewerOutput {
112    /// A log entry was added.
113    Added(u64),
114    /// A log entry was removed.
115    Removed(u64),
116    /// All entries were cleared.
117    Cleared,
118    /// An old entry was evicted due to max_entries limit.
119    Evicted(u64),
120    /// The search text changed.
121    SearchChanged(String),
122    /// A filter toggle changed.
123    FilterChanged,
124}
125
126/// State for a LogViewer component.
127///
128/// Contains the log entries, search state, and severity filter toggles.
129#[derive(Clone, Debug)]
130pub struct LogViewerState {
131    /// All log entries in insertion order.
132    entries: Vec<StatusLogEntry>,
133    /// Next entry ID counter.
134    next_id: u64,
135    /// Maximum number of entries to keep.
136    max_entries: usize,
137    /// The search input field state.
138    search: InputFieldState,
139    /// The current search text (cached for filtering).
140    search_text: String,
141    /// Scroll offset for the visible log.
142    scroll_offset: usize,
143    /// Severity filter toggles (true = show).
144    show_info: bool,
145    /// Whether to show success entries.
146    show_success: bool,
147    /// Whether to show warning entries.
148    show_warning: bool,
149    /// Whether to show error entries.
150    show_error: bool,
151    /// Whether to show timestamps.
152    show_timestamps: bool,
153    /// Optional title for the log block.
154    title: Option<String>,
155    /// Internal focus state.
156    focus: Focus,
157    /// Whether the component is focused.
158    focused: bool,
159    /// Whether the component is disabled.
160    disabled: bool,
161}
162
163impl Default for LogViewerState {
164    fn default() -> Self {
165        Self {
166            entries: Vec::new(),
167            next_id: 0,
168            max_entries: 1000,
169            search: InputFieldState::new(),
170            search_text: String::new(),
171            scroll_offset: 0,
172            show_info: true,
173            show_success: true,
174            show_warning: true,
175            show_error: true,
176            show_timestamps: false,
177            title: None,
178            focus: Focus::Log,
179            focused: false,
180            disabled: false,
181        }
182    }
183}
184
185impl PartialEq for LogViewerState {
186    fn eq(&self, other: &Self) -> bool {
187        self.entries == other.entries
188            && self.next_id == other.next_id
189            && self.max_entries == other.max_entries
190            && self.search_text == other.search_text
191            && self.scroll_offset == other.scroll_offset
192            && self.show_info == other.show_info
193            && self.show_success == other.show_success
194            && self.show_warning == other.show_warning
195            && self.show_error == other.show_error
196            && self.show_timestamps == other.show_timestamps
197            && self.title == other.title
198            && self.focus == other.focus
199            && self.focused == other.focused
200            && self.disabled == other.disabled
201    }
202}
203
204impl LogViewerState {
205    /// Creates a new empty log viewer state.
206    ///
207    /// # Example
208    ///
209    /// ```rust
210    /// use envision::component::LogViewerState;
211    ///
212    /// let state = LogViewerState::new();
213    /// assert!(state.is_empty());
214    /// assert_eq!(state.max_entries(), 1000);
215    /// ```
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Sets the maximum number of entries (builder pattern).
221    pub fn with_max_entries(mut self, max: usize) -> Self {
222        self.max_entries = max;
223        self
224    }
225
226    /// Sets whether to show timestamps (builder pattern).
227    pub fn with_timestamps(mut self, show: bool) -> Self {
228        self.show_timestamps = show;
229        self
230    }
231
232    /// Sets the title (builder pattern).
233    pub fn with_title(mut self, title: impl Into<String>) -> Self {
234        self.title = Some(title.into());
235        self
236    }
237
238    /// Sets the disabled state (builder pattern).
239    pub fn with_disabled(mut self, disabled: bool) -> Self {
240        self.disabled = disabled;
241        self
242    }
243
244    // ---- Entry manipulation ----
245
246    /// Adds an info-level entry, returning its ID.
247    ///
248    /// # Example
249    ///
250    /// ```rust
251    /// use envision::component::LogViewerState;
252    ///
253    /// let mut state = LogViewerState::new();
254    /// let id = state.push_info("Server started");
255    /// assert_eq!(state.len(), 1);
256    /// ```
257    pub fn push_info(&mut self, message: impl Into<String>) -> u64 {
258        self.push_entry(message.into(), StatusLogLevel::Info, None)
259    }
260
261    /// Adds a success-level entry, returning its ID.
262    pub fn push_success(&mut self, message: impl Into<String>) -> u64 {
263        self.push_entry(message.into(), StatusLogLevel::Success, None)
264    }
265
266    /// Adds a warning-level entry, returning its ID.
267    pub fn push_warning(&mut self, message: impl Into<String>) -> u64 {
268        self.push_entry(message.into(), StatusLogLevel::Warning, None)
269    }
270
271    /// Adds an error-level entry, returning its ID.
272    pub fn push_error(&mut self, message: impl Into<String>) -> u64 {
273        self.push_entry(message.into(), StatusLogLevel::Error, None)
274    }
275
276    /// Adds an info-level entry with a timestamp, returning its ID.
277    pub fn push_info_with_timestamp(
278        &mut self,
279        message: impl Into<String>,
280        timestamp: impl Into<String>,
281    ) -> u64 {
282        self.push_entry(message.into(), StatusLogLevel::Info, Some(timestamp.into()))
283    }
284
285    /// Adds a success-level entry with a timestamp, returning its ID.
286    pub fn push_success_with_timestamp(
287        &mut self,
288        message: impl Into<String>,
289        timestamp: impl Into<String>,
290    ) -> u64 {
291        self.push_entry(
292            message.into(),
293            StatusLogLevel::Success,
294            Some(timestamp.into()),
295        )
296    }
297
298    /// Adds a warning-level entry with a timestamp, returning its ID.
299    pub fn push_warning_with_timestamp(
300        &mut self,
301        message: impl Into<String>,
302        timestamp: impl Into<String>,
303    ) -> u64 {
304        self.push_entry(
305            message.into(),
306            StatusLogLevel::Warning,
307            Some(timestamp.into()),
308        )
309    }
310
311    /// Adds an error-level entry with a timestamp, returning its ID.
312    pub fn push_error_with_timestamp(
313        &mut self,
314        message: impl Into<String>,
315        timestamp: impl Into<String>,
316    ) -> u64 {
317        self.push_entry(
318            message.into(),
319            StatusLogLevel::Error,
320            Some(timestamp.into()),
321        )
322    }
323
324    /// Internal method to add an entry.
325    fn push_entry(
326        &mut self,
327        message: String,
328        level: StatusLogLevel,
329        timestamp: Option<String>,
330    ) -> u64 {
331        let id = self.next_id;
332        self.next_id += 1;
333        let entry = match timestamp {
334            Some(ts) => StatusLogEntry::with_timestamp(id, message, level, ts),
335            None => StatusLogEntry::new(id, message, level),
336        };
337        self.entries.push(entry);
338        // Evict oldest if over limit
339        while self.entries.len() > self.max_entries {
340            self.entries.remove(0);
341        }
342        id
343    }
344
345    /// Removes an entry by ID. Returns true if the entry was found.
346    pub fn remove(&mut self, id: u64) -> bool {
347        if let Some(pos) = self.entries.iter().position(|e| e.id() == id) {
348            self.entries.remove(pos);
349            true
350        } else {
351            false
352        }
353    }
354
355    /// Clears all entries.
356    pub fn clear(&mut self) {
357        self.entries.clear();
358        self.scroll_offset = 0;
359    }
360
361    // ---- Accessors ----
362
363    /// Returns all entries in insertion order.
364    pub fn entries(&self) -> &[StatusLogEntry] {
365        &self.entries
366    }
367
368    /// Returns the number of entries.
369    pub fn len(&self) -> usize {
370        self.entries.len()
371    }
372
373    /// Returns true if there are no entries.
374    pub fn is_empty(&self) -> bool {
375        self.entries.is_empty()
376    }
377
378    /// Returns the maximum number of entries.
379    pub fn max_entries(&self) -> usize {
380        self.max_entries
381    }
382
383    /// Sets the maximum number of entries.
384    pub fn set_max_entries(&mut self, max: usize) {
385        self.max_entries = max;
386        while self.entries.len() > self.max_entries {
387            self.entries.remove(0);
388        }
389    }
390
391    /// Returns the current search text.
392    pub fn search_text(&self) -> &str {
393        &self.search_text
394    }
395
396    /// Returns the scroll offset.
397    pub fn scroll_offset(&self) -> usize {
398        self.scroll_offset
399    }
400
401    /// Sets the scroll offset.
402    pub fn set_scroll_offset(&mut self, offset: usize) {
403        let max = self.visible_entries().len().saturating_sub(1);
404        self.scroll_offset = offset.min(max);
405    }
406
407    /// Returns whether timestamps are shown.
408    pub fn show_timestamps(&self) -> bool {
409        self.show_timestamps
410    }
411
412    /// Sets whether timestamps are shown.
413    pub fn set_show_timestamps(&mut self, show: bool) {
414        self.show_timestamps = show;
415    }
416
417    /// Returns the title.
418    pub fn title(&self) -> Option<&str> {
419        self.title.as_deref()
420    }
421
422    /// Sets the title.
423    pub fn set_title(&mut self, title: Option<String>) {
424        self.title = title;
425    }
426
427    /// Returns true if info entries are shown.
428    pub fn show_info(&self) -> bool {
429        self.show_info
430    }
431
432    /// Returns true if success entries are shown.
433    pub fn show_success(&self) -> bool {
434        self.show_success
435    }
436
437    /// Returns true if warning entries are shown.
438    pub fn show_warning(&self) -> bool {
439        self.show_warning
440    }
441
442    /// Returns true if error entries are shown.
443    pub fn show_error(&self) -> bool {
444        self.show_error
445    }
446
447    /// Sets the info filter.
448    pub fn set_show_info(&mut self, show: bool) {
449        self.show_info = show;
450    }
451
452    /// Sets the success filter.
453    pub fn set_show_success(&mut self, show: bool) {
454        self.show_success = show;
455    }
456
457    /// Sets the warning filter.
458    pub fn set_show_warning(&mut self, show: bool) {
459        self.show_warning = show;
460    }
461
462    /// Sets the error filter.
463    pub fn set_show_error(&mut self, show: bool) {
464        self.show_error = show;
465    }
466
467    /// Returns whether the search bar is focused.
468    pub fn is_search_focused(&self) -> bool {
469        self.focus == Focus::Search
470    }
471
472    // ---- Filtering ----
473
474    /// Returns the entries that match the current filters and search text.
475    ///
476    /// Entries are returned newest-first.
477    ///
478    /// # Example
479    ///
480    /// ```rust
481    /// use envision::component::LogViewerState;
482    ///
483    /// let mut state = LogViewerState::new();
484    /// state.push_info("hello world");
485    /// state.push_error("connection failed");
486    /// state.push_info("hello again");
487    ///
488    /// assert_eq!(state.visible_entries().len(), 3);
489    /// ```
490    pub fn visible_entries(&self) -> Vec<&StatusLogEntry> {
491        self.entries
492            .iter()
493            .rev()
494            .filter(|entry| self.matches_filters(entry))
495            .collect()
496    }
497
498    /// Returns true if an entry passes both the level filter and search filter.
499    fn matches_filters(&self, entry: &StatusLogEntry) -> bool {
500        // Level filter
501        let level_ok = match entry.level() {
502            StatusLogLevel::Info => self.show_info,
503            StatusLogLevel::Success => self.show_success,
504            StatusLogLevel::Warning => self.show_warning,
505            StatusLogLevel::Error => self.show_error,
506        };
507
508        if !level_ok {
509            return false;
510        }
511
512        // Search text filter (case-insensitive)
513        if self.search_text.is_empty() {
514            return true;
515        }
516
517        let search_lower = self.search_text.to_lowercase();
518        entry.message().to_lowercase().contains(&search_lower)
519    }
520
521    // ---- Instance methods ----
522
523    /// Returns true if the component is focused.
524    pub fn is_focused(&self) -> bool {
525        self.focused
526    }
527
528    /// Sets the focus state.
529    pub fn set_focused(&mut self, focused: bool) {
530        self.focused = focused;
531    }
532
533    /// Returns true if the component is disabled.
534    pub fn is_disabled(&self) -> bool {
535        self.disabled
536    }
537
538    /// Sets the disabled state.
539    pub fn set_disabled(&mut self, disabled: bool) {
540        self.disabled = disabled;
541    }
542
543    /// Maps an input event to a log viewer message.
544    pub fn handle_event(&self, event: &Event) -> Option<LogViewerMessage> {
545        LogViewer::handle_event(self, event)
546    }
547
548    /// Dispatches an event, updating state and returning any output.
549    pub fn dispatch_event(&mut self, event: &Event) -> Option<LogViewerOutput> {
550        LogViewer::dispatch_event(self, event)
551    }
552
553    /// Updates the state with a message, returning any output.
554    pub fn update(&mut self, msg: LogViewerMessage) -> Option<LogViewerOutput> {
555        LogViewer::update(self, msg)
556    }
557}
558
559/// A searchable log viewer with severity filtering.
560///
561/// Composes a log display with a search input field and severity-level
562/// toggle filters. The search is case-insensitive and matches against
563/// entry messages.
564///
565/// # Key Bindings (Log Mode)
566///
567/// - `Up` / `k` — Scroll up
568/// - `Down` / `j` — Scroll down
569/// - `Home` — Scroll to top (newest)
570/// - `End` — Scroll to bottom (oldest)
571/// - `/` — Focus search bar
572/// - `1` — Toggle Info filter
573/// - `2` — Toggle Success filter
574/// - `3` — Toggle Warning filter
575/// - `4` — Toggle Error filter
576///
577/// # Key Bindings (Search Mode)
578///
579/// - `Escape` — Clear search and return to log
580/// - `Enter` — Return to log (keep search text)
581/// - Standard text editing keys
582pub struct LogViewer(PhantomData<()>);
583
584impl Component for LogViewer {
585    type State = LogViewerState;
586    type Message = LogViewerMessage;
587    type Output = LogViewerOutput;
588
589    fn init() -> Self::State {
590        LogViewerState::default()
591    }
592
593    fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
594        if !state.focused || state.disabled {
595            return None;
596        }
597
598        let key = event.as_key()?;
599
600        match state.focus {
601            Focus::Log => match key.code {
602                KeyCode::Up | KeyCode::Char('k') => Some(LogViewerMessage::ScrollUp),
603                KeyCode::Down | KeyCode::Char('j') => Some(LogViewerMessage::ScrollDown),
604                KeyCode::Home => Some(LogViewerMessage::ScrollToTop),
605                KeyCode::End => Some(LogViewerMessage::ScrollToBottom),
606                KeyCode::Char('/') => Some(LogViewerMessage::FocusSearch),
607                KeyCode::Char('1') => Some(LogViewerMessage::ToggleInfo),
608                KeyCode::Char('2') => Some(LogViewerMessage::ToggleSuccess),
609                KeyCode::Char('3') => Some(LogViewerMessage::ToggleWarning),
610                KeyCode::Char('4') => Some(LogViewerMessage::ToggleError),
611                _ => None,
612            },
613            Focus::Search => match key.code {
614                KeyCode::Esc => Some(LogViewerMessage::ClearSearch),
615                KeyCode::Enter => Some(LogViewerMessage::FocusLog),
616                KeyCode::Char(c) => {
617                    if key.modifiers.contains(KeyModifiers::CONTROL) {
618                        None
619                    } else {
620                        Some(LogViewerMessage::SearchInput(c))
621                    }
622                }
623                KeyCode::Backspace => Some(LogViewerMessage::SearchBackspace),
624                KeyCode::Delete => Some(LogViewerMessage::SearchDelete),
625                KeyCode::Left => Some(LogViewerMessage::SearchLeft),
626                KeyCode::Right => Some(LogViewerMessage::SearchRight),
627                KeyCode::Home => Some(LogViewerMessage::SearchHome),
628                KeyCode::End => Some(LogViewerMessage::SearchEnd),
629                _ => None,
630            },
631        }
632    }
633
634    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
635        if state.disabled {
636            return None;
637        }
638
639        match msg {
640            LogViewerMessage::ScrollUp => {
641                if state.scroll_offset > 0 {
642                    state.scroll_offset -= 1;
643                }
644                None
645            }
646            LogViewerMessage::ScrollDown => {
647                let max = state.visible_entries().len().saturating_sub(1);
648                if state.scroll_offset < max {
649                    state.scroll_offset += 1;
650                }
651                None
652            }
653            LogViewerMessage::ScrollToTop => {
654                state.scroll_offset = 0;
655                None
656            }
657            LogViewerMessage::ScrollToBottom => {
658                let max = state.visible_entries().len().saturating_sub(1);
659                state.scroll_offset = max;
660                None
661            }
662            LogViewerMessage::FocusSearch => {
663                state.focus = Focus::Search;
664                state.search.set_focused(true);
665                None
666            }
667            LogViewerMessage::FocusLog => {
668                state.focus = Focus::Log;
669                state.search.set_focused(false);
670                None
671            }
672            LogViewerMessage::SearchInput(c) => {
673                state.search.update(InputFieldMessage::Insert(c));
674                state.search_text = state.search.value().to_string();
675                state.scroll_offset = 0;
676                Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
677            }
678            LogViewerMessage::SearchBackspace => {
679                state.search.update(InputFieldMessage::Backspace);
680                state.search_text = state.search.value().to_string();
681                state.scroll_offset = 0;
682                Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
683            }
684            LogViewerMessage::SearchDelete => {
685                state.search.update(InputFieldMessage::Delete);
686                state.search_text = state.search.value().to_string();
687                state.scroll_offset = 0;
688                Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
689            }
690            LogViewerMessage::SearchLeft => {
691                state.search.update(InputFieldMessage::Left);
692                None
693            }
694            LogViewerMessage::SearchRight => {
695                state.search.update(InputFieldMessage::Right);
696                None
697            }
698            LogViewerMessage::SearchHome => {
699                state.search.update(InputFieldMessage::Home);
700                None
701            }
702            LogViewerMessage::SearchEnd => {
703                state.search.update(InputFieldMessage::End);
704                None
705            }
706            LogViewerMessage::ClearSearch => {
707                state.search.update(InputFieldMessage::Clear);
708                state.search_text.clear();
709                state.scroll_offset = 0;
710                state.focus = Focus::Log;
711                state.search.set_focused(false);
712                Some(LogViewerOutput::SearchChanged(String::new()))
713            }
714            LogViewerMessage::ToggleInfo => {
715                state.show_info = !state.show_info;
716                state.scroll_offset = 0;
717                Some(LogViewerOutput::FilterChanged)
718            }
719            LogViewerMessage::ToggleSuccess => {
720                state.show_success = !state.show_success;
721                state.scroll_offset = 0;
722                Some(LogViewerOutput::FilterChanged)
723            }
724            LogViewerMessage::ToggleWarning => {
725                state.show_warning = !state.show_warning;
726                state.scroll_offset = 0;
727                Some(LogViewerOutput::FilterChanged)
728            }
729            LogViewerMessage::ToggleError => {
730                state.show_error = !state.show_error;
731                state.scroll_offset = 0;
732                Some(LogViewerOutput::FilterChanged)
733            }
734            LogViewerMessage::Push {
735                message,
736                level,
737                timestamp,
738            } => {
739                let id = state.push_entry(message, level, timestamp);
740                // Check if eviction happened
741                if state.entries.len() > state.max_entries {
742                    // Already evicted in push_entry
743                }
744                Some(LogViewerOutput::Added(id))
745            }
746            LogViewerMessage::Clear => {
747                state.clear();
748                Some(LogViewerOutput::Cleared)
749            }
750            LogViewerMessage::Remove(id) => {
751                if state.remove(id) {
752                    Some(LogViewerOutput::Removed(id))
753                } else {
754                    None
755                }
756            }
757        }
758    }
759
760    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
761        if area.height < 3 {
762            return;
763        }
764
765        // Layout: search bar (1 line) + filter bar (1 line) + log area
766        let chunks = Layout::default()
767            .direction(Direction::Vertical)
768            .constraints([
769                Constraint::Length(1),
770                Constraint::Length(1),
771                Constraint::Min(1),
772            ])
773            .split(area);
774
775        let search_area = chunks[0];
776        let filter_area = chunks[1];
777        let log_area = chunks[2];
778
779        // Render search bar
780        render_search_bar(state, frame, search_area, theme);
781
782        // Render filter bar
783        render_filter_bar(state, frame, filter_area, theme);
784
785        // Render log entries
786        render_log(state, frame, log_area, theme);
787    }
788}
789
790impl Focusable for LogViewer {
791    fn is_focused(state: &Self::State) -> bool {
792        state.focused
793    }
794
795    fn set_focused(state: &mut Self::State, focused: bool) {
796        state.focused = focused;
797    }
798}
799
800/// Renders the search bar area.
801fn render_search_bar(state: &LogViewerState, frame: &mut Frame, area: Rect, theme: &Theme) {
802    let search_style = if state.disabled {
803        theme.disabled_style()
804    } else if state.focus == Focus::Search {
805        theme.focused_style()
806    } else {
807        theme.normal_style()
808    };
809
810    let prefix = if state.search_text.is_empty() {
811        "/ Search..."
812    } else {
813        ""
814    };
815
816    let display = if state.search_text.is_empty() {
817        prefix.to_string()
818    } else {
819        format!("/ {}", state.search.value())
820    };
821
822    let paragraph = Paragraph::new(display).style(search_style);
823    frame.render_widget(paragraph, area);
824
825    // Show cursor when search is focused
826    if state.focused && state.focus == Focus::Search && !state.disabled {
827        let cursor_x = area.x + 2 + state.search.cursor_display_position() as u16;
828        if cursor_x < area.right() {
829            frame.set_cursor_position(Position::new(cursor_x, area.y));
830        }
831    }
832}
833
834/// Renders the filter bar showing which severity levels are active.
835fn render_filter_bar(state: &LogViewerState, frame: &mut Frame, area: Rect, theme: &Theme) {
836    let filter_style = if state.disabled {
837        theme.disabled_style()
838    } else {
839        theme.normal_style()
840    };
841
842    let info_marker = if state.show_info { "●" } else { "○" };
843    let success_marker = if state.show_success { "●" } else { "○" };
844    let warning_marker = if state.show_warning { "●" } else { "○" };
845    let error_marker = if state.show_error { "●" } else { "○" };
846
847    let spans = vec![
848        Span::styled(
849            format!("1:{} Info ", info_marker),
850            if state.disabled {
851                filter_style
852            } else {
853                Style::default().fg(StatusLogLevel::Info.color())
854            },
855        ),
856        Span::styled(
857            format!("2:{} Success ", success_marker),
858            if state.disabled {
859                filter_style
860            } else {
861                Style::default().fg(StatusLogLevel::Success.color())
862            },
863        ),
864        Span::styled(
865            format!("3:{} Warning ", warning_marker),
866            if state.disabled {
867                filter_style
868            } else {
869                Style::default().fg(StatusLogLevel::Warning.color())
870            },
871        ),
872        Span::styled(
873            format!("4:{} Error", error_marker),
874            if state.disabled {
875                filter_style
876            } else {
877                Style::default().fg(StatusLogLevel::Error.color())
878            },
879        ),
880    ];
881
882    let line = Line::from(spans);
883    let paragraph = Paragraph::new(line);
884    frame.render_widget(paragraph, area);
885}
886
887/// Renders the log entries area.
888fn render_log(state: &LogViewerState, frame: &mut Frame, area: Rect, theme: &Theme) {
889    let visible = state.visible_entries();
890
891    let border_style = if state.disabled {
892        theme.disabled_style()
893    } else if state.focused && state.focus == Focus::Log {
894        theme.focused_border_style()
895    } else {
896        theme.border_style()
897    };
898
899    let mut block = Block::default()
900        .borders(Borders::ALL)
901        .border_style(border_style);
902
903    if let Some(ref title) = state.title {
904        let match_count = visible.len();
905        let total_count = state.entries.len();
906        if match_count < total_count {
907            block = block.title(format!("{} ({}/{})", title, match_count, total_count));
908        } else {
909            block = block.title(format!("{} ({})", title, total_count));
910        }
911    }
912
913    let inner = block.inner(area);
914    frame.render_widget(block, area);
915
916    if inner.height == 0 || inner.width == 0 {
917        return;
918    }
919
920    let items: Vec<ListItem> = visible
921        .iter()
922        .skip(state.scroll_offset)
923        .take(inner.height as usize)
924        .map(|entry| {
925            let style = if state.disabled {
926                theme.disabled_style()
927            } else {
928                Style::default().fg(entry.level().color())
929            };
930
931            let mut text = String::new();
932            text.push_str(entry.level().prefix());
933            text.push(' ');
934
935            if state.show_timestamps {
936                if let Some(ts) = entry.timestamp() {
937                    text.push_str(ts);
938                    text.push(' ');
939                }
940            }
941
942            text.push_str(entry.message());
943
944            // Highlight search matches
945            if !state.search_text.is_empty() && !state.disabled {
946                let msg_lower = text.to_lowercase();
947                let search_lower = state.search_text.to_lowercase();
948                if msg_lower.contains(&search_lower) {
949                    // For simplicity, apply a highlight style to the entire line
950                    // when it contains a match
951                    let style = style.add_modifier(Modifier::BOLD);
952                    return ListItem::new(text).style(style);
953                }
954            }
955
956            ListItem::new(text).style(style)
957        })
958        .collect();
959
960    let list = List::new(items);
961    frame.render_widget(list, inner);
962}
963
964#[cfg(test)]
965mod tests;