diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index a6ccbbc..7b498b2 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -4,15 +4,14 @@ //! 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 //! 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. +//! title and letter-keyed choices that open [dialogs](kiln_ui::dialog) or descend into +//! 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::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::log::{log_preview_line, LogWidget}; +use crate::render::{board_to_screen, screen_to_board, BoardWidget}; use crate::ui::Ui; use kiln_core::Board; use kiln_core::log::LogLine; @@ -25,6 +24,8 @@ use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; 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. 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. 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 @@ -76,12 +43,12 @@ impl MenuLevel { /// never ticks the game. pub(crate) struct EditorState { /// The freshly reloaded world being edited. - world: World, + pub(crate) 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, + /// Starts as `[MenuLevel::Main]` and is never emptied below that root. + pub(crate) menu: Vec, /// The currently open dialog overlay, if any. While `Some`, the editor is in /// [`InputMode::Dialog`](crate::input::InputMode::Dialog). pub(crate) dialog: Option>, @@ -111,7 +78,7 @@ impl EditorState { Self { world, board_name, - menu: vec![MenuLevel::World], + menu: vec![MenuLevel::Main], dialog: None, code_editor: None, cursor: (0, 0), @@ -143,46 +110,18 @@ impl EditorState { if self.menu.len() > 1 { self.menu.pop(); } 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 - /// 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 { - match (self.current_menu(), key) { - (MenuLevel::World, 'n') => Some(self.world.name.clone()), - (MenuLevel::World, 'e') => Some(self.world.start.clone()), - _ => None, - } + /// Dispatches a letter key against the current menu level: asks MenuLevel what it should + /// do with the key, and passes an &mut EditorState to do it. + pub(crate) fn menu_key(&mut self, c: char) { + self.current_menu().perform_key(c, self); } /// 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(); self.dialog = Some(Dialog::text("World Name", current, |opt, ed: &mut EditorState| { 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`]. - fn open_entry_dialog(&mut self) { + pub(crate) fn open_entry_dialog(&mut self) { let mut names: Vec = self.world.boards.keys().cloned().collect(); names.sort(); 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. - fn open_scripts_dialog(&mut self) { + pub(crate) fn open_scripts_dialog(&mut self) { let mut names: Vec = self.world.scripts.keys().cloned().collect(); names.sort(); 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). - fn open_boards_dialog(&mut self) { + pub(crate) fn open_boards_dialog(&mut self) { let mut names: Vec = self.world.boards.keys().cloned().collect(); names.sort(); 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 { - 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)). /// /// Forwards the event to the dialog; on `Submit`/`Cancel` it takes the dialog out of @@ -378,19 +296,32 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) .merge_borders(MergeStrategy::Exact); let sb_inner = sb_block.inner(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. + // Blank/Separator entries render as spacers to group items. 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 sep_style = Style::default().fg(Color::DarkGray); let mut lines: Vec = 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))); + for entry in ed.current_menu().entries() { + match entry { + MenuEntry::Item{ key, label, .. } => { + lines.push(Line::from(vec![ + Span::styled(format!("[{}] ", key), key_style), + Span::styled(label, label_style), + ])); + } + 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("")); diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs new file mode 100644 index 0000000..4b33347 --- /dev/null +++ b/kiln-tui/src/editor_menu.rs @@ -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 { + 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, + }, + /// 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 String>) +} + +impl MenuEntry { + pub(crate) fn item(key: char, label: impl Into, action: F) -> Self { + MenuEntry::Item { key, label: label.into(), action: Box::new(action) } + } + + pub(crate) fn value 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 { + 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(); + }), + ] +} \ No newline at end of file diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index eeea5d9..56c3f54 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -22,6 +22,7 @@ mod term; mod transition; mod ui; mod utils; +mod editor_menu; use crate::animation::{AnimWidget, AnimationLayer}; use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input};