refactored log
This commit is contained in:
@@ -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<Line> = 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: <latest message>` 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::<Vec<_>>();
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
+18
-57
@@ -8,6 +8,7 @@
|
|||||||
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
|
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
|
||||||
|
|
||||||
mod cp437;
|
mod cp437;
|
||||||
|
mod log;
|
||||||
mod render;
|
mod render;
|
||||||
mod scroll_overlay;
|
mod scroll_overlay;
|
||||||
mod term;
|
mod term;
|
||||||
@@ -25,19 +26,17 @@ use ratatui::crossterm::event::{
|
|||||||
};
|
};
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::layout::{Constraint, Layout, Spacing};
|
use ratatui::layout::{Constraint, Layout, Spacing};
|
||||||
use ratatui::style::{Color, Style};
|
|
||||||
use ratatui::symbols::merge::MergeStrategy;
|
use ratatui::symbols::merge::MergeStrategy;
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::widgets::Block;
|
||||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
use render::BoardWidget;
|
||||||
use render::{BoardWidget};
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use term::TerminalCaps;
|
use term::TerminalCaps;
|
||||||
|
use crate::log::{log_preview_line, LogWidget};
|
||||||
use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayWidget};
|
use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayWidget};
|
||||||
use crate::speech::SpeechBubblesWidget;
|
use crate::speech::SpeechBubblesWidget;
|
||||||
use crate::ui::Ui;
|
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
|
/// 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.
|
/// 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,
|
terminal: &mut ratatui::DefaultTerminal,
|
||||||
game: &mut GameState,
|
game: &mut GameState,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
) -> std::io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
// Redraw every iteration; ratatui diffs against the previous frame so
|
// 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.
|
// Right arrow moves right; 'l' is the log panel.
|
||||||
KeyCode::Right => game.try_move(Direction::East),
|
KeyCode::Right => game.try_move(Direction::East),
|
||||||
// 'l' toggles the log panel; reset scroll to newest.
|
// 'l' toggles the log panel; reset scroll to newest.
|
||||||
KeyCode::Char('l') => {
|
KeyCode::Char('l') => ui.log.toggle(),
|
||||||
ui.log_open = !ui.log_open;
|
|
||||||
ui.scroll = 0;
|
|
||||||
}
|
|
||||||
// PageUp/PageDown scroll the log when it's open.
|
// PageUp/PageDown scroll the log when it's open.
|
||||||
KeyCode::PageUp 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 => scroll_log(ui, game, 5),
|
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),
|
MouseEventKind::ScrollDown => ui.scroll_lines(1),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if ui.log_open {
|
} else if ui.log.open {
|
||||||
match m.kind {
|
match m.kind {
|
||||||
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
|
MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()),
|
||||||
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
|
MouseEventKind::ScrollDown => ui.log.scroll_by(1, game.log.len()),
|
||||||
MouseEventKind::Drag(_) => {
|
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.
|
// it up (smaller row) grows the panel. Keep both usable.
|
||||||
let total = terminal.size()?.height;
|
let total = terminal.size()?.height;
|
||||||
let target = total.saturating_sub(m.row);
|
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
|
/// 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
|
/// 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.
|
/// previewed in the board's bottom-left border after a `[l]og:` label.
|
||||||
/// A scroll overlay is drawn on top of everything when active.
|
/// A scroll overlay is drawn on top of everything when active.
|
||||||
///
|
|
||||||
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
||||||
// Borrow the board for the duration of the frame (no script runs during draw).
|
// Borrow the board for the duration of the frame (no script runs during draw).
|
||||||
let board = game.board();
|
let board = game.board();
|
||||||
let board = &*board;
|
let board = &*board;
|
||||||
|
|
||||||
if ui.log_open {
|
if ui.log.open {
|
||||||
// Split the screen: board on top, log panel of `log_height` at the bottom.
|
// Split the screen: board on top, log panel of `height` at the bottom.
|
||||||
let [board_area, log_area] =
|
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))
|
.spacing(Spacing::Overlap(1))
|
||||||
.areas(frame.area());
|
.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(block, board_area);
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
frame.render_widget(BoardWidget::new(board), inner);
|
||||||
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
||||||
|
frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log);
|
||||||
// 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<Line> = 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);
|
|
||||||
} else {
|
} else {
|
||||||
// Panel closed: board fills the screen, latest message in the border.
|
// Panel closed: board fills the screen, latest message in the border.
|
||||||
let mut block = Block::bordered().title(format!(" {} ", board.name));
|
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());
|
let inner = block.inner(frame.area());
|
||||||
frame.render_widget(block, frame.area());
|
frame.render_widget(block, frame.area());
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
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);
|
frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the `[l]og: <latest message>` 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)
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-10
@@ -1,16 +1,13 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use kiln_core::game::GameState;
|
use kiln_core::game::GameState;
|
||||||
|
use crate::log::LogState;
|
||||||
use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState};
|
use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState};
|
||||||
|
|
||||||
/// View-only UI state for the log panel and scroll overlay. This is presentation
|
/// 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`].
|
/// state, so it lives in the front-end rather than on the engine's [`GameState`].
|
||||||
pub(crate) struct Ui {
|
pub(crate) struct Ui {
|
||||||
/// Whether the bottom log panel is open.
|
/// State for the log panel (open/closed, height, scroll position).
|
||||||
pub(crate) log_open: bool,
|
pub(crate) log: LogState,
|
||||||
/// 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,
|
|
||||||
/// Scroll position, bounds, and animation state for an active scroll overlay.
|
/// Scroll position, bounds, and animation state for an active scroll overlay.
|
||||||
pub(crate) overlay: ScrollOverlayState,
|
pub(crate) overlay: ScrollOverlayState,
|
||||||
}
|
}
|
||||||
@@ -18,9 +15,7 @@ pub(crate) struct Ui {
|
|||||||
impl Default for Ui {
|
impl Default for Ui {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
log_open: false,
|
log: LogState::new(),
|
||||||
log_height: 10,
|
|
||||||
scroll: 0,
|
|
||||||
overlay: ScrollOverlayState::default(),
|
overlay: ScrollOverlayState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,4 +58,4 @@ impl Ui {
|
|||||||
self.overlay.pending_choice = choice;
|
self.overlay.pending_choice = choice;
|
||||||
self.overlay.anim = Some(ScrollAnimState::Closing(progress));
|
self.overlay.anim = Some(ScrollAnimState::Closing(progress));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-25
@@ -1,7 +1,6 @@
|
|||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::prelude::{Color, Line, Span, Style};
|
use ratatui::prelude::Color;
|
||||||
use kiln_core::log::LogLine;
|
|
||||||
|
|
||||||
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`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)
|
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::<Vec<_>>();
|
|
||||||
Line::from(spans)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if two `Rect`s share at least one cell.
|
/// Returns true if two `Rect`s share at least one cell.
|
||||||
pub fn rects_overlap(a: Rect, b: Rect) -> bool {
|
pub fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||||
a.x < b.x + b.width
|
a.x < b.x + b.width
|
||||||
|
|||||||
Reference in New Issue
Block a user