//! World editor mode: state, rendering, and the editor pause menu. //! //! 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 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) 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 crate::editor_menu; use crate::editor_menu::{MenuEntry, MenuLevel}; use crate::log::{LogWidget, log_preview_line}; use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::ui::Ui; use crate::utils::rgba8_to_color; use kiln_core::cp437::tile_to_char; use kiln_core::glyph::Glyph; use kiln_core::log::LogLine; use kiln_core::world::World; use kiln_core::{Archetype, Board}; use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome}; use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse}; use kiln_ui::glyph_dialog::GlyphDialog; 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, Span}; use ratatui::widgets::{Block, Paragraph}; use std::cell::{Ref, RefMut}; /// How long each half of the cursor blink lasts, in seconds. const BLINK_SECS: f32 = 0.5; /// Default sidebar width in terminal columns (borders included). const DEFAULT_SIDEBAR_WIDTH: u16 = 24; /// Smallest width either the sidebar or the board may shrink to when resizing. const MIN_PANEL_WIDTH: u16 = 8; /// The cursor glyph: a solid full block (CP437 219) drawn in light gray. const CURSOR_CHAR: char = '█'; /// Height (in rows) of the drawing-controls footer pinned to the sidebar bottom. const DRAW_FOOTER_HEIGHT: u16 = 5; /// All state for an active editing session. /// /// Built by reloading the world file, so the running game has no bearing on what /// the editor shows. Holds no [`GameState`](kiln_core::game::GameState) — editing /// never ticks the game. pub(crate) struct EditorState { /// The freshly reloaded world being edited. 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::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>, /// 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, /// The open glyph picker overlay, if any. While `Some` the editor is in /// [`InputMode::GlyphDialog`](crate::input::InputMode::GlyphDialog). pub(crate) glyph_dialog: Option>, /// Cursor position in board cells, clamped to the board bounds. cursor: (i32, i32), /// The archetype the drawing tools stamp on [`place_current`](EditorState::place_current). /// Defaults to [`Archetype::Wall`]; the UI to change it comes later. current_archetype: Archetype, /// The glyph the drawing tools stamp. Starts as the current archetype's default /// glyph and is edited via the glyph picker (`g`). current_glyph: Glyph, /// Whether draw mode is on: when set, moving the cursor stamps the current thing /// into each newly-entered cell (as if `space` were pressed after the move). draw_mode: bool, /// Width of the right-hand sidebar in terminal columns (mouse-resizable). sidebar_width: u16, /// Whether the blinking cursor is currently in its visible phase. blink_on: bool, /// Time accumulated toward the next blink toggle. blink_timer: f32, /// Inner board rect from the last [`draw_editor`] call, used to convert a /// right-click's screen position back to a board cell (written during render, /// read during input — the same pattern as `ScrollOverlayState::max_scroll`). last_board_area: Rect, /// Editor-side log lines (the editor has no game log to draw from). log: Vec, } impl EditorState { /// Builds an editor session for `world`, starting on its designated start board. pub(crate) fn new(world: World) -> Self { let board_name = world.start.clone(); Self { world, board_name, menu: vec![MenuLevel::Main], dialog: None, code_editor: None, glyph_dialog: None, cursor: (0, 0), current_archetype: Archetype::Wall, current_glyph: Archetype::Wall.default_glyph(), draw_mode: false, sidebar_width: DEFAULT_SIDEBAR_WIDTH, blink_on: true, blink_timer: 0.0, last_board_area: Rect::default(), log: vec![LogLine::raw("editing — press [esc] for the menu")], } } /// 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::>() .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::editor_menu_items()); } } /// 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`]. 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 { ed.world.name = s; } }, )); } /// Opens a list dialog to choose the entry board; selecting one writes [`World::start`]. 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( "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; selecting one opens it in the code editor. 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| match resp { ListDialogResponse::Select(name) | ListDialogResponse::Create(name) => { let source = ed.world.scripts.get(&name).cloned().unwrap_or_default(); ed.code_editor = Some(CodeEditor::new(&name, &source)); } ListDialogResponse::Cancel => {} }, )); } /// 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). 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| {}, )); } /// Opens the glyph picker seeded from the current drawing glyph. On OK the chosen /// glyph becomes the new drawing glyph (used by subsequent placements). pub(crate) fn open_glyph_picker(&mut self) { self.glyph_dialog = Some(GlyphDialog::new( "Glyph", self.current_glyph, |chosen, ed: &mut EditorState| { if let Some(g) = chosen { ed.current_glyph = g; } }, )); } /// Stamps the current drawing thing (archetype + glyph) into the cell under the /// cursor, applying the placement rules (see [`Board::place_archetype`]). pub(crate) fn place_current(&mut self) { let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize); let (arch, glyph) = (self.current_archetype, self.current_glyph); self.board_mut().place_archetype(x, y, arch, glyph); } /// Toggles draw mode (whether cursor movement auto-stamps the current thing). pub(crate) fn toggle_draw_mode(&mut self) { self.draw_mode = !self.draw_mode; if self.draw_mode { self.place_current() } } /// Borrows the board currently being edited. pub(crate) fn board(&self) -> Ref<'_, Board> { self.world.boards[&self.board_name].borrow() } /// Mutably borrows the board currently being edited. pub(crate) fn board_mut(&self) -> RefMut<'_, Board> { self.world.boards[&self.board_name].borrow_mut() } /// Advances the cursor blink by `dt` seconds (the editor's only animation). pub(crate) fn tick(&mut self, dt: f32) { self.blink_timer += dt; if self.blink_timer >= BLINK_SECS { self.blink_timer -= BLINK_SECS; self.blink_on = !self.blink_on; } } /// Moves the cursor by `(dx, dy)`, clamped to the board bounds. When draw mode is /// on and the cursor lands on a new cell, stamps the current thing there. 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 before = self.cursor; self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1); self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1); if self.draw_mode && self.cursor != before { self.place_current(); } } /// Moves the cursor to the cell under a screen position, if it lands on the /// board (right-click handling). No-op when the click is off the board. When draw /// mode is on and the cursor lands on a new cell, stamps the current thing there. pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) { let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy); if let Some((bx, by)) = cell { let target = (bx as i32, by as i32); let moved = target != self.cursor; self.cursor = target; if self.draw_mode && moved { self.place_current(); } } } /// Resizes the sidebar to `target` columns, clamped so neither the sidebar nor /// the board can shrink below [`MIN_PANEL_WIDTH`] (mirrors [`LogState::resize`]). pub(crate) fn resize_sidebar(&mut self, target: u16, total_w: u16) { let max = total_w.saturating_sub(MIN_PANEL_WIDTH).max(MIN_PANEL_WIDTH); self.sidebar_width = target.clamp(MIN_PANEL_WIDTH, max); } } /// 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); } } } /// Handles an input event while the glyph picker is open ([`InputMode::GlyphDialog`](crate::input::InputMode::GlyphDialog)). /// /// Mirrors [`handle_dialog_input`]: forwards to the picker and, on `Submit`/`Cancel`, /// takes it out of [`EditorState::glyph_dialog`] and fires its callback via /// [`GlyphDialog::finish`]. pub(crate) fn handle_glyph_dialog_input(event: Event, ed: &mut EditorState) { let Some(dialog) = ed.glyph_dialog.as_mut() else { return; }; match dialog.handle_event(&event) { DialogResult::Continue => {} result => { let dialog = ed.glyph_dialog.take().expect("glyph dialog present"); dialog.finish(result, ed); } } } /// 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 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()); } 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() .title(format!(" {} ", ed.breadcrumb())) .merge_borders(MergeStrategy::Exact); let sb_inner = sb_block.inner(sidebar_area); frame.render_widget(sb_block, sidebar_area); // 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 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("")); lines.push(Line::from(Span::styled( "[esc] menu", Style::default().fg(Color::DarkGray), ))); // Split the sidebar: the menu fills the top, the drawing-controls footer (the // current thing being drawn) is pinned to the bottom regardless of menu level. let [menu_area, footer_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(DRAW_FOOTER_HEIGHT)]) .areas(sb_inner); frame.render_widget(Paragraph::new(lines), menu_area); frame.render_widget( Paragraph::new(draw_footer_lines(ed, key_style, label_style, sep_style)), footer_area, ); // ── Log panel (full width, below board + sidebar) ───────────────────────── if let Some(log_area) = log_area { frame.render_stateful_widget(LogWidget::new(&ed.log), log_area, &mut ui.log); } } /// Builds the drawing-controls footer: the current archetype name, the glyph control /// (label + a live preview of the current drawing glyph), and the place / draw-mode /// key hints. Shown at the bottom of the sidebar regardless of the open menu level. fn draw_footer_lines<'a>( ed: &EditorState, key_style: Style, label_style: Style, sep_style: Style, ) -> Vec> { // A one-cell preview of the current glyph in its own fg/bg colors. let preview = Span::styled( tile_to_char(ed.current_glyph.tile).to_string(), Style::default() .fg(rgba8_to_color(ed.current_glyph.fg)) .bg(rgba8_to_color(ed.current_glyph.bg)), ); vec![ // Separator marking off the footer from the menu above. Line::from(Span::styled("─".repeat(64), sep_style)), // The archetype currently being drawn (no UI to change it yet). Line::from(Span::styled(ed.current_archetype.name(), label_style)), Line::from(vec![ Span::styled("[g] ", key_style), Span::styled("Glyph ", label_style), preview, ]), Line::from(vec![ Span::styled("[spc] ", key_style), Span::styled("Place", label_style), ]), Line::from(vec![ Span::styled("[tab] ", key_style), Span::styled( format!("Draw mode ({})", if ed.draw_mode { "on" } else { "off" }), label_style, ), ]), ] } /// Computes the board / sidebar / optional-log rects for the editor. /// /// When the log is open the screen splits vertically first (so the log spans the /// full width), then the top region splits horizontally into board + sidebar. /// When closed, only the horizontal board/sidebar split applies. `Overlap(1)` /// makes adjacent panels share their dividing border. fn editor_layout(area: Rect, ui: &Ui, sidebar_width: u16) -> (Rect, Rect, Option) { let split_h = |top: Rect| { Layout::horizontal([Constraint::Min(3), Constraint::Length(sidebar_width)]) .spacing(Spacing::Overlap(1)) .areas::<2>(top) }; if ui.log.open { let [top, log_area] = Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)]) .spacing(Spacing::Overlap(1)) .areas(area); let [board_area, sidebar_area] = split_h(top); (board_area, sidebar_area, Some(log_area)) } else { let [board_area, sidebar_area] = split_h(area); (board_area, sidebar_area, None) } }