glyph serialization

This commit is contained in:
2026-07-26 23:29:43 -05:00
parent 9844671c46
commit ab5f22fe76
20 changed files with 408 additions and 143 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui;
use crate::utils::{glyph_to_span, rgba8_to_color};
use kiln_core::cp437::tile_to_char;
use crate::utils::glyph_char;
use kiln_core::game::GameState;
use kiln_core::glyph::Glyph;
use kiln_core::log::LogLine;
@@ -510,7 +510,7 @@ fn draw_footer_lines<'a>(
) -> Vec<Line<'a>> {
// 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(),
glyph_char(ed.current_glyph).to_string(),
Style::default()
.fg(rgba8_to_color(ed.current_glyph.fg))
.bg(rgba8_to_color(ed.current_glyph.bg)),
+48 -27
View File
@@ -20,7 +20,7 @@
use color::Rgba8;
use kiln_core::colors::NAMED_COLORS;
use kiln_core::cp437::tile_to_char;
use kiln_core::cp437::{char_to_tile, tile_to_char};
use kiln_core::glyph::Glyph;
use kiln_ui::dialog::DialogResult;
use kiln_ui::text_field::TextField;
@@ -55,6 +55,17 @@ const GRID_ROWS: u32 = 8;
/// The strip slot index that means "custom color" (one past the named colors).
const CUSTOM: usize = NAMED_COLORS.len();
/// The character drawn for CP437 slot `index` in the picker grid and preview.
///
/// Slot 0 holds `'\0'`, the transparent sentinel, which has no printable form —
/// it shows as a blank, matching how a transparent cell renders on the board (see
/// [`crate::utils::glyph_char`]). The slot is still selectable: picking it is how
/// you author an invisible glyph.
fn glyph_char_for(index: u32) -> char {
let ch = tile_to_char(index);
if ch == '\0' { ' ' } else { ch }
}
/// A single fg/bg color selector: a strip of the named swatches plus a custom slot,
/// with an editable hex field used when the custom slot is selected.
///
@@ -126,8 +137,13 @@ type GlyphCallback<Ctx> = Box<dyn FnOnce(Option<Glyph>, &mut Ctx)>;
pub struct GlyphDialog<Ctx> {
/// Title shown in the window's top border.
title: String,
/// The currently selected tile index (`0..256`).
tile: u32,
/// The currently selected CP437 index (`0..256`).
///
/// Kept as an index, not the `char` a [`Glyph`] stores, because the picker is
/// a 32x8 grid over the CP437 table and navigates by row/column arithmetic.
/// Conversion happens only when seeding from ([`char_to_tile`]) and emitting
/// to ([`tile_to_char`]) a `Glyph`.
index: u32,
/// Foreground color selector.
fg: ColorPicker,
/// Background color selector.
@@ -139,10 +155,14 @@ pub struct GlyphDialog<Ctx> {
}
impl<Ctx> GlyphDialog<Ctx> {
/// Builds a glyph picker seeded from `initial`: tile selection starts at
/// `initial.tile`, and each color picker is seeded from `initial.fg`/`initial.bg`
/// (a matching named swatch if any, else the custom slot). `on_done` receives
/// `Some(glyph)` on OK or `None` on cancel, plus `&mut Ctx`.
/// Builds a glyph picker seeded from `initial`: the grid selects the CP437
/// slot holding `initial.tile`, and each color picker is seeded from
/// `initial.fg`/`initial.bg` (a matching named swatch if any, else the custom
/// slot). `on_done` receives `Some(glyph)` on OK or `None` on cancel, plus
/// `&mut Ctx`.
///
/// A glyph whose character has no CP437 slot (a map may carry any character)
/// falls back to slot 0, the transparent sentinel.
pub fn new(
title: impl Into<String>,
initial: Glyph,
@@ -150,7 +170,7 @@ impl<Ctx> GlyphDialog<Ctx> {
) -> Self {
Self {
title: title.into(),
tile: initial.tile,
index: char_to_tile(initial.tile).unwrap_or(0),
fg: ColorPicker::new(initial.fg),
bg: ColorPicker::new(initial.bg),
focus: Focus::Grid,
@@ -211,7 +231,7 @@ impl<Ctx> GlyphDialog<Ctx> {
/// The chosen [`Glyph`], or `None` if either color is currently unresolved.
fn glyph(&self) -> Option<Glyph> {
Some(Glyph {
tile: self.tile,
tile: tile_to_char(self.index),
fg: self.fg.color()?,
bg: self.bg.color()?,
})
@@ -280,7 +300,7 @@ impl<Ctx> GlyphDialog<Ctx> {
/// Moves the grid selection one cell for an arrow key, clamped at the edges.
fn move_grid(&mut self, code: KeyCode) {
let (col, row) = (self.tile % GRID_COLS, self.tile / GRID_COLS);
let (col, row) = (self.index % GRID_COLS, self.index / GRID_COLS);
let (col, row) = match code {
KeyCode::Left => (col.saturating_sub(1), row),
KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row),
@@ -288,7 +308,7 @@ impl<Ctx> GlyphDialog<Ctx> {
KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)),
_ => (col, row),
};
self.tile = row * GRID_COLS + col;
self.index = row * GRID_COLS + col;
}
/// Draws the 32×8 character grid into `area`, centered horizontally. When `colors`
@@ -308,7 +328,7 @@ impl<Ctx> GlyphDialog<Ctx> {
if x >= area.right() || y >= area.bottom() {
continue;
}
let style = if tile == self.tile {
let style = if tile == self.index {
if self.focus == Focus::Grid {
// Bright cursor so the selection reads even against the colored grid.
Style::default().fg(Color::Black).bg(CURSOR_BG)
@@ -319,7 +339,7 @@ impl<Ctx> GlyphDialog<Ctx> {
base
};
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(&tile_to_char(tile).to_string());
cell.set_symbol(&glyph_char_for(tile).to_string());
cell.set_style(style);
}
}
@@ -430,7 +450,7 @@ impl<Ctx> GlyphDialog<Ctx> {
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_style),
Span::styled(tile_to_char(self.tile).to_string(), preview_style),
Span::styled(glyph_char_for(self.index).to_string(), preview_style),
Span::styled(" [esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
@@ -567,10 +587,11 @@ mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
/// A glyph with custom (non-named) colors for seeding tests.
/// A glyph with custom (non-named) colors for seeding tests. Its character
/// is CP437 slot 5 (`♣`), so the picker seeds its grid selection there.
fn sample() -> Glyph {
Glyph {
tile: 5,
tile: '',
fg: Rgba8 {
r: 0xFF,
g: 0x00,
@@ -594,7 +615,7 @@ mod tests {
#[test]
fn seed_custom_colors_go_to_custom_slot() {
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert_eq!(d.tile, 5);
assert_eq!(d.index, 5, "seeded to the CP437 slot holding the glyph's char");
assert!(d.fg.is_custom() && d.bg.is_custom());
assert_eq!(d.fg.field.value(), "FF0000");
assert_eq!(d.bg.field.value(), "0000FF");
@@ -604,7 +625,7 @@ mod tests {
#[test]
fn seed_matching_named_color_selects_its_swatch() {
let g = Glyph {
tile: 1,
tile: '',
fg: NAMED_COLORS[4].1, // Red
bg: NAMED_COLORS[0].1, // Black
};
@@ -634,7 +655,7 @@ mod tests {
fn tab_cycles_focus_skipping_unreachable_custom_fields() {
// A named-only glyph: neither color is custom, so the field stops are skipped.
let g = Glyph {
tile: 0,
tile: '\0',
fg: NAMED_COLORS[1].1,
bg: NAMED_COLORS[2].1,
};
@@ -667,20 +688,20 @@ mod tests {
#[test]
fn arrows_move_grid_selection_and_clamp() {
let mut g = sample();
g.tile = 0;
g.tile = '\0'; // CP437 slot 0, the grid's top-left
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
d.handle_event(&key(KeyCode::Right));
assert_eq!(d.tile, 1);
assert_eq!(d.index, 1);
d.handle_event(&key(KeyCode::Down));
assert_eq!(d.tile, 1 + GRID_COLS);
assert_eq!(d.index, 1 + GRID_COLS);
d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, GRID_COLS);
assert_eq!(d.index, GRID_COLS);
d.handle_event(&key(KeyCode::Up));
assert_eq!(d.tile, 0);
assert_eq!(d.index, 0);
// Clamps at the top-left corner.
d.handle_event(&key(KeyCode::Up));
d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, 0);
assert_eq!(d.index, 0);
}
#[test]
@@ -707,11 +728,11 @@ mod tests {
let mut out: Option<Option<Glyph>> = None;
let mut d: GlyphDialog<Option<Option<Glyph>>> =
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
d.handle_event(&key(KeyCode::Right)); // tile 5 -> 6
d.handle_event(&key(KeyCode::Right)); // CP437 slot 5 -> 6
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
let chosen = out.unwrap().unwrap();
assert_eq!(chosen.tile, 6);
assert_eq!(chosen.tile, tile_to_char(6));
assert_eq!(chosen.fg, sample().fg);
assert_eq!(chosen.bg, sample().bg);
+2 -3
View File
@@ -5,11 +5,10 @@
//! and background colors. Objects are drawn over their floor cell, and the
//! player is drawn on top of everything.
use crate::utils::rgba8_to_color;
use crate::utils::{glyph_char, rgba8_to_color};
use color::Rgba8;
use kiln_core::Board;
use kiln_core::Lighting;
use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
@@ -162,7 +161,7 @@ impl Widget for BoardWidget<'_> {
Some(l) => (l.tint(bx, by, glyph.fg), l.tint(bx, by, glyph.bg)),
None => (glyph.fg, glyph.bg),
};
cell.set_char(tile_to_char(glyph.tile))
cell.set_char(glyph_char(glyph))
.set_fg(rgba8_to_color(fg))
.set_bg(rgba8_to_color(bg));
} else {
+2 -2
View File
@@ -8,7 +8,7 @@
use std::collections::VecDeque;
use crate::utils::rgba8_to_color;
use kiln_core::Builtin;
use kiln_core::cp437::tile_to_char;
use crate::utils::glyph_char;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
@@ -45,7 +45,7 @@ impl Widget for StatusSidebarWidget {
// Draw the gem indicator from the gem archetype's default glyph, so the
// sidebar matches what gems look like on the board (no hardcoded ♦/color).
let gem_glyph = Builtin::Gem.default_glyph_for("gem");
let gem_char = tile_to_char(gem_glyph.tile).to_string();
let gem_char = glyph_char(gem_glyph).to_string();
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
// Build the key row: 8 ♀ glyphs, colored when held, near-black when absent.
+16 -5
View File
@@ -1,5 +1,4 @@
use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::layout::Rect;
use ratatui::prelude::Color;
@@ -14,15 +13,27 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
/// Converts a [`Glyph`] to a single-character styled [`Span`].
/// The character this terminal front-end draws for `glyph`.
///
/// The tile index is mapped to a CP437 character; fg and bg are both applied.
/// A glyph carries its character directly, so this is almost the identity — the
/// one case that needs handling is the transparent sentinel `'\0'`, which means
/// "draw nothing". By the time a glyph reaches a renderer, `Board::glyph_at` has
/// already exhausted its precedence chain, so there is nothing underneath left to
/// reveal and the cell is simply blank. Writing the NUL through to the terminal
/// buffer would emit an unprintable character instead.
///
/// How a sentinel looks on screen is a front-end decision, which is why this
/// lives here rather than in kiln-core.
pub fn glyph_char(glyph: Glyph) -> char {
if glyph.is_visible() { glyph.tile } else { ' ' }
}
/// Converts a [`Glyph`] to a single-character styled [`Span`], applying fg and bg.
pub fn glyph_to_span(glyph: Glyph) -> Span<'static> {
let ch = tile_to_char(glyph.tile).to_string();
let style = Style::default()
.fg(rgba8_to_color(glyph.fg))
.bg(rgba8_to_color(glyph.bg));
Span::styled(ch, style)
Span::styled(glyph_char(glyph).to_string(), style)
}
/// Returns true if two `Rect`s share at least one cell.