1use 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#[derive(Debug, Clone)]
41pub struct LogViewerState {
42 pub content: Vec<String>,
44 pub scroll_y: usize,
46 pub scroll_x: usize,
48 pub visible_height: usize,
50 pub visible_width: usize,
52 pub search: SearchState,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct SearchState {
59 pub active: bool,
61 pub query: String,
63 pub matches: Vec<usize>,
65 pub current_match: usize,
67}
68
69impl LogViewerState {
70 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 pub fn empty() -> Self {
84 Self::new(Vec::new())
85 }
86
87 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 pub fn append(&mut self, line: String) {
97 self.content.push(line);
98 }
99
100 pub fn scroll_up(&mut self) {
102 self.scroll_y = self.scroll_y.saturating_sub(1);
103 }
104
105 pub fn scroll_down(&mut self) {
107 if self.scroll_y + 1 < self.content.len() {
108 self.scroll_y += 1;
109 }
110 }
111
112 pub fn page_up(&mut self) {
114 self.scroll_y = self.scroll_y.saturating_sub(self.visible_height);
115 }
116
117 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 pub fn scroll_left(&mut self) {
125 self.scroll_x = self.scroll_x.saturating_sub(4);
126 }
127
128 pub fn scroll_right(&mut self) {
130 self.scroll_x += 4;
131 }
132
133 pub fn go_to_top(&mut self) {
135 self.scroll_y = 0;
136 }
137
138 pub fn go_to_bottom(&mut self) {
140 self.scroll_y = self.content.len().saturating_sub(self.visible_height);
141 }
142
143 pub fn go_to_line(&mut self, line: usize) {
145 self.scroll_y = line.min(self.content.len().saturating_sub(1));
146 }
147
148 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 pub fn cancel_search(&mut self) {
158 self.search.active = false;
159 }
160
161 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 if !self.search.matches.is_empty() {
179 self.scroll_y = self.search.matches[0];
180 }
181 }
182
183 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 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#[derive(Debug, Clone)]
208pub struct LogViewerStyle {
209 pub border_style: Style,
211 pub line_number_style: Style,
213 pub content_style: Style,
215 pub current_match_style: Style,
217 pub match_style: Style,
219 pub level_colors: LogLevelColors,
221 pub show_line_numbers: bool,
223 pub line_number_width: usize,
225}
226
227#[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 pub fn style_for_line(&self, line: &str) -> Style {
301 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
328pub struct LogViewer<'a> {
330 state: &'a LogViewerState,
331 style: LogViewerStyle,
332 title: Option<&'a str>,
333}
334
335impl<'a> LogViewer<'a> {
336 pub fn new(state: &'a LogViewerState) -> Self {
338 Self {
339 state,
340 style: LogViewerStyle::default(),
341 title: None,
342 }
343 }
344
345 pub fn title(mut self, title: &'a str) -> Self {
347 self.title = Some(title);
348 self
349 }
350
351 pub fn style(mut self, style: LogViewerStyle) -> Self {
353 self.style = style;
354 self
355 }
356
357 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
359 self.style(LogViewerStyle::from(theme))
360 }
361
362 pub fn show_line_numbers(mut self, show: bool) -> Self {
364 self.style.show_line_numbers = show;
365 self
366 }
367
368 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 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 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 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 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 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 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 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 let lines = self.build_lines(inner);
465 let para = Paragraph::new(lines);
466 para.render(inner, buf);
467
468 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 render_status_bar(self.state, chunks[1], buf);
478
479 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(); 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); 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); 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); 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 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 }
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}