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
+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 {