glyph picker

This commit is contained in:
2026-06-20 15:27:09 -05:00
parent e74d015d1b
commit 05142e5712
12 changed files with 638 additions and 26 deletions
-89
View File
@@ -1,89 +0,0 @@
//! CP437 → Unicode mapping for rendering glyph tile indices as terminal text.
//!
//! kiln stores each cell's visual as a `tile: u32` index into a bitmap font.
//! The default kiln font is IBM CP437, where the tile index equals the CP437
//! code point. A terminal can't draw the bitmap font, so kiln-tui reinterprets
//! the tile index as a character via this table and prints that character with
//! the cell's foreground/background colors.
/// CP437 code point → Unicode scalar value.
///
/// Index this array by a byte value (0255) to get the displayable character.
/// The low control range (0x000x1F) maps to CP437's graphic glyphs (hearts,
/// arrows, musical notes, etc.) rather than ASCII control codes, matching how
/// these byte values render in a DOS/ZZT-style font. Code 0x00 maps to a blank
/// space since a literal NUL is not displayable.
const CP437: [char; 256] = [
// 0x000x0F
' ', '☺', '☻', '♥', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 0x200x2F (ASCII space onward)
' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/',
// 0x300x3F
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?',
// 0x400x4F
'@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
// 0x500x5F
'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_',
// 0x600x6F
'`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
// 0x700x7F
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', '⌂',
// 0x800x8F
'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å',
// 0x900x9F
'É', 'æ', 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ',
// 0xA00xAF
'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»',
// 0xB00xBF (shading + box drawing)
'░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐',
// 0xC00xCF
'└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧',
// 0xD00xDF
'╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', '▀',
// 0xE00xEF
'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩',
// 0xF00xFF
'≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{00A0}',
];
/// Converts a glyph tile index into a displayable character.
///
/// Tile indices in `0..256` use the [`CP437`] table. Larger indices (which a
/// non-default font could in principle reference) fall back to interpreting the
/// index as a raw Unicode scalar, then to a blank space if that is not a valid
/// or printable character.
pub fn tile_to_char(tile: u32) -> char {
if tile < 256 {
CP437[tile as usize]
} else {
char::from_u32(tile).unwrap_or(' ')
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_passthrough() {
// The printable ASCII range must map to itself.
assert_eq!(tile_to_char('@' as u32), '@'); // player tile 64
assert_eq!(tile_to_char('#' as u32), '#'); // wall tile 35
assert_eq!(tile_to_char(' ' as u32), ' '); // empty tile 32
}
#[test]
fn box_drawing_and_blocks() {
assert_eq!(tile_to_char(0xB0), '░');
assert_eq!(tile_to_char(0xDB), '█');
assert_eq!(tile_to_char(0xC4), '─');
}
#[test]
fn out_of_range_falls_back_to_space() {
// A surrogate code point is not a valid char → blank.
assert_eq!(tile_to_char(0xD800), ' ');
}
}
+44
View File
@@ -10,6 +10,7 @@
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;
@@ -55,6 +56,9 @@ pub(crate) struct 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>,
/// The open glyph picker overlay, if any. While `Some` the editor is in
/// [`InputMode::GlyphDialog`](crate::input::InputMode::GlyphDialog).
pub(crate) glyph_dialog: Option<GlyphDialog<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).
@@ -81,6 +85,7 @@ impl EditorState {
menu: vec![MenuLevel::Main],
dialog: None,
code_editor: None,
glyph_dialog: None,
cursor: (0, 0),
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
blink_on: true,
@@ -176,6 +181,27 @@ impl 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).
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,
|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
)));
}
},
));
}
/// Borrows the board currently being edited.
pub(crate) fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.board_name].borrow()
@@ -235,6 +261,24 @@ pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) {
}
}
/// 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.
+7 -1
View File
@@ -33,6 +33,8 @@ pub enum InputMode {
Dialog,
/// The script code editor is open; all input drives the editor.
CodeEditor,
/// The glyph picker is open over the editor; all input drives the picker.
GlyphDialog,
/// 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,7 +56,9 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
}
match mode {
Mode::Edit(ed) => {
if ed.dialog.is_some() {
if ed.glyph_dialog.is_some() {
InputMode::GlyphDialog
} else if ed.dialog.is_some() {
InputMode::Dialog
} else if ed.code_editor.is_some() {
InputMode::CodeEditor
@@ -122,6 +126,8 @@ 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.
KeyCode::Char('g') => ed.open_glyph_picker(),
// A menu letter opens its dialog; unmatched letters are ignored.
KeyCode::Char(c) => {
ed.menu_key(c);
+10 -5
View File
@@ -5,10 +5,9 @@
//! (or HJKL). There is no editor — this is a play-only front-end.
//!
//! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
//! as characters (see [`kiln_core::cp437`]) and drawn with each cell's RGB colors.
mod animation;
mod cp437;
mod editor;
mod input;
mod log;
@@ -25,7 +24,10 @@ mod utils;
mod editor_menu;
use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input};
use crate::editor::{
EditorState, draw_editor, handle_code_editor_input, handle_dialog_input,
handle_glyph_dialog_input,
};
use crate::input::{
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input,
@@ -170,6 +172,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::GlyphDialog => handle_glyph_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"
@@ -263,9 +266,11 @@ fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
);
}
}
// The dialog overlay only exists while editing.
// The dialog / glyph-picker overlays only exist while editing.
Mode::Edit(ed) => {
if let Some(d) = &ed.dialog {
if let Some(g) = &ed.glyph_dialog {
g.draw(frame);
} else if let Some(d) = &ed.dialog {
d.draw(frame);
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
//! Rendering a kiln [`Board`] into a ratatui buffer as colored text.
//!
//! Each board cell becomes one terminal cell: the glyph's tile index is mapped
//! to a character (see [`crate::cp437`]) and drawn with the glyph's foreground
//! to a character (see [`kiln_core::cp437`]) and drawn with the glyph's foreground
//! and background colors. Objects are drawn over their floor cell, and the
//! player is drawn on top of everything.
use crate::cp437::tile_to_char;
use kiln_core::cp437::tile_to_char;
use kiln_core::Board;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;