1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43#[cfg_attr(
44 feature = "serialization",
45 derive(serde::Serialize, serde::Deserialize)
46)]
47enum Focus {
48 #[default]
50 Log,
51 Search,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum LogViewerMessage {
58 ScrollUp,
60 ScrollDown,
62 ScrollToTop,
64 ScrollToBottom,
66 FocusSearch,
68 FocusLog,
70 SearchInput(char),
72 SearchBackspace,
74 SearchDelete,
76 SearchLeft,
78 SearchRight,
80 SearchHome,
82 SearchEnd,
84 ClearSearch,
86 ToggleInfo,
88 ToggleSuccess,
90 ToggleWarning,
92 ToggleError,
94 Push {
96 message: String,
98 level: StatusLogLevel,
100 timestamp: Option<String>,
102 },
103 Clear,
105 Remove(u64),
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
111pub enum LogViewerOutput {
112 Added(u64),
114 Removed(u64),
116 Cleared,
118 Evicted(u64),
120 SearchChanged(String),
122 FilterChanged,
124}
125
126#[derive(Clone, Debug)]
130pub struct LogViewerState {
131 entries: Vec<StatusLogEntry>,
133 next_id: u64,
135 max_entries: usize,
137 search: InputFieldState,
139 search_text: String,
141 scroll_offset: usize,
143 show_info: bool,
145 show_success: bool,
147 show_warning: bool,
149 show_error: bool,
151 show_timestamps: bool,
153 title: Option<String>,
155 focus: Focus,
157 focused: bool,
159 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 pub fn new() -> Self {
217 Self::default()
218 }
219
220 pub fn with_max_entries(mut self, max: usize) -> Self {
222 self.max_entries = max;
223 self
224 }
225
226 pub fn with_timestamps(mut self, show: bool) -> Self {
228 self.show_timestamps = show;
229 self
230 }
231
232 pub fn with_title(mut self, title: impl Into<String>) -> Self {
234 self.title = Some(title.into());
235 self
236 }
237
238 pub fn with_disabled(mut self, disabled: bool) -> Self {
240 self.disabled = disabled;
241 self
242 }
243
244 pub fn push_info(&mut self, message: impl Into<String>) -> u64 {
258 self.push_entry(message.into(), StatusLogLevel::Info, None)
259 }
260
261 pub fn push_success(&mut self, message: impl Into<String>) -> u64 {
263 self.push_entry(message.into(), StatusLogLevel::Success, None)
264 }
265
266 pub fn push_warning(&mut self, message: impl Into<String>) -> u64 {
268 self.push_entry(message.into(), StatusLogLevel::Warning, None)
269 }
270
271 pub fn push_error(&mut self, message: impl Into<String>) -> u64 {
273 self.push_entry(message.into(), StatusLogLevel::Error, None)
274 }
275
276 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 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 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 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 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 while self.entries.len() > self.max_entries {
340 self.entries.remove(0);
341 }
342 id
343 }
344
345 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 pub fn clear(&mut self) {
357 self.entries.clear();
358 self.scroll_offset = 0;
359 }
360
361 pub fn entries(&self) -> &[StatusLogEntry] {
365 &self.entries
366 }
367
368 pub fn len(&self) -> usize {
370 self.entries.len()
371 }
372
373 pub fn is_empty(&self) -> bool {
375 self.entries.is_empty()
376 }
377
378 pub fn max_entries(&self) -> usize {
380 self.max_entries
381 }
382
383 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 pub fn search_text(&self) -> &str {
393 &self.search_text
394 }
395
396 pub fn scroll_offset(&self) -> usize {
398 self.scroll_offset
399 }
400
401 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 pub fn show_timestamps(&self) -> bool {
409 self.show_timestamps
410 }
411
412 pub fn set_show_timestamps(&mut self, show: bool) {
414 self.show_timestamps = show;
415 }
416
417 pub fn title(&self) -> Option<&str> {
419 self.title.as_deref()
420 }
421
422 pub fn set_title(&mut self, title: Option<String>) {
424 self.title = title;
425 }
426
427 pub fn show_info(&self) -> bool {
429 self.show_info
430 }
431
432 pub fn show_success(&self) -> bool {
434 self.show_success
435 }
436
437 pub fn show_warning(&self) -> bool {
439 self.show_warning
440 }
441
442 pub fn show_error(&self) -> bool {
444 self.show_error
445 }
446
447 pub fn set_show_info(&mut self, show: bool) {
449 self.show_info = show;
450 }
451
452 pub fn set_show_success(&mut self, show: bool) {
454 self.show_success = show;
455 }
456
457 pub fn set_show_warning(&mut self, show: bool) {
459 self.show_warning = show;
460 }
461
462 pub fn set_show_error(&mut self, show: bool) {
464 self.show_error = show;
465 }
466
467 pub fn is_search_focused(&self) -> bool {
469 self.focus == Focus::Search
470 }
471
472 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 fn matches_filters(&self, entry: &StatusLogEntry) -> bool {
500 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 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 pub fn is_focused(&self) -> bool {
525 self.focused
526 }
527
528 pub fn set_focused(&mut self, focused: bool) {
530 self.focused = focused;
531 }
532
533 pub fn is_disabled(&self) -> bool {
535 self.disabled
536 }
537
538 pub fn set_disabled(&mut self, disabled: bool) {
540 self.disabled = disabled;
541 }
542
543 pub fn handle_event(&self, event: &Event) -> Option<LogViewerMessage> {
545 LogViewer::handle_event(self, event)
546 }
547
548 pub fn dispatch_event(&mut self, event: &Event) -> Option<LogViewerOutput> {
550 LogViewer::dispatch_event(self, event)
551 }
552
553 pub fn update(&mut self, msg: LogViewerMessage) -> Option<LogViewerOutput> {
555 LogViewer::update(self, msg)
556 }
557}
558
559pub 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 if state.entries.len() > state.max_entries {
742 }
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 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(state, frame, search_area, theme);
781
782 render_filter_bar(state, frame, filter_area, theme);
784
785 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
800fn 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 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
834fn 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
887fn 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 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 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;