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 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 pub fn style_for_line(&self, line: &str) -> Style {
271 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
298pub struct LogViewer<'a> {
300 state: &'a LogViewerState,
301 style: LogViewerStyle,
302 title: Option<&'a str>,
303}
304
305impl<'a> LogViewer<'a> {
306 pub fn new(state: &'a LogViewerState) -> Self {
308 Self {
309 state,
310 style: LogViewerStyle::default(),
311 title: None,
312 }
313 }
314
315 pub fn title(mut self, title: &'a str) -> Self {
317 self.title = Some(title);
318 self
319 }
320
321 pub fn style(mut self, style: LogViewerStyle) -> Self {
323 self.style = style;
324 self
325 }
326
327 pub fn show_line_numbers(mut self, show: bool) -> Self {
329 self.style.show_line_numbers = show;
330 self
331 }
332
333 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 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 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 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 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 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 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 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 let lines = self.build_lines(inner);
430 let para = Paragraph::new(lines);
431 para.render(inner, buf);
432
433 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 render_status_bar(self.state, chunks[1], buf);
443
444 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(); 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); 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); 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); 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 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 }
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}