editor ui

This commit is contained in:
2026-06-17 18:25:09 -05:00
parent 6464e47747
commit fb8f6df501
11 changed files with 982 additions and 135 deletions
+2 -1
View File
@@ -5,5 +5,6 @@ edition = "2024"
[dependencies]
kiln-core = { path = "../kiln-core" }
kiln-ui = { path = "../kiln-ui" }
color = "0.3.3"
ratatui = { version = "0.30.1", features = ["unstable-rendered-line-info"] }
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
+202 -22
View File
@@ -2,24 +2,28 @@
//!
//! The editor is a separate top-level [`Mode`](crate::mode::Mode): it holds its
//! own freshly reloaded [`World`] and never ticks the game. The board is shown
//! with a resizable right-hand sidebar (just a "coming soon" placeholder for now)
//! and a blinking cursor moved with the arrow keys or a right-click.
//! with a blinking cursor (moved with the arrow keys or a right-click) and a
//! resizable right-hand sidebar that hosts the [menu tree](MenuLevel): a breadcrumb
//! title and letter-keyed choices that open [dialogs](kiln_ui::dialog). `Esc` walks
//! back up the tree, opening the overlay [editor menu](editor_menu_items) at the root.
use std::cell::Ref;
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
use crate::log::{LogWidget, log_preview_line};
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui;
use kiln_core::Board;
use kiln_core::log::LogLine;
use kiln_core::world::World;
use ratatui::Frame;
use ratatui::crossterm::event::Event;
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::Line;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use crate::log::{log_preview_line, LogWidget};
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
use crate::render::{board_to_screen, screen_to_board, BoardWidget};
use crate::ui::Ui;
use std::cell::Ref;
/// How long each half of the cursor blink lasts, in seconds.
const BLINK_SECS: f32 = 0.5;
@@ -30,6 +34,40 @@ const MIN_PANEL_WIDTH: u16 = 8;
/// The cursor glyph: a solid full block (CP437 219) drawn in light gray.
const CURSOR_CHAR: char = '█';
/// One level in the editor's sidebar menu tree.
///
/// The menu is a stack of levels (the current one is the stack's last element); a
/// breadcrumb is built from their [`title`](MenuLevel::title)s. Today only the
/// top-level [`World`](MenuLevel::World) exists — every choice opens a dialog rather
/// than a sub-menu — but the stack keeps room for nested menus later.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum MenuLevel {
/// The top-level world menu: set name / entry board, browse scripts / boards.
World,
}
impl MenuLevel {
/// The breadcrumb segment shown for this level.
fn title(self) -> &'static str {
match self {
MenuLevel::World => "World",
}
}
/// The static `(key, label)` choices shown for this level. Keep in sync with the
/// key dispatch in [`handle_editor_input`](crate::input::handle_editor_input).
fn choices(self) -> &'static [(char, &'static str)] {
match self {
MenuLevel::World => &[
('n', "Name"),
('e', "Entry board"),
('s', "Scripts"),
('b', "Boards"),
],
}
}
}
/// All state for an active editing session.
///
/// Built by reloading the world file, so the running game has no bearing on what
@@ -40,6 +78,12 @@ pub(crate) struct EditorState {
world: World,
/// Key (in [`World::boards`]) of the board currently being edited.
board_name: String,
/// The sidebar menu navigation stack; the last element is the current level.
/// Starts as `[MenuLevel::World]` and is never emptied below that root.
menu: Vec<MenuLevel>,
/// The currently open dialog overlay, if any. While `Some`, the editor is in
/// [`InputMode::Dialog`](crate::input::InputMode::Dialog).
pub(crate) dialog: Option<Dialog<EditorState>>,
/// Cursor position in board cells, clamped to the board bounds.
cursor: (i32, i32),
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
@@ -63,6 +107,8 @@ impl EditorState {
Self {
world,
board_name,
menu: vec![MenuLevel::World],
dialog: None,
cursor: (0, 0),
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
blink_on: true,
@@ -72,6 +118,106 @@ impl EditorState {
}
}
/// The breadcrumb for the current menu path, e.g. `"World"` or `"World > Scripts"`.
fn breadcrumb(&self) -> String {
self.menu
.iter()
.map(|l| l.title())
.collect::<Vec<_>>()
.join(" > ")
}
/// The menu level currently shown in the sidebar (top of the stack).
fn current_menu(&self) -> MenuLevel {
*self.menu.last().expect("menu stack is never empty")
}
/// Handles `Esc` in the menu: pops to the parent level, or — at the root world
/// menu — opens the existing overlay editor menu (Play / Quit / Resume).
pub(crate) fn menu_escape(&mut self, ui: &mut Ui) {
if self.menu.len() > 1 {
self.menu.pop();
} else {
ui.open_menu(editor_menu_items());
}
}
/// Dispatches a letter key against the current menu level. Returns `true` if the
/// key matched a choice (and opened a dialog), so the caller can stop handling it.
pub(crate) fn menu_key(&mut self, c: char) -> bool {
match (self.current_menu(), c) {
(MenuLevel::World, 'n') => {
self.open_name_dialog();
true
}
(MenuLevel::World, 'e') => {
self.open_entry_dialog();
true
}
(MenuLevel::World, 's') => {
self.open_scripts_dialog();
true
}
(MenuLevel::World, 'b') => {
self.open_boards_dialog();
true
}
_ => false,
}
}
/// The "current value" shown beneath a menu choice in the sidebar, if any.
/// Used for `[n] Name` (current world name) and `[e] Entry board` (current start).
fn menu_value(&self, key: char) -> Option<String> {
match (self.current_menu(), key) {
(MenuLevel::World, 'n') => Some(self.world.name.clone()),
(MenuLevel::World, 'e') => Some(self.world.start.clone()),
_ => None,
}
}
/// Opens a text dialog to rename the world; OK writes [`World::name`].
fn open_name_dialog(&mut self) {
let current = self.world.name.clone();
self.dialog = Some(Dialog::text("World Name", current, |opt, ed: &mut EditorState| {
if let Some(s) = opt {
ed.world.name = s;
}
}));
}
/// Opens a list dialog to choose the entry board; selecting one writes [`World::start`].
fn open_entry_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list(
"Entry Board",
names,
false,
|resp, ed: &mut EditorState| {
if let ListDialogResponse::Select(s) = resp {
ed.world.start = s;
}
},
));
}
/// Opens a list dialog of the world's scripts. Select-only for now (no side effect).
fn open_scripts_dialog(&mut self) {
let mut names: Vec<String> = self.world.scripts.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list("Scripts", names, false, |resp, ed: &mut EditorState| {
ed.log.push(LogLine::raw(format!("Script dialog: {:?}", resp)))
}));
}
/// Opens a list dialog of the world's boards. Select-only for now (no side effect).
fn open_boards_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {}));
}
/// Borrows the board currently being edited.
pub(crate) fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.board_name].borrow()
@@ -88,7 +234,10 @@ impl EditorState {
/// Moves the cursor by `(dx, dy)`, clamped to the board bounds.
pub(crate) fn move_cursor(&mut self, dx: i32, dy: i32) {
let (w, h) = { let b = self.board(); (b.width as i32, b.height as i32) };
let (w, h) = {
let b = self.board();
(b.width as i32, b.height as i32)
};
self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1);
self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1);
}
@@ -131,6 +280,24 @@ pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
]
}
/// Handles an input event while a dialog is open ([`InputMode::Dialog`](crate::input::InputMode::Dialog)).
///
/// Forwards the event to the dialog; on `Submit`/`Cancel` it takes the dialog out of
/// [`EditorState::dialog`] (so the callback receives an un-borrowed `&mut EditorState`)
/// and fires the callback via [`Dialog::finish`].
pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) {
let Some(dialog) = ed.dialog.as_mut() else {
return;
};
match dialog.handle_event(&event) {
DialogResult::Continue => {}
result => {
let dialog = ed.dialog.take().expect("dialog present");
dialog.finish(result, ed);
}
}
}
/// Renders the editor: the board (with a blinking cursor and coordinate readout),
/// the right-hand sidebar, and — when open — the bottom log panel spanning the
/// full width (board and sidebar sit side-by-side above it).
@@ -169,20 +336,33 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
// Record the board rect for right-click hit-testing (board borrow now dropped).
ed.last_board_area = inner;
// ── Sidebar: a placeholder "coming soon" label centered in the panel ──────
let sb_block = Block::bordered().title(" Editor ").merge_borders(MergeStrategy::Exact);
// ── Sidebar: the current menu (breadcrumb title + letter-keyed choices) ───
let sb_block = Block::bordered()
.title(format!(" {} ", ed.breadcrumb()))
.merge_borders(MergeStrategy::Exact);
let sb_inner = sb_block.inner(sidebar_area);
frame.render_widget(sb_block, sidebar_area);
// Center one line vertically, then horizontally via Paragraph::centered.
let [_, mid, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
]).areas(sb_inner);
frame.render_widget(
Paragraph::new("coming soon").style(Style::default().fg(Color::DarkGray)).centered(),
mid,
);
// One line per choice (`[k] Label`); a choice with a current value (e.g. the
// world name) gets a second, indented line beneath it in a distinct color.
let key_style = Style::default().fg(Color::Rgb(150, 200, 255));
let label_style = Style::default().fg(Color::White);
let value_style = Style::default().fg(Color::Rgb(200, 180, 120));
let mut lines: Vec<Line> = Vec::new();
for (k, label) in ed.current_menu().choices() {
lines.push(Line::from(vec![
Span::styled(format!("[{k}] "), key_style),
Span::styled(*label, label_style),
]));
if let Some(val) = ed.menu_value(*k) {
lines.push(Line::from(Span::styled(format!(" {val}"), value_style)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"[esc] menu",
Style::default().fg(Color::DarkGray),
)));
frame.render_widget(Paragraph::new(lines), sb_inner);
// ── Log panel (full width, below board + sidebar) ─────────────────────────
if let Some(log_area) = log_area {
+68 -35
View File
@@ -1,10 +1,10 @@
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
use kiln_core::Direction;
use kiln_core::game::{GameState, ScrollLine};
use crate::editor::{editor_menu_items, EditorState};
use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc};
use crate::editor::EditorState;
use crate::menu::{ActionRc as MenuActionRc, MenuItem, MenuKey, pause_menu_items};
use crate::mode::Mode;
use crate::ui::Ui;
use kiln_core::Direction;
use kiln_core::game::{GameState, ScrollLine};
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
/// Moves the player and, if the move crosses a portal, captures both board Rcs
/// and stores them in [`Ui::pending_transition`] for the next draw call.
@@ -29,6 +29,8 @@ pub enum InputMode {
Menu,
/// Editing a world: arrow keys move the cursor, the sidebar is shown.
Editor,
/// A dialog overlay is open over the editor; all input drives the dialog.
Dialog,
/// An animation is running and has not claimed any input mode; all input is discarded.
Ignore,
/// Reserved for a post-transition freeze; nothing currently produces this mode.
@@ -42,32 +44,52 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
if let Some(anim) = &ui.active_animation {
return anim.input_mode_during();
}
if ui.pending_transition.is_some() { return InputMode::Ignore; }
if ui.menu.is_some() { return InputMode::Menu; }
if ui.pending_transition.is_some() {
return InputMode::Ignore;
}
if ui.menu.is_some() {
return InputMode::Menu;
}
match mode {
Mode::Edit(_) => InputMode::Editor,
Mode::Edit(ed) => {
if ed.dialog.is_some() {
InputMode::Dialog
} else {
InputMode::Editor
}
}
Mode::Play(game) => {
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
if game.active_scroll.is_some() {
InputMode::Scroll
} else {
InputMode::Board
}
}
}
}
/// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit.
pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, game: &mut GameState, ui: &mut Ui) -> bool {
pub(crate) fn handle_board_input(
event: Event,
log_open: bool,
terminal_h: u16,
game: &mut GameState,
ui: &mut Ui,
) -> bool {
match event {
Event::Key(key) => match key.code {
KeyCode::Esc => ui.open_menu(pause_menu_items()),
KeyCode::Up => try_move_with_transition(game, ui, Direction::North),
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
KeyCode::Up => try_move_with_transition(game, ui, Direction::North),
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
KeyCode::Right => try_move_with_transition(game, ui, Direction::East),
KeyCode::Char('l') => ui.log.toggle(),
KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()),
KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()),
KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()),
_ => {}
},
Event::Mouse(m) if log_open => match m.kind {
MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()),
MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()),
MouseEventKind::ScrollDown => ui.log.scroll_by(1, game.log.len()),
MouseEventKind::Drag(_) => {
// The divider sits at row `total - height`; dragging up grows the panel.
@@ -87,20 +109,19 @@ pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16,
/// A right-click jumps the cursor to the clicked board cell, and dragging the
/// board/sidebar divider resizes the sidebar (the divider sits at `term_w -
/// sidebar_width`, mirroring the log's bottom divider).
pub(crate) fn handle_editor_input(
event: Event,
term_w: u16,
ed: &mut EditorState,
ui: &mut Ui,
) {
pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorState, ui: &mut Ui) {
match event {
Event::Key(key) => match key.code {
KeyCode::Esc => ui.open_menu(editor_menu_items()),
KeyCode::Up => ed.move_cursor(0, -1),
KeyCode::Down => ed.move_cursor(0, 1),
KeyCode::Left => ed.move_cursor(-1, 0),
KeyCode::Esc => ed.menu_escape(ui),
KeyCode::Up => ed.move_cursor(0, -1),
KeyCode::Down => ed.move_cursor(0, 1),
KeyCode::Left => ed.move_cursor(-1, 0),
KeyCode::Right => ed.move_cursor(1, 0),
KeyCode::Char('l') => ui.log.toggle(),
// A menu letter opens its dialog; unmatched letters are ignored.
KeyCode::Char(c) => {
ed.menu_key(c);
}
_ => {}
},
Event::Mouse(m) => match m.kind {
@@ -119,7 +140,9 @@ 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()); }
if c == letter as char {
return Some(choice.clone());
}
letter += 1;
}
}
@@ -128,7 +151,9 @@ fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
/// Handles an input event in [`InputMode::Scroll`].
pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) {
let lines: Vec<ScrollLine> = game.active_scroll.as_ref()
let lines: Vec<ScrollLine> = game
.active_scroll
.as_ref()
.map(|s| s.lines.clone())
.unwrap_or_default();
let has_choices = lines.iter().any(|l| matches!(l, ScrollLine::Choice { .. }));
@@ -137,7 +162,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
Event::Key(key) => match key.code {
// Esc dismisses only when there are no choices.
KeyCode::Esc if !has_choices => ui.begin_close(None, game),
KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Down => ui.scroll_lines(1),
KeyCode::Char(c) => {
if let Some(ch) = resolve_choice(&lines, c) {
@@ -147,7 +172,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
_ => {}
},
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollDown => ui.scroll_lines(1),
_ => {}
},
@@ -163,15 +188,23 @@ pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) {
match event {
Event::Key(key) => {
let pressed = match key.code {
KeyCode::Esc => Some(MenuKey::Esc),
KeyCode::Char(c) => Some(MenuKey::Char(c)),
KeyCode::Up => { ui.scroll_menu(-1); None }
KeyCode::Down => { ui.scroll_menu(1); None }
KeyCode::Esc => Some(MenuKey::Esc),
KeyCode::Char(c) => Some(MenuKey::Char(c)),
KeyCode::Up => {
ui.scroll_menu(-1);
None
}
KeyCode::Down => {
ui.scroll_menu(1);
None
}
_ => None,
};
if let Some(mk) = pressed {
// Clone the Rc to release the borrow on ui.menu before calling the action.
let action: Option<MenuActionRc> = ui.menu.as_ref()
let action: Option<MenuActionRc> = ui
.menu
.as_ref()
.and_then(|m| m.items.iter().find(|i| i.key == mk))
.map(|i| i.action_rc());
if let Some(action) = action {
@@ -180,7 +213,7 @@ pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) {
}
}
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => ui.scroll_menu(-1),
MouseEventKind::ScrollUp => ui.scroll_menu(-1),
MouseEventKind::ScrollDown => ui.scroll_menu(1),
_ => {}
},
+62 -33
View File
@@ -10,19 +10,31 @@
mod animation;
mod cp437;
mod editor;
mod input;
mod log;
mod menu;
mod mode;
mod overlay;
mod render;
mod scroll_overlay;
mod speech;
mod term;
mod transition;
mod utils;
mod speech;
mod ui;
mod input;
mod utils;
use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{EditorState, draw_editor, handle_dialog_input};
use crate::input::{
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input,
};
use crate::log::{LogWidget, log_preview_line};
use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget;
use crate::ui::Ui;
use kiln_core::game::GameState;
use kiln_core::log::LogLine;
use kiln_core::world;
@@ -39,18 +51,6 @@ use std::io;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use term::TerminalCaps;
use crate::animation::{AnimationLayer, AnimWidget};
use crate::editor::{draw_editor, EditorState};
use crate::input::{
current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input, InputMode,
};
use crate::log::{log_preview_line, LogWidget};
use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget;
use crate::ui::Ui;
/// 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).
@@ -96,7 +96,10 @@ fn main() -> ExitCode {
let mut mode = Mode::Play(game);
// Remember the world path so the editor (and play-reload) can reload from it.
let mut ui = Ui { world_path: path.clone(), ..Ui::default() };
let mut ui = Ui {
world_path: path.clone(),
..Ui::default()
};
let result = run(&mut terminal, &mut mode, &mut ui);
if caps.keyboard_enhancement {
@@ -121,11 +124,7 @@ const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30);
/// only until the next frame deadline (`event::poll`), then advances the game by
/// the real time elapsed since the last tick. `poll` returns the instant a key
/// arrives, so input stays responsive while the game keeps ticking on its own.
fn run(
terminal: &mut ratatui::DefaultTerminal,
mode: &mut Mode,
ui: &mut Ui,
) -> io::Result<()> {
fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> io::Result<()> {
let mut last_tick = Instant::now();
loop {
// Redraw every iteration; ratatui diffs against the previous frame so
@@ -158,14 +157,18 @@ fn run(
handle_board_input(event, ui.log.open, size.height, game, ui);
}
InputMode::Scroll => handle_scroll_input(event, game, ui),
_ => unreachable!("Menu and Ignore handled above; Editor input mode only occurs in Edit mode"),
_ => unreachable!(
"Menu and Ignore handled above; Editor input mode only occurs in Edit mode"
),
},
Mode::Edit(ed) => match input_mode {
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
_ => unreachable!("Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode")
InputMode::Dialog => handle_dialog_input(event, ed),
_ => unreachable!(
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
),
},
}
}
}
@@ -194,7 +197,9 @@ fn run(
/// [`GameState`] (so it reflects any edits) and re-runs object `init()` hooks. A
/// load failure is logged to the play game (if any) and leaves the mode unchanged.
fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) {
let Some(pending) = ui.pending_mode.take() else { return };
let Some(pending) = ui.pending_mode.take() else {
return;
};
match world::load(&ui.world_path) {
Ok(world) => match pending {
PendingMode::EnterEditor => *mode = Mode::Edit(EditorState::new(world)),
@@ -225,19 +230,38 @@ fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
}
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
let is_overlay_anim = ui.active_animation.as_ref()
let is_overlay_anim = ui
.active_animation
.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
.unwrap_or(false);
let area = frame.area();
if is_overlay_anim {
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area);
frame.render_widget(
AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()),
area,
);
} else if let Some(menu) = ui.menu.as_mut() {
frame.render_stateful_widget(MenuWidget, area, menu);
} else if let Mode::Play(game) = mode {
// The scroll window only exists while playing.
if let Some(scroll) = &game.active_scroll {
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
} else {
match mode {
// The scroll window only exists while playing.
Mode::Play(game) => {
if let Some(scroll) = &game.active_scroll {
frame.render_stateful_widget(
ScrollOverlayWidget::new(&scroll.lines),
area,
&mut ui.overlay,
);
}
}
// The dialog overlay only exists while editing.
Mode::Edit(ed) => {
if let Some(d) = &ed.dialog {
d.draw(frame);
}
}
}
}
}
@@ -284,13 +308,18 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
ui.start_transition(&old_rc.borrow(), &new_rc.borrow(), inner);
}
let is_board_anim = ui.active_animation.as_ref()
let is_board_anim = ui
.active_animation
.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Board))
.unwrap_or(false);
if is_board_anim {
// Board-layer animation (wipe): replaces the board entirely.
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), inner);
frame.render_widget(
AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()),
inner,
);
} else {
let board = &game.board();
frame.render_widget(BoardWidget::new(board), inner);
+17 -16
View File
@@ -4,6 +4,7 @@
//! centering, animated height, and the bordered block. Callers fill the
//! returned content area with their own content.
use kiln_ui::dim_area;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
@@ -35,7 +36,9 @@ pub(crate) fn render_overlay_frame(
area: Rect,
buf: &mut Buffer,
) -> Option<(Rect, Rect)> {
if progress <= 0.0 { return None; }
if progress <= 0.0 {
return None;
}
let outer_w = area.width / style.width_div;
let outer_h = (area.height as f32 * style.height_fraction) as u16 & !1;
@@ -43,19 +46,19 @@ pub(crate) fn render_overlay_frame(
let actual_h = ((outer_h as f32 * progress).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 };
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,
};
// Dim all background cells so board/game content doesn'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(style.bg)).render(overlay_rect, buf);
dim_area(area, buf);
Fill::new(" ")
.style(Style::default().bg(style.bg))
.render(overlay_rect, buf);
let block = Block::bordered()
.border_style(Style::default().fg(style.border_fg).bg(style.bg))
@@ -64,10 +67,8 @@ pub(crate) fn render_overlay_frame(
block.render(overlay_rect, buf);
// Split inner into content (left) and scrollbar column (right).
let [content_rect, scrollbar_rect] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(1),
]).areas(inner);
let [content_rect, scrollbar_rect] =
Layout::horizontal([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
Some((content_rect, scrollbar_rect))
}