editor ui
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user