//! Log panel widget and state for kiln-tui. //! //! [`LogWidget`] renders the scrollable log panel as a ratatui [`StatefulWidget`]; //! [`LogState`] holds whether the panel is open, its height, and scroll position. use crate::utils::rgba8_to_color; use kiln_core::log::{LogLine, LogSpan}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap}; /// View state for the log panel. pub struct LogState { /// Whether the log panel is currently open. pub open: bool, /// Height of the panel in terminal rows (borders included) when open. pub height: u16, /// Scroll offset in lines from the newest message (0 = newest at top). pub scroll: u16, } impl Default for LogState { fn default() -> Self { Self { open: false, height: 10, scroll: 0, } } } impl LogState { /// Creates a `LogState` with sensible defaults (closed, 10 rows tall). pub fn new() -> Self { Self::default() } /// Toggles the panel open or closed, resetting scroll to the top on open. pub fn toggle(&mut self) { self.open = !self.open; self.scroll = 0; } /// Scrolls by `delta` lines, clamped so the offset stays within `[0, log_len - 1]`. pub fn scroll_by(&mut self, delta: i32, log_len: usize) { let max = log_len.saturating_sub(1) as i32; self.scroll = (self.scroll as i32 + delta).clamp(0, max) as u16; } /// Resizes the panel, clamping height so both the panel and the board remain usable. pub fn resize(&mut self, target: u16, total: u16) { self.height = target.clamp(3, total.saturating_sub(3).max(3)); } } /// A ratatui widget that renders the log panel. /// /// Displays all log lines newest-first in a bordered block, wrapped to width. /// Scrolled by [`LogState::scroll`]. pub struct LogWidget<'a> { /// The log lines to display. pub logs: &'a [LogLine], } impl<'a> LogWidget<'a> { /// Creates a widget for the given log slice. pub fn new(logs: &'a [LogLine]) -> Self { Self { logs } } } impl StatefulWidget for LogWidget<'_> { type State = LogState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut LogState) { let block = Block::bordered() .merge_borders(MergeStrategy::Exact) .title(Span::styled( " [l]og: ", Style::default().fg(Color::DarkGray), )); let lines: Vec = self.logs.iter().rev().map(logline_to_line).collect(); Paragraph::new(lines) .block(block) .scroll((state.scroll, 0)) .wrap(Wrap { trim: false }) .render(area, buf); } } /// Builds the `[l]og: ` preview for the board's bottom border. pub fn log_preview_line(logs: &[LogLine]) -> Line<'static> { let mut spans = vec![Span::styled( " [l]og: ", Style::default().fg(Color::DarkGray), )]; if let Some(latest) = logs.last() { spans.extend(logline_to_line(latest).spans); } spans.push(Span::raw(" ")); Line::from(spans) } /// Converts a core [`LogLine`] into a styled ratatui [`Line`] for display. /// /// Each [`LogSpan`] becomes a ratatui [`Span`]; a span's foreground/background /// color is applied only when present, so `None` colors inherit the surrounding style. pub fn logline_to_line(line: &LogLine) -> Line<'static> { let spans = line .spans .iter() .map(|s: &LogSpan| { let mut style = Style::default(); if let Some(fg) = s.fg { style = style.fg(rgba8_to_color(fg)); } if let Some(bg) = s.bg { style = style.bg(rgba8_to_color(bg)); } Span::styled(s.text.clone(), style) }) .collect::>(); Line::from(spans) }