217 lines
9.2 KiB
Rust
217 lines
9.2 KiB
Rust
|
|
//! 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 resizable right-hand sidebar (just a "coming soon" placeholder for now)
|
||
|
|
//! and a blinking cursor moved with the arrow keys or a right-click.
|
||
|
|
|
||
|
|
use std::cell::Ref;
|
||
|
|
use kiln_core::Board;
|
||
|
|
use kiln_core::log::LogLine;
|
||
|
|
use kiln_core::world::World;
|
||
|
|
use ratatui::Frame;
|
||
|
|
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
|
||
|
|
use ratatui::style::{Color, Style};
|
||
|
|
use ratatui::symbols::merge::MergeStrategy;
|
||
|
|
use ratatui::text::Line;
|
||
|
|
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;
|
||
|
|
|
||
|
|
/// 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 = '█';
|
||
|
|
|
||
|
|
/// 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.
|
||
|
|
world: World,
|
||
|
|
/// Key (in [`World::boards`]) of the board currently being edited.
|
||
|
|
board_name: String,
|
||
|
|
/// Cursor position in board cells, clamped to the board bounds.
|
||
|
|
cursor: (i32, i32),
|
||
|
|
/// 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<LogLine>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
cursor: (0, 0),
|
||
|
|
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")],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Borrows the board currently being edited.
|
||
|
|
pub(crate) fn board(&self) -> Ref<'_, Board> {
|
||
|
|
self.world.boards[&self.board_name].borrow()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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.
|
||
|
|
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) };
|
||
|
|
self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1);
|
||
|
|
self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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.
|
||
|
|
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 {
|
||
|
|
self.cursor = (bx as i32, by as i32);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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();
|
||
|
|
}),
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
inner
|
||
|
|
};
|
||
|
|
// 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);
|
||
|
|
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,
|
||
|
|
);
|
||
|
|
|
||
|
|
// ── 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<Rect>) {
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|