diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index bb335dd..830feb8 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,3 +1,4 @@ +use crate::log::LogLine; use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -35,7 +36,7 @@ impl Hash for Glyph { } impl Glyph { - /// Returns the glyph used to render the player: tile 64 (`@`) in light blue on black. + /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. /// /// This is the only hardcoded glyph; all other glyphs come from the map /// file palette. It will be removed once the player becomes a scripted @@ -43,8 +44,8 @@ impl Glyph { pub const fn player() -> Self { Self { tile: 64, - fg: Rgba8 { r: 173, g: 216, b: 230, a: 255 }, // light blue - bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, // black + fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white + bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue } } } @@ -394,12 +395,22 @@ impl Board { pub struct GameState { /// The currently active board. pub board: Board, + /// The in-game message log, oldest first (newest pushed at the end). + pub log: Vec, } impl GameState { /// Creates a `GameState` from a pre-loaded [`Board`]. pub fn new(board: Board) -> Self { - Self { board } + Self { + board, + log: Vec::new(), + } + } + + /// Appends a styled message to the log. + pub fn log(&mut self, line: LogLine) { + self.log.push(line); } /// Attempts to move the player by `(dx, dy)` cells. diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index c3e3eeb..2cf956e 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -1,4 +1,6 @@ /// Core game types: [`game::Board`], [`game::Glyph`], [`game::Archetype`], etc. pub mod game; +/// Styled log messages ([`log::LogLine`]) for the in-game message feed. +pub mod log; /// Map file loading and saving (`.toml` format). pub mod map_file; diff --git a/kiln-core/src/log.rs b/kiln-core/src/log.rs new file mode 100644 index 0000000..3d096f2 --- /dev/null +++ b/kiln-core/src/log.rs @@ -0,0 +1,63 @@ +//! Styled log messages for the in-game message feed. +//! +//! A [`LogLine`] is one message in the game's log; it is a sequence of +//! [`LogSpan`]s so a single message can mix colors (e.g. a rainbow status +//! badge). Colors are stored as UI-agnostic [`Rgba8`] values — kiln-core does +//! not depend on any front-end — and each front-end converts a `LogLine` into +//! its own styled-text type at render time. + +use color::Rgba8; + +/// One run of text within a [`LogLine`], with optional foreground/background +/// colors. `None` for a color means "use the terminal/front-end default". +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogSpan { + /// The text of this run. + pub text: String, + /// Foreground color, or `None` to inherit the default. + pub fg: Option, + /// Background color, or `None` to inherit the default. + pub bg: Option, +} + +/// A single, possibly multi-colored log message: an ordered list of [`LogSpan`]s. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LogLine { + /// The styled runs that make up the message, in display order. + pub spans: Vec, +} + +impl LogLine { + /// Creates an empty line, to be filled with [`LogLine::push`]. + pub fn new() -> Self { + Self { spans: Vec::new() } + } + + /// Creates a line with a single default-colored span. + pub fn raw(text: impl Into) -> Self { + Self { + spans: vec![LogSpan { + text: text.into(), + fg: None, + bg: None, + }], + } + } + + /// Appends a styled span and returns `self`, so calls can be chained when + /// building a multi-color line. + pub fn push(mut self, text: impl Into, fg: Option, bg: Option) -> Self { + self.spans.push(LogSpan { + text: text.into(), + fg, + bg, + }); + self + } + + /// Add a whole other LogLine, all its spans, on to the end of this one. + pub fn append(mut self, mut other: LogLine) -> Self { + self.spans.append(&mut other.spans); + self + } +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index bdc660c..9f5517d 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -12,14 +12,44 @@ mod render; mod term; use kiln_core::game::GameState; +use kiln_core::log::LogLine; use kiln_core::map_file; use ratatui::Frame; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use ratatui::widgets::Block; -use render::BoardWidget; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, +}; +use ratatui::crossterm::execute; +use ratatui::layout::{Constraint, Layout, Spacing}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Wrap}; +use render::{BoardWidget, logline_to_line}; +use std::io; use std::process::ExitCode; +use ratatui::symbols::merge::MergeStrategy; use term::TerminalCaps; +/// View-only UI state for the log panel. This is presentation state, so it lives +/// in the front-end rather than on the engine's [`GameState`]. +struct Ui { + /// Whether the bottom log panel is open. + log_open: bool, + /// Height (in terminal rows, borders included) of the log panel when open. + log_height: u16, + /// Vertical scroll offset of the log panel, in lines from the top (newest). + scroll: u16, +} + +impl Default for Ui { + fn default() -> Self { + Self { + log_open: false, + log_height: 10, + scroll: 0, + } + } +} + /// Entry point: parse the map path, load the board, then run the play loop with /// the terminal in raw/alternate-screen mode (restored on every exit path). fn main() -> ExitCode { @@ -43,6 +73,10 @@ fn main() -> ExitCode { // `init` enters the alternate screen and raw mode; `restore` always undoes it. let mut terminal = ratatui::init(); + // Capture mouse events so the log panel can be scrolled with the wheel and + // resized by dragging its divider. Disabled again before restoring. + let _ = execute!(io::stdout(), EnableMouseCapture); + // Detect terminal capabilities (now that we're in raw mode) and enable the // Kitty keyboard protocol when it's available, so scripts can rely on the // richer bindings it provides. Disabled again before restoring the terminal. @@ -51,11 +85,19 @@ fn main() -> ExitCode { let _ = term::push_kitty_flags(); } - let result = run(&mut terminal, &mut game, &caps); + // Seed the log: the terminal-capabilities badge plus a couple of test + // messages (temporary, just to exercise the panel). + // Note these will appear in reverse order, latest on top + game.log(LogLine::raw("Press 'q' to exit.")); + game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline())); + + let mut ui = Ui::default(); + let result = run(&mut terminal, &mut game, &mut ui); if caps.keyboard_enhancement { let _ = term::pop_kitty_flags(); } + let _ = execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); if let Err(e) = result { @@ -69,44 +111,114 @@ fn main() -> ExitCode { fn run( terminal: &mut ratatui::DefaultTerminal, game: &mut GameState, - caps: &TerminalCaps, + ui: &mut Ui, ) -> std::io::Result<()> { loop { - // Redraw the board every iteration; ratatui diffs against the previous - // frame so only changed cells are actually written to the terminal. - terminal.draw(|frame| draw(frame, game, caps))?; + // Redraw every iteration; ratatui diffs against the previous frame so + // only changed cells are actually written to the terminal. + terminal.draw(|frame| draw(frame, game, ui))?; - // Block until the next input event. - if let Event::Key(key) = event::read()? { - // Act on Press and Repeat so holding a key moves the player - // continuously (the Kitty protocol emits Repeat events while a key is - // held). Ignore Release, which that protocol also emits and which - // would otherwise fire a second, spurious move per keypress. - if key.kind == KeyEventKind::Release { - continue; + match event::read()? { + Event::Key(key) => { + // Act on Press and Repeat so holding a key moves the player + // continuously (the Kitty protocol emits Repeat events while a + // key is held). Ignore Release, which that protocol also emits + // and which would otherwise fire a second, spurious move. + if key.kind == KeyEventKind::Release { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Up => game.try_move(0, -1), + KeyCode::Down => game.try_move(0, 1), + KeyCode::Left => game.try_move(-1, 0), + // Right arrow moves right; 'l' is reserved for the log panel. + KeyCode::Right => game.try_move(1, 0), + // 'l' toggles the log panel; reset the scroll to the newest. + KeyCode::Char('l') => { + ui.log_open = !ui.log_open; + ui.scroll = 0; + } + // 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), + _ => {} + } } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), - KeyCode::Up | KeyCode::Char('k') => game.try_move(0, -1), - KeyCode::Down | KeyCode::Char('j') => game.try_move(0, 1), - KeyCode::Left | KeyCode::Char('h') => game.try_move(-1, 0), - KeyCode::Right | KeyCode::Char('l') => game.try_move(1, 0), + // Wheel scrolls the open log; dragging the divider resizes it. + Event::Mouse(m) if ui.log_open => match m.kind { + MouseEventKind::ScrollUp => scroll_log(ui, game, -1), + MouseEventKind::ScrollDown => scroll_log(ui, game, 1), + MouseEventKind::Drag(_) => { + // The divider sits at row `total - log_height`; dragging it up + // (smaller row) grows the panel. Keep both regions 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)); + } _ => {} - } + }, + _ => {} } } } -/// Render a single frame: a bordered block titled with the board name and -/// controls, containing the board itself. -fn draw(frame: &mut Frame, game: &GameState, caps: &TerminalCaps) { - let board = &game.board; - let block = Block::bordered() - .title(format!(" {} — arrows move · q quit ", board.name)) - // Surface detected terminal capabilities in the bottom-right border. - .title_bottom(caps.summary_line().right_aligned()); - // Draw the board inside the border, not over it. - let inner = block.inner(frame.area()); - frame.render_widget(block, frame.area()); - frame.render_widget(BoardWidget::new(board), inner); +/// 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. +fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { + let board = &game.board; + + if ui.log_open { + // Split the screen: board on top, log panel of `log_height` at the bottom. + let [board_area, log_area] = + Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log_height)]) + .spacing(Spacing::Overlap(1)) + .areas(frame.area()); + + let block = Block::bordered().title(format!(" {} ", board.name)).merge_borders(MergeStrategy::Exact); + let inner = block.inner(board_area); + frame.render_widget(block, board_area); + frame.render_widget(BoardWidget::new(board), 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); + } 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()); + let inner = block.inner(frame.area()); + frame.render_widget(block, frame.area()); + frame.render_widget(BoardWidget::new(board), inner); + } +} + +/// 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/render.rs b/kiln-tui/src/render.rs index 70c1446..c20b1f5 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -8,9 +8,11 @@ use crate::cp437::tile_to_char; use color::Rgba8; use kiln_core::game::{Board, Glyph}; +use kiln_core::log::LogLine; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. @@ -21,6 +23,29 @@ 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) +} + /// A ratatui widget that draws a [`Board`] (with objects and the player) into a /// rectangular area, scrolled so the player stays visible on larger boards. pub struct BoardWidget<'a> { diff --git a/kiln-tui/src/term.rs b/kiln-tui/src/term.rs index 768f385..b99c32f 100644 --- a/kiln-tui/src/term.rs +++ b/kiln-tui/src/term.rs @@ -9,10 +9,10 @@ use ratatui::crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; +use color::Rgba8; +use kiln_core::log::LogLine; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::supports_keyboard_enhancement; -use ratatui::style::{Color, Style}; -use ratatui::text::{Line, Span}; use std::io; /// The input capabilities of the host terminal, detected once at startup. @@ -48,15 +48,16 @@ impl TerminalCaps { } } - /// A one-line styled summary for the UI, e.g. `"truecolor · enhanced input"`. + /// A one-line styled summary of terminal capabilities as a [`LogLine`], + /// e.g. `"truecolor · enhanced input"`, seeded into the game log at startup. /// /// When truecolor is available the word "truecolor" is rendered with each /// letter a different rainbow color — a fitting flex, since only a terminal /// that can show per-cell RGB can display it. Otherwise the color word is the /// plain `"256-color"`. The input descriptor is always drawn in the default /// color. - pub fn summary_line(&self) -> Line<'static> { - let mut spans: Vec> = vec![Span::raw(" ")]; + pub fn status_logline(&self) -> LogLine { + let mut line = LogLine::new(); if self.truecolor { // Rotate the hue across the letters so each gets a distinct spectrum color. @@ -64,13 +65,10 @@ impl TerminalCaps { let n = word.chars().count(); for (i, ch) in word.chars().enumerate() { let (r, g, b) = hue_to_rgb(i as f32 / n as f32 * 360.0); - spans.push(Span::styled( - ch.to_string(), - Style::default().fg(Color::Rgb(r, g, b)), - )); + line = line.push(ch.to_string(), Some(Rgba8 { r, g, b, a: 255 }), None); } } else { - spans.push(Span::raw("256-color")); + line = line.push("256-color", None, None); } let input = if self.keyboard_enhancement { @@ -78,9 +76,7 @@ impl TerminalCaps { } else { "legacy input" }; - spans.push(Span::raw(format!(" · {input} "))); - - Line::from(spans) + line.push(format!(" · {input}"), None, None) } }