Added scroll()
This commit is contained in:
+155
-32
@@ -11,7 +11,7 @@ mod cp437;
|
||||
mod render;
|
||||
mod term;
|
||||
|
||||
use kiln_core::game::GameState;
|
||||
use kiln_core::game::{GameState, ScrollLine};
|
||||
use kiln_core::log::LogLine;
|
||||
use kiln_core::map_file;
|
||||
use kiln_core::Direction;
|
||||
@@ -31,8 +31,19 @@ use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
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`].
|
||||
/// Animation state for the scroll overlay window.
|
||||
enum ScrollAnimState {
|
||||
/// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s.
|
||||
Opening(f32),
|
||||
/// Window is fully open.
|
||||
Open,
|
||||
/// Window is closing: progress from 1.0 → 0.0 over 0.5 s.
|
||||
/// When it reaches 0 the `choice` (if any) is dispatched and the overlay clears.
|
||||
Closing { progress: f32, choice: Option<String> },
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
struct Ui {
|
||||
/// Whether the bottom log panel is open.
|
||||
log_open: bool,
|
||||
@@ -40,6 +51,10 @@ struct Ui {
|
||||
log_height: u16,
|
||||
/// Vertical scroll offset of the log panel, in lines from the top (newest).
|
||||
scroll: u16,
|
||||
/// Vertical scroll offset within an active scroll overlay.
|
||||
scroll_offset: u16,
|
||||
/// Animation state for the scroll overlay; `None` when no overlay is active.
|
||||
scroll_anim: Option<ScrollAnimState>,
|
||||
}
|
||||
|
||||
impl Default for Ui {
|
||||
@@ -48,10 +63,37 @@ impl Default for Ui {
|
||||
log_open: false,
|
||||
log_height: 10,
|
||||
scroll: 0,
|
||||
scroll_offset: 0,
|
||||
scroll_anim: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the scroll-close animation with the optional player choice.
|
||||
/// Uses the current opening progress if the overlay was still animating in.
|
||||
fn begin_close(ui: &mut Ui, choice: Option<String>) {
|
||||
let progress = match &ui.scroll_anim {
|
||||
Some(ScrollAnimState::Opening(p)) => p.min(1.0),
|
||||
_ => 1.0,
|
||||
};
|
||||
ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice });
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
|
||||
let mut letter = b'a';
|
||||
for line in lines {
|
||||
if let ScrollLine::Choice { choice, .. } = line {
|
||||
if c == letter as char {
|
||||
return Some(choice.clone());
|
||||
}
|
||||
letter += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -142,36 +184,74 @@ fn run(
|
||||
// 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.
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Up => game.try_move(Direction::North),
|
||||
KeyCode::Down => game.try_move(Direction::South),
|
||||
KeyCode::Left => game.try_move(Direction::West),
|
||||
// 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;
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
// When a scroll overlay is active (and not already animating
|
||||
// closed), intercept all keys for scroll navigation/choices.
|
||||
let scroll_active = game.active_scroll.is_some()
|
||||
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
|
||||
if scroll_active {
|
||||
let scroll = game.active_scroll.as_ref().unwrap();
|
||||
let has_choices = scroll.lines.iter()
|
||||
.any(|l| matches!(l, ScrollLine::Choice { .. }));
|
||||
match key.code {
|
||||
// Esc dismisses the scroll only when there are no choices;
|
||||
// with choices present the player must select one.
|
||||
KeyCode::Esc if !has_choices => begin_close(ui, None),
|
||||
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(ch) = resolve_choice(&scroll.lines, c) {
|
||||
begin_close(ui, Some(ch));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Up => game.try_move(Direction::North),
|
||||
KeyCode::Down => game.try_move(Direction::South),
|
||||
KeyCode::Left => game.try_move(Direction::West),
|
||||
// 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;
|
||||
}
|
||||
// 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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
_ => {}
|
||||
},
|
||||
// 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 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));
|
||||
}
|
||||
// Mouse: scroll overlay captures wheel events when active; otherwise
|
||||
// wheel scrolls the log and drag resizes it.
|
||||
Event::Mouse(m) => {
|
||||
let scroll_active = game.active_scroll.is_some()
|
||||
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
|
||||
if scroll_active {
|
||||
match m.kind {
|
||||
MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
|
||||
MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
|
||||
_ => {}
|
||||
}
|
||||
} else 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 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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +262,35 @@ fn run(
|
||||
if last_tick.elapsed() >= FRAME {
|
||||
let dt = last_tick.elapsed();
|
||||
last_tick = Instant::now();
|
||||
game.tick(dt);
|
||||
|
||||
// Advance scroll animation regardless of game pause state.
|
||||
let dt_s = dt.as_secs_f32();
|
||||
match &mut ui.scroll_anim {
|
||||
Some(ScrollAnimState::Opening(p)) => {
|
||||
*p += dt_s / 0.5;
|
||||
if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); }
|
||||
}
|
||||
Some(ScrollAnimState::Closing { progress, choice }) => {
|
||||
*progress -= dt_s / 0.5;
|
||||
if *progress <= 0.0 {
|
||||
// Animation done — dispatch choice and clear the scroll.
|
||||
let ch = choice.take();
|
||||
game.close_scroll(ch.as_deref());
|
||||
ui.scroll_anim = None;
|
||||
ui.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Tick the game only when no scroll overlay is blocking.
|
||||
if game.active_scroll.is_none() {
|
||||
game.tick(dt);
|
||||
// Detect a scroll just opened by the tick; start its open animation.
|
||||
if game.active_scroll.is_some() && ui.scroll_anim.is_none() {
|
||||
ui.scroll_anim = Some(ScrollAnimState::Opening(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +305,7 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
|
||||
/// 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: &Ui) {
|
||||
// Borrow the board for the duration of the frame (no script runs during draw).
|
||||
let board = game.board();
|
||||
@@ -239,6 +348,20 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
||||
frame.render_widget(BoardWidget::new(board), inner);
|
||||
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
|
||||
}
|
||||
|
||||
// Scroll overlay: drawn last so it sits above all other content.
|
||||
if let Some(scroll) = &game.active_scroll {
|
||||
let anim = match &ui.scroll_anim {
|
||||
Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0),
|
||||
Some(ScrollAnimState::Open) => 1.0,
|
||||
Some(ScrollAnimState::Closing { progress, .. }) => progress.clamp(0.0, 1.0),
|
||||
// Fallback: overlay exists but animation not set yet — show fully open.
|
||||
None => 1.0,
|
||||
};
|
||||
// Capture area before the mutable buffer borrow to satisfy the borrow checker.
|
||||
let area = frame.area();
|
||||
render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `[l]og: <latest message>` preview shown in the board's bottom-left
|
||||
|
||||
+95
-2
@@ -8,13 +8,13 @@
|
||||
use crate::cp437::tile_to_char;
|
||||
use color::Rgba8;
|
||||
use kiln_core::Board;
|
||||
use kiln_core::game::SpeechBubble;
|
||||
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
|
||||
use kiln_core::log::LogLine;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
|
||||
///
|
||||
@@ -234,3 +234,96 @@ fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
|
||||
cell.set_char(ch).set_style(style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls.
|
||||
///
|
||||
/// The overlay dims the entire background, then renders a `Block`-bordered window
|
||||
/// whose height animates from 0 → full (opening) or full → 0 (closing) based on
|
||||
/// `anim` (0.0 = invisible, 1.0 = fully open). Text is pre-wrapped so the exact
|
||||
/// line count is known for the animation; a `Paragraph` renders the content.
|
||||
/// Choice lines show an `[a]` / `[b]` key prefix in yellow. `offset` scrolls the
|
||||
/// content vertically.
|
||||
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) {
|
||||
if anim <= 0.0 { return; }
|
||||
|
||||
let bg = Color::Rgb(30, 25, 10);
|
||||
let text_style = Style::default().fg(Color::White).bg(bg);
|
||||
let key_style = Style::default().fg(Color::Yellow).bg(bg);
|
||||
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg);
|
||||
let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg);
|
||||
|
||||
// Pre-wrap text so we know the total line count for the animation height.
|
||||
// max_text_w is the usable text width inside the block (border + 1 pad each side).
|
||||
let max_text_w = (area.width as usize).saturating_sub(6).clamp(10, 60);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut choice_letter = b'a';
|
||||
for item in &scroll.lines {
|
||||
match item {
|
||||
ScrollLine::Text(s) => {
|
||||
for wrapped in wrap_text(s, max_text_w) {
|
||||
// Leading space provides left-padding inside the border.
|
||||
lines.push(Line::styled(format!(" {wrapped}"), text_style));
|
||||
}
|
||||
}
|
||||
ScrollLine::Choice { display, .. } => {
|
||||
let key = choice_letter as char;
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" [{key}] "), key_style),
|
||||
Span::styled(display.clone(), choice_style),
|
||||
]));
|
||||
choice_letter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derive overlay dimensions. Width fits the longest line; height animates.
|
||||
let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(0).min(max_text_w);
|
||||
let outer_w = (content_w + 4) as u16; // border (1) + pad (1) each side = +4 total
|
||||
let full_outer_h = (lines.len() + 2) as u16; // +2 for top/bottom border rows
|
||||
let max_outer_h = ((area.height as f32 * 0.85) as u16).max(4);
|
||||
let actual_outer_h = ((full_outer_h as f32 * anim).ceil() as u16)
|
||||
.min(max_outer_h)
|
||||
.max(1);
|
||||
|
||||
let left = area.x + area.width.saturating_sub(outer_w) / 2;
|
||||
let top = area.y + area.height.saturating_sub(actual_outer_h) / 2;
|
||||
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_outer_h };
|
||||
|
||||
// Dim the background — no ratatui widget does this, so it stays manual.
|
||||
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render the bordered box, then the scrollable content inside it.
|
||||
let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg));
|
||||
let inner = block.inner(overlay_rect);
|
||||
block.render(overlay_rect, buf);
|
||||
Paragraph::new(lines).scroll((offset, 0)).render(inner, buf);
|
||||
}
|
||||
|
||||
/// Wraps `text` at word boundaries so no line exceeds `max_width` characters.
|
||||
/// Used to pre-compute line count for the scroll overlay's animated height.
|
||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
||||
if max_width == 0 { return vec![text.to_string()]; }
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if current.is_empty() {
|
||||
current.push_str(word);
|
||||
} else if current.len() + 1 + word.len() <= max_width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut current));
|
||||
current.push_str(word);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() { lines.push(current); }
|
||||
if lines.is_empty() { lines.push(String::new()); }
|
||||
lines
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user