mod state;
mod view;
use std::marker::PhantomData;
use ratatui::prelude::*;
use super::{
Component, EventContext, InputFieldMessage, InputFieldState, RenderContext, StatusLogEntry,
StatusLogLevel,
};
use crate::input::{Event, Key};
pub use state::LogViewerState;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
enum Focus {
#[default]
Log,
Search,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogViewerMessage {
ScrollUp,
ScrollDown,
ScrollToTop,
ScrollToBottom,
FocusSearch,
FocusLog,
SearchInput(char),
SearchBackspace,
SearchDelete,
SearchLeft,
SearchRight,
SearchHome,
SearchEnd,
ClearSearch,
ToggleInfo,
ToggleSuccess,
ToggleWarning,
ToggleError,
Push {
message: String,
level: StatusLogLevel,
timestamp: Option<String>,
},
Clear,
Remove(u64),
ToggleFollow,
ToggleRegex,
ConfirmSearch,
SearchHistoryUp,
SearchHistoryDown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogViewerOutput {
Added(u64),
Removed(u64),
Cleared,
Evicted(u64),
SearchChanged(String),
FilterChanged,
FollowToggled(bool),
RegexToggled(bool),
}
pub struct LogViewer(PhantomData<()>);
impl Component for LogViewer {
type State = LogViewerState;
type Message = LogViewerMessage;
type Output = LogViewerOutput;
fn init() -> Self::State {
LogViewerState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
match state.focus {
Focus::Log => match key.code {
Key::Up | Key::Char('k') => Some(LogViewerMessage::ScrollUp),
Key::Down | Key::Char('j') => Some(LogViewerMessage::ScrollDown),
Key::Home => Some(LogViewerMessage::ScrollToTop),
Key::End => Some(LogViewerMessage::ScrollToBottom),
Key::Char('/') => Some(LogViewerMessage::FocusSearch),
Key::Char('f') => Some(LogViewerMessage::ToggleFollow),
Key::Char('1') => Some(LogViewerMessage::ToggleInfo),
Key::Char('2') => Some(LogViewerMessage::ToggleSuccess),
Key::Char('3') => Some(LogViewerMessage::ToggleWarning),
Key::Char('4') => Some(LogViewerMessage::ToggleError),
_ => None,
},
Focus::Search => match key.code {
Key::Esc => Some(LogViewerMessage::ClearSearch),
Key::Enter => Some(LogViewerMessage::ConfirmSearch),
Key::Up => Some(LogViewerMessage::SearchHistoryUp),
Key::Down => Some(LogViewerMessage::SearchHistoryDown),
Key::Char(c) => {
if key.modifiers.ctrl() {
match c {
'r' => Some(LogViewerMessage::ToggleRegex),
_ => None,
}
} else {
Some(LogViewerMessage::SearchInput(c))
}
}
Key::Backspace => Some(LogViewerMessage::SearchBackspace),
Key::Delete => Some(LogViewerMessage::SearchDelete),
Key::Left => Some(LogViewerMessage::SearchLeft),
Key::Right => Some(LogViewerMessage::SearchRight),
Key::Home => Some(LogViewerMessage::SearchHome),
Key::End => Some(LogViewerMessage::SearchEnd),
_ => None,
},
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
LogViewerMessage::ScrollUp => {
state.follow = false;
let len = state.visible_entries().len();
state.scroll.set_content_length(len);
state.scroll.set_viewport_height(1.min(len));
state.scroll.scroll_up();
None
}
LogViewerMessage::ScrollDown => {
state.follow = false;
let len = state.visible_entries().len();
state.scroll.set_content_length(len);
state.scroll.set_viewport_height(1.min(len));
state.scroll.scroll_down();
None
}
LogViewerMessage::ScrollToTop => {
let len = state.visible_entries().len();
state.scroll.set_content_length(len);
state.scroll.set_viewport_height(1.min(len));
state.scroll.scroll_to_start();
None
}
LogViewerMessage::ScrollToBottom => {
let len = state.visible_entries().len();
state.scroll.set_content_length(len);
state.scroll.set_viewport_height(1.min(len));
state.scroll.scroll_to_end();
None
}
LogViewerMessage::FocusSearch => {
state.focus = Focus::Search;
None
}
LogViewerMessage::FocusLog => {
state.focus = Focus::Log;
state.history_index = None;
None
}
LogViewerMessage::SearchInput(c) => {
state.search.update(InputFieldMessage::Insert(c));
state.search_text = state.search.value().to_string();
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
}
LogViewerMessage::SearchBackspace => {
state.search.update(InputFieldMessage::Backspace);
state.search_text = state.search.value().to_string();
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
}
LogViewerMessage::SearchDelete => {
state.search.update(InputFieldMessage::Delete);
state.search_text = state.search.value().to_string();
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
}
LogViewerMessage::SearchLeft => {
state.search.update(InputFieldMessage::Left);
None
}
LogViewerMessage::SearchRight => {
state.search.update(InputFieldMessage::Right);
None
}
LogViewerMessage::SearchHome => {
state.search.update(InputFieldMessage::Home);
None
}
LogViewerMessage::SearchEnd => {
state.search.update(InputFieldMessage::End);
None
}
LogViewerMessage::ClearSearch => {
state.search.update(InputFieldMessage::Clear);
state.search_text.clear();
state.scroll.set_offset(0);
state.focus = Focus::Log;
state.history_index = None;
Some(LogViewerOutput::SearchChanged(String::new()))
}
LogViewerMessage::ToggleInfo => {
state.show_info = !state.show_info;
state.scroll.set_offset(0);
Some(LogViewerOutput::FilterChanged)
}
LogViewerMessage::ToggleSuccess => {
state.show_success = !state.show_success;
state.scroll.set_offset(0);
Some(LogViewerOutput::FilterChanged)
}
LogViewerMessage::ToggleWarning => {
state.show_warning = !state.show_warning;
state.scroll.set_offset(0);
Some(LogViewerOutput::FilterChanged)
}
LogViewerMessage::ToggleError => {
state.show_error = !state.show_error;
state.scroll.set_offset(0);
Some(LogViewerOutput::FilterChanged)
}
LogViewerMessage::Push {
message,
level,
timestamp,
} => {
let id = state.push_entry(message, level, timestamp);
if state.follow {
state.scroll.set_offset(0);
}
Some(LogViewerOutput::Added(id))
}
LogViewerMessage::Clear => {
state.clear();
Some(LogViewerOutput::Cleared)
}
LogViewerMessage::Remove(id) => {
if state.remove(id) {
Some(LogViewerOutput::Removed(id))
} else {
None
}
}
LogViewerMessage::ToggleFollow => {
state.follow = !state.follow;
if state.follow {
state.scroll.set_offset(0);
}
Some(LogViewerOutput::FollowToggled(state.follow))
}
LogViewerMessage::ToggleRegex => {
state.use_regex = !state.use_regex;
state.scroll.set_offset(0);
Some(LogViewerOutput::RegexToggled(state.use_regex))
}
LogViewerMessage::ConfirmSearch => {
if !state.search_text.is_empty() {
state.search_history.retain(|h| h != &state.search_text);
state.search_history.push(state.search_text.clone());
while state.search_history.len() > state.max_history {
state.search_history.remove(0);
}
}
state.history_index = None;
state.focus = Focus::Log;
None
}
LogViewerMessage::SearchHistoryUp => {
if state.search_history.is_empty() {
return None;
}
let new_index = match state.history_index {
None => state.search_history.len().saturating_sub(1),
Some(idx) => idx.saturating_sub(1),
};
state.history_index = Some(new_index);
let text = state.search_history[new_index].clone();
state.search.update(InputFieldMessage::Clear);
for c in text.chars() {
state.search.update(InputFieldMessage::Insert(c));
}
state.search_text = text;
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
}
LogViewerMessage::SearchHistoryDown => {
if state.search_history.is_empty() {
return None;
}
match state.history_index {
None => None,
Some(idx) => {
if idx + 1 >= state.search_history.len() {
state.history_index = None;
state.search.update(InputFieldMessage::Clear);
state.search_text.clear();
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(String::new()))
} else {
let new_index = idx + 1;
state.history_index = Some(new_index);
let text = state.search_history[new_index].clone();
state.search.update(InputFieldMessage::Clear);
for c in text.chars() {
state.search.update(InputFieldMessage::Insert(c));
}
state.search_text = text;
state.scroll.set_offset(0);
Some(LogViewerOutput::SearchChanged(state.search_text.clone()))
}
}
}
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height < 3 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::container("log_viewer")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(ctx.area);
let search_area = chunks[0];
let filter_area = chunks[1];
let log_area = chunks[2];
view::render_search_bar(state, &mut ctx.with_area(search_area));
view::render_filter_bar(state, &mut ctx.with_area(filter_area));
view::render_log(state, &mut ctx.with_area(log_area));
}
}
#[cfg(test)]
mod enhancement_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;