spinners and fmt
This commit is contained in:
@@ -9,10 +9,10 @@
|
||||
//! but open animations may advertise an interactive mode so the overlay is
|
||||
//! responsive before the animation completes.
|
||||
|
||||
use crate::input::InputMode;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use crate::input::InputMode;
|
||||
|
||||
/// Which region of the terminal an animation occupies.
|
||||
pub enum AnimationLayer {
|
||||
@@ -32,7 +32,9 @@ pub trait Animation {
|
||||
fn layer(&self) -> AnimationLayer;
|
||||
/// Which [`InputMode`] is active while this animation is running.
|
||||
/// Defaults to [`InputMode::Ignore`] (all input discarded).
|
||||
fn input_mode_during(&self) -> InputMode { InputMode::Ignore }
|
||||
fn input_mode_during(&self) -> InputMode {
|
||||
InputMode::Ignore
|
||||
}
|
||||
/// Which [`InputMode`] to enter when the animation finishes.
|
||||
fn input_mode_after(&self) -> InputMode;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,12 @@ impl EditorState {
|
||||
let mut world = self.world.deep_clone();
|
||||
// Start the playtest on the board open in the editor, not world.start.
|
||||
world.start = self.board_name.clone();
|
||||
// Editor-stamped machines (spinners, pushers) are plain terrain cells; expand
|
||||
// them into their scripted objects so they actually run, just as the map loader
|
||||
// does on disk-loaded worlds.
|
||||
for board in world.boards.values() {
|
||||
board.borrow_mut().expand_builtin_archetypes();
|
||||
}
|
||||
let mut game = GameState::from_world(world);
|
||||
game.log(LogLine::raw("[esc] to return to the editor"));
|
||||
game.run_init();
|
||||
|
||||
+34
-13
@@ -1,7 +1,7 @@
|
||||
use kiln_core::{Archetype, Direction};
|
||||
use crate::editor::EditorState;
|
||||
use crate::menu::{MenuItem, MenuKey};
|
||||
use crate::mode::PendingMode;
|
||||
use kiln_core::{Archetype, Direction, SpinDirection};
|
||||
|
||||
/// One level in the editor's sidebar menu tree.
|
||||
///
|
||||
@@ -72,16 +72,29 @@ impl MenuLevel {
|
||||
MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Crate)),
|
||||
MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::VCrate)),
|
||||
MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::HCrate)),
|
||||
|
||||
],
|
||||
MenuLevel::Machines => vec![
|
||||
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
|
||||
MenuEntry::item('s', "/ CW spinner", |ed| {
|
||||
ed.set_current(Archetype::Spinner(SpinDirection::Clockwise))
|
||||
}),
|
||||
MenuEntry::item('c', "\\ CCW spinner", |ed| {
|
||||
ed.set_current(Archetype::Spinner(SpinDirection::CounterClockwise))
|
||||
}),
|
||||
],
|
||||
MenuLevel::Pushers => vec![
|
||||
MenuEntry::item('n', "▲ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::North))),
|
||||
MenuEntry::item('s', "▼ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::South))),
|
||||
MenuEntry::item('e', "► Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::East))),
|
||||
MenuEntry::item('w', "◄ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::West))),
|
||||
MenuEntry::item('n', "▲ Pusher", |ed| {
|
||||
ed.set_current(Archetype::Pusher(Direction::North))
|
||||
}),
|
||||
MenuEntry::item('s', "▼ Pusher", |ed| {
|
||||
ed.set_current(Archetype::Pusher(Direction::South))
|
||||
}),
|
||||
MenuEntry::item('e', "► Pusher", |ed| {
|
||||
ed.set_current(Archetype::Pusher(Direction::East))
|
||||
}),
|
||||
MenuEntry::item('w', "◄ Pusher", |ed| {
|
||||
ed.set_current(Archetype::Pusher(Direction::West))
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -91,7 +104,7 @@ impl MenuLevel {
|
||||
/// access to the editor session. Unmatched keys are ignored.
|
||||
pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) {
|
||||
let action = self.entries().into_iter().find_map(|entry| match entry {
|
||||
MenuEntry::Item{ key, action, .. } if key == ch => Some(action),
|
||||
MenuEntry::Item { key, action, .. } if key == ch => Some(action),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(action) = action {
|
||||
@@ -105,7 +118,7 @@ impl MenuLevel {
|
||||
/// [`Blank`](MenuEntry::Blank)/[`Separator`](MenuEntry::Separator) spacer.
|
||||
pub(crate) enum MenuEntry {
|
||||
/// A selectable, key-activated item.
|
||||
Item{
|
||||
Item {
|
||||
/// The letter the user presses to activate this item.
|
||||
key: char,
|
||||
/// Display label shown after the `[key]` in the sidebar.
|
||||
@@ -119,15 +132,23 @@ pub(crate) enum MenuEntry {
|
||||
/// A horizontal rule spanning the sidebar width.
|
||||
Separator,
|
||||
/// The current value of some field
|
||||
CurrentValue(Box<dyn FnOnce(& EditorState) -> String>)
|
||||
CurrentValue(Box<dyn FnOnce(&EditorState) -> String>),
|
||||
}
|
||||
|
||||
impl MenuEntry {
|
||||
pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(key: char, label: impl Into<String>, action: F) -> Self {
|
||||
MenuEntry::Item { key, label: label.into(), action: Box::new(action) }
|
||||
pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(
|
||||
key: char,
|
||||
label: impl Into<String>,
|
||||
action: F,
|
||||
) -> Self {
|
||||
MenuEntry::Item {
|
||||
key,
|
||||
label: label.into(),
|
||||
action: Box::new(action),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value<F: FnOnce(& EditorState) -> String + 'static>(action: F) -> Self {
|
||||
pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self {
|
||||
MenuEntry::CurrentValue(Box::new(action))
|
||||
}
|
||||
}
|
||||
@@ -151,4 +172,4 @@ pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -3,6 +3,7 @@
|
||||
//! [`LogWidget`] renders the scrollable log panel as a ratatui [`StatefulWidget`];
|
||||
//! [`LogState`] holds whether the panel is open, its height, and scroll position.
|
||||
|
||||
use crate::utils::rgba8_to_color;
|
||||
use kiln_core::log::{LogLine, LogSpan};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -10,7 +11,6 @@ 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.
|
||||
pub struct LogState {
|
||||
@@ -24,7 +24,11 @@ pub struct LogState {
|
||||
|
||||
impl Default for LogState {
|
||||
fn default() -> Self {
|
||||
Self { open: false, height: 10, scroll: 0 }
|
||||
Self {
|
||||
open: false,
|
||||
height: 10,
|
||||
scroll: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +78,10 @@ impl StatefulWidget for LogWidget<'_> {
|
||||
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)));
|
||||
.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)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
mod animation;
|
||||
mod editor;
|
||||
mod editor_menu;
|
||||
mod input;
|
||||
mod log;
|
||||
mod menu;
|
||||
@@ -21,7 +22,6 @@ mod term;
|
||||
mod transition;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod editor_menu;
|
||||
|
||||
use crate::animation::{AnimWidget, AnimationLayer};
|
||||
use crate::editor::{
|
||||
|
||||
+90
-40
@@ -6,7 +6,11 @@
|
||||
//! the closure body — this lets [`handle_menu_input`](crate::input::handle_menu_input) clone
|
||||
//! the action reference before releasing the borrow on [`Ui`](crate::ui::Ui).
|
||||
|
||||
use std::rc::Rc;
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::mode::PendingMode;
|
||||
use crate::overlay::{OverlayStyle, render_overlay_frame};
|
||||
use crate::ui::Ui;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
@@ -14,11 +18,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{
|
||||
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
|
||||
};
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::mode::PendingMode;
|
||||
use crate::overlay::{OverlayStyle, render_overlay_frame};
|
||||
use crate::ui::Ui;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Shared-ownership reference to a menu item action closure.
|
||||
///
|
||||
@@ -65,12 +65,12 @@ pub struct MenuItem {
|
||||
|
||||
impl MenuItem {
|
||||
/// Creates a menu item with the given key, label, and action closure.
|
||||
pub fn new(
|
||||
key: MenuKey,
|
||||
label: impl Into<String>,
|
||||
action: impl Fn(&mut Ui) + 'static,
|
||||
) -> Self {
|
||||
Self { key, label: label.into(), action: Rc::new(action) }
|
||||
pub fn new(key: MenuKey, label: impl Into<String>, action: impl Fn(&mut Ui) + 'static) -> Self {
|
||||
Self {
|
||||
key,
|
||||
label: label.into(),
|
||||
action: Rc::new(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a cloned `Rc` to the action closure so the caller can release any
|
||||
@@ -117,7 +117,14 @@ impl StatefulWidget for MenuWidget {
|
||||
type State = MenuState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut MenuState) {
|
||||
render_menu_at(&state.items, 1.0, state.offset, area, buf, Some(&mut state.max_scroll));
|
||||
render_menu_at(
|
||||
&state.items,
|
||||
1.0,
|
||||
state.offset,
|
||||
area,
|
||||
buf,
|
||||
Some(&mut state.max_scroll),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +140,10 @@ pub struct MenuOpenAnimation {
|
||||
impl MenuOpenAnimation {
|
||||
/// Creates a new open animation from a snapshot of the menu items.
|
||||
pub fn new(items: Vec<MenuItem>) -> Self {
|
||||
Self { items, progress: 0.0 }
|
||||
Self {
|
||||
items,
|
||||
progress: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,15 +154,28 @@ impl Animation for MenuOpenAnimation {
|
||||
}
|
||||
|
||||
fn draw(&self, area: Rect, buf: &mut Buffer) {
|
||||
render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None);
|
||||
render_menu_at(
|
||||
&self.items,
|
||||
self.progress.clamp(0.0, 1.0),
|
||||
0,
|
||||
area,
|
||||
buf,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
fn layer(&self) -> AnimationLayer {
|
||||
AnimationLayer::Overlay
|
||||
}
|
||||
|
||||
/// The menu is interactive from the first frame of the open animation.
|
||||
fn input_mode_during(&self) -> InputMode { InputMode::Menu }
|
||||
fn input_mode_during(&self) -> InputMode {
|
||||
InputMode::Menu
|
||||
}
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Menu }
|
||||
fn input_mode_after(&self) -> InputMode {
|
||||
InputMode::Menu
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes a menu overlay with an animated collapse.
|
||||
@@ -167,7 +190,10 @@ pub struct MenuCloseAnimation {
|
||||
impl MenuCloseAnimation {
|
||||
/// Creates a new close animation from a snapshot of the menu items.
|
||||
pub fn new(items: Vec<MenuItem>) -> Self {
|
||||
Self { items, progress: 1.0 }
|
||||
Self {
|
||||
items,
|
||||
progress: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +204,23 @@ impl Animation for MenuCloseAnimation {
|
||||
}
|
||||
|
||||
fn draw(&self, area: Rect, buf: &mut Buffer) {
|
||||
render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None);
|
||||
render_menu_at(
|
||||
&self.items,
|
||||
self.progress.clamp(0.0, 1.0),
|
||||
0,
|
||||
area,
|
||||
buf,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
fn layer(&self) -> AnimationLayer {
|
||||
AnimationLayer::Overlay
|
||||
}
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Board }
|
||||
fn input_mode_after(&self) -> InputMode {
|
||||
InputMode::Board
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the menu overlay at a given animation progress.
|
||||
@@ -202,23 +239,30 @@ fn render_menu_at(
|
||||
let Some((content_area, scrollbar_area)) =
|
||||
render_overlay_frame(&MENU_STYLE, progress, area, buf)
|
||||
else {
|
||||
if let Some(m) = out_max_scroll { *m = 0; }
|
||||
if let Some(m) = out_max_scroll {
|
||||
*m = 0;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(MENU_STYLE.bg);
|
||||
let key_style = Style::default()
|
||||
.fg(Color::Rgb(150, 200, 255))
|
||||
.bg(MENU_STYLE.bg);
|
||||
let label_style = Style::default().fg(Color::White).bg(MENU_STYLE.bg);
|
||||
|
||||
let lines: Vec<Line> = items.iter().map(|item| {
|
||||
let key_text = match &item.key {
|
||||
MenuKey::Char(c) => format!("[{c}] "),
|
||||
MenuKey::Esc => "[esc] ".to_string(),
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(key_text, key_style),
|
||||
Span::styled(item.label.clone(), label_style),
|
||||
])
|
||||
}).collect();
|
||||
let lines: Vec<Line> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let key_text = match &item.key {
|
||||
MenuKey::Char(c) => format!("[{c}] "),
|
||||
MenuKey::Esc => "[esc] ".to_string(),
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(key_text, key_style),
|
||||
Span::styled(item.label.clone(), label_style),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content_lines = lines.len() as u16;
|
||||
let max_scroll = content_lines.saturating_sub(content_area.height);
|
||||
@@ -233,18 +277,24 @@ fn render_menu_at(
|
||||
Constraint::Length(pad),
|
||||
Constraint::Length(content_lines.min(content_area.height)),
|
||||
Constraint::Fill(1),
|
||||
]).areas(content_area);
|
||||
])
|
||||
.areas(content_area);
|
||||
use ratatui::widgets::Widget;
|
||||
para.render(content_rect, buf);
|
||||
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(m) = out_max_scroll { *m = max_scroll; }
|
||||
if let Some(m) = out_max_scroll {
|
||||
*m = max_scroll;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the items for the standard (play-mode) pause menu.
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! while editing: entering the editor drops the running game, and returning to
|
||||
//! play rebuilds it fresh from the world file.
|
||||
|
||||
use kiln_core::game::GameState;
|
||||
use crate::editor::EditorState;
|
||||
use kiln_core::game::GameState;
|
||||
|
||||
/// The top-level application state: exactly one variant is live at any moment.
|
||||
///
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
//! and background colors. Objects are drawn over their floor cell, and the
|
||||
//! player is drawn on top of everything.
|
||||
|
||||
use kiln_core::cp437::tile_to_char;
|
||||
use crate::utils::rgba8_to_color;
|
||||
use kiln_core::Board;
|
||||
use kiln_core::cp437::tile_to_char;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use crate::utils::rgba8_to_color;
|
||||
|
||||
/// 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.
|
||||
@@ -60,7 +60,8 @@ impl<'a> BoardWidget<'a> {
|
||||
/// reports off-screen cells as `None` — needed for exact cursor placement.)
|
||||
pub fn board_to_screen(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);
|
||||
let (off_y, pad_y, rows) =
|
||||
BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
||||
// Cell must fall within the visible window on both axes.
|
||||
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
|
||||
return None;
|
||||
@@ -75,7 +76,8 @@ pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Optio
|
||||
/// the drawn board (in the centering pad or past the visible cells).
|
||||
pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> {
|
||||
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);
|
||||
let (off_y, pad_y, rows) =
|
||||
BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
||||
// Screen-space offset from the first drawn cell; reject anything before it.
|
||||
let col = (sx as i32) - (area.x as i32 + pad_x as i32);
|
||||
let row = (sy as i32) - (area.y as i32 + pad_y as i32);
|
||||
@@ -140,4 +142,3 @@ impl Widget for BoardWidget<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! and handle the cosmetic open/close transitions; while either plays, all input
|
||||
//! is suppressed.
|
||||
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::overlay::{OverlayStyle, render_overlay_frame};
|
||||
use kiln_core::game::ScrollLine;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
@@ -13,9 +16,6 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{
|
||||
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap,
|
||||
};
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::overlay::{OverlayStyle, render_overlay_frame};
|
||||
|
||||
/// Duration of the open and close animations, in seconds.
|
||||
const ANIM_SECS: f32 = 0.25;
|
||||
@@ -77,7 +77,10 @@ pub struct ScrollOpenAnimation {
|
||||
impl ScrollOpenAnimation {
|
||||
/// Creates a new open animation from a snapshot of the scroll's lines.
|
||||
pub fn new(lines: Vec<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 0.0 }
|
||||
Self {
|
||||
lines,
|
||||
progress: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +94,13 @@ impl Animation for ScrollOpenAnimation {
|
||||
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
fn layer(&self) -> AnimationLayer {
|
||||
AnimationLayer::Overlay
|
||||
}
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Scroll }
|
||||
fn input_mode_after(&self) -> InputMode {
|
||||
InputMode::Scroll
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes a scroll overlay with an animated collapse.
|
||||
@@ -112,7 +119,10 @@ pub struct ScrollCloseAnimation {
|
||||
impl ScrollCloseAnimation {
|
||||
/// Creates a new close animation from a snapshot of the scroll's lines.
|
||||
pub fn new(lines: Vec<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 1.0 }
|
||||
Self {
|
||||
lines,
|
||||
progress: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +136,13 @@ impl Animation for ScrollCloseAnimation {
|
||||
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
fn layer(&self) -> AnimationLayer {
|
||||
AnimationLayer::Overlay
|
||||
}
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Board }
|
||||
fn input_mode_after(&self) -> InputMode {
|
||||
InputMode::Board
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the scroll overlay at a given animation progress using the shared
|
||||
@@ -146,13 +160,17 @@ fn render_scroll_at(
|
||||
let Some((content_area, scrollbar_area)) =
|
||||
render_overlay_frame(&SCROLL_STYLE, progress, area, buf)
|
||||
else {
|
||||
if let Some(s) = state { s.max_scroll = 0; }
|
||||
if let Some(s) = state {
|
||||
s.max_scroll = 0;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg);
|
||||
let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg);
|
||||
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(SCROLL_STYLE.bg);
|
||||
let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg);
|
||||
let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg);
|
||||
let choice_style = Style::default()
|
||||
.fg(Color::Rgb(200, 200, 100))
|
||||
.bg(SCROLL_STYLE.bg);
|
||||
|
||||
// Build Lines — whitespace-leading lines are centered, others left-aligned.
|
||||
let mut ratatui_lines: Vec<Line> = Vec::new();
|
||||
@@ -192,15 +210,21 @@ fn render_scroll_at(
|
||||
Constraint::Length(pad),
|
||||
Constraint::Length(content_lines.min(content_area.height)),
|
||||
Constraint::Fill(1),
|
||||
]).areas(content_area);
|
||||
])
|
||||
.areas(content_area);
|
||||
para.render(content_rect, buf);
|
||||
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(s) = state { s.max_scroll = max_scroll; }
|
||||
if let Some(s) = state {
|
||||
s.max_scroll = max_scroll;
|
||||
}
|
||||
}
|
||||
|
||||
+131
-69
@@ -1,11 +1,11 @@
|
||||
use crate::render::BoardWidget;
|
||||
use crate::utils::rects_overlap;
|
||||
use kiln_core::game::SpeechBubble;
|
||||
use kiln_core::{Board, Direction};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::{Color, Line, Style, Widget};
|
||||
use ratatui::widgets::{Block, Fill, Paragraph};
|
||||
use kiln_core::game::SpeechBubble;
|
||||
use kiln_core::{Board, Direction};
|
||||
use crate::render::BoardWidget;
|
||||
use crate::utils::rects_overlap;
|
||||
|
||||
/// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board.
|
||||
///
|
||||
@@ -57,7 +57,12 @@ impl<'a> SpeechBubblesWidget<'a> {
|
||||
placed: &mut Vec<Rect>,
|
||||
player: Option<(u16, u16)>,
|
||||
) -> (Rect, Direction) {
|
||||
let dirs = [Direction::North, Direction::South, Direction::East, Direction::West];
|
||||
let dirs = [
|
||||
Direction::North,
|
||||
Direction::South,
|
||||
Direction::East,
|
||||
Direction::West,
|
||||
];
|
||||
// Collect (rect, tail_length, dir) for every direction that can be placed.
|
||||
// stable min_by_key preserves the first element on ties, so N > S > E > W wins.
|
||||
let best = dirs
|
||||
@@ -70,7 +75,17 @@ impl<'a> SpeechBubblesWidget<'a> {
|
||||
|
||||
let (rect, dir) = best.map_or_else(
|
||||
// Forced fallback: place at top-left, caller suppresses tail via `on_screen`.
|
||||
|| (Rect { x: area.x + 1, y: area.y + 1, width: box_w, height: box_h }, Direction::North),
|
||||
|| {
|
||||
(
|
||||
Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: box_w,
|
||||
height: box_h,
|
||||
},
|
||||
Direction::North,
|
||||
)
|
||||
},
|
||||
|(r, _, d)| (r, d),
|
||||
);
|
||||
|
||||
@@ -81,9 +96,7 @@ impl<'a> SpeechBubblesWidget<'a> {
|
||||
|
||||
impl Widget for SpeechBubblesWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let bubble_style = Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Rgb(20, 20, 40));
|
||||
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));
|
||||
@@ -98,7 +111,8 @@ impl Widget for SpeechBubblesWidget<'_> {
|
||||
let player = player_vis.then_some((px, py));
|
||||
|
||||
// Map each bubble to its speaker's (clamped) screen position.
|
||||
let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self.bubbles
|
||||
let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self
|
||||
.bubbles
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
let obj = self.board.objects.get(&b.object_id)?;
|
||||
@@ -120,7 +134,14 @@ impl Widget for SpeechBubblesWidget<'_> {
|
||||
let box_h = lines.len() as u16 + 2;
|
||||
let (rect, dir) =
|
||||
SpeechBubblesWidget::place_bubble(area, sx, sy, box_w, box_h, &mut placed, player);
|
||||
placements.push(Placement { sx, sy, on_screen, lines, rect, dir });
|
||||
placements.push(Placement {
|
||||
sx,
|
||||
sy,
|
||||
on_screen,
|
||||
lines,
|
||||
rect,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
|
||||
draw_tails(&placements, buf, border_style);
|
||||
@@ -137,32 +158,42 @@ impl Widget for SpeechBubblesWidget<'_> {
|
||||
/// Skipped for off-screen speakers.
|
||||
fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
|
||||
for p in placements {
|
||||
if !p.on_screen { continue; }
|
||||
if !p.on_screen {
|
||||
continue;
|
||||
}
|
||||
let r = p.rect;
|
||||
match p.dir {
|
||||
Direction::North => {
|
||||
let col = tail_col(p.sx, r);
|
||||
for y in (r.y + r.height)..p.sy {
|
||||
if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); }
|
||||
if let Some(c) = buf.cell_mut((col, y)) {
|
||||
c.set_char('│').set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Direction::South => {
|
||||
let col = tail_col(p.sx, r);
|
||||
for y in (p.sy + 1)..r.y {
|
||||
if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); }
|
||||
if let Some(c) = buf.cell_mut((col, y)) {
|
||||
c.set_char('│').set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Direction::East => {
|
||||
let row = join_row(p.sy, r);
|
||||
for x in (p.sx + 1)..r.x {
|
||||
if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); }
|
||||
if let Some(c) = buf.cell_mut((x, row)) {
|
||||
c.set_char('─').set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Direction::West => {
|
||||
// Box right border is at r.x + r.width - 1; first tail col is r.x + r.width.
|
||||
let row = join_row(p.sy, r);
|
||||
for x in (r.x + r.width)..p.sx {
|
||||
if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); }
|
||||
if let Some(c) = buf.cell_mut((x, row)) {
|
||||
c.set_char('─').set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,14 +204,23 @@ fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
|
||||
///
|
||||
/// Overwrites any stray tail chars that passed through another box's area.
|
||||
/// Order within this pass doesn't matter since placed rects are non-overlapping.
|
||||
fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Style, border_style: Style) {
|
||||
fn draw_boxes(
|
||||
placements: &[Placement<'_>],
|
||||
buf: &mut Buffer,
|
||||
bubble_style: Style,
|
||||
border_style: Style,
|
||||
) {
|
||||
for p in placements {
|
||||
let box_rect = p.rect;
|
||||
let block = Block::bordered().border_style(border_style).style(bubble_style);
|
||||
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> = p.lines.iter()
|
||||
let para_lines: Vec<Line> = p
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| Line::styled(format!(" {l}"), bubble_style))
|
||||
.collect();
|
||||
Paragraph::new(para_lines).render(text_rect, buf);
|
||||
@@ -193,7 +233,9 @@ fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Styl
|
||||
/// Skipped for off-screen speakers.
|
||||
fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
|
||||
for p in placements {
|
||||
if !p.on_screen { continue; }
|
||||
if !p.on_screen {
|
||||
continue;
|
||||
}
|
||||
let r = p.rect;
|
||||
match p.dir {
|
||||
Direction::North => {
|
||||
@@ -249,32 +291,37 @@ fn try_place_direction(
|
||||
|
||||
// Fixed (non-shifting) dimension: center on the speaker along the perpendicular axis.
|
||||
let fixed_left = center_left(obj_sx, box_w, area) as i32;
|
||||
let fixed_top = center_top(obj_sy, box_h, area) as i32;
|
||||
let fixed_top = center_top(obj_sy, box_h, area) as i32;
|
||||
|
||||
// Starting position on the shifting axis and the per-iteration step.
|
||||
let (mut pos, delta): (i32, i32) = match dir {
|
||||
Direction::North => (obj_sy as i32 - bh - 1, -1),
|
||||
Direction::South => (obj_sy as i32 + 2, 1),
|
||||
Direction::East => (obj_sx as i32 + 2, 1),
|
||||
Direction::West => (obj_sx as i32 - bw - 1, -1),
|
||||
Direction::North => (obj_sy as i32 - bh - 1, -1),
|
||||
Direction::South => (obj_sy as i32 + 2, 1),
|
||||
Direction::East => (obj_sx as i32 + 2, 1),
|
||||
Direction::West => (obj_sx as i32 - bw - 1, -1),
|
||||
};
|
||||
|
||||
for _ in 0..=20 {
|
||||
let (left, top) = match dir {
|
||||
Direction::North | Direction::South => (fixed_left, pos),
|
||||
Direction::East | Direction::West => (pos, fixed_top),
|
||||
Direction::East | Direction::West => (pos, fixed_top),
|
||||
};
|
||||
|
||||
// Out-of-viewport: shifting further in this direction only makes it worse.
|
||||
if left < area.left() as i32
|
||||
|| top < area.top() as i32
|
||||
|| left + bw > area.right() as i32
|
||||
|| top + bh > area.bottom() as i32
|
||||
|| top < area.top() as i32
|
||||
|| left + bw > area.right() as i32
|
||||
|| top + bh > area.bottom() as i32
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
let r = Rect { x: left as u16, y: top as u16, width: box_w, height: box_h };
|
||||
let r = Rect {
|
||||
x: left as u16,
|
||||
y: top as u16,
|
||||
width: box_w,
|
||||
height: box_h,
|
||||
};
|
||||
|
||||
let overlaps_placed = placed.iter().any(|p| rects_overlap(r, *p));
|
||||
let blocks_player = player.is_some_and(|(px, py)| {
|
||||
@@ -298,8 +345,8 @@ fn tail_length(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16) -> u32 {
|
||||
match dir {
|
||||
Direction::North => (obj_sy as i32 - r.y as i32 - r.height as i32).max(0) as u32,
|
||||
Direction::South => (r.y as i32 - obj_sy as i32 - 1).max(0) as u32,
|
||||
Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32,
|
||||
Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32,
|
||||
Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32,
|
||||
Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,46 +423,47 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn big_area() -> Rect {
|
||||
Rect { x: 0, y: 0, width: 100, height: 40 }
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 40,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial positions: each direction leaves exactly 1 tail cell ──────────
|
||||
|
||||
#[test]
|
||||
fn north_places_box_above_with_one_tail_row() {
|
||||
let (r, len) = try_place_direction(
|
||||
Direction::North, big_area(), 50, 20, 10, 5, &[], None,
|
||||
).unwrap();
|
||||
assert_eq!(r.y, 14); // 20 - 5 - 1
|
||||
assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row
|
||||
let (r, len) =
|
||||
try_place_direction(Direction::North, big_area(), 50, 20, 10, 5, &[], None).unwrap();
|
||||
assert_eq!(r.y, 14); // 20 - 5 - 1
|
||||
assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row
|
||||
assert_eq!(len, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn south_places_box_below_with_one_tail_row() {
|
||||
let (r, len) = try_place_direction(
|
||||
Direction::South, big_area(), 50, 20, 10, 5, &[], None,
|
||||
).unwrap();
|
||||
assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row
|
||||
let (r, len) =
|
||||
try_place_direction(Direction::South, big_area(), 50, 20, 10, 5, &[], None).unwrap();
|
||||
assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row
|
||||
assert_eq!(len, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_places_box_right_with_one_tail_col() {
|
||||
let (r, len) = try_place_direction(
|
||||
Direction::East, big_area(), 50, 20, 10, 5, &[], None,
|
||||
).unwrap();
|
||||
assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col
|
||||
let (r, len) =
|
||||
try_place_direction(Direction::East, big_area(), 50, 20, 10, 5, &[], None).unwrap();
|
||||
assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col
|
||||
assert_eq!(len, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn west_places_box_left_with_one_tail_col() {
|
||||
let (r, len) = try_place_direction(
|
||||
Direction::West, big_area(), 50, 20, 10, 5, &[], None,
|
||||
).unwrap();
|
||||
assert_eq!(r.x, 39); // 50 - 10 - 1
|
||||
assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50
|
||||
let (r, len) =
|
||||
try_place_direction(Direction::West, big_area(), 50, 20, 10, 5, &[], None).unwrap();
|
||||
assert_eq!(r.x, 39); // 50 - 10 - 1
|
||||
assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50
|
||||
assert_eq!(len, 1);
|
||||
}
|
||||
|
||||
@@ -425,12 +473,25 @@ mod tests {
|
||||
fn north_shifts_up_when_initial_position_blocked() {
|
||||
// Blocking rect touches only the bottom row of the initial box (rows 14–18),
|
||||
// so the box needs to shift up by exactly 1 to clear it.
|
||||
let blocking = Rect { x: 0, y: 18, width: 100, height: 1 };
|
||||
let blocking = Rect {
|
||||
x: 0,
|
||||
y: 18,
|
||||
width: 100,
|
||||
height: 1,
|
||||
};
|
||||
let (r, len) = try_place_direction(
|
||||
Direction::North, big_area(), 50, 20, 10, 5, &[blocking], None,
|
||||
).unwrap();
|
||||
assert_eq!(r.y, 13); // shifted up one row from initial 14
|
||||
assert_eq!(len, 2); // tail is now 2 rows (rows 18–19)
|
||||
Direction::North,
|
||||
big_area(),
|
||||
50,
|
||||
20,
|
||||
10,
|
||||
5,
|
||||
&[blocking],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.y, 13); // shifted up one row from initial 14
|
||||
assert_eq!(len, 2); // tail is now 2 rows (rows 18–19)
|
||||
}
|
||||
|
||||
// ── Out of room ───────────────────────────────────────────────────────────
|
||||
@@ -438,9 +499,7 @@ mod tests {
|
||||
#[test]
|
||||
fn north_returns_none_when_no_room_above() {
|
||||
// Speaker at row 4; North needs box_h(5) + 1(tail gap) = 6 rows, only 4 available.
|
||||
let result = try_place_direction(
|
||||
Direction::North, big_area(), 50, 4, 10, 5, &[], None,
|
||||
);
|
||||
let result = try_place_direction(Direction::North, big_area(), 50, 4, 10, 5, &[], None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
@@ -450,9 +509,8 @@ mod tests {
|
||||
fn place_bubble_prefers_north_when_all_equal() {
|
||||
// All four directions have tail_length = 1; North wins by preference order.
|
||||
let mut placed = vec![];
|
||||
let (_, dir) = SpeechBubblesWidget::place_bubble(
|
||||
big_area(), 50, 20, 10, 5, &mut placed, None,
|
||||
);
|
||||
let (_, dir) =
|
||||
SpeechBubblesWidget::place_bubble(big_area(), 50, 20, 10, 5, &mut placed, None);
|
||||
assert_eq!(dir, Direction::North);
|
||||
}
|
||||
|
||||
@@ -460,9 +518,8 @@ mod tests {
|
||||
fn place_bubble_falls_back_to_south_when_north_blocked() {
|
||||
// Speaker at row 4: North can't fit (same condition as the None test above).
|
||||
let mut placed = vec![];
|
||||
let (_, dir) = SpeechBubblesWidget::place_bubble(
|
||||
big_area(), 50, 4, 10, 5, &mut placed, None,
|
||||
);
|
||||
let (_, dir) =
|
||||
SpeechBubblesWidget::place_bubble(big_area(), 50, 4, 10, 5, &mut placed, None);
|
||||
assert_eq!(dir, Direction::South);
|
||||
}
|
||||
|
||||
@@ -471,10 +528,15 @@ mod tests {
|
||||
#[test]
|
||||
fn join_row_clamps_speaker_to_box_interior() {
|
||||
// Box: y=10, height=5 → top border row 10, interior rows 11–13, bottom border row 14.
|
||||
let r = Rect { x: 0, y: 10, width: 10, height: 5 };
|
||||
assert_eq!(join_row(8, r), 11); // speaker above box → first interior row
|
||||
assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged
|
||||
assert_eq!(join_row(20, r), 13); // speaker below box → last interior row
|
||||
let r = Rect {
|
||||
x: 0,
|
||||
y: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
};
|
||||
assert_eq!(join_row(8, r), 11); // speaker above box → first interior row
|
||||
assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged
|
||||
assert_eq!(join_row(20, r), 13); // speaker below box → last interior row
|
||||
}
|
||||
|
||||
// ── center_top centering ──────────────────────────────────────────────────
|
||||
|
||||
+15
-12
@@ -6,15 +6,11 @@
|
||||
//! [`Animation::draw`] blends the two buffers — old cells for unrevealed positions,
|
||||
//! new cells for revealed ones.
|
||||
|
||||
use kiln_core::Board;
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
widgets::Widget,
|
||||
};
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::render::BoardWidget;
|
||||
use kiln_core::Board;
|
||||
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||
|
||||
/// How long the wipe lasts in seconds.
|
||||
pub const TRANSITION_DURATION: f32 = 0.25;
|
||||
@@ -41,10 +37,14 @@ fn lfsr_sequence(total: usize) -> Vec<u32> {
|
||||
let idx = state as u32 - 1;
|
||||
if (idx as usize) < total {
|
||||
seq.push(idx);
|
||||
if seq.len() == total { break; }
|
||||
if seq.len() == total {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = lfsr_step(state);
|
||||
if state == 1 { break; } // full cycle — shouldn't happen for total <= 65535
|
||||
if state == 1 {
|
||||
break;
|
||||
} // full cycle — shouldn't happen for total <= 65535
|
||||
}
|
||||
seq
|
||||
}
|
||||
@@ -99,8 +99,7 @@ impl Animation for TransitionAnimation {
|
||||
fn update(&mut self, dt: f32) -> bool {
|
||||
self.elapsed += dt;
|
||||
let total = self.revealed.len();
|
||||
let target =
|
||||
((self.elapsed / TRANSITION_DURATION) * total as f32) as usize;
|
||||
let target = ((self.elapsed / TRANSITION_DURATION) * total as f32) as usize;
|
||||
let target = target.min(total);
|
||||
|
||||
// Mark each newly revealed cell in the bitmap.
|
||||
@@ -131,7 +130,11 @@ impl Animation for TransitionAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Board }
|
||||
fn layer(&self) -> AnimationLayer {
|
||||
AnimationLayer::Board
|
||||
}
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Board }
|
||||
fn input_mode_after(&self) -> InputMode {
|
||||
InputMode::Board
|
||||
}
|
||||
}
|
||||
|
||||
+29
-13
@@ -1,15 +1,15 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use kiln_core::Board;
|
||||
use kiln_core::game::GameState;
|
||||
use crate::animation::Animation;
|
||||
use crate::editor::EditorState;
|
||||
use crate::log::LogState;
|
||||
use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState};
|
||||
use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState};
|
||||
use crate::mode::{Mode, PendingMode};
|
||||
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
|
||||
use crate::transition::TransitionAnimation;
|
||||
use kiln_core::Board;
|
||||
use kiln_core::game::GameState;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering.
|
||||
type PendingTransition = Option<(Rc<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
||||
@@ -66,8 +66,8 @@ impl Default for Ui {
|
||||
impl Ui {
|
||||
/// Scrolls the scroll overlay by `delta` lines, clamped to the valid range.
|
||||
pub(crate) fn scroll_lines(&mut self, delta: i32) {
|
||||
self.overlay.offset = (self.overlay.offset as i32 + delta)
|
||||
.clamp(0, self.overlay.max_scroll as i32) as u16;
|
||||
self.overlay.offset =
|
||||
(self.overlay.offset as i32 + delta).clamp(0, self.overlay.max_scroll as i32) as u16;
|
||||
}
|
||||
|
||||
/// Scrolls the menu overlay by `delta` lines, clamped to the valid range.
|
||||
@@ -80,14 +80,21 @@ impl Ui {
|
||||
/// Opens a menu: stores the items as [`MenuState`] and starts the open animation.
|
||||
pub(crate) fn open_menu(&mut self, items: Vec<MenuItem>) {
|
||||
self.active_animation = Some(Box::new(MenuOpenAnimation::new(items.clone())));
|
||||
self.menu = Some(MenuState { items, ..Default::default() });
|
||||
self.menu = Some(MenuState {
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts the close animation using a snapshot of the current menu items.
|
||||
///
|
||||
/// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`).
|
||||
pub(crate) fn begin_close_menu(&mut self) {
|
||||
let items = self.menu.as_ref().map(|m| m.items.clone()).unwrap_or_default();
|
||||
let items = self
|
||||
.menu
|
||||
.as_ref()
|
||||
.map(|m| m.items.clone())
|
||||
.unwrap_or_default();
|
||||
self.active_animation = Some(Box::new(MenuCloseAnimation::new(items)));
|
||||
}
|
||||
|
||||
@@ -111,7 +118,9 @@ impl Ui {
|
||||
// Returning to Board mode: eagerly clear scroll and menu state so
|
||||
// neither renders as open for one extra frame before the next tick.
|
||||
if matches!(after, crate::input::InputMode::Board) {
|
||||
if let Mode::Play(game) = mode { game.handle_scroll(); }
|
||||
if let Mode::Play(game) = mode {
|
||||
game.handle_scroll();
|
||||
}
|
||||
self.menu = None;
|
||||
}
|
||||
}
|
||||
@@ -147,7 +156,9 @@ impl Ui {
|
||||
if let Some(scroll) = game.active_scroll.as_mut() {
|
||||
scroll.choice = choice;
|
||||
}
|
||||
let lines = game.active_scroll.as_ref()
|
||||
let lines = game
|
||||
.active_scroll
|
||||
.as_ref()
|
||||
.map(|s| s.lines.clone())
|
||||
.unwrap_or_default();
|
||||
self.active_animation = Some(Box::new(ScrollCloseAnimation::new(lines)));
|
||||
@@ -157,7 +168,12 @@ impl Ui {
|
||||
///
|
||||
/// Called from `draw_board_area` when [`pending_transition`](Ui::pending_transition)
|
||||
/// is set, so the [`TransitionAnimation`] is sized to the exact inner board area.
|
||||
pub(crate) fn start_transition(&mut self, old: &Board, new: &Board, area: ratatui::layout::Rect) {
|
||||
pub(crate) fn start_transition(
|
||||
&mut self,
|
||||
old: &Board,
|
||||
new: &Board,
|
||||
area: ratatui::layout::Rect,
|
||||
) {
|
||||
self.active_animation = Some(Box::new(TransitionAnimation::new(old, new, area)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,5 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
|
||||
|
||||
/// 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
|
||||
}
|
||||
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user