Files
kiln/kiln-ui/src/glyph_dialog.rs
T

500 lines
20 KiB
Rust
Raw Normal View History

2026-06-20 15:27:09 -05:00
//! A modal **glyph picker** dialog: choose a [`Glyph`] (a CP437 tile index plus
//! foreground/background colors) from a 32×8 character grid and two hex color fields.
//!
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route
//! events to [`handle_event`](GlyphDialog::handle_event), [`draw`](GlyphDialog::draw)
//! it, and on dismissal call [`finish`](GlyphDialog::finish), which fires a stored
//! callback with the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
//!
//! It is the first kiln-ui widget that depends on `kiln-core` (for [`Glyph`]/`Rgba8`
//! and the [`tile_to_char`] table); kiln-ui is otherwise game-agnostic.
use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use crate::dialog::DialogResult;
use crate::dim_area;
use crate::text_field::TextField;
/// Background color filling the dialog window (matches [`crate::dialog`]).
const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for the dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210);
/// Background highlight applied to the *label* of the currently focused color field.
const LABEL_FOCUS_BG: Color = Color::Rgb(40, 60, 110);
/// Color for the bracketed key hints in the footer.
const KEY: Color = Color::Rgb(150, 200, 255);
/// Background of the selected grid cell while the grid is focused (a bright cursor).
const CURSOR_BG: Color = Color::Rgb(255, 215, 0);
/// Number of columns in the character grid (32 cols × 8 rows = 256 tiles).
const GRID_COLS: u32 = 32;
/// Number of rows in the character grid.
const GRID_ROWS: u32 = 8;
/// Which sub-control of the picker currently has keyboard focus.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
/// The 32×8 character grid (arrow keys move the selection).
Grid,
/// The foreground-color hex field.
Fg,
/// The background-color hex field.
Bg,
}
/// Boxed callback fired once when the picker is dismissed: `Some(glyph)` on OK,
/// `None` on cancel.
type GlyphCallback<Ctx> = Box<dyn FnOnce(Option<Glyph>, &mut Ctx)>;
/// An open glyph-picker dialog, generic over the host context `Ctx` its callback mutates.
pub struct GlyphDialog<Ctx> {
/// Title shown in the window's top border.
title: String,
/// The currently selected tile index (`0..256`).
tile: u32,
/// Foreground color as bare hex digits (6 chars when valid, no `#`).
fg: TextField,
/// Background color as bare hex digits (6 chars when valid, no `#`).
bg: TextField,
/// Which sub-control currently has focus.
focus: Focus,
/// The callback fired exactly once on dismissal.
callback: GlyphCallback<Ctx>,
}
impl<Ctx> GlyphDialog<Ctx> {
/// Builds a glyph picker seeded from `initial`. The tile selection starts at
/// `initial.tile` and the two hex fields are pre-filled from `initial.fg`/`initial.bg`
/// (6 uppercase hex digits). `on_done` receives `Some(glyph)` on OK or `None` on
/// cancel, plus `&mut Ctx`.
pub fn new(
title: impl Into<String>,
initial: Glyph,
on_done: impl FnOnce(Option<Glyph>, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
tile: initial.tile,
fg: TextField::sized(hex6(initial.fg), 6usize),
bg: TextField::sized(hex6(initial.bg), 6usize),
focus: Focus::Grid,
callback: Box::new(on_done),
}
}
/// Updates the picker for an input event and reports whether it should close.
///
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when
/// both color fields are valid hex; `Tab`/`Shift-Tab` cycle focus; arrow keys move
/// the grid selection when the grid is focused, otherwise edit the focused field.
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
let Event::Key(key) = event else {
return DialogResult::Continue;
};
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => DialogResult::Cancel,
KeyCode::Enter => {
if self.ok_enabled() {
DialogResult::Submit
} else {
DialogResult::Continue
}
}
// Tab cycles focus forward; Shift-Tab (or BackTab) cycles backward.
KeyCode::Tab if shift => self.cycle_focus(false),
KeyCode::Tab => self.cycle_focus(true),
KeyCode::BackTab => self.cycle_focus(false),
// Everything else either moves the grid cursor or edits the focused field.
_ => {
match self.focus {
Focus::Grid => self.move_grid(key.code),
Focus::Fg => {
self.fg.handle_key(*key);
}
Focus::Bg => {
self.bg.handle_key(*key);
}
}
DialogResult::Continue
}
}
}
/// Consumes the dialog and fires its callback. `result` must be the
/// [`DialogResult`] just returned by [`handle_event`](GlyphDialog::handle_event);
/// only [`DialogResult::Submit`] with both colors valid yields `Some(glyph)`.
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
let chosen = if matches!(result, DialogResult::Submit) {
self.glyph()
} else {
None
};
(self.callback)(chosen, ctx);
}
/// The chosen [`Glyph`], or `None` if either color field is currently invalid.
fn glyph(&self) -> Option<Glyph> {
let fg = parse_hex6(self.fg.value())?;
let bg = parse_hex6(self.bg.value())?;
Some(Glyph {
tile: self.tile,
fg,
bg,
})
}
/// Whether OK is currently allowed (both color fields parse as valid hex).
fn ok_enabled(&self) -> bool {
parse_hex6(self.fg.value()).is_some() && parse_hex6(self.bg.value()).is_some()
}
/// Moves focus to the next (or previous) sub-control, wrapping around.
fn cycle_focus(&mut self, forward: bool) -> DialogResult {
self.focus = match (self.focus, forward) {
(Focus::Grid, true) => Focus::Fg,
(Focus::Fg, true) => Focus::Bg,
(Focus::Bg, true) => Focus::Grid,
(Focus::Grid, false) => Focus::Bg,
(Focus::Fg, false) => Focus::Grid,
(Focus::Bg, false) => Focus::Fg,
};
DialogResult::Continue
}
/// 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) = match code {
KeyCode::Left => (col.saturating_sub(1), row),
KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row),
KeyCode::Up => (col, row.saturating_sub(1)),
KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)),
_ => (col, row),
};
self.tile = row * GRID_COLS + col;
}
/// Draws the picker centered over the whole frame: dims the background, then
/// renders the bordered window, the character grid, the two color fields, and the
/// OK/Cancel footer. Takes `&mut Frame` (not a `Widget`) so it can place a real
/// terminal cursor in the focused color field.
pub fn draw(&self, frame: &mut Frame) {
let area = frame.area();
dim_area(area, frame.buffer_mut());
// Size the window: wide enough for the 32-cell grid, tall enough for the grid
// (8) + a gap + the fields row + the footer, all within borders.
let want_w = 40u16;
let want_h = 14u16;
let w = want_w.min(area.width.saturating_sub(2)).max(8);
let h = want_h.min(area.height.saturating_sub(2)).max(5);
let rect = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
};
// Window frame: clear underlying content, then a bordered titled block.
frame.render_widget(Clear, rect);
let block = Block::bordered()
.title(format!(" {} ", self.title))
.border_style(Style::default().fg(BORDER).bg(BG))
.style(Style::default().bg(BG));
let inner = block.inner(rect);
frame.render_widget(block, rect);
// Stack: grid (8 rows), gap, fields row, filler, footer.
let [grid_area, _gap, fields_area, _filler, footer_area] = Layout::vertical([
Constraint::Length(GRID_ROWS as u16),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
// Both colors must be valid for the grid to preview them; otherwise gray-on-black.
let colors = parse_hex6(self.fg.value()).zip(parse_hex6(self.bg.value()));
self.draw_grid(frame, grid_area, colors);
let field_cursor = self.draw_fields(frame, fields_area);
self.draw_footer(frame, footer_area);
// Place a real terminal cursor in the focused field (none when the grid is focused).
if let Some((cx, cy)) = field_cursor {
frame.set_cursor_position((cx, cy));
}
}
/// Draws the 32×8 character grid into `area`, centered horizontally. When `colors`
/// is `Some((fg, bg))` each glyph is drawn in those colors; otherwise gray-on-black.
/// The selected cell is shown reversed (a bright cursor when the grid is focused).
fn draw_grid(&self, frame: &mut Frame, area: Rect, colors: Option<(Rgba8, Rgba8)>) {
// Center the fixed-width grid within whatever inner width we got.
let grid_x = area.x + area.width.saturating_sub(GRID_COLS as u16) / 2;
let base = match colors {
Some((fg, bg)) => Style::default().fg(to_color(fg)).bg(to_color(bg)),
None => Style::default().fg(Color::Gray).bg(Color::Black),
};
let buf = frame.buffer_mut();
for tile in 0..(GRID_COLS * GRID_ROWS) {
let x = grid_x + (tile % GRID_COLS) as u16;
let y = area.y + (tile / GRID_COLS) as u16;
// Skip cells that would fall outside the (possibly clamped) grid area.
if x >= area.right() || y >= area.bottom() {
continue;
}
let style = if tile == self.tile {
if self.focus == Focus::Grid {
// Bright cursor so the selection reads even against the colored grid.
Style::default().fg(Color::Black).bg(CURSOR_BG)
} else {
base.add_modifier(Modifier::REVERSED)
}
} else {
base
};
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(&tile_to_char(tile).to_string());
cell.set_style(style);
}
}
}
/// Draws the `fg`/`bg` labeled hex fields into `area`, centered. Returns the screen
/// position of the terminal cursor for the focused field (or `None` if the grid is
/// focused), so the caller can place it after dropping the buffer borrow.
fn draw_fields(&self, frame: &mut Frame, area: Rect) -> Option<(u16, u16)> {
// Each field box is at least 6 wide, growing if the (invalid) value is longer.
let fg_w = self.fg.value().chars().count().max(6) as u16;
let bg_w = self.bg.value().chars().count().max(6) as u16;
// Layout: "fg " + field + " " + "bg " + field.
let total = 3 + fg_w + 2 + 3 + bg_w;
let start_x = area.x + area.width.saturating_sub(total) / 2;
let y = area.y;
let fg_label_x = start_x;
let fg_field_x = fg_label_x + 3;
let bg_label_x = fg_field_x + fg_w + 2;
let bg_field_x = bg_label_x + 3;
let buf = frame.buffer_mut();
draw_label(buf, fg_label_x, y, "fg ", self.focus == Focus::Fg);
draw_swatch(buf, fg_field_x, y, fg_w, self.fg.value());
draw_label(buf, bg_label_x, y, "bg ", self.focus == Focus::Bg);
draw_swatch(buf, bg_field_x, y, bg_w, self.bg.value());
// The terminal cursor goes in the focused field, clamped to its box.
match self.focus {
Focus::Fg => Some((fg_field_x + (self.fg.display_cursor() as u16).min(fg_w - 1), y)),
Focus::Bg => Some((bg_field_x + (self.bg.display_cursor() as u16).min(bg_w - 1), y)),
Focus::Grid => None,
}
}
/// Draws the `[enter] OK [esc] Cancel` footer (OK dimmed when disabled).
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
let ok_style = if self.ok_enabled() {
Style::default().fg(Color::White).bg(BG)
} else {
Style::default().fg(Color::DarkGray).bg(BG)
};
let key_style = Style::default().fg(KEY).bg(BG);
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_style),
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
frame.render_widget(Paragraph::new(footer), area);
}
}
/// Draws a field label, highlighting its background when the field is focused.
fn draw_label(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, text: &str, focused: bool) {
let style = if focused {
Style::default().fg(Color::White).bg(LABEL_FOCUS_BG)
} else {
Style::default().fg(Color::Gray).bg(BG)
};
buf.set_string(x, y, text, style);
}
/// Draws a color swatch box of width `w`: the parsed color as background with
/// contrasting text when `value` is valid hex, otherwise a red "invalid" box.
fn draw_swatch(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, w: u16, value: &str) {
let style = match parse_hex6(value) {
Some(c) => Style::default().fg(contrast(c)).bg(to_color(c)),
None => Style::default().fg(Color::White).bg(Color::Rgb(150, 30, 30)),
};
// Left-align the value in a fixed-width box so the swatch fills the whole field.
let shown = format!("{value:<width$}", width = w as usize);
buf.set_string(x, y, shown, style);
}
/// Formats an [`Rgba8`] as 6 uppercase hex digits (no `#`); alpha is dropped.
fn hex6(c: Rgba8) -> String {
format!("{:02X}{:02X}{:02X}", c.r, c.g, c.b)
}
/// Parses exactly 6 hex digits (no `#`) into an opaque [`Rgba8`], or `None` if the
/// string is not exactly six hex characters.
fn parse_hex6(s: &str) -> Option<Rgba8> {
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(Rgba8 {
r: u8::from_str_radix(&s[0..2], 16).ok()?,
g: u8::from_str_radix(&s[2..4], 16).ok()?,
b: u8::from_str_radix(&s[4..6], 16).ok()?,
a: 255,
})
}
/// Converts a core [`Rgba8`] to a ratatui [`Color`] (alpha dropped).
fn to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
/// Picks black or white for legible text on a `c` background, by luminance.
fn contrast(c: Rgba8) -> Color {
// Rec. 601 luma; > ~50% brightness reads better with black text.
let luma = 0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32;
if luma > 128.0 {
Color::Black
} else {
Color::White
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
/// A glyph with distinct, valid colors for seeding tests.
fn sample() -> Glyph {
Glyph {
tile: 5,
fg: Rgba8 {
r: 0xFF,
g: 0x00,
b: 0x00,
a: 255,
},
bg: Rgba8 {
r: 0x00,
g: 0x00,
b: 0xFF,
a: 255,
},
}
}
/// Builds a key-press event with no modifiers.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn seed_round_trips_tile_and_colors() {
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert_eq!(d.tile, 5);
assert_eq!(d.fg.value(), "FF0000");
assert_eq!(d.bg.value(), "0000FF");
assert!(d.ok_enabled());
}
#[test]
fn tab_cycles_focus_both_ways() {
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert!(d.focus == Focus::Grid);
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::Fg);
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::Bg);
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::Grid);
// BackTab goes the other way.
d.handle_event(&key(KeyCode::BackTab));
assert!(d.focus == Focus::Bg);
}
#[test]
fn arrows_move_grid_selection_and_clamp() {
let mut g = sample();
g.tile = 0;
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
d.handle_event(&key(KeyCode::Right));
assert_eq!(d.tile, 1);
d.handle_event(&key(KeyCode::Down));
assert_eq!(d.tile, 1 + GRID_COLS);
d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, GRID_COLS);
d.handle_event(&key(KeyCode::Up));
assert_eq!(d.tile, 0);
// Clamps at the top-left corner.
d.handle_event(&key(KeyCode::Up));
d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, 0);
}
#[test]
fn invalid_hex_disables_ok_and_blocks_enter() {
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
// Focus the fg field and delete a digit → "FF000" (5 chars) is invalid.
d.handle_event(&key(KeyCode::Tab));
d.handle_event(&key(KeyCode::Backspace));
assert_eq!(d.fg.value(), "FF000");
assert!(!d.ok_enabled());
assert!(matches!(
d.handle_event(&key(KeyCode::Enter)),
DialogResult::Continue
));
// Type the digit back → valid again.
d.handle_event(&key(KeyCode::Char('0')));
assert_eq!(d.fg.value(), "FF0000");
assert!(d.ok_enabled());
}
#[test]
fn submit_yields_glyph_cancel_yields_none() {
// Submit returns the chosen glyph.
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
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.fg, sample().fg);
assert_eq!(chosen.bg, sample().bg);
// Cancel returns None.
let mut out2: Option<Option<Glyph>> = None;
let d2: GlyphDialog<Option<Option<Glyph>>> =
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
d2.finish(DialogResult::Cancel, &mut out2);
assert!(out2.unwrap().is_none());
}
#[test]
fn parse_hex6_validation() {
assert!(parse_hex6("FF0000").is_some());
assert!(parse_hex6("ff00 aa").is_none()); // space / wrong length
assert!(parse_hex6("FF00").is_none()); // too short
assert!(parse_hex6("GGGGGG").is_none()); // non-hex
}
}