pub mod entry;
pub use entry::{StatusLogEntry, StatusLogLevel};
use ratatui::widgets::{Block, Borders, List, ListItem};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq)]
pub enum StatusLogMessage {
Push {
message: String,
level: StatusLogLevel,
timestamp: Option<String>,
},
Clear,
Remove(u64),
ScrollUp,
ScrollDown,
ScrollToTop,
ScrollToBottom,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StatusLogOutput {
Added(u64),
Removed(u64),
Cleared,
Evicted(u64),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct StatusLogState {
entries: Vec<StatusLogEntry>,
next_id: u64,
max_entries: usize,
show_timestamps: bool,
scroll_offset: usize,
title: Option<String>,
}
impl Default for StatusLogState {
fn default() -> Self {
Self {
entries: Vec::new(),
next_id: 0,
max_entries: 50,
show_timestamps: false,
scroll_offset: 0,
title: None,
}
}
}
impl StatusLogState {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_entries(mut self, max: usize) -> Self {
self.max_entries = max;
self
}
pub fn with_show_timestamps(mut self, show: bool) -> Self {
self.show_timestamps = show;
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn info(&mut self, message: impl Into<String>) -> u64 {
self.push(message, StatusLogLevel::Info, None)
}
pub fn success(&mut self, message: impl Into<String>) -> u64 {
self.push(message, StatusLogLevel::Success, None)
}
pub fn warning(&mut self, message: impl Into<String>) -> u64 {
self.push(message, StatusLogLevel::Warning, None)
}
pub fn error(&mut self, message: impl Into<String>) -> u64 {
self.push(message, StatusLogLevel::Error, None)
}
pub fn info_with_timestamp(
&mut self,
message: impl Into<String>,
timestamp: impl Into<String>,
) -> u64 {
self.push(message, StatusLogLevel::Info, Some(timestamp.into()))
}
pub fn success_with_timestamp(
&mut self,
message: impl Into<String>,
timestamp: impl Into<String>,
) -> u64 {
self.push(message, StatusLogLevel::Success, Some(timestamp.into()))
}
pub fn warning_with_timestamp(
&mut self,
message: impl Into<String>,
timestamp: impl Into<String>,
) -> u64 {
self.push(message, StatusLogLevel::Warning, Some(timestamp.into()))
}
pub fn error_with_timestamp(
&mut self,
message: impl Into<String>,
timestamp: impl Into<String>,
) -> u64 {
self.push(message, StatusLogLevel::Error, Some(timestamp.into()))
}
fn push(
&mut self,
message: impl Into<String>,
level: StatusLogLevel,
timestamp: Option<String>,
) -> u64 {
let id = self.next_id;
self.next_id += 1;
let entry = if let Some(ts) = timestamp {
StatusLogEntry::with_timestamp(id, message, level, ts)
} else {
StatusLogEntry::new(id, message, level)
};
self.entries.push(entry);
id
}
fn enforce_limit(&mut self) -> Option<u64> {
if self.entries.len() > self.max_entries {
let evicted = self.entries.remove(0);
Some(evicted.id)
} else {
None
}
}
pub fn entries(&self) -> &[StatusLogEntry] {
&self.entries
}
pub fn entries_newest_first(&self) -> impl Iterator<Item = &StatusLogEntry> {
self.entries.iter().rev()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn max_entries(&self) -> usize {
self.max_entries
}
pub fn set_max_entries(&mut self, max: usize) {
self.max_entries = max;
if self.entries.len() > max {
let excess = self.entries.len() - max;
self.entries.drain(..excess);
if self.scroll_offset >= self.entries.len() {
self.scroll_offset = self.entries.len().saturating_sub(1);
}
}
}
pub fn show_timestamps(&self) -> bool {
self.show_timestamps
}
pub fn set_show_timestamps(&mut self, show: bool) {
self.show_timestamps = show;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll_offset = offset.min(self.entries.len().saturating_sub(1));
}
pub fn remove(&mut self, id: u64) -> bool {
let len_before = self.entries.len();
self.entries.retain(|e| e.id != id);
self.entries.len() < len_before
}
pub fn clear(&mut self) {
self.entries.clear();
self.scroll_offset = 0;
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn update(&mut self, msg: StatusLogMessage) -> Option<StatusLogOutput> {
StatusLog::update(self, msg)
}
}
pub struct StatusLog;
impl Component for StatusLog {
type State = StatusLogState;
type Message = StatusLogMessage;
type Output = StatusLogOutput;
fn init() -> Self::State {
StatusLogState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
StatusLogMessage::Push {
message,
level,
timestamp,
} => {
let id = state.push(message, level, timestamp);
if let Some(evicted_id) = state.enforce_limit() {
return Some(StatusLogOutput::Evicted(evicted_id));
}
Some(StatusLogOutput::Added(id))
}
StatusLogMessage::Clear => {
if state.entries.is_empty() {
None
} else {
state.clear();
Some(StatusLogOutput::Cleared)
}
}
StatusLogMessage::Remove(id) => {
if state.remove(id) {
Some(StatusLogOutput::Removed(id))
} else {
None
}
}
StatusLogMessage::ScrollUp => {
if state.scroll_offset > 0 {
state.scroll_offset -= 1;
}
None
}
StatusLogMessage::ScrollDown => {
if state.scroll_offset < state.entries.len().saturating_sub(1) {
state.scroll_offset += 1;
}
None
}
StatusLogMessage::ScrollToTop => {
state.scroll_offset = 0;
None
}
StatusLogMessage::ScrollToBottom => {
state.scroll_offset = state.entries.len().saturating_sub(1);
None
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Up | Key::Char('k') => Some(StatusLogMessage::ScrollUp),
Key::Down | Key::Char('j') => Some(StatusLogMessage::ScrollDown),
Key::Home => Some(StatusLogMessage::ScrollToTop),
Key::End => Some(StatusLogMessage::ScrollToBottom),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.width == 0 || ctx.area.height == 0 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::StatusLog)
.with_id("status_log")
.with_meta("entry_count", state.len().to_string()),
);
});
let block = if let Some(title) = &state.title {
Block::default().borders(Borders::ALL).title(title.as_str())
} else {
Block::default().borders(Borders::ALL)
};
let inner = block.inner(ctx.area);
let items: Vec<ListItem> = state
.entries_newest_first()
.skip(state.scroll_offset)
.take(inner.height as usize)
.map(|entry| {
let prefix = entry.level.prefix();
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
match entry.level {
StatusLogLevel::Info => ctx.theme.info_style(),
StatusLogLevel::Success => ctx.theme.success_style(),
StatusLogLevel::Warning => ctx.theme.warning_style(),
StatusLogLevel::Error => ctx.theme.error_style(),
}
};
let content = if state.show_timestamps {
if let Some(ts) = &entry.timestamp {
format!("{} [{}] {}", prefix, ts, entry.message)
} else {
format!("{} {}", prefix, entry.message)
}
} else {
format!("{} {}", prefix, entry.message)
};
ListItem::new(content).style(style)
})
.collect();
ctx.frame.render_widget(block, ctx.area);
if !items.is_empty() {
let list = List::new(items);
ctx.frame.render_widget(list, inner);
}
}
}
#[cfg(test)]
mod tests;