From 970733d8acb4a4c86801b267e9d508e51f7b2f8f Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Thu, 11 Jun 2026 21:55:53 -0500 Subject: [PATCH] refactored log --- kiln-tui/src/log.rs | 115 ++++++++++++++++++++++++++++++++++++++++++ kiln-tui/src/main.rs | 75 +++++++-------------------- kiln-tui/src/ui.rs | 15 ++---- kiln-tui/src/utils.rs | 26 +--------- 4 files changed, 139 insertions(+), 92 deletions(-) create mode 100644 kiln-tui/src/log.rs diff --git a/kiln-tui/src/log.rs b/kiln-tui/src/log.rs new file mode 100644 index 0000000..deca78d --- /dev/null +++ b/kiln-tui/src/log.rs @@ -0,0 +1,115 @@ +//! 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 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}; +use crate::utils::rgba8_to_color; + +/// View state for the log panel. +#[derive(Default)] +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 LogState { + /// Creates a `LogState` with sensible defaults (closed, 10 rows tall). + pub fn new() -> Self { + Self { open: false, height: 10, scroll: 0 } + } + + /// 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) +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index c4c84a4..5a7f326 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -8,6 +8,7 @@ //! as characters (see [`cp437`]) and drawn with each cell's RGB colors. mod cp437; +mod log; mod render; mod scroll_overlay; mod term; @@ -25,19 +26,17 @@ use ratatui::crossterm::event::{ }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Spacing}; -use ratatui::style::{Color, Style}; use ratatui::symbols::merge::MergeStrategy; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph, Wrap}; -use render::{BoardWidget}; +use ratatui::widgets::Block; +use render::BoardWidget; use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; +use crate::log::{log_preview_line, LogWidget}; use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayWidget}; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; -use crate::utils::logline_to_line; /// Returns the choice string for the given key character, or `None` if it doesn't /// match any choice in the scroll. Choices are assigned letters a, b, c… in order. @@ -128,7 +127,7 @@ fn run( terminal: &mut ratatui::DefaultTerminal, game: &mut GameState, ui: &mut Ui, -) -> std::io::Result<()> { +) -> io::Result<()> { let mut last_tick = Instant::now(); loop { // Redraw every iteration; ratatui diffs against the previous frame so @@ -175,13 +174,10 @@ fn run( // Right arrow moves right; 'l' is the log panel. KeyCode::Right => game.try_move(Direction::East), // 'l' toggles the log panel; reset scroll to newest. - KeyCode::Char('l') => { - ui.log_open = !ui.log_open; - ui.scroll = 0; - } + KeyCode::Char('l') => ui.log.toggle(), // PageUp/PageDown scroll the log when it's open. - KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5), - KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5), + KeyCode::PageUp if ui.log.open => ui.log.scroll_by(-5, game.log.len()), + KeyCode::PageDown if ui.log.open => ui.log.scroll_by(5, game.log.len()), _ => {} } } @@ -197,16 +193,16 @@ fn run( MouseEventKind::ScrollDown => ui.scroll_lines(1), _ => {} } - } else if ui.log_open { + } else if ui.log.open { match m.kind { - MouseEventKind::ScrollUp => scroll_log(ui, game, -1), - MouseEventKind::ScrollDown => scroll_log(ui, game, 1), + MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()), + MouseEventKind::ScrollDown => ui.log.scroll_by(1, game.log.len()), MouseEventKind::Drag(_) => { - // The divider sits at row `total - log_height`; dragging + // The divider sits at row `total - height`; dragging // it up (smaller row) grows the panel. Keep both usable. let total = terminal.size()?.height; let target = total.saturating_sub(m.row); - ui.log_height = target.clamp(3, total.saturating_sub(3).max(3)); + ui.log.resize(target, total); } _ => {} } @@ -227,27 +223,19 @@ fn run( } } -/// Adjusts the log scroll offset by `delta` lines, clamped so it can neither go -/// above the newest message nor scroll past the oldest. -fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) { - let max = game.log.len().saturating_sub(1) as i32; - ui.scroll = (ui.scroll as i32 + delta).clamp(0, max) as u16; -} - /// Render a single frame: the board in a bordered block, and — when open — a log /// panel across the bottom. When the panel is closed the latest log message is /// previewed in the board's bottom-left border after a `[l]og:` label. /// A scroll overlay is drawn on top of everything when active. -/// fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { // Borrow the board for the duration of the frame (no script runs during draw). let board = game.board(); let board = &*board; - if ui.log_open { - // Split the screen: board on top, log panel of `log_height` at the bottom. + if ui.log.open { + // Split the screen: board on top, log panel of `height` at the bottom. let [board_area, log_area] = - Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log_height)]) + Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)]) .spacing(Spacing::Overlap(1)) .areas(frame.area()); @@ -258,24 +246,11 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { frame.render_widget(block, board_area); frame.render_widget(BoardWidget::new(board), inner); frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); - - // Newest message on top: reverse the log into ratatui lines. - let log_block = Block::bordered() - .title(Span::styled( - " [l]og: ", - Style::default().fg(Color::DarkGray), - )) - .merge_borders(MergeStrategy::Exact); - let lines: Vec = game.log.iter().rev().map(logline_to_line).collect(); - let paragraph = Paragraph::new(lines) - .block(log_block) - .scroll((ui.scroll, 0)) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, log_area); + frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); } else { // Panel closed: board fills the screen, latest message in the border. let mut block = Block::bordered().title(format!(" {} ", board.name)); - block = block.title_bottom(log_preview(game).left_aligned()); + block = block.title_bottom(log_preview_line(&game.log).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); frame.render_widget(BoardWidget::new(board), inner); @@ -289,17 +264,3 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay); } } - -/// Builds the `[l]og: ` preview shown in the board's bottom-left -/// border when the log panel is closed. -fn log_preview(game: &GameState) -> Line<'static> { - let mut spans = vec![Span::styled( - " [l]og: ", - Style::default().fg(Color::DarkGray), - )]; - if let Some(latest) = game.log.last() { - spans.extend(logline_to_line(latest).spans); - } - spans.push(Span::raw(" ")); - Line::from(spans) -} diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 467a644..3c8eb41 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -1,16 +1,13 @@ use std::time::Duration; use kiln_core::game::GameState; +use crate::log::LogState; use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState}; /// View-only UI state for the log panel and scroll overlay. This is presentation /// state, so it lives in the front-end rather than on the engine's [`GameState`]. pub(crate) struct Ui { - /// Whether the bottom log panel is open. - pub(crate) log_open: bool, - /// Height (in terminal rows, borders included) of the log panel when open. - pub(crate) log_height: u16, - /// Vertical scroll offset of the log panel, in lines from the top (newest). - pub(crate) scroll: u16, + /// State for the log panel (open/closed, height, scroll position). + pub(crate) log: LogState, /// Scroll position, bounds, and animation state for an active scroll overlay. pub(crate) overlay: ScrollOverlayState, } @@ -18,9 +15,7 @@ pub(crate) struct Ui { impl Default for Ui { fn default() -> Self { Self { - log_open: false, - log_height: 10, - scroll: 0, + log: LogState::new(), overlay: ScrollOverlayState::default(), } } @@ -63,4 +58,4 @@ impl Ui { self.overlay.pending_choice = choice; self.overlay.anim = Some(ScrollAnimState::Closing(progress)); } -} \ No newline at end of file +} diff --git a/kiln-tui/src/utils.rs b/kiln-tui/src/utils.rs index b148efd..fbba75c 100644 --- a/kiln-tui/src/utils.rs +++ b/kiln-tui/src/utils.rs @@ -1,7 +1,6 @@ use color::Rgba8; use ratatui::layout::Rect; -use ratatui::prelude::{Color, Line, Span, Style}; -use kiln_core::log::LogLine; +use ratatui::prelude::Color; /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// @@ -11,29 +10,6 @@ pub fn rgba8_to_color(c: Rgba8) -> Color { Color::Rgb(c.r, c.g, c.b) } -/// Converts a core [`LogLine`] into a styled ratatui [`Line`] for display. -/// -/// Each [`LogSpan`](kiln_core::log::LogSpan) becomes a ratatui [`Span`]; a span's -/// foreground/background color is applied only when present, so `None` colors -/// inherit the surrounding (default) style. -pub fn logline_to_line(line: &LogLine) -> Line<'static> { - let spans = line - .spans - .iter() - .map(|s| { - 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) -} - /// Returns true if two `Rect`s share at least one cell. pub fn rects_overlap(a: Rect, b: Rect) -> bool { a.x < b.x + b.width