Log panel
This commit is contained in:
+147
-35
@@ -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<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 {
|
||||
// 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: <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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user