Skip to main content

ratatui_interact/components/
log_viewer.rs

1//! Log viewer widget
2//!
3//! A scrollable text viewer with line numbers, search highlighting, and log-level coloring.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{LogViewer, LogViewerState, LogViewerStyle};
9//! use ratatui::layout::Rect;
10//!
11//! // Create content
12//! let content: Vec<String> = vec![
13//!     "[INFO] Application started".into(),
14//!     "[DEBUG] Loading config...".into(),
15//!     "[WARN] Config file not found, using defaults".into(),
16//!     "[ERROR] Connection failed".into(),
17//! ];
18//!
19//! // Create state
20//! let mut state = LogViewerState::new(content);
21//!
22//! // Create viewer
23//! let viewer = LogViewer::new(&state)
24//!     .title("Application Log")
25//!     .show_line_numbers(true);
26//! ```
27
28use ratatui::{
29    buffer::Buffer,
30    layout::{Constraint, Direction, Layout, Rect},
31    style::{Color, Style},
32    text::{Line, Span},
33    widgets::{
34        Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
35        Widget,
36    },
37};
38
39/// State for the log viewer widget
40#[derive(Debug, Clone)]
41pub struct LogViewerState {
42    /// Content lines
43    pub content: Vec<String>,
44    /// Vertical scroll position
45    pub scroll_y: usize,
46    /// Horizontal scroll position
47    pub scroll_x: usize,
48    /// Visible viewport height (set during render)
49    pub visible_height: usize,
50    /// Visible viewport width (set during render)
51    pub visible_width: usize,
52    /// Search state
53    pub search: SearchState,
54}
55
56/// Search state for log viewer
57#[derive(Debug, Clone, Default)]
58pub struct SearchState {
59    /// Whether search is active
60    pub active: bool,
61    /// Current search query
62    pub query: String,
63    /// Line indices that match the query
64    pub matches: Vec<usize>,
65    /// Current match index
66    pub current_match: usize,
67}
68
69impl LogViewerState {
70    /// Create a new log viewer state with content
71    pub fn new(content: Vec<String>) -> Self {
72        Self {
73            content,
74            scroll_y: 0,
75            scroll_x: 0,
76            visible_height: 0,
77            visible_width: 0,
78            search: SearchState::default(),
79        }
80    }
81
82    /// Create an empty log viewer state
83    pub fn empty() -> Self {
84        Self::new(Vec::new())
85    }
86
87    /// Set content
88    pub fn set_content(&mut self, content: Vec<String>) {
89        self.content = content;
90        self.scroll_y = 0;
91        self.scroll_x = 0;
92        self.search.matches.clear();
93    }
94
95    /// Append a line to content
96    pub fn append(&mut self, line: String) {
97        self.content.push(line);
98    }
99
100    /// Scroll up by one line
101    pub fn scroll_up(&mut self) {
102        self.scroll_y = self.scroll_y.saturating_sub(1);
103    }
104
105    /// Scroll down by one line
106    pub fn scroll_down(&mut self) {
107        if self.scroll_y + 1 < self.content.len() {
108            self.scroll_y += 1;
109        }
110    }
111
112    /// Scroll up by one page
113    pub fn page_up(&mut self) {
114        self.scroll_y = self.scroll_y.saturating_sub(self.visible_height);
115    }
116
117    /// Scroll down by one page
118    pub fn page_down(&mut self) {
119        let max_scroll = self.content.len().saturating_sub(self.visible_height);
120        self.scroll_y = (self.scroll_y + self.visible_height).min(max_scroll);
121    }
122
123    /// Scroll left
124    pub fn scroll_left(&mut self) {
125        self.scroll_x = self.scroll_x.saturating_sub(4);
126    }
127
128    /// Scroll right
129    pub fn scroll_right(&mut self) {
130        self.scroll_x += 4;
131    }
132
133    /// Go to top
134    pub fn go_to_top(&mut self) {
135        self.scroll_y = 0;
136    }
137
138    /// Go to bottom
139    pub fn go_to_bottom(&mut self) {
140        self.scroll_y = self.content.len().saturating_sub(self.visible_height);
141    }
142
143    /// Go to a specific line (0-indexed)
144    pub fn go_to_line(&mut self, line: usize) {
145        self.scroll_y = line.min(self.content.len().saturating_sub(1));
146    }
147
148    /// Start search mode
149    pub fn start_search(&mut self) {
150        self.search.active = true;
151        self.search.query.clear();
152        self.search.matches.clear();
153        self.search.current_match = 0;
154    }
155
156    /// Cancel search mode
157    pub fn cancel_search(&mut self) {
158        self.search.active = false;
159    }
160
161    /// Update search with new query
162    pub fn update_search(&mut self) {
163        self.search.matches.clear();
164        self.search.current_match = 0;
165
166        if self.search.query.is_empty() {
167            return;
168        }
169
170        let query = self.search.query.to_lowercase();
171        for (idx, line) in self.content.iter().enumerate() {
172            if line.to_lowercase().contains(&query) {
173                self.search.matches.push(idx);
174            }
175        }
176
177        // Jump to first match if any
178        if !self.search.matches.is_empty() {
179            self.scroll_y = self.search.matches[0];
180        }
181    }
182
183    /// Go to next search match
184    pub fn next_match(&mut self) {
185        if self.search.matches.is_empty() {
186            return;
187        }
188        self.search.current_match = (self.search.current_match + 1) % self.search.matches.len();
189        self.scroll_y = self.search.matches[self.search.current_match];
190    }
191
192    /// Go to previous search match
193    pub fn prev_match(&mut self) {
194        if self.search.matches.is_empty() {
195            return;
196        }
197        if self.search.current_match == 0 {
198            self.search.current_match = self.search.matches.len() - 1;
199        } else {
200            self.search.current_match -= 1;
201        }
202        self.scroll_y = self.search.matches[self.search.current_match];
203    }
204}
205
206/// Style configuration for log viewer
207#[derive(Debug, Clone)]
208pub struct LogViewerStyle {
209    /// Border style
210    pub border_style: Style,
211    /// Line number style
212    pub line_number_style: Style,
213    /// Default content style
214    pub content_style: Style,
215    /// Current search match highlight
216    pub current_match_style: Style,
217    /// Other search match highlight
218    pub match_style: Style,
219    /// Log level colors
220    pub level_colors: LogLevelColors,
221    /// Whether to show line numbers
222    pub show_line_numbers: bool,
223    /// Line number width
224    pub line_number_width: usize,
225}
226
227/// Colors for different log levels
228#[derive(Debug, Clone)]
229pub struct LogLevelColors {
230    pub error: Color,
231    pub warn: Color,
232    pub info: Color,
233    pub debug: Color,
234    pub trace: Color,
235    pub success: Color,
236}
237
238impl Default for LogLevelColors {
239    fn default() -> Self {
240        Self {
241            error: Color::Red,
242            warn: Color::Yellow,
243            info: Color::White,
244            debug: Color::DarkGray,
245            trace: Color::DarkGray,
246            success: Color::Green,
247        }
248    }
249}
250
251impl Default for LogViewerStyle {
252    fn default() -> Self {
253        Self {
254            border_style: Style::default().fg(Color::Cyan),
255            line_number_style: Style::default().fg(Color::DarkGray),
256            content_style: Style::default().fg(Color::White),
257            current_match_style: Style::default().bg(Color::Yellow).fg(Color::Black),
258            match_style: Style::default()
259                .bg(Color::Rgb(60, 60, 30))
260                .fg(Color::Yellow),
261            level_colors: LogLevelColors::default(),
262            show_line_numbers: true,
263            line_number_width: 6,
264        }
265    }
266}
267
268impl LogViewerStyle {
269    /// Get style for a line based on its content
270    pub fn style_for_line(&self, line: &str) -> Style {
271        // Check for log level indicators
272        let lower = line.to_lowercase();
273
274        if lower.contains("[error]") || lower.contains("error:") || lower.contains("failed") {
275            Style::default().fg(self.level_colors.error)
276        } else if lower.contains("[warn]") || lower.contains("warning:") {
277            Style::default().fg(self.level_colors.warn)
278        } else if lower.contains("[debug]") {
279            Style::default().fg(self.level_colors.debug)
280        } else if lower.contains("[trace]") {
281            Style::default().fg(self.level_colors.trace)
282        } else if lower.contains("✓")
283            || lower.contains("success")
284            || lower.contains("completed")
285            || lower.contains("[ok]")
286        {
287            Style::default().fg(self.level_colors.success)
288        } else if lower.contains("✗") {
289            Style::default().fg(self.level_colors.error)
290        } else if lower.contains("▶") || lower.contains("starting") {
291            Style::default().fg(Color::Blue)
292        } else {
293            self.content_style
294        }
295    }
296}
297
298/// Log viewer widget
299pub struct LogViewer<'a> {
300    state: &'a LogViewerState,
301    style: LogViewerStyle,
302    title: Option<&'a str>,
303}
304
305impl<'a> LogViewer<'a> {
306    /// Create a new log viewer
307    pub fn new(state: &'a LogViewerState) -> Self {
308        Self {
309            state,
310            style: LogViewerStyle::default(),
311            title: None,
312        }
313    }
314
315    /// Set the title
316    pub fn title(mut self, title: &'a str) -> Self {
317        self.title = Some(title);
318        self
319    }
320
321    /// Set the style
322    pub fn style(mut self, style: LogViewerStyle) -> Self {
323        self.style = style;
324        self
325    }
326
327    /// Enable or disable line numbers
328    pub fn show_line_numbers(mut self, show: bool) -> Self {
329        self.style.show_line_numbers = show;
330        self
331    }
332
333    /// Build content lines
334    fn build_lines(&self, inner: Rect) -> Vec<Line<'static>> {
335        let visible_height = inner.height as usize;
336        let visible_width = if self.style.show_line_numbers {
337            inner
338                .width
339                .saturating_sub(self.style.line_number_width as u16 + 1) as usize
340        } else {
341            inner.width as usize
342        };
343
344        let start_line = self.state.scroll_y;
345        let end_line = (start_line + visible_height).min(self.state.content.len());
346
347        let mut lines = Vec::new();
348
349        for line_idx in start_line..end_line {
350            let line = &self.state.content[line_idx];
351
352            // Check if this line is a search match
353            let is_match = self.state.search.matches.contains(&line_idx);
354            let is_current_match = self
355                .state
356                .search
357                .matches
358                .get(self.state.search.current_match)
359                == Some(&line_idx);
360
361            // Apply horizontal scroll
362            let chars: Vec<char> = line.chars().collect();
363            let display_line: String = chars
364                .iter()
365                .skip(self.state.scroll_x)
366                .take(visible_width)
367                .collect();
368
369            // Determine content style
370            let content_style = if is_current_match {
371                self.style.current_match_style
372            } else if is_match {
373                self.style.match_style
374            } else {
375                self.style.style_for_line(line)
376            };
377
378            let mut spans = Vec::new();
379
380            // Line number
381            if self.style.show_line_numbers {
382                let line_num = format!(
383                    "{:>width$} ",
384                    line_idx + 1,
385                    width = self.style.line_number_width
386                );
387                spans.push(Span::styled(line_num, self.style.line_number_style));
388            }
389
390            // Content
391            spans.push(Span::styled(display_line, content_style));
392
393            lines.push(Line::from(spans));
394        }
395
396        lines
397    }
398}
399
400impl Widget for LogViewer<'_> {
401    fn render(self, area: Rect, buf: &mut Buffer) {
402        // Layout: content + status bar + optional search bar
403        let constraints = if self.state.search.active {
404            vec![
405                Constraint::Min(1),
406                Constraint::Length(1),
407                Constraint::Length(1),
408            ]
409        } else {
410            vec![Constraint::Min(1), Constraint::Length(1)]
411        };
412
413        let chunks = Layout::default()
414            .direction(Direction::Vertical)
415            .constraints(constraints)
416            .split(area);
417
418        // Content area
419        let title = self.title.map(|t| format!(" {} ", t)).unwrap_or_default();
420        let block = Block::default()
421            .title(title)
422            .borders(Borders::ALL)
423            .border_style(self.style.border_style);
424
425        let inner = block.inner(chunks[0]);
426        block.render(chunks[0], buf);
427
428        // Content
429        let lines = self.build_lines(inner);
430        let para = Paragraph::new(lines);
431        para.render(inner, buf);
432
433        // Scrollbar
434        if self.state.content.len() > inner.height as usize {
435            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
436            let mut scrollbar_state =
437                ScrollbarState::new(self.state.content.len()).position(self.state.scroll_y);
438            scrollbar.render(inner, buf, &mut scrollbar_state);
439        }
440
441        // Status bar
442        render_status_bar(self.state, chunks[1], buf);
443
444        // Search bar
445        if self.state.search.active && chunks.len() > 2 {
446            render_search_bar(self.state, chunks[2], buf);
447        }
448    }
449}
450
451fn render_status_bar(state: &LogViewerState, area: Rect, buf: &mut Buffer) {
452    let total_lines = state.content.len();
453    let current_line = state.scroll_y + 1;
454    let percent = if total_lines > 0 {
455        (current_line as f64 / total_lines as f64 * 100.0) as u16
456    } else {
457        0
458    };
459
460    let h_scroll_info = if state.scroll_x > 0 {
461        format!(" | Col: {}", state.scroll_x + 1)
462    } else {
463        String::new()
464    };
465
466    let search_info = if !state.search.matches.is_empty() {
467        format!(
468            " | Match {}/{}",
469            state.search.current_match + 1,
470            state.search.matches.len()
471        )
472    } else if !state.search.query.is_empty() && state.search.matches.is_empty() {
473        " | No matches".to_string()
474    } else {
475        String::new()
476    };
477
478    let status = Line::from(vec![
479        Span::styled(" ↑↓", Style::default().fg(Color::Yellow)),
480        Span::raw(": scroll | "),
481        Span::styled("/", Style::default().fg(Color::Yellow)),
482        Span::raw(": search | "),
483        Span::styled("n/N", Style::default().fg(Color::Yellow)),
484        Span::raw(": next/prev | "),
485        Span::styled("g/G", Style::default().fg(Color::Yellow)),
486        Span::raw(": top/bottom | "),
487        Span::raw(format!(
488            "Line {}/{} ({}%){}{}",
489            current_line, total_lines, percent, h_scroll_info, search_info
490        )),
491    ]);
492
493    let para = Paragraph::new(status).style(Style::default().bg(Color::DarkGray));
494    para.render(area, buf);
495}
496
497fn render_search_bar(state: &LogViewerState, area: Rect, buf: &mut Buffer) {
498    let search_line = Line::from(vec![
499        Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
500        Span::raw(state.search.query.clone()),
501        Span::styled("▌", Style::default().fg(Color::White)),
502    ]);
503
504    let para = Paragraph::new(search_line).style(Style::default().bg(Color::Rgb(40, 40, 60)));
505    para.render(area, buf);
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_log_viewer_state_new() {
514        let content = vec!["Line 1".into(), "Line 2".into()];
515        let state = LogViewerState::new(content);
516        assert_eq!(state.content.len(), 2);
517        assert_eq!(state.scroll_y, 0);
518        assert_eq!(state.scroll_x, 0);
519    }
520
521    #[test]
522    fn test_log_viewer_state_empty() {
523        let state = LogViewerState::empty();
524        assert!(state.content.is_empty());
525    }
526
527    #[test]
528    fn test_log_viewer_state() {
529        let content = vec!["Line 1".into(), "Line 2".into(), "Line 3".into()];
530        let mut state = LogViewerState::new(content);
531
532        assert_eq!(state.scroll_y, 0);
533        state.scroll_down();
534        assert_eq!(state.scroll_y, 1);
535        state.scroll_up();
536        assert_eq!(state.scroll_y, 0);
537    }
538
539    #[test]
540    fn test_horizontal_scroll() {
541        let content = vec!["Long line of text".into()];
542        let mut state = LogViewerState::new(content);
543
544        state.scroll_right();
545        assert_eq!(state.scroll_x, 4);
546        state.scroll_right();
547        assert_eq!(state.scroll_x, 8);
548        state.scroll_left();
549        assert_eq!(state.scroll_x, 4);
550        state.scroll_left();
551        assert_eq!(state.scroll_x, 0);
552        state.scroll_left(); // Should not go negative
553        assert_eq!(state.scroll_x, 0);
554    }
555
556    #[test]
557    fn test_page_navigation() {
558        let content: Vec<String> = (0..100).map(|i| format!("Line {}", i)).collect();
559        let mut state = LogViewerState::new(content);
560        state.visible_height = 10;
561
562        state.page_down();
563        assert_eq!(state.scroll_y, 10);
564        state.page_down();
565        assert_eq!(state.scroll_y, 20);
566
567        state.page_up();
568        assert_eq!(state.scroll_y, 10);
569        state.page_up();
570        assert_eq!(state.scroll_y, 0);
571    }
572
573    #[test]
574    fn test_go_to_top_bottom() {
575        let content: Vec<String> = (0..50).map(|i| format!("Line {}", i)).collect();
576        let mut state = LogViewerState::new(content);
577        state.visible_height = 10;
578
579        state.go_to_bottom();
580        assert_eq!(state.scroll_y, 40); // 50 - 10
581
582        state.go_to_top();
583        assert_eq!(state.scroll_y, 0);
584    }
585
586    #[test]
587    fn test_go_to_line() {
588        let content: Vec<String> = (0..50).map(|i| format!("Line {}", i)).collect();
589        let mut state = LogViewerState::new(content);
590
591        state.go_to_line(25);
592        assert_eq!(state.scroll_y, 25);
593
594        state.go_to_line(100); // Clamped to max
595        assert_eq!(state.scroll_y, 49);
596    }
597
598    #[test]
599    fn test_set_content() {
600        let mut state = LogViewerState::new(vec!["Old content".into()]);
601        state.scroll_y = 10;
602        state.scroll_x = 5;
603        state.search.query = "test".into();
604
605        state.set_content(vec!["New content".into()]);
606        assert_eq!(state.content.len(), 1);
607        assert_eq!(state.content[0], "New content");
608        assert_eq!(state.scroll_y, 0);
609        assert_eq!(state.scroll_x, 0);
610    }
611
612    #[test]
613    fn test_append() {
614        let mut state = LogViewerState::new(vec!["Line 1".into()]);
615        state.append("Line 2".into());
616        assert_eq!(state.content.len(), 2);
617        assert_eq!(state.content[1], "Line 2");
618    }
619
620    #[test]
621    fn test_search() {
622        let content = vec![
623            "First line".into(),
624            "Second line with error".into(),
625            "Third line".into(),
626            "Another error here".into(),
627        ];
628        let mut state = LogViewerState::new(content);
629
630        state.start_search();
631        state.search.query = "error".into();
632        state.update_search();
633
634        assert_eq!(state.search.matches.len(), 2);
635        assert_eq!(state.search.matches[0], 1);
636        assert_eq!(state.search.matches[1], 3);
637    }
638
639    #[test]
640    fn test_search_case_insensitive() {
641        let content = vec![
642            "ERROR message".into(),
643            "error again".into(),
644            "No match".into(),
645        ];
646        let mut state = LogViewerState::new(content);
647
648        state.search.query = "error".into();
649        state.update_search();
650
651        assert_eq!(state.search.matches.len(), 2);
652    }
653
654    #[test]
655    fn test_search_empty_query() {
656        let content = vec!["Line 1".into(), "Line 2".into()];
657        let mut state = LogViewerState::new(content);
658
659        state.search.query = "".into();
660        state.update_search();
661
662        assert!(state.search.matches.is_empty());
663    }
664
665    #[test]
666    fn test_cancel_search() {
667        let mut state = LogViewerState::new(vec!["Test".into()]);
668        state.start_search();
669        assert!(state.search.active);
670        state.cancel_search();
671        assert!(!state.search.active);
672    }
673
674    #[test]
675    fn test_next_prev_match() {
676        let content = vec![
677            "Line 1".into(),
678            "Match here".into(),
679            "Line 3".into(),
680            "Match here too".into(),
681        ];
682        let mut state = LogViewerState::new(content);
683
684        state.search.query = "match".into();
685        state.update_search();
686
687        assert_eq!(state.search.current_match, 0);
688        state.next_match();
689        assert_eq!(state.search.current_match, 1);
690        state.next_match();
691        assert_eq!(state.search.current_match, 0); // Wrap around
692        state.prev_match();
693        assert_eq!(state.search.current_match, 1);
694    }
695
696    #[test]
697    fn test_next_prev_match_empty() {
698        let mut state = LogViewerState::new(vec!["No matches".into()]);
699        state.search.query = "xyz".into();
700        state.update_search();
701
702        // Should not panic with empty matches
703        state.next_match();
704        state.prev_match();
705        assert_eq!(state.search.current_match, 0);
706    }
707
708    #[test]
709    fn test_style_for_line() {
710        let style = LogViewerStyle::default();
711
712        let error_style = style.style_for_line("[ERROR] Something failed");
713        assert_eq!(error_style.fg, Some(Color::Red));
714
715        let warn_style = style.style_for_line("[WARN] Warning message");
716        assert_eq!(warn_style.fg, Some(Color::Yellow));
717
718        let success_style = style.style_for_line("✓ Task completed");
719        assert_eq!(success_style.fg, Some(Color::Green));
720    }
721
722    #[test]
723    fn test_style_for_line_debug_trace() {
724        let style = LogViewerStyle::default();
725
726        let debug_style = style.style_for_line("[DEBUG] Debug message");
727        assert_eq!(debug_style.fg, Some(Color::DarkGray));
728
729        let trace_style = style.style_for_line("[TRACE] Trace message");
730        assert_eq!(trace_style.fg, Some(Color::DarkGray));
731    }
732
733    #[test]
734    fn test_style_default_values() {
735        let style = LogViewerStyle::default();
736        assert!(style.show_line_numbers);
737        assert_eq!(style.line_number_width, 6);
738    }
739
740    #[test]
741    fn test_log_level_colors_default() {
742        let colors = LogLevelColors::default();
743        assert_eq!(colors.error, Color::Red);
744        assert_eq!(colors.warn, Color::Yellow);
745        assert_eq!(colors.info, Color::White);
746        assert_eq!(colors.debug, Color::DarkGray);
747        assert_eq!(colors.success, Color::Green);
748    }
749
750    #[test]
751    fn test_log_viewer_render() {
752        let content = vec!["[INFO] Test".into(), "[ERROR] Error".into()];
753        let state = LogViewerState::new(content);
754        let viewer = LogViewer::new(&state).title("Test Log");
755
756        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
757        viewer.render(Rect::new(0, 0, 80, 20), &mut buf);
758        // Just verify it doesn't panic
759    }
760
761    #[test]
762    fn test_log_viewer_show_line_numbers() {
763        let content = vec!["Line 1".into()];
764        let state = LogViewerState::new(content);
765        let viewer = LogViewer::new(&state).show_line_numbers(false);
766
767        let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
768        viewer.render(Rect::new(0, 0, 40, 10), &mut buf);
769    }
770}