From 0dc69c7ebb02b3544aec55f262b530ccdc2b3ce0 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 20 Jun 2026 17:53:47 -0500 Subject: [PATCH] basic drawing --- kiln-core/src/board.rs | 100 +++++++++++++++++++++++ kiln-core/src/lib.rs | 5 +- kiln-tui/src/editor.rs | 181 ++++++++++++++++++++++++++++++++--------- kiln-tui/src/input.rs | 5 +- 4 files changed, 251 insertions(+), 40 deletions(-) diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index a6f773e..9639969 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -329,6 +329,50 @@ impl Board { self.objects.insert(id, object); id } + + /// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at + /// `(x, y)`, applying the editor's placement/removal rules. + /// + /// Two cases, keyed only on the archetype (the floor is **never** touched — the + /// drawing tools cannot place, remove, or alter a floor): + /// + /// - **Terrain** (`arch != Empty`, always solid today): removes any solid object + /// already in the cell, then writes `(glyph, arch)` into the cell's terrain. + /// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in + /// it, leaving the floor (a visible `Empty` cell on a lower layer) in place. + /// + /// Terrain is written to the cell's existing terrain layer (the single non-`Empty` + /// archetype across layers, if any) or else the top layer; a vacated terrain cell + /// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)` + /// is out of bounds. + pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) { + if arch == Archetype::Empty { + // Erase: drop every object in the cell and clear its terrain (keep floor). + for id in self.object_ids_at(x, y) { + self.objects.remove(&id); + } + if let Some(z) = self.terrain_layer_at(x, y) { + *self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty); + } + return; + } + + // Placing solid terrain: a solid object can't share the cell, so drop it. + if let Some(id) = self.solid_object_id_at(x, y) { + self.objects.remove(&id); + } + // Reuse the existing terrain layer if the cell already has terrain, else the + // top layer (so the new wall draws above any floor on a lower layer). + let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1); + *self.get_mut(z, x, y) = (glyph, arch); + } + + /// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty` + /// (the cell's single terrain archetype, if any). By the one-solid-per-cell + /// invariant there is at most one such layer. + fn terrain_layer_at(&self, x: usize, y: usize) -> Option { + (0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty) + } } #[cfg(test)] @@ -559,6 +603,62 @@ pub(crate) mod tests { assert_eq!(board.glyph_at(0, 0).tile, '*' as u32); } + #[test] + fn place_wall_keeps_floor_and_removes_solid_object() { + // Floor on layer 0, terrain layer on top; a solid object sits at (1,0). + let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]); + let floor = Glyph { + tile: '.' as u32, + ..Glyph::transparent() + }; + add_floor(&mut board, floor); + + let wall = Archetype::Wall.default_glyph(); + board.place_archetype(1, 0, Archetype::Wall, wall); + + // The wall landed on the terrain (top) layer; the floor below is untouched. + assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall)); + assert_eq!(board.get(0, 1, 0).0, floor); + // The solid object that was there is gone. + assert!(board.object_ids_at(1, 0).is_empty()); + assert_eq!(board.glyph_at(1, 0), wall); + } + + #[test] + fn place_wall_overwrites_existing_terrain_in_place() { + // A crate already occupies the top layer at (1,0). + let mut board = open_board(3, 1, (2, 0), vec![]); + crate_at(&mut board, 1, 0); + let wall = Archetype::Wall.default_glyph(); + board.place_archetype(1, 0, Archetype::Wall, wall); + assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall)); + } + + #[test] + fn erase_removes_terrain_and_objects_but_keeps_floor() { + // Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0). + let mut obj = ObjectDef::new(1, 0); + obj.solid = false; + let mut board = open_board(3, 1, (2, 0), vec![obj]); + let floor = Glyph { + tile: '.' as u32, + ..Glyph::transparent() + }; + add_floor(&mut board, floor); + wall_at(&mut board, 1, 0); + + board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent()); + + // Terrain cleared to transparent Empty; object removed; floor still there. + assert_eq!( + board.get(1, 1, 0), + &(Glyph::transparent(), Archetype::Empty) + ); + assert!(board.object_ids_at(1, 0).is_empty()); + assert_eq!(board.get(0, 1, 0).0, floor); + assert_eq!(board.glyph_at(1, 0), floor); + } + #[test] fn fresh_board_is_valid_and_reports_errors() { let mut board = open_board(1, 1, (0, 0), vec![]); diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 1dd6176..a6dc9b1 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -2,10 +2,10 @@ mod action; mod archetype; mod board; mod builtin_scripts; -/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font. -pub mod cp437; /// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor. pub mod colors; +/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font. +pub mod cp437; /// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. @@ -23,6 +23,7 @@ mod utils; /// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). pub mod world; +pub use archetype::Archetype; pub use board::Board; pub use utils::Direction; diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index b088821..83a9847 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -8,15 +8,20 @@ //! 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 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; -use kiln_core::world::World; use ratatui::Frame; use ratatui::crossterm::event::Event; use ratatui::layout::{Constraint, Layout, Rect, Spacing}; @@ -24,9 +29,7 @@ use ratatui::style::{Color, Style}; 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}; +use std::cell::{Ref, RefMut}; /// How long each half of the cursor blink lasts, in seconds. const BLINK_SECS: f32 = 0.5; @@ -36,6 +39,8 @@ const DEFAULT_SIDEBAR_WIDTH: u16 = 24; 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. /// @@ -61,6 +66,15 @@ pub(crate) struct EditorState { 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. @@ -87,6 +101,9 @@ impl EditorState { 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, @@ -128,11 +145,15 @@ impl EditorState { /// 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; - } - })); + 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`]. @@ -155,22 +176,27 @@ impl EditorState { 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 { + 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()); + self.world + .scripts + .insert(editor.name().to_string(), editor.text()); } } @@ -178,35 +204,54 @@ impl EditorState { 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| {})); + self.dialog = Some(Dialog::list( + "Boards", + names, + false, + |_resp, _ed: &mut EditorState| {}, + )); } - /// Opens the glyph picker seeded from the glyph currently under the cursor. - /// On OK the chosen glyph is logged (there is no paint tool yet). + /// 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) { - let glyph = { - let board = self.board(); - board.glyph_at(self.cursor.0 as usize, self.cursor.1 as usize) - }; self.glyph_dialog = Some(GlyphDialog::new( "Glyph", - glyph, + self.current_glyph, |chosen, ed: &mut EditorState| { if let Some(g) = chosen { - ed.log.push(LogLine::raw(format!( - "glyph tile={} fg=#{:02X}{:02X}{:02X} bg=#{:02X}{:02X}{:02X}", - g.tile, g.fg.r, g.fg.g, g.fg.b, g.bg.r, g.bg.g, g.bg.b - ))); + 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; @@ -216,22 +261,33 @@ impl EditorState { } } - /// Moves the cursor by `(dx, dy)`, clamped to the board bounds. + /// 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. + /// 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 { - self.cursor = (bx as i32, by as i32); + let target = (bx as i32, by as i32); + let moved = target != self.cursor; + self.cursor = target; + if self.draw_mode && moved { + self.place_current(); + } } } @@ -283,7 +339,9 @@ pub(crate) fn handle_glyph_dialog_input(event: Event, ed: &mut EditorState) { /// /// 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 }; + let Some(editor) = ed.code_editor.as_mut() else { + return; + }; if let CodeEditorOutcome::Exit = editor.handle_event(&event) { ed.close_script_editor(); } @@ -350,7 +408,7 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) let mut lines: Vec = Vec::new(); for entry in ed.current_menu().entries() { match entry { - MenuEntry::Item{ key, label, .. } => { + MenuEntry::Item { key, label, .. } => { lines.push(Line::from(vec![ Span::styled(format!("[{}] ", key), key_style), Span::styled(label, label_style), @@ -373,7 +431,16 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) "[esc] menu", Style::default().fg(Color::DarkGray), ))); - frame.render_widget(Paragraph::new(lines), sb_inner); + // 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 { @@ -381,6 +448,46 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) } } +/// 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 diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index 1905620..ac5e69f 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -126,8 +126,11 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat KeyCode::Left => ed.move_cursor(-1, 0), KeyCode::Right => ed.move_cursor(1, 0), KeyCode::Char('l') => ui.log.toggle(), - // `g` opens the glyph picker for the cell under the cursor. + // `g` opens the glyph picker to edit the current drawing glyph. KeyCode::Char('g') => ed.open_glyph_picker(), + // Space stamps the current thing; Tab toggles draw mode. + KeyCode::Char(' ') => ed.place_current(), + KeyCode::Tab => ed.toggle_draw_mode(), // A menu letter opens its dialog; unmatched letters are ignored. KeyCode::Char(c) => { ed.menu_key(c);