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 From<&crate::theme::Theme> for LogLevelColors {
252    fn from(theme: &crate::theme::Theme) -> Self {
253        let p = &theme.palette;
254        Self {
255            error: p.error,
256            warn: p.warning,
257            info: p.text,
258            debug: p.text_disabled,
259            trace: p.text_disabled,
260            success: p.success,
261        }
262    }
263}
264
265impl Default for LogViewerStyle {
266    fn default() -> Self {
267        Self {
268            border_style: Style::default().fg(Color::Cyan),
269            line_number_style: Style::default().fg(Color::DarkGray),
270            content_style: Style::default().fg(Color::White),
271            current_match_style: Style::default().bg(Color::Yellow).fg(Color::Black),
272            match_style: Style::default()
273                .bg(Color::Rgb(60, 60, 30))
274                .fg(Color::Yellow),
275            level_colors: LogLevelColors::default(),
276            show_line_numbers: true,
277            line_number_width: 6,
278        }
279    }
280}
281
282impl From<&crate::theme::Theme> for LogViewerStyle {
283    fn from(theme: &crate::theme::Theme) -> Self {
284        let p = &theme.palette;
285        Self {
286            border_style: Style::default().fg(p.border_accent),
287            line_number_style: Style::default().fg(p.text_disabled),
288            content_style: Style::default().fg(p.text),
289            current_match_style: Style::default().bg(p.highlight_bg).fg(p.highlight_fg),
290            match_style: Style::default().bg(Color::Rgb(60, 60, 30)).fg(p.primary),
291            level_colors: LogLevelColors::from(theme),
292            show_line_numbers: true,
293            line_number_width: 6,
294        }
295    }
296}
297
298impl LogViewerStyle {
299    /// Get style for a line based on its content
300    pub fn style_for_line(&self, line: &str) -> Style {
301        // Check for log level indicators
302        let lower = line.to_lowercase();
303
304        if lower.contains("[error]") || lower.contains("error:") || lower.contains("failed") {
305            Style::default().fg(self.level_colors.error)
306        } else if lower.contains("[warn]") || lower.contains("warning:") {
307            Style::default().fg(self.level_colors.warn)
308        } else if lower.contains("[debug]") {
309            Style::default().fg(self.level_colors.debug)
310        } else if lower.contains("[trace]") {
311            Style::default().fg(self.level_colors.trace)
312        } else if lower.contains("✓")
313            || lower.contains("success")
314            || lower.contains("completed")
315            || lower.contains("[ok]")
316        {
317            Style::default().fg(self.level_colors.success)
318        } else if lower.contains("✗") {
319            Style::default().fg(self.level_colors.error)
320        } else if lower.contains("▶") || lower.contains("starting") {
321            Style::default().fg(Color::Blue)
322        } else {
323            self.content_style
324        }
325    }
326}
327
328/// Log viewer widget
329pub struct LogViewer<'a> {
330    state: &'a LogViewerState,
331    style: LogViewerStyle,
332    title: Option<&'a str>,
333}
334
335impl<'a> LogViewer<'a> {
336    /// Create a new log viewer
337    pub fn new(state: &'a LogViewerState) -> Self {
338        Self {
339            state,
340            style: LogViewerStyle::default(),
341            title: None,
342        }
343    }
344
345    /// Set the title
346    pub fn title(mut self, title: &'a str) -> Self {
347        self.title = Some(title);
348        self
349    }
350
351    /// Set the style
352    pub fn style(mut self, style: LogViewerStyle) -> Self {
353        self.style = style;
354        self
355    }
356
357    /// Apply a theme to derive the style
358    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
359        self.style(LogViewerStyle::from(theme))
360    }
361
362    /// Enable or disable line numbers
363    pub fn show_line_numbers(mut self, show: bool) -> Self {
364        self.style.show_line_numbers = show;
365        self
366    }
367
368    /// Build content lines
369    fn build_lines(&self, inner: Rect) -> Vec<Line<'static>> {
370        let visible_height = inner.height as usize;
371        let visible_width = if self.style.show_line_numbers {
372            inner
373                .width
374                .saturating_sub(self.style.line_number_width as u16 + 1) as usize
375        } else {
376            inner.width as usize
377        };
378
379        let start_line = self.state.scroll_y;
380        let end_line = (start_line + visible_height).min(self.state.content.len());
381
382        let mut lines = Vec::new();
383
384        for line_idx in start_line..end_line {
385            let line = &self.state.content[line_idx];
386
387            // Check if this line is a search match
388            let is_match = self.state.search.matches.contains(&line_idx);
389            let is_current_match = self
390                .state
391                .search
392                .matches
393                .get(self.state.search.current_match)
394                == Some(&line_idx);
395
396            // Apply horizontal scroll
397            let chars: Vec<char> = line.chars().collect();
398            let display_line: String = chars
399                .iter()
400                .skip(self.state.scroll_x)
401                .take(visible_width)
402                .collect();
403
404            // Determine content style
405            let content_style = if is_current_match {
406                self.style.current_match_style
407            } else if is_match {
408                self.style.match_style
409            } else {
410                self.style.style_for_line(line)
411            };
412
413            let mut spans = Vec::new();
414
415            // Line number
416            if self.style.show_line_numbers {
417                let line_num = format!(
418                    "{:>width$} ",
419                    line_idx + 1,
420                    width = self.style.line_number_width
421                );
422                spans.push(Span::styled(line_num, self.style.line_number_style));
423            }
424
425            // Content
426            spans.push(Span::styled(display_line, content_style));
427
428            lines.push(Line::from(spans));
429        }
430
431        lines
432    }
433}
434
435impl Widget for LogViewer<'_> {
436    fn render(self, area: Rect, buf: &mut Buffer) {
437        // Layout: content + status bar + optional search bar
438        let constraints = if self.state.search.active {
439            vec![
440                Constraint::Min(1),
441                Constraint::Length(1),
442                Constraint::Length(1),
443            ]
444        } else {
445            vec![Constraint::Min(1), Constraint::Length(1)]
446        };
447
448        let chunks = Layout::default()
449            .direction(Direction::Vertical)
450            .constraints(constraints)
451            .split(area);
452
453        // Content area
454        let title = self.title.map(|t| format!(" {} ", t)).unwrap_or_default();
455        let block = Block::default()
456            .title(title)
457            .borders(Borders::ALL)
458            .border_style(self.style.border_style);
459
460        let inner = block.inner(chunks[0]);
461        block.render(chunks[0], buf);
462
463        // Content
464        let lines = self.build_lines(inner);
465        let para = Paragraph::new(lines);
466        para.render(inner, buf);
467
468        // Scrollbar
469        if self.state.content.len() > inner.height as usize {
470            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
471            let mut scrollbar_state =
472                ScrollbarState::new(self.state.content.len()).position(self.state.scroll_y);
473            scrollbar.render(inner, buf, &mut scrollbar_state);
474        }
475
476        // Status bar
477        render_status_bar(self.state, chunks[1], buf);
478
479        // Search bar
480        if self.state.search.active && chunks.len() > 2 {
481            render_search_bar(self.state, chunks[2], buf);
482        }
483    }
484}
485
486fn render_status_bar(state: &LogViewerState, area: Rect, buf: &mut Buffer) {
487    let total_lines = state.content.len();
488    let current_line = state.scroll_y + 1;
489    let percent = if total_lines > 0 {
490        (current_line as f64 / total_lines as f64 * 100.0) as u16
491    } else {
492        0
493    };
494
495    let h_scroll_info = if state.scroll_x > 0 {
496        format!(" | Col: {}", state.scroll_x + 1)
497    } else {
498        String::new()
499    };
500
501    let search_info = if !state.search.matches.is_empty() {
502        format!(
503            " | Match {}/{}",
504            state.search.current_match + 1,
505            state.search.matches.len()
506        )
507    } else if !state.search.query.is_empty() && state.search.matches.is_empty() {
508        " | No matches".to_string()
509    } else {
510        String::new()
511    };
512
513    let status = Line::from(vec![
514        Span::styled(" ↑↓", Style::default().fg(Color::Yellow)),
515        Span::raw(": scroll | "),
516        Span::styled("/", Style::default().fg(Color::Yellow)),
517        Span::raw(": search | "),
518        Span::styled("n/N", Style::default().fg(Color::Yellow)),
519        Span::raw(": next/prev | "),
520        Span::styled("g/G", Style::default().fg(Color::Yellow)),
521        Span::raw(": top/bottom | "),
522        Span::raw(format!(
523            "Line {}/{} ({}%){}{}",
524            current_line, total_lines, percent, h_scroll_info, search_info
525        )),
526    ]);
527
528    let para = Paragraph::new(status).style(Style::default().bg(Color::DarkGray));
529    para.render(area, buf);
530}
531
532fn render_search_bar(state: &LogViewerState, area: Rect, buf: &mut Buffer) {
533    let search_line = Line::from(vec![
534        Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
535        Span::raw(state.search.query.clone()),
536        Span::styled("▌", Style::default().fg(Color::White)),
537    ]);
538
539    let para = Paragraph::new(search_line).style(Style::default().bg(Color::Rgb(40, 40, 60)));
540    para.render(area, buf);
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_log_viewer_state_new() {
549        let content = vec!["Line 1".into(), "Line 2".into()];
550        let state = LogViewerState::new(content);
551        assert_eq!(state.content.len(), 2);
552        assert_eq!(state.scroll_y, 0);
553        assert_eq!(state.scroll_x, 0);
554    }
555
556    #[test]
557    fn test_log_viewer_state_empty() {
558        let state = LogViewerState::empty();
559        assert!(state.content.is_empty());
560    }
561
562    #[test]
563    fn test_log_viewer_state() {
564        let content = vec!["Line 1".into(), "Line 2".into(), "Line 3".into()];
565        let mut state = LogViewerState::new(content);
566
567        assert_eq!(state.scroll_y, 0);
568        state.scroll_down();
569        assert_eq!(state.scroll_y, 1);
570        state.scroll_up();
571        assert_eq!(state.scroll_y, 0);
572    }
573
574    #[test]
575    fn test_horizontal_scroll() {
576        let content = vec!["Long line of text".into()];
577        let mut state = LogViewerState::new(content);
578
579        state.scroll_right();
580        assert_eq!(state.scroll_x, 4);
581        state.scroll_right();
582        assert_eq!(state.scroll_x, 8);
583        state.scroll_left();
584        assert_eq!(state.scroll_x, 4);
585        state.scroll_left();
586        assert_eq!(state.scroll_x, 0);
587        state.scroll_left(); // Should not go negative
588        assert_eq!(state.scroll_x, 0);
589    }
590
591    #[test]
592    fn test_page_navigation() {
593        let content: Vec<String> = (0..100).map(|i| format!("Line {}", i)).collect();
594        let mut state = LogViewerState::new(content);
595        state.visible_height = 10;
596
597        state.page_down();
598        assert_eq!(state.scroll_y, 10);
599        state.page_down();
600        assert_eq!(state.scroll_y, 20);
601
602        state.page_up();
603        assert_eq!(state.scroll_y, 10);
604        state.page_up();
605        assert_eq!(state.scroll_y, 0);
606    }
607
608    #[test]
609    fn test_go_to_top_bottom() {
610        let content: Vec<String> = (0..50).map(|i| format!("Line {}", i)).collect();
611        let mut state = LogViewerState::new(content);
612        state.visible_height = 10;
613
614        state.go_to_bottom();
615        assert_eq!(state.scroll_y, 40); // 50 - 10
616
617        state.go_to_top();
618        assert_eq!(state.scroll_y, 0);
619    }
620
621    #[test]
622    fn test_go_to_line() {
623        let content: Vec<String> = (0..50).map(|i| format!("Line {}", i)).collect();
624        let mut state = LogViewerState::new(content);
625
626        state.go_to_line(25);
627        assert_eq!(state.scroll_y, 25);
628
629        state.go_to_line(100); // Clamped to max
630        assert_eq!(state.scroll_y, 49);
631    }
632
633    #[test]
634    fn test_set_content() {
635        let mut state = LogViewerState::new(vec!["Old content".into()]);
636        state.scroll_y = 10;
637        state.scroll_x = 5;
638        state.search.query = "test".into();
639
640        state.set_content(vec!["New content".into()]);
641        assert_eq!(state.content.len(), 1);
642        assert_eq!(state.content[0], "New content");
643        assert_eq!(state.scroll_y, 0);
644        assert_eq!(state.scroll_x, 0);
645    }
646
647    #[test]
648    fn test_append() {
649        let mut state = LogViewerState::new(vec!["Line 1".into()]);
650        state.append("Line 2".into());
651        assert_eq!(state.content.len(), 2);
652        assert_eq!(state.content[1], "Line 2");
653    }
654
655    #[test]
656    fn test_search() {
657        let content = vec![
658            "First line".into(),
659            "Second line with error".into(),
660            "Third line".into(),
661            "Another error here".into(),
662        ];
663        let mut state = LogViewerState::new(content);
664
665        state.start_search();
666        state.search.query = "error".into();
667        state.update_search();
668
669        assert_eq!(state.search.matches.len(), 2);
670        assert_eq!(state.search.matches[0], 1);
671        assert_eq!(state.search.matches[1], 3);
672    }
673
674    #[test]
675    fn test_search_case_insensitive() {
676        let content = vec![
677            "ERROR message".into(),
678            "error again".into(),
679            "No match".into(),
680        ];
681        let mut state = LogViewerState::new(content);
682
683        state.search.query = "error".into();
684        state.update_search();
685
686        assert_eq!(state.search.matches.len(), 2);
687    }
688
689    #[test]
690    fn test_search_empty_query() {
691        let content = vec!["Line 1".into(), "Line 2".into()];
692        let mut state = LogViewerState::new(content);
693
694        state.search.query = "".into();
695        state.update_search();
696
697        assert!(state.search.matches.is_empty());
698    }
699
700    #[test]
701    fn test_cancel_search() {
702        let mut state = LogViewerState::new(vec!["Test".into()]);
703        state.start_search();
704        assert!(state.search.active);
705        state.cancel_search();
706        assert!(!state.search.active);
707    }
708
709    #[test]
710    fn test_next_prev_match() {
711        let content = vec![
712            "Line 1".into(),
713            "Match here".into(),
714            "Line 3".into(),
715            "Match here too".into(),
716        ];
717        let mut state = LogViewerState::new(content);
718
719        state.search.query = "match".into();
720        state.update_search();
721
722        assert_eq!(state.search.current_match, 0);
723        state.next_match();
724        assert_eq!(state.search.current_match, 1);
725        state.next_match();
726        assert_eq!(state.search.current_match, 0); // Wrap around
727        state.prev_match();
728        assert_eq!(state.search.current_match, 1);
729    }
730
731    #[test]
732    fn test_next_prev_match_empty() {
733        let mut state = LogViewerState::new(vec!["No matches".into()]);
734        state.search.query = "xyz".into();
735        state.update_search();
736
737        // Should not panic with empty matches
738        state.next_match();
739        state.prev_match();
740        assert_eq!(state.search.current_match, 0);
741    }
742
743    #[test]
744    fn test_style_for_line() {
745        let style = LogViewerStyle::default();
746
747        let error_style = style.style_for_line("[ERROR] Something failed");
748        assert_eq!(error_style.fg, Some(Color::Red));
749
750        let warn_style = style.style_for_line("[WARN] Warning message");
751        assert_eq!(warn_style.fg, Some(Color::Yellow));
752
753        let success_style = style.style_for_line("✓ Task completed");
754        assert_eq!(success_style.fg, Some(Color::Green));
755    }
756
757    #[test]
758    fn test_style_for_line_debug_trace() {
759        let style = LogViewerStyle::default();
760
761        let debug_style = style.style_for_line("[DEBUG] Debug message");
762        assert_eq!(debug_style.fg, Some(Color::DarkGray));
763
764        let trace_style = style.style_for_line("[TRACE] Trace message");
765        assert_eq!(trace_style.fg, Some(Color::DarkGray));
766    }
767
768    #[test]
769    fn test_style_default_values() {
770        let style = LogViewerStyle::default();
771        assert!(style.show_line_numbers);
772        assert_eq!(style.line_number_width, 6);
773    }
774
775    #[test]
776    fn test_log_level_colors_default() {
777        let colors = LogLevelColors::default();
778        assert_eq!(colors.error, Color::Red);
779        assert_eq!(colors.warn, Color::Yellow);
780        assert_eq!(colors.info, Color::White);
781        assert_eq!(colors.debug, Color::DarkGray);
782        assert_eq!(colors.success, Color::Green);
783    }
784
785    #[test]
786    fn test_log_viewer_render() {
787        let content = vec!["[INFO] Test".into(), "[ERROR] Error".into()];
788        let state = LogViewerState::new(content);
789        let viewer = LogViewer::new(&state).title("Test Log");
790
791        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
792        viewer.render(Rect::new(0, 0, 80, 20), &mut buf);
793        // Just verify it doesn't panic
794    }
795
796    #[test]
797    fn test_log_viewer_show_line_numbers() {
798        let content = vec!["Line 1".into()];
799        let state = LogViewerState::new(content);
800        let viewer = LogViewer::new(&state).show_line_numbers(false);
801
802        let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
803        viewer.render(Rect::new(0, 0, 40, 10), &mut buf);
804    }
805}