ui crate, editor

This commit is contained in:
2026-06-18 11:40:06 -05:00
parent fb8f6df501
commit 1351055d27
14 changed files with 2277 additions and 104 deletions
+70 -32
View File
@@ -7,6 +7,7 @@
//! 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 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};
@@ -84,6 +85,9 @@ pub(crate) struct EditorState {
/// 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>>,
/// The open script code editor, if any. While `Some` it replaces the board view and
/// the editor is in [`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor).
pub(crate) code_editor: Option<CodeEditor>,
/// Cursor position in board cells, clamped to the board bounds.
cursor: (i32, i32),
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
@@ -109,6 +113,7 @@ impl EditorState {
board_name,
menu: vec![MenuLevel::World],
dialog: None,
code_editor: None,
cursor: (0, 0),
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
blink_on: true,
@@ -202,15 +207,31 @@ impl EditorState {
));
}
/// Opens a list dialog of the world's scripts. Select-only for now (no side effect).
/// Opens a list dialog of the world's scripts; selecting one opens it in the code editor.
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)))
if let ListDialogResponse::Select(name) = resp {
ed.open_script_editor(&name);
}
}));
}
/// Opens the code editor on the named script (empty if the name is unknown).
fn open_script_editor(&mut self, name: &str) {
let source = self.world.scripts.get(name).cloned().unwrap_or_default();
self.code_editor = Some(CodeEditor::new(name, &source));
}
/// Closes the code editor, saving its text back into the in-memory script pool
/// (mirrors how the Name/Entry-board dialogs mutate the loaded `World`).
pub(crate) fn close_script_editor(&mut self) {
if let Some(editor) = self.code_editor.take() {
self.world.scripts.insert(editor.name().to_string(), editor.text());
}
}
/// 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();
@@ -298,43 +319,60 @@ pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) {
}
}
/// Handles an input event while the code editor is open ([`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor)).
///
/// Forwards key/mouse events to the editor; on `Exit` (Esc) it closes and saves the script.
pub(crate) fn handle_code_editor_input(event: Event, ed: &mut EditorState) {
let Some(editor) = ed.code_editor.as_mut() else { return };
if let CodeEditorOutcome::Exit = editor.handle_event(&event) {
ed.close_script_editor();
}
}
/// 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).
pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) {
let (board_area, sidebar_area, log_area) = editor_layout(frame.area(), ui, ed.sidebar_width);
// ── Board, cursor, coordinate readout ────────────────────────────────────
let inner = {
let board = ed.board();
// Title carries the board name (top-left) and live cursor coords (top-right).
let mut block = Block::bordered()
.title(format!(" {} ", board.name))
.title_top(Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned())
.merge_borders(MergeStrategy::Exact);
// When the log is closed, preview the latest line in the bottom border.
if log_area.is_none() {
block = block.title_bottom(log_preview_line(&ed.log).left_aligned());
}
let inner = block.inner(board_area);
frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(&board), inner);
// Overlay the cursor during its visible phase (a light-gray solid block).
if ed.blink_on {
let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize);
if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy)
&& let Some(cell) = frame.buffer_mut().cell_mut((sx, sy))
{
cell.set_char(CURSOR_CHAR)
.set_fg(Color::Rgb(180, 180, 180))
.set_bg(Color::Black);
// ── Board area: the open script editor, else the board with its cursor ────
if let Some(editor) = ed.code_editor.as_mut() {
// A script is being edited: the code editor takes the board's place.
editor.draw(frame, board_area);
} else {
let inner = {
let board = ed.board();
// Title carries the board name (top-left) and live cursor coords (top-right).
let mut block = Block::bordered()
.title(format!(" {} ", board.name))
.title_top(
Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned(),
)
.merge_borders(MergeStrategy::Exact);
// When the log is closed, preview the latest line in the bottom border.
if log_area.is_none() {
block = block.title_bottom(log_preview_line(&ed.log).left_aligned());
}
}
inner
};
// Record the board rect for right-click hit-testing (board borrow now dropped).
ed.last_board_area = inner;
let inner = block.inner(board_area);
frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(&board), inner);
// Overlay the cursor during its visible phase (a light-gray solid block).
if ed.blink_on {
let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize);
if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy)
&& let Some(cell) = frame.buffer_mut().cell_mut((sx, sy))
{
cell.set_char(CURSOR_CHAR)
.set_fg(Color::Rgb(180, 180, 180))
.set_bg(Color::Black);
}
}
inner
};
// Record the board rect for right-click hit-testing (board borrow now dropped).
ed.last_board_area = inner;
}
// ── Sidebar: the current menu (breadcrumb title + letter-keyed choices) ───
let sb_block = Block::bordered()
+4
View File
@@ -31,6 +31,8 @@ pub enum InputMode {
Editor,
/// A dialog overlay is open over the editor; all input drives the dialog.
Dialog,
/// The script code editor is open; all input drives the editor.
CodeEditor,
/// 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.
@@ -54,6 +56,8 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
Mode::Edit(ed) => {
if ed.dialog.is_some() {
InputMode::Dialog
} else if ed.code_editor.is_some() {
InputMode::CodeEditor
} else {
InputMode::Editor
}
+8 -2
View File
@@ -24,7 +24,7 @@ mod ui;
mod utils;
use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{EditorState, draw_editor, handle_dialog_input};
use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input};
use crate::input::{
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input,
@@ -40,7 +40,8 @@ use kiln_core::log::LogLine;
use kiln_core::world;
use ratatui::Frame;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind,
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyEventKind,
};
use ratatui::crossterm::execute;
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
@@ -77,6 +78,9 @@ fn main() -> ExitCode {
// resized by dragging its divider. Disabled again before restoring.
let _ = execute!(io::stdout(), EnableMouseCapture);
// Enable bracketed paste so the code editor receives terminal pastes as one event.
let _ = execute!(io::stdout(), EnableBracketedPaste);
// Detect terminal capabilities (now that we're in raw mode) and enable the
// Kitty keyboard protocol when it's available, so scripts can rely on the
// richer bindings it provides. Disabled again before restoring the terminal.
@@ -105,6 +109,7 @@ fn main() -> ExitCode {
if caps.keyboard_enhancement {
let _ = term::pop_kitty_flags();
}
let _ = execute!(io::stdout(), DisableBracketedPaste);
let _ = execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
@@ -164,6 +169,7 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
Mode::Edit(ed) => match input_mode {
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
InputMode::Dialog => handle_dialog_input(event, ed),
InputMode::CodeEditor => handle_code_editor_input(event, ed),
_ => unreachable!(
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
),