editor menus

This commit is contained in:
2026-06-20 11:53:02 -05:00
parent d270ee8d16
commit e74d015d1b
3 changed files with 163 additions and 110 deletions
+38 -107
View File
@@ -4,15 +4,14 @@
//! own freshly reloaded [`World`] and never ticks the game. The board is shown //! own freshly reloaded [`World`] and never ticks the game. The board is shown
//! with a blinking cursor (moved with the arrow keys or a right-click) and a //! 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 //! 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 //! title and letter-keyed choices that open [dialogs](kiln_ui::dialog) or descend into
//! back up the tree, opening the overlay [editor menu](editor_menu_items) at the root. //! sub-menus. `Esc` walks back up the tree, opening the overlay
//! [editor menu](editor_menu::editor_menu_items) at the root.
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome}; use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse}; use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
use crate::log::{LogWidget, log_preview_line}; use crate::log::{log_preview_line, LogWidget};
use crate::menu::{MenuItem, MenuKey}; use crate::render::{board_to_screen, screen_to_board, BoardWidget};
use crate::mode::PendingMode;
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui; use crate::ui::Ui;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::log::LogLine; use kiln_core::log::LogLine;
@@ -25,6 +24,8 @@ use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph}; use ratatui::widgets::{Block, Paragraph};
use std::cell::Ref; use std::cell::Ref;
use crate::editor_menu;
use crate::editor_menu::{MenuEntry, MenuLevel};
/// How long each half of the cursor blink lasts, in seconds. /// How long each half of the cursor blink lasts, in seconds.
const BLINK_SECS: f32 = 0.5; const BLINK_SECS: f32 = 0.5;
@@ -35,40 +36,6 @@ const MIN_PANEL_WIDTH: u16 = 8;
/// The cursor glyph: a solid full block (CP437 219) drawn in light gray. /// The cursor glyph: a solid full block (CP437 219) drawn in light gray.
const CURSOR_CHAR: char = '█'; 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. /// All state for an active editing session.
/// ///
/// Built by reloading the world file, so the running game has no bearing on what /// Built by reloading the world file, so the running game has no bearing on what
@@ -76,12 +43,12 @@ impl MenuLevel {
/// never ticks the game. /// never ticks the game.
pub(crate) struct EditorState { pub(crate) struct EditorState {
/// The freshly reloaded world being edited. /// The freshly reloaded world being edited.
world: World, pub(crate) world: World,
/// Key (in [`World::boards`]) of the board currently being edited. /// Key (in [`World::boards`]) of the board currently being edited.
board_name: String, board_name: String,
/// The sidebar menu navigation stack; the last element is the current level. /// The sidebar menu navigation stack; the last element is the current level.
/// Starts as `[MenuLevel::World]` and is never emptied below that root. /// Starts as `[MenuLevel::Main]` and is never emptied below that root.
menu: Vec<MenuLevel>, pub(crate) menu: Vec<MenuLevel>,
/// The currently open dialog overlay, if any. While `Some`, the editor is in /// The currently open dialog overlay, if any. While `Some`, the editor is in
/// [`InputMode::Dialog`](crate::input::InputMode::Dialog). /// [`InputMode::Dialog`](crate::input::InputMode::Dialog).
pub(crate) dialog: Option<Dialog<EditorState>>, pub(crate) dialog: Option<Dialog<EditorState>>,
@@ -111,7 +78,7 @@ impl EditorState {
Self { Self {
world, world,
board_name, board_name,
menu: vec![MenuLevel::World], menu: vec![MenuLevel::Main],
dialog: None, dialog: None,
code_editor: None, code_editor: None,
cursor: (0, 0), cursor: (0, 0),
@@ -143,46 +110,18 @@ impl EditorState {
if self.menu.len() > 1 { if self.menu.len() > 1 {
self.menu.pop(); self.menu.pop();
} else { } else {
ui.open_menu(editor_menu_items()); ui.open_menu(editor_menu::editor_menu_items());
} }
} }
/// Dispatches a letter key against the current menu level. Returns `true` if the /// Dispatches a letter key against the current menu level: asks MenuLevel what it should
/// key matched a choice (and opened a dialog), so the caller can stop handling it. /// do with the key, and passes an &mut EditorState to do it.
pub(crate) fn menu_key(&mut self, c: char) -> bool { pub(crate) fn menu_key(&mut self, c: char) {
match (self.current_menu(), c) { self.current_menu().perform_key(c, self);
(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`]. /// Opens a text dialog to rename the world; OK writes [`World::name`].
fn open_name_dialog(&mut self) { pub(crate) fn open_name_dialog(&mut self) {
let current = self.world.name.clone(); let current = self.world.name.clone();
self.dialog = Some(Dialog::text("World Name", current, |opt, ed: &mut EditorState| { self.dialog = Some(Dialog::text("World Name", current, |opt, ed: &mut EditorState| {
if let Some(s) = opt { if let Some(s) = opt {
@@ -192,7 +131,7 @@ impl EditorState {
} }
/// Opens a list dialog to choose the entry board; selecting one writes [`World::start`]. /// Opens a list dialog to choose the entry board; selecting one writes [`World::start`].
fn open_entry_dialog(&mut self) { pub(crate) fn open_entry_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect(); let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort(); names.sort();
self.dialog = Some(Dialog::list( self.dialog = Some(Dialog::list(
@@ -208,7 +147,7 @@ impl EditorState {
} }
/// Opens a list dialog of the world's scripts; selecting one opens it in the code editor. /// Opens a list dialog of the world's scripts; selecting one opens it in the code editor.
fn open_scripts_dialog(&mut self) { pub(crate) fn open_scripts_dialog(&mut self) {
let mut names: Vec<String> = self.world.scripts.keys().cloned().collect(); let mut names: Vec<String> = self.world.scripts.keys().cloned().collect();
names.sort(); names.sort();
self.dialog = Some(Dialog::list("Scripts", names, true, |resp, ed: &mut EditorState| { self.dialog = Some(Dialog::list("Scripts", names, true, |resp, ed: &mut EditorState| {
@@ -231,7 +170,7 @@ impl EditorState {
} }
/// Opens a list dialog of the world's boards. Select-only for now (no side effect). /// Opens a list dialog of the world's boards. Select-only for now (no side effect).
fn open_boards_dialog(&mut self) { pub(crate) fn open_boards_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect(); let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort(); names.sort();
self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {})); self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {}));
@@ -278,27 +217,6 @@ impl EditorState {
} }
} }
/// Returns the items for the editor's pause menu (opened with `Esc` while editing).
///
/// `[p]` reloads the world and returns to play; `[q]` quits; `Esc` resumes editing.
/// Like all menu actions these take only `&mut Ui` — the mode swap is deferred to
/// the run loop via [`Ui::pending_mode`](crate::ui::Ui::pending_mode).
pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
vec![
MenuItem::new(MenuKey::Char('p'), "Play world", |ui| {
ui.pending_mode = Some(PendingMode::PlayWorld);
ui.begin_close_menu();
}),
MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| {
ui.should_quit = true;
ui.begin_close_menu();
}),
MenuItem::new(MenuKey::Esc, "Resume editing", |ui| {
ui.begin_close_menu();
}),
]
}
/// Handles an input event while a dialog is open ([`InputMode::Dialog`](crate::input::InputMode::Dialog)). /// 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 /// Forwards the event to the dialog; on `Submit`/`Cancel` it takes the dialog out of
@@ -378,21 +296,34 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
.merge_borders(MergeStrategy::Exact); .merge_borders(MergeStrategy::Exact);
let sb_inner = sb_block.inner(sidebar_area); let sb_inner = sb_block.inner(sidebar_area);
frame.render_widget(sb_block, sidebar_area); frame.render_widget(sb_block, sidebar_area);
// One line per choice (`[k] Label`); a choice with a current value (e.g. the // One line per item (`[k] Label`); an item with a current value (e.g. the
// world name) gets a second, indented line beneath it in a distinct color. // world name) gets a second, indented line beneath it in a distinct color.
// Blank/Separator entries render as spacers to group items.
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)); let key_style = Style::default().fg(Color::Rgb(150, 200, 255));
let label_style = Style::default().fg(Color::White); let label_style = Style::default().fg(Color::White);
let value_style = Style::default().fg(Color::Rgb(200, 180, 120)); let value_style = Style::default().fg(Color::Rgb(200, 180, 120));
let sep_style = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for (k, label) in ed.current_menu().choices() { for entry in ed.current_menu().entries() {
match entry {
MenuEntry::Item{ key, label, .. } => {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(format!("[{k}] "), key_style), Span::styled(format!("[{}] ", key), key_style),
Span::styled(*label, label_style), Span::styled(label, label_style),
])); ]));
if let Some(val) = ed.menu_value(*k) { }
MenuEntry::Blank => lines.push(Line::from("")),
// A horizontal rule spanning the sidebar's inner width.
MenuEntry::Separator => lines.push(Line::from(Span::styled(
"".repeat(sb_inner.width as usize),
sep_style,
))),
MenuEntry::CurrentValue(val_f) => {
let val = val_f(ed);
lines.push(Line::from(Span::styled(format!(" {val}"), value_style))); lines.push(Line::from(Span::styled(format!(" {val}"), value_style)));
} }
} }
}
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
"[esc] menu", "[esc] menu",
+121
View File
@@ -0,0 +1,121 @@
use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
/// 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. A choice either opens
/// a [dialog](kiln_ui::dialog) or pushes a child level — e.g. [`Main`](MenuLevel::Main)
/// descends into [`World`](MenuLevel::World) or [`Floor`](MenuLevel::Floor).
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum MenuLevel {
/// The top-level main menu: switch boards, view scripts, more later
Main,
/// Menu of world-related concerns: set name / entry board
World,
/// Menu of floor options
Floor
}
impl MenuLevel {
/// The breadcrumb segment shown for this level.
pub(crate) fn title(self) -> &'static str {
match self {
MenuLevel::Main => "Main",
MenuLevel::World => "World",
MenuLevel::Floor => "Floor"
}
}
/// The list of entries for each menu
pub(crate) fn entries(self) -> Vec<MenuEntry> {
match self {
MenuLevel::Main => vec![
MenuEntry::item('w', "World...", |ed| ed.menu.push(MenuLevel::World)),
MenuEntry::item('s', "Scripts...", |ed| ed.open_scripts_dialog()),
MenuEntry::item('b', "Boards...", |ed| ed.open_boards_dialog()),
MenuEntry::Separator,
MenuEntry::item('f', "Floor", |ed| ed.menu.push(MenuLevel::Floor)),
],
MenuLevel::World => vec![
MenuEntry::item('n', "Name", |ed| ed.open_name_dialog()),
MenuEntry::value(|ed| ed.world.name.clone()),
MenuEntry::item('e', "Entry board...", |ed| ed.open_entry_dialog()),
MenuEntry::value(|ed| ed.world.start.clone()),
],
MenuLevel::Floor => vec![
MenuEntry::item('g', "Grass", |_| {}),
MenuEntry::item('d', "Dirt", |_| {}),
MenuEntry::item('s', "Stone", |_| {}),
MenuEntry::item('c', "Custom glyph [TODO]", |_| {}),
],
}
}
/// Dispatches a letter key against this menu level: finds the selectable
/// item bound to `ch` (spacers are skipped) and runs its action with full `&mut`
/// 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),
_ => None,
});
if let Some(action) = action {
action(editor);
}
}
}
/// One line in a sidebar menu level: a selectable [`Item`](MenuEntry::Item), a
/// [`CurrentValue`](MenuEntry::CurrentValue) readout, or a visual
/// [`Blank`](MenuEntry::Blank)/[`Separator`](MenuEntry::Separator) spacer.
pub(crate) enum MenuEntry {
/// A selectable, key-activated item.
Item{
/// The letter the user presses to activate this item.
key: char,
/// Display label shown after the `[key]` in the sidebar.
label: String,
/// Run when the user presses `key`; mutates the editor session.
action: Box<dyn FnOnce(&mut EditorState)>,
},
/// A blank spacer line. Part of the menu API; no current level uses one yet.
#[allow(dead_code)]
Blank,
/// A horizontal rule spanning the sidebar width.
Separator,
/// The current value of some field
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 value<F: FnOnce(& EditorState) -> String + 'static>(action: F) -> Self {
MenuEntry::CurrentValue(Box::new(action))
}
}
/// Returns the items for the editor's pause menu (opened with `Esc` while editing).
///
/// `[p]` reloads the world and returns to play; `[q]` quits; `Esc` resumes editing.
/// Like all menu actions these take only `&mut Ui` — the mode swap is deferred to
/// the run loop via [`Ui::pending_mode`](crate::ui::Ui::pending_mode).
pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
vec![
MenuItem::new(MenuKey::Char('p'), "Play world", |ui| {
ui.pending_mode = Some(PendingMode::PlayWorld);
ui.begin_close_menu();
}),
MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| {
ui.should_quit = true;
ui.begin_close_menu();
}),
MenuItem::new(MenuKey::Esc, "Resume editing", |ui| {
ui.begin_close_menu();
}),
]
}
+1
View File
@@ -22,6 +22,7 @@ mod term;
mod transition; mod transition;
mod ui; mod ui;
mod utils; mod utils;
mod editor_menu;
use crate::animation::{AnimWidget, AnimationLayer}; use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input}; use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input};