refactoring
This commit is contained in:
+27
-59
@@ -9,7 +9,10 @@
|
|||||||
|
|
||||||
mod cp437;
|
mod cp437;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod scroll_overlay;
|
||||||
mod term;
|
mod term;
|
||||||
|
mod utils;
|
||||||
|
mod speech;
|
||||||
|
|
||||||
use kiln_core::game::{GameState, ScrollLine};
|
use kiln_core::game::{GameState, ScrollLine};
|
||||||
use kiln_core::log::LogLine;
|
use kiln_core::log::LogLine;
|
||||||
@@ -25,22 +28,14 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::symbols::merge::MergeStrategy;
|
use ratatui::symbols::merge::MergeStrategy;
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||||
use render::{BoardWidget, logline_to_line};
|
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::scroll_overlay::{ScrollAnimState, ScrollOverlayState, ScrollOverlayWidget};
|
||||||
/// Animation state for the scroll overlay window.
|
use crate::speech::SpeechBubblesWidget;
|
||||||
enum ScrollAnimState {
|
use crate::utils::logline_to_line;
|
||||||
/// 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
|
/// 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`].
|
||||||
@@ -51,12 +46,8 @@ struct Ui {
|
|||||||
log_height: u16,
|
log_height: u16,
|
||||||
/// Vertical scroll offset of the log panel, in lines from the top (newest).
|
/// Vertical scroll offset of the log panel, in lines from the top (newest).
|
||||||
scroll: u16,
|
scroll: u16,
|
||||||
/// Vertical scroll offset within an active scroll overlay.
|
/// Scroll position, bounds, and animation state for an active scroll overlay.
|
||||||
scroll_offset: u16,
|
overlay: ScrollOverlayState,
|
||||||
/// Maximum valid value for `scroll_offset`; updated each frame by the renderer.
|
|
||||||
scroll_max: u16,
|
|
||||||
/// Animation state for the scroll overlay; `None` when no overlay is active.
|
|
||||||
scroll_anim: Option<ScrollAnimState>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Ui {
|
impl Default for Ui {
|
||||||
@@ -65,9 +56,7 @@ impl Default for Ui {
|
|||||||
log_open: false,
|
log_open: false,
|
||||||
log_height: 10,
|
log_height: 10,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
scroll_offset: 0,
|
overlay: ScrollOverlayState::default(),
|
||||||
scroll_max: 0,
|
|
||||||
scroll_anim: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,19 +64,20 @@ impl Default for Ui {
|
|||||||
impl Ui {
|
impl Ui {
|
||||||
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
|
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
|
||||||
fn scroll_lines(&mut self, delta: i32) {
|
fn scroll_lines(&mut self, delta: i32) {
|
||||||
self.scroll_offset = (self.scroll_offset as i32 + delta)
|
self.overlay.offset = (self.overlay.offset as i32 + delta)
|
||||||
.clamp(0, self.scroll_max as i32) as u16;
|
.clamp(0, self.overlay.max_scroll as i32) as u16;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the scroll-close animation with the optional player choice.
|
/// Starts the scroll-close animation with the optional player choice.
|
||||||
/// Uses the current opening progress if the overlay was still animating in.
|
/// Uses the current opening progress if the overlay was still animating in.
|
||||||
fn begin_close(ui: &mut Ui, choice: Option<String>) {
|
fn begin_close(ui: &mut Ui, choice: Option<String>) {
|
||||||
let progress = match &ui.scroll_anim {
|
let progress = match &ui.overlay.anim {
|
||||||
Some(ScrollAnimState::Opening(p)) => p.min(1.0),
|
Some(ScrollAnimState::Opening(p)) => p.min(1.0),
|
||||||
_ => 1.0,
|
_ => 1.0,
|
||||||
};
|
};
|
||||||
ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice });
|
ui.overlay.pending_choice = choice;
|
||||||
|
ui.overlay.anim = Some(ScrollAnimState::Closing(progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -199,7 +189,7 @@ fn run(
|
|||||||
// When a scroll overlay is active (and not already animating
|
// When a scroll overlay is active (and not already animating
|
||||||
// closed), intercept all keys for scroll navigation/choices.
|
// closed), intercept all keys for scroll navigation/choices.
|
||||||
let scroll_active = game.active_scroll.is_some()
|
let scroll_active = game.active_scroll.is_some()
|
||||||
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
|
&& !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done));
|
||||||
if scroll_active {
|
if scroll_active {
|
||||||
let scroll = game.active_scroll.as_ref().unwrap();
|
let scroll = game.active_scroll.as_ref().unwrap();
|
||||||
let has_choices = scroll.lines.iter()
|
let has_choices = scroll.lines.iter()
|
||||||
@@ -241,7 +231,7 @@ fn run(
|
|||||||
// wheel scrolls the log and drag resizes it.
|
// wheel scrolls the log and drag resizes it.
|
||||||
Event::Mouse(m) => {
|
Event::Mouse(m) => {
|
||||||
let scroll_active = game.active_scroll.is_some()
|
let scroll_active = game.active_scroll.is_some()
|
||||||
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
|
&& !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done));
|
||||||
if scroll_active {
|
if scroll_active {
|
||||||
match m.kind {
|
match m.kind {
|
||||||
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
|
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
|
||||||
@@ -276,31 +266,19 @@ fn run(
|
|||||||
|
|
||||||
// Advance scroll animation regardless of game pause state.
|
// Advance scroll animation regardless of game pause state.
|
||||||
let dt_s = dt.as_secs_f32();
|
let dt_s = dt.as_secs_f32();
|
||||||
match &mut ui.scroll_anim {
|
ui.overlay.advance_anim(dt_s);
|
||||||
Some(ScrollAnimState::Opening(p)) => {
|
if ui.overlay.close_finished() {
|
||||||
*p += dt_s / 0.5;
|
// Animation done — dispatch choice and clear the scroll.
|
||||||
if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); }
|
game.close_scroll(ui.overlay.take_choice().as_deref());
|
||||||
}
|
ui.overlay = ScrollOverlayState::default();
|
||||||
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;
|
|
||||||
ui.scroll_max = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick the game only when no scroll overlay is blocking.
|
// Tick the game only when no scroll overlay is blocking.
|
||||||
if game.active_scroll.is_none() {
|
if game.active_scroll.is_none() {
|
||||||
game.tick(dt);
|
game.tick(dt);
|
||||||
// Detect a scroll just opened by the tick; start its open animation.
|
// Detect a scroll just opened by the tick; start its open animation.
|
||||||
if game.active_scroll.is_some() && ui.scroll_anim.is_none() {
|
if game.active_scroll.is_some() && ui.overlay.anim.is_none() {
|
||||||
ui.scroll_anim = Some(ScrollAnimState::Opening(0.0));
|
ui.overlay.anim = Some(ScrollAnimState::Opening(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,9 +297,6 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
|
|||||||
/// 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.
|
||||||
///
|
///
|
||||||
/// TODO: `draw` mutates `ui.scroll_max` as a side-effect of rendering. This is a wart —
|
|
||||||
/// ideally render functions are pure and all state lives in the game loop. The max scroll
|
|
||||||
/// should be computed independently of drawing and stored before the draw call.
|
|
||||||
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();
|
||||||
@@ -340,7 +315,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
|||||||
let inner = block.inner(board_area);
|
let inner = block.inner(board_area);
|
||||||
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);
|
||||||
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
|
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
||||||
|
|
||||||
// Newest message on top: reverse the log into ratatui lines.
|
// Newest message on top: reverse the log into ratatui lines.
|
||||||
let log_block = Block::bordered()
|
let log_block = Block::bordered()
|
||||||
@@ -362,21 +337,14 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
|||||||
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);
|
||||||
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
|
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll overlay: drawn last so it sits above all other content.
|
// Scroll overlay: drawn last so it sits above all other content.
|
||||||
if let Some(scroll) = &game.active_scroll {
|
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.
|
// Capture area before the mutable buffer borrow to satisfy the borrow checker.
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
ui.scroll_max = render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
|
frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-261
@@ -6,47 +6,11 @@
|
|||||||
//! player is drawn on top of everything.
|
//! player is drawn on top of everything.
|
||||||
|
|
||||||
use crate::cp437::tile_to_char;
|
use crate::cp437::tile_to_char;
|
||||||
use color::Rgba8;
|
|
||||||
use kiln_core::Board;
|
use kiln_core::Board;
|
||||||
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
|
|
||||||
use kiln_core::log::LogLine;
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::widgets::Widget;
|
||||||
use ratatui::text::{Line, Span};
|
use crate::utils::rgba8_to_color;
|
||||||
use ratatui::widgets::{Block, BorderType, Fill, Paragraph, Scrollbar, ScrollbarOrientation,
|
|
||||||
ScrollbarState, StatefulWidget, Widget, Wrap};
|
|
||||||
|
|
||||||
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
|
|
||||||
///
|
|
||||||
/// The alpha channel is dropped — terminals have no per-cell alpha. Truecolor
|
|
||||||
/// requires terminal support; terminals limited to 256 colors approximate it.
|
|
||||||
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::<Vec<_>>();
|
|
||||||
Line::from(spans)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a
|
/// 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.
|
/// rectangular area, scrolled so the player stays visible on larger boards.
|
||||||
@@ -115,225 +79,3 @@ impl Widget for BoardWidget<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a board cell coordinate to terminal screen coordinates within `area`,
|
|
||||||
/// accounting for centering/scrolling. Returns `None` if the cell is off-screen.
|
|
||||||
pub fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> {
|
|
||||||
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
|
|
||||||
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
|
||||||
if bx < off_x || bx >= off_x + cols { return None; }
|
|
||||||
if by < off_y || by >= off_y + rows { return None; }
|
|
||||||
let sx = area.x + pad_x + (bx - off_x) as u16;
|
|
||||||
let sy = area.y + pad_y + (by - off_y) as u16;
|
|
||||||
Some((sx, sy))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws speech bubbles for all active [`SpeechBubble`]s over the board area.
|
|
||||||
///
|
|
||||||
/// Each bubble appears as a box-drawing frame with a tail pointing down at the
|
|
||||||
/// speaker's cell. Bubbles that would collide (same horizontal range, adjacent
|
|
||||||
/// vertically) are shifted upward one at a time until there is no overlap.
|
|
||||||
pub fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles: &[SpeechBubble]) {
|
|
||||||
// Determine the screen position of each visible bubble's speaker.
|
|
||||||
let mut items: Vec<(u16, u16, &SpeechBubble)> = bubbles
|
|
||||||
.iter()
|
|
||||||
.filter_map(|b| {
|
|
||||||
let obj = board.objects.get(&b.object_id)?;
|
|
||||||
let (sx, sy) = board_screen_pos(area, board, obj.x, obj.y)?;
|
|
||||||
Some((sx, sy, b))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Process bubbles lowest on screen first so upward-shift doesn't interfere
|
|
||||||
// with already-placed higher bubbles.
|
|
||||||
items.sort_by_key(|&(_, sy, _)| std::cmp::Reverse(sy));
|
|
||||||
|
|
||||||
// Track the rects of already-placed bubbles to detect collisions.
|
|
||||||
let mut placed: Vec<Rect> = Vec::new();
|
|
||||||
|
|
||||||
let bubble_style = Style::default()
|
|
||||||
.fg(Color::White)
|
|
||||||
.bg(Color::Rgb(20, 20, 40));
|
|
||||||
let border_style = Style::default()
|
|
||||||
.fg(Color::Rgb(160, 160, 220))
|
|
||||||
.bg(Color::Rgb(20, 20, 40));
|
|
||||||
|
|
||||||
for (obj_sx, obj_sy, bubble) in items {
|
|
||||||
let text_w = bubble.text.chars().count() as u16;
|
|
||||||
// Box: ╭ space text space ╮ → width = text + 4
|
|
||||||
let box_w = text_w + 4;
|
|
||||||
// 4 rows: top border, text, bottom border, tail
|
|
||||||
let box_h: u16 = 4;
|
|
||||||
|
|
||||||
// Center the box over the object; clamp to area bounds.
|
|
||||||
let raw_left = obj_sx.saturating_sub(box_w / 2);
|
|
||||||
let left = raw_left.min(area.right().saturating_sub(box_w));
|
|
||||||
let left = left.max(area.left());
|
|
||||||
|
|
||||||
// Desired top: 4 rows above the object (rows: top, text, bottom, tail → speaker).
|
|
||||||
let desired_top = obj_sy.saturating_sub(box_h);
|
|
||||||
|
|
||||||
// Shift upward until no overlap with already-placed bubbles.
|
|
||||||
let mut top = desired_top;
|
|
||||||
let mut iterations = 0u16;
|
|
||||||
'place: loop {
|
|
||||||
if iterations > 20 || top < area.top() { break; }
|
|
||||||
let candidate = Rect { x: left, y: top, width: box_w, height: box_h };
|
|
||||||
for p in &placed {
|
|
||||||
if rects_overlap(candidate, *p) {
|
|
||||||
top = top.saturating_sub(1);
|
|
||||||
iterations += 1;
|
|
||||||
continue 'place;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if top < area.top() { continue; } // no room; skip this bubble
|
|
||||||
|
|
||||||
placed.push(Rect { x: left, y: top, width: box_w, height: box_h });
|
|
||||||
|
|
||||||
// Render the rounded box (3 rows: top border, text, bottom border) with ratatui.
|
|
||||||
let box_rect = Rect { x: left, y: top, width: box_w, height: 3 };
|
|
||||||
let block = Block::bordered()
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(border_style)
|
|
||||||
.style(bubble_style);
|
|
||||||
let text_rect = block.inner(box_rect);
|
|
||||||
block.render(box_rect, buf);
|
|
||||||
Paragraph::new(Line::styled(format!(" {}", bubble.text), bubble_style))
|
|
||||||
.render(text_rect, buf);
|
|
||||||
|
|
||||||
// Overwrite the tail-join cell on the bottom border, then draw the tail itself.
|
|
||||||
// These are the only two cells that ratatui's Block can't produce for us.
|
|
||||||
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
|
|
||||||
write_cell(buf, tail_col, top + 2, '╮', border_style);
|
|
||||||
write_cell(buf, tail_col, top + 3, '│', border_style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if two `Rect`s share at least one cell.
|
|
||||||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
|
||||||
a.x < b.x + b.width
|
|
||||||
&& b.x < a.x + a.width
|
|
||||||
&& a.y < b.y + b.height
|
|
||||||
&& b.y < a.y + a.height
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a single styled character into the buffer at `(x, y)`.
|
|
||||||
fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
|
|
||||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
||||||
cell.set_char(ch).set_style(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls.
|
|
||||||
///
|
|
||||||
/// Returns the maximum valid scroll offset (0 when all content fits). The caller should
|
|
||||||
/// store this and use it to clamp `offset` on future frames.
|
|
||||||
///
|
|
||||||
/// The overlay dims the entire background, then renders a `Block`-bordered window.
|
|
||||||
/// Width is half the screen; height is 3/4 the screen rounded down to an even number
|
|
||||||
/// so the open/close animation (0.0 → 1.0) grows by 2 rows at a time from the center.
|
|
||||||
/// If the content is shorter than the window, it is vertically centered via `Layout`.
|
|
||||||
/// Text lines whose source starts with whitespace are rendered horizontally centered
|
|
||||||
/// (whitespace stripped); all other lines are left-aligned. `offset` scrolls content.
|
|
||||||
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) -> u16 {
|
|
||||||
if anim <= 0.0 { return 0; }
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Fixed proportional sizing. Height is forced even so each animation step
|
|
||||||
// expands the window by exactly 2 rows (one on each side from the center).
|
|
||||||
let outer_w = area.width / 2;
|
|
||||||
let outer_h = (area.height * 3 / 4) & !1;
|
|
||||||
|
|
||||||
// Animate height: keep it even at every intermediate frame too.
|
|
||||||
let actual_h = ((outer_h as f32 * anim).round() as u16 & !1).max(2);
|
|
||||||
|
|
||||||
let left = area.x + area.width.saturating_sub(outer_w) / 2;
|
|
||||||
let top = area.y + area.height.saturating_sub(actual_h) / 2;
|
|
||||||
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h };
|
|
||||||
|
|
||||||
// Build Lines without pre-wrapping — Paragraph handles wrapping at render time.
|
|
||||||
// Lines whose source starts with whitespace are centered; others are left-aligned.
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
let mut choice_letter = b'a';
|
|
||||||
for item in &scroll.lines {
|
|
||||||
match item {
|
|
||||||
ScrollLine::Text(s) => {
|
|
||||||
let text = s.trim();
|
|
||||||
let line = Line::styled(text.to_string(), text_style);
|
|
||||||
lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
|
||||||
line.centered()
|
|
||||||
} else {
|
|
||||||
line
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dim the whole background, then use Fill to paint the overlay area with solid
|
|
||||||
// background-colored spaces so board glyphs can't show through.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf);
|
|
||||||
|
|
||||||
// Render the bordered box, then split inner: text on the left, scrollbar on the right.
|
|
||||||
let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg));
|
|
||||||
let inner = block.inner(overlay_rect);
|
|
||||||
block.render(overlay_rect, buf);
|
|
||||||
|
|
||||||
let [content_area, scrollbar_area] = Layout::horizontal([
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(1),
|
|
||||||
]).areas(inner);
|
|
||||||
|
|
||||||
// Measure wrapped content height using the narrower content_area width, clamp
|
|
||||||
// scroll so content can't be pushed out of view.
|
|
||||||
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
|
||||||
let content_lines = para.line_count(content_area.width) as u16;
|
|
||||||
let max_scroll = content_lines.saturating_sub(content_area.height);
|
|
||||||
let clamped = offset.min(max_scroll);
|
|
||||||
let para = para.scroll((clamped, 0));
|
|
||||||
|
|
||||||
// Center content vertically when it's shorter than the window.
|
|
||||||
let pad = content_area.height.saturating_sub(content_lines) / 2;
|
|
||||||
let [_, content_rect, _] = Layout::vertical([
|
|
||||||
Constraint::Length(pad),
|
|
||||||
Constraint::Length(content_lines.min(content_area.height)),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
]).areas(content_area);
|
|
||||||
para.render(content_rect, buf);
|
|
||||||
|
|
||||||
// Scrollbar — only shown when content exceeds the visible height.
|
|
||||||
// content_length is set to max_scroll + 1 so that position 0 maps to the top
|
|
||||||
// of the track and position max_scroll maps exactly to the bottom. Using the
|
|
||||||
// full content_lines as content_length would leave the thumb short of the
|
|
||||||
// bottom at max scroll because ratatui's thumb formula is based on
|
|
||||||
// (content_length - 1) as the maximum position.
|
|
||||||
if content_lines > content_area.height {
|
|
||||||
let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize)
|
|
||||||
.position(clamped as usize);
|
|
||||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
|
||||||
.render(scrollbar_area, buf, &mut sb_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
max_scroll
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! The scroll overlay widget, shown when a script calls `scroll()`.
|
||||||
|
//!
|
||||||
|
//! Uses [`StatefulWidget`] rather than [`Widget`] because rendering computes
|
||||||
|
//! `max_scroll` (the highest valid scroll offset) as a side-effect that the
|
||||||
|
//! caller needs to clamp future scroll input. [`ScrollOverlayState`] carries
|
||||||
|
//! `offset` in and `max_scroll` out across the render call.
|
||||||
|
|
||||||
|
use kiln_core::game::{Scroll, ScrollLine};
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{
|
||||||
|
Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
|
||||||
|
Widget, Wrap,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Animation state for the scroll overlay window.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum ScrollAnimState {
|
||||||
|
/// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s.
|
||||||
|
Opening(f32),
|
||||||
|
/// Window is fully open.
|
||||||
|
#[default]
|
||||||
|
Open,
|
||||||
|
/// Window is closing: progress from 1.0 → 0.0 over 0.5 s.
|
||||||
|
Closing(f32),
|
||||||
|
/// Close animation finished; the caller should dispatch
|
||||||
|
/// [`ScrollOverlayState::pending_choice`] and reset the state.
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State threaded through a [`ScrollOverlayWidget`] render call.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ScrollOverlayState {
|
||||||
|
/// The current scroll offset (input — read during render).
|
||||||
|
pub offset: u16,
|
||||||
|
/// The maximum valid scroll offset (output — written during render).
|
||||||
|
pub max_scroll: u16,
|
||||||
|
/// Open/close animation state (input — read during render).
|
||||||
|
pub anim: Option<ScrollAnimState>,
|
||||||
|
/// The player's choice to dispatch when [`close_finished`](Self::close_finished)
|
||||||
|
/// is true. Set by the caller before starting a close animation.
|
||||||
|
pub pending_choice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollOverlayState {
|
||||||
|
/// Advances the open/close animation by `dt` seconds.
|
||||||
|
///
|
||||||
|
/// When a closing animation completes, transitions `anim` to
|
||||||
|
/// [`ScrollAnimState::Done`]. Check [`close_finished`](Self::close_finished)
|
||||||
|
/// after calling this, then use [`take_choice`](Self::take_choice) to
|
||||||
|
/// retrieve the pending choice before resetting the state.
|
||||||
|
pub fn advance_anim(&mut self, dt: f32) {
|
||||||
|
match &mut self.anim {
|
||||||
|
Some(ScrollAnimState::Opening(p)) => {
|
||||||
|
*p += dt / 0.5;
|
||||||
|
if *p >= 1.0 { self.anim = Some(ScrollAnimState::Open); }
|
||||||
|
}
|
||||||
|
Some(ScrollAnimState::Closing(p)) => {
|
||||||
|
*p -= dt / 0.5;
|
||||||
|
if *p <= 0.0 { self.anim = Some(ScrollAnimState::Done); }
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when a closing animation has just finished and
|
||||||
|
/// [`pending_choice`](Self::pending_choice) is ready to be dispatched.
|
||||||
|
pub fn close_finished(&self) -> bool {
|
||||||
|
matches!(self.anim, Some(ScrollAnimState::Done))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes and returns the pending choice. Returns `None` if no choice
|
||||||
|
/// was queued (e.g. the player dismissed without selecting).
|
||||||
|
pub fn take_choice(&mut self) -> Option<String> {
|
||||||
|
self.pending_choice.take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ratatui widget that draws the full-screen scroll overlay.
|
||||||
|
///
|
||||||
|
/// The overlay dims the background, then renders a `Block`-bordered window at
|
||||||
|
/// half screen width × 3/4 screen height. Height animates via `state.anim`
|
||||||
|
/// (0.0 → 1.0 = opening, 1.0 → 0.0 = closing), always staying even so each
|
||||||
|
/// step grows the window by exactly 2 rows from the center. Text lines whose
|
||||||
|
/// source starts with whitespace are stripped and centered; all others are
|
||||||
|
/// left-aligned. Selectable choices show `[a]`/`[b]` prefixes.
|
||||||
|
pub struct ScrollOverlayWidget<'a> {
|
||||||
|
/// The scroll content to display.
|
||||||
|
pub scroll: &'a Scroll,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ScrollOverlayWidget<'a> {
|
||||||
|
/// Creates a widget for the given scroll content.
|
||||||
|
pub fn new(scroll: &'a Scroll) -> Self {
|
||||||
|
Self { scroll }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||||
|
type State = ScrollOverlayState;
|
||||||
|
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut ScrollOverlayState) {
|
||||||
|
let anim = match &state.anim {
|
||||||
|
Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0),
|
||||||
|
Some(ScrollAnimState::Open) => 1.0,
|
||||||
|
Some(ScrollAnimState::Closing(p)) => p.clamp(0.0, 1.0),
|
||||||
|
Some(ScrollAnimState::Done) | None => return,
|
||||||
|
};
|
||||||
|
if anim <= 0.0 {
|
||||||
|
state.max_scroll = 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);
|
||||||
|
|
||||||
|
// Fixed proportional sizing. Height is forced even so each animation step
|
||||||
|
// expands the window by exactly 2 rows (one on each side from the center).
|
||||||
|
let outer_w = area.width / 2;
|
||||||
|
let outer_h = (area.height * 3 / 4) & !1;
|
||||||
|
|
||||||
|
// Animate height: keep it even at every intermediate frame too.
|
||||||
|
let actual_h = ((outer_h as f32 * anim).round() as u16 & !1).max(2);
|
||||||
|
|
||||||
|
let left = area.x + area.width.saturating_sub(outer_w) / 2;
|
||||||
|
let top = area.y + area.height.saturating_sub(actual_h) / 2;
|
||||||
|
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h };
|
||||||
|
|
||||||
|
// Build Lines without pre-wrapping — Paragraph handles wrapping at render time.
|
||||||
|
// Lines whose source starts with whitespace are centered; others are left-aligned.
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let mut choice_letter = b'a';
|
||||||
|
for item in &self.scroll.lines {
|
||||||
|
match item {
|
||||||
|
ScrollLine::Text(s) => {
|
||||||
|
let text = s.trim();
|
||||||
|
let line = Line::styled(text.to_string(), text_style);
|
||||||
|
lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||||
|
line.centered()
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dim the whole background, then use Fill to paint the overlay area with solid
|
||||||
|
// background-colored spaces so board glyphs can't show through.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf);
|
||||||
|
|
||||||
|
// Render the bordered box, then split inner: text on the left, scrollbar on the right.
|
||||||
|
let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg));
|
||||||
|
let inner = block.inner(overlay_rect);
|
||||||
|
block.render(overlay_rect, buf);
|
||||||
|
|
||||||
|
let [content_area, scrollbar_area] = Layout::horizontal([
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
]).areas(inner);
|
||||||
|
|
||||||
|
// Measure wrapped content height using the narrower content_area width, clamp
|
||||||
|
// scroll so content can't be pushed out of view.
|
||||||
|
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||||
|
let content_lines = para.line_count(content_area.width) as u16;
|
||||||
|
let max_scroll = content_lines.saturating_sub(content_area.height);
|
||||||
|
let clamped = state.offset.min(max_scroll);
|
||||||
|
let para = para.scroll((clamped, 0));
|
||||||
|
|
||||||
|
// Center content vertically when it's shorter than the window.
|
||||||
|
let pad = content_area.height.saturating_sub(content_lines) / 2;
|
||||||
|
let [_, content_rect, _] = Layout::vertical([
|
||||||
|
Constraint::Length(pad),
|
||||||
|
Constraint::Length(content_lines.min(content_area.height)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
]).areas(content_area);
|
||||||
|
para.render(content_rect, buf);
|
||||||
|
|
||||||
|
// Scrollbar — only shown when content exceeds the visible height.
|
||||||
|
// content_length is set to max_scroll + 1 so that position 0 maps to the top
|
||||||
|
// of the track and position max_scroll maps exactly to the bottom. Using the
|
||||||
|
// full content_lines as content_length would leave the thumb short of the
|
||||||
|
// bottom at max scroll because ratatui's thumb formula is based on
|
||||||
|
// (content_length - 1) as the maximum position.
|
||||||
|
if content_lines > content_area.height {
|
||||||
|
let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize)
|
||||||
|
.position(clamped as usize);
|
||||||
|
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
|
.render(scrollbar_area, buf, &mut sb_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.max_scroll = max_scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::prelude::{Color, Line, Style, Widget};
|
||||||
|
use ratatui::widgets::{Block, Fill, Paragraph};
|
||||||
|
use kiln_core::Board;
|
||||||
|
use kiln_core::game::SpeechBubble;
|
||||||
|
use crate::render::BoardWidget;
|
||||||
|
use crate::utils::rects_overlap;
|
||||||
|
|
||||||
|
/// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board.
|
||||||
|
///
|
||||||
|
/// Each bubble appears as a box-drawing frame with a tail pointing down at the speaker's cell.
|
||||||
|
/// Bubbles that would collide (same horizontal range, adjacent vertically) are shifted upward
|
||||||
|
/// one at a time until there is no overlap.
|
||||||
|
pub struct SpeechBubblesWidget<'a> {
|
||||||
|
/// The board, used to look up object positions and viewport layout.
|
||||||
|
pub board: &'a Board,
|
||||||
|
/// The active speech bubbles to render.
|
||||||
|
pub bubbles: &'a [SpeechBubble],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SpeechBubblesWidget<'a> {
|
||||||
|
/// Creates a widget for the given board and bubble slice.
|
||||||
|
pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self {
|
||||||
|
Self { board, bubbles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a non-overlapping screen position for a bubble and records it in `placed`.
|
||||||
|
///
|
||||||
|
/// Sizes the bubble from `lines`, centers it horizontally over `(obj_sx, obj_sy)`,
|
||||||
|
/// then shifts upward until it clears all already-placed rects (or runs out of room).
|
||||||
|
/// Returns the full bubble rect (including the tail row) on success, or `None` if
|
||||||
|
/// the bubble can't fit within `area`. Appends to `placed` on success.
|
||||||
|
fn place_bubble(
|
||||||
|
area: Rect,
|
||||||
|
obj_sx: u16,
|
||||||
|
obj_sy: u16,
|
||||||
|
lines: &[&str],
|
||||||
|
placed: &mut Vec<Rect>,
|
||||||
|
) -> Option<Rect> {
|
||||||
|
let text_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
|
||||||
|
let box_w = text_w + 4;
|
||||||
|
let box_h = lines.len() as u16 + 3;
|
||||||
|
|
||||||
|
// Center horizontally over the speaker, clamped inside the viewport.
|
||||||
|
let left = obj_sx.saturating_sub(box_w / 2)
|
||||||
|
.min(area.right().saturating_sub(box_w))
|
||||||
|
.max(area.left());
|
||||||
|
|
||||||
|
// Start directly above the speaker and shift upward until no overlap.
|
||||||
|
let mut top = obj_sy.saturating_sub(box_h);
|
||||||
|
let mut iterations = 0u16;
|
||||||
|
'place: loop {
|
||||||
|
if iterations > 20 || top < area.top() { return None; }
|
||||||
|
let candidate = Rect { x: left, y: top, width: box_w, height: box_h };
|
||||||
|
for p in placed.iter() {
|
||||||
|
if rects_overlap(candidate, *p) {
|
||||||
|
top = top.saturating_sub(1);
|
||||||
|
iterations += 1;
|
||||||
|
continue 'place;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect = Rect { x: left, y: top, width: box_w, height: box_h };
|
||||||
|
placed.push(rect);
|
||||||
|
Some(rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for SpeechBubblesWidget<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
// Determine the screen position of each visible bubble's speaker.
|
||||||
|
let mut items: Vec<(u16, u16, &SpeechBubble)> = self.bubbles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| {
|
||||||
|
let obj = self.board.objects.get(&b.object_id)?;
|
||||||
|
let (sx, sy) = board_screen_pos(area, self.board, obj.x, obj.y)?;
|
||||||
|
Some((sx, sy, b))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Process bubbles lowest on screen first so upward-shift doesn't interfere
|
||||||
|
// with already-placed higher bubbles.
|
||||||
|
items.sort_by_key(|&(_, sy, _)| std::cmp::Reverse(sy));
|
||||||
|
|
||||||
|
// Track the rects of already-placed bubbles to detect collisions.
|
||||||
|
let mut placed: Vec<Rect> = Vec::new();
|
||||||
|
|
||||||
|
let bubble_style = Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.bg(Color::Rgb(20, 20, 40));
|
||||||
|
let border_style = Style::default()
|
||||||
|
.fg(Color::Rgb(160, 160, 220))
|
||||||
|
.bg(Color::Rgb(20, 20, 40));
|
||||||
|
|
||||||
|
for (obj_sx, obj_sy, bubble) in items {
|
||||||
|
let lines: Vec<&str> = bubble.text.split('\n').collect();
|
||||||
|
let Some(r) = Self::place_bubble(area, obj_sx, obj_sy, &lines, &mut placed) else { continue };
|
||||||
|
|
||||||
|
// Render the bordered box (top border + one row per line + bottom border).
|
||||||
|
let box_rect = Rect { height: r.height - 1, ..r };
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_style(border_style)
|
||||||
|
.style(bubble_style);
|
||||||
|
let text_rect = block.inner(box_rect);
|
||||||
|
block.render(box_rect, buf);
|
||||||
|
|
||||||
|
Fill::new(" ").style(bubble_style).render(text_rect, buf);
|
||||||
|
|
||||||
|
let para_lines: Vec<Line> = lines.iter()
|
||||||
|
.map(|l| Line::styled(format!(" {l}"), bubble_style))
|
||||||
|
.collect();
|
||||||
|
Paragraph::new(para_lines).render(text_rect, buf);
|
||||||
|
|
||||||
|
// Overwrite the tail-join cell on the bottom border, then draw the tail itself.
|
||||||
|
// These are the only two cells that ratatui's Block can't produce for us.
|
||||||
|
let tail_col = obj_sx.clamp(r.x + 1, r.x + r.width - 2);
|
||||||
|
let border_bottom = r.y + r.height - 2;
|
||||||
|
if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) {
|
||||||
|
cell.set_char('┬').set_style(border_style);
|
||||||
|
}
|
||||||
|
if let Some(cell) = buf.cell_mut((tail_col, border_bottom + 1)) {
|
||||||
|
cell.set_char('│').set_style(border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a board cell coordinate to terminal screen coordinates within `area`,
|
||||||
|
/// accounting for centering/scrolling. Returns `None` if the cell is off-screen.
|
||||||
|
fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> {
|
||||||
|
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
|
||||||
|
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
||||||
|
if bx < off_x || bx >= off_x + cols { return None; }
|
||||||
|
if by < off_y || by >= off_y + rows { return None; }
|
||||||
|
let sx = area.x + pad_x + (bx - off_x) as u16;
|
||||||
|
let sy = area.y + pad_y + (by - off_y) as u16;
|
||||||
|
Some((sx, sy))
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use color::Rgba8;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::prelude::{Color, Line, Span, Style};
|
||||||
|
use kiln_core::log::LogLine;
|
||||||
|
|
||||||
|
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
|
||||||
|
///
|
||||||
|
/// The alpha channel is dropped — terminals have no per-cell alpha. Truecolor
|
||||||
|
/// requires terminal support; terminals limited to 256 colors approximate it.
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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
|
||||||
|
&& b.x < a.x + a.width
|
||||||
|
&& a.y < b.y + b.height
|
||||||
|
&& b.y < a.y + a.height
|
||||||
|
}
|
||||||
+1
-1
@@ -126,7 +126,7 @@ greeter = """
|
|||||||
fn init() {
|
fn init() {
|
||||||
log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`);
|
log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`);
|
||||||
set_tile(2); // change my glyph to ☻ — proves the write path
|
set_tile(2); // change my glyph to ☻ — proves the write path
|
||||||
say("Hello there, traveller!");
|
say("Hello there,\\ntraveller!");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick(dt) {
|
fn tick(dt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user