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

753 lines
29 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
2026-06-20 16:21:04 -05:00
//! foreground/background colors) from a 32×8 character grid and two color pickers.
//!
//! Each color is chosen from a horizontal strip of the 16 EGA/VGA
//! [named colors](kiln_core::colors::NAMED_COLORS) followed by a **custom** slot; when
//! the custom slot is selected its hex field below becomes editable (focus it by
//! pressing Down/Tab on the strip). The hex value is the source of truth — picking a
//! named swatch fills it in.
2026-06-20 15:27:09 -05:00
//!
2026-07-11 00:11:35 -05:00
//! This mirrors the [`dialog`](kiln_ui::dialog) API — build a [`GlyphDialog`], route
2026-06-20 15:53:15 -05:00
//! events to [`handle_event`](GlyphDialog::handle_event), draw it via its
2026-07-11 00:11:35 -05:00
//! [`CursorOverlay`](kiln_ui::CursorOverlay) impl (see [`kiln_ui::render_overlay`]), and on
2026-06-20 15:53:15 -05:00
//! dismissal call [`finish`](GlyphDialog::finish), which fires a stored callback with
//! the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
2026-06-20 15:27:09 -05:00
//!
2026-07-11 00:11:35 -05:00
//! This is the glyph-picker widget that couples the game engine to the reusable UI: it
//! depends on `kiln-core` (for [`Glyph`]/`Rgba8`, the [`tile_to_char`] table, and the
//! named colors), so it lives here in kiln-tui rather than in the game-agnostic `kiln-ui`
//! crate — building its dialog scaffolding on `kiln_ui`'s public widgets.
2026-06-20 15:27:09 -05:00
use color::Rgba8;
2026-06-20 16:21:04 -05:00
use kiln_core::colors::NAMED_COLORS;
2026-06-20 15:27:09 -05:00
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
2026-07-11 00:11:35 -05:00
use kiln_ui::dialog::DialogResult;
use kiln_ui::text_field::TextField;
use kiln_ui::{CursorOverlay, dim_area};
2026-06-20 15:53:15 -05:00
use ratatui::buffer::Buffer;
2026-06-20 15:27:09 -05:00
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
2026-06-20 15:53:15 -05:00
use ratatui::layout::{Constraint, Layout, Position, Rect};
2026-06-20 15:27:09 -05:00
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
2026-06-20 15:53:15 -05:00
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
2026-06-20 15:27:09 -05:00
2026-07-11 00:11:35 -05:00
/// Background color filling the dialog window (matches [`kiln_ui::dialog`]).
2026-06-20 15:27:09 -05:00
const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for the dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210);
2026-06-20 16:21:04 -05:00
/// Background highlight applied to the *label* of the currently focused control.
2026-06-20 15:27:09 -05:00
const LABEL_FOCUS_BG: Color = Color::Rgb(40, 60, 110);
2026-06-20 16:21:04 -05:00
/// Background of an editable hex field (so it reads as an input box).
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
/// Background of an invalid hex field.
const INVALID_BG: Color = Color::Rgb(150, 30, 30);
2026-06-20 15:27:09 -05:00
/// 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;
2026-06-20 16:21:04 -05:00
/// The strip slot index that means "custom color" (one past the named colors).
const CUSTOM: usize = NAMED_COLORS.len();
/// 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.
///
/// The hex `field` is the source of truth — [`color`](ColorPicker::color) parses it,
/// and selecting a named swatch fills it with that color's hex.
struct ColorPicker {
/// Selected slot: `0..NAMED_COLORS.len()` is a named color, [`CUSTOM`] is custom.
selected: usize,
/// Hex value (6 bare digits, no `#`). Holds the selected named color's hex, or the
/// user's entry while `selected == CUSTOM`.
field: TextField,
}
impl ColorPicker {
/// Builds a picker seeded from `initial`: selects the matching named swatch if one
/// exists, otherwise the custom slot, with the hex field pre-filled either way.
fn new(initial: Rgba8) -> Self {
let selected = NAMED_COLORS
.iter()
.position(|(_, c)| *c == initial)
.unwrap_or(CUSTOM);
Self {
selected,
field: TextField::sized(hex6(initial), 6),
}
}
/// Whether the custom slot is selected (its hex field is then editable).
fn is_custom(&self) -> bool {
self.selected == CUSTOM
}
/// The resolved color, or `None` if the (custom) hex is currently invalid.
fn color(&self) -> Option<Rgba8> {
parse_hex6(self.field.value())
}
/// Moves the strip selection by `delta`, clamped to `0..=CUSTOM`. Landing on a named
/// swatch fills the hex field with that color (keeping the hex the source of truth).
fn move_select(&mut self, delta: i32) {
let next = (self.selected as i32 + delta).clamp(0, CUSTOM as i32) as usize;
self.selected = next;
if next != CUSTOM {
self.field.set(hex6(NAMED_COLORS[next].1));
}
}
}
2026-06-20 15:27:09 -05:00
/// 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,
2026-06-20 16:21:04 -05:00
/// The foreground color strip (Left/Right select; Down enters its custom field).
FgStrip,
/// The foreground custom hex field (only reachable when fg is on the custom slot).
FgField,
/// The background color strip.
BgStrip,
/// The background custom hex field.
BgField,
2026-06-20 15:27:09 -05:00
}
/// 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,
2026-06-20 16:21:04 -05:00
/// Foreground color selector.
fg: ColorPicker,
/// Background color selector.
bg: ColorPicker,
2026-06-20 15:27:09 -05:00
/// Which sub-control currently has focus.
focus: Focus,
/// The callback fired exactly once on dismissal.
callback: GlyphCallback<Ctx>,
}
impl<Ctx> GlyphDialog<Ctx> {
2026-06-20 16:21:04 -05:00
/// 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`.
2026-06-20 15:27:09 -05:00
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,
2026-06-20 16:21:04 -05:00
fg: ColorPicker::new(initial.fg),
bg: ColorPicker::new(initial.bg),
2026-06-20 15:27:09 -05:00
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
2026-06-20 16:21:04 -05:00
/// both colors resolve; `Tab`/`Shift-Tab` cycle focus. On the grid, arrows move the
/// selection; on a strip, Left/Right pick a swatch and Down enters the custom field;
/// in a custom field, keys edit it and Up returns to the strip.
2026-06-20 15:27:09 -05:00
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),
2026-06-20 16:21:04 -05:00
// Everything else is routed to the focused control.
2026-06-20 15:27:09 -05:00
_ => {
match self.focus {
Focus::Grid => self.move_grid(key.code),
2026-06-20 16:21:04 -05:00
Focus::FgStrip => self.handle_strip(key.code, true),
Focus::BgStrip => self.handle_strip(key.code, false),
Focus::FgField => self.handle_field(*key, true),
Focus::BgField => self.handle_field(*key, false),
2026-06-20 15:27:09 -05:00
}
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);
}
2026-06-20 16:21:04 -05:00
/// The chosen [`Glyph`], or `None` if either color is currently unresolved.
2026-06-20 15:27:09 -05:00
fn glyph(&self) -> Option<Glyph> {
Some(Glyph {
tile: self.tile,
2026-06-20 16:21:04 -05:00
fg: self.fg.color()?,
bg: self.bg.color()?,
2026-06-20 15:27:09 -05:00
})
}
2026-06-20 16:21:04 -05:00
/// Whether OK is currently allowed (both colors resolve).
2026-06-20 15:27:09 -05:00
fn ok_enabled(&self) -> bool {
2026-06-20 16:21:04 -05:00
self.fg.color().is_some() && self.bg.color().is_some()
2026-06-20 15:27:09 -05:00
}
2026-06-20 16:21:04 -05:00
/// Moves focus to the next (or previous) control, skipping a color's custom field
/// unless that color is on its custom slot.
2026-06-20 15:27:09 -05:00
fn cycle_focus(&mut self, forward: bool) -> DialogResult {
2026-06-20 16:21:04 -05:00
// Build the live list of focus stops (custom fields only when reachable).
let mut stops = vec![Focus::Grid, Focus::FgStrip];
if self.fg.is_custom() {
stops.push(Focus::FgField);
}
stops.push(Focus::BgStrip);
if self.bg.is_custom() {
stops.push(Focus::BgField);
}
let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0);
let n = stops.len();
2026-06-21 01:32:47 -05:00
let j = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
2026-06-20 16:21:04 -05:00
self.focus = stops[j];
2026-06-20 15:27:09 -05:00
DialogResult::Continue
}
2026-06-20 16:21:04 -05:00
/// Handles a key on a color strip: Left/Right pick a swatch; Down enters the custom
/// field when the custom slot is selected.
fn handle_strip(&mut self, code: KeyCode, is_fg: bool) {
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
match code {
KeyCode::Left => picker.move_select(-1),
KeyCode::Right => picker.move_select(1),
KeyCode::Down if picker.is_custom() => {
2026-06-21 01:32:47 -05:00
self.focus = if is_fg {
Focus::FgField
} else {
Focus::BgField
};
2026-06-20 16:21:04 -05:00
}
_ => {}
}
}
/// Handles a key in a custom hex field: Up returns to the strip; everything else
/// edits the field.
fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) {
if matches!(key.code, KeyCode::Up) {
2026-06-21 01:32:47 -05:00
self.focus = if is_fg {
Focus::FgStrip
} else {
Focus::BgStrip
};
2026-06-20 16:21:04 -05:00
return;
}
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
picker.field.handle_key(key);
}
2026-06-20 15:27:09 -05:00
/// 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 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).
2026-06-20 15:53:15 -05:00
fn draw_grid(&self, buf: &mut Buffer, area: Rect, colors: Option<(Rgba8, Rgba8)>) {
2026-06-20 15:27:09 -05:00
// 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),
};
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);
}
}
}
2026-06-20 16:21:04 -05:00
/// Draws a color strip into `area`: a focusable label, then a solid swatch per named
/// color plus a final custom slot. The selected slot is marked with `●`; the custom
/// slot is otherwise marked `+`.
2026-06-21 01:32:47 -05:00
fn draw_strip(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
picker: &ColorPicker,
focused: bool,
) {
2026-06-20 16:21:04 -05:00
draw_label(buf, area.x, area.y, label, focused);
let sx = area.x + 3;
2026-06-20 15:27:09 -05:00
let y = area.y;
2026-06-20 16:21:04 -05:00
// Iterate the named slots *and* one extra (the custom slot, == CUSTOM), so a
// plain range over slot indices is clearer than enumerating the array.
#[allow(clippy::needless_range_loop)]
for slot in 0..=CUSTOM {
let x = sx + slot as u16;
if x >= area.right() {
break;
}
// A named slot shows its fixed color; the custom slot shows the current
// custom color (a dark placeholder when its hex is invalid).
let slot_rgba = if slot < NAMED_COLORS.len() {
Some(NAMED_COLORS[slot].1)
} else {
picker.color()
};
let bg = slot_rgba.map(to_color).unwrap_or(Color::Rgb(40, 40, 40));
let marker_fg = slot_rgba.map(contrast).unwrap_or(Color::White);
let symbol = if slot == picker.selected {
"●" // current selection
} else if slot == CUSTOM {
"+" // denotes the custom slot
} else {
" "
};
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(symbol);
cell.set_style(Style::default().fg(marker_fg).bg(bg));
}
}
}
2026-06-20 15:27:09 -05:00
2026-06-20 16:21:04 -05:00
/// Draws a color's hex row into `area`. When the custom slot is selected this is an
/// editable `#RRGGBB` input box (red when invalid); otherwise it shows the selected
/// named color's name + hex, dimmed. Returns the caret position when the field is the
/// focused custom field.
fn draw_color_field(
&self,
buf: &mut Buffer,
area: Rect,
picker: &ColorPicker,
focused: bool,
) -> Option<Position> {
let x = area.x + 3;
let y = area.y;
if picker.is_custom() {
// "#" prefix then a (≥6-wide) hex input box.
buf.set_string(x, y, "#", Style::default().fg(Color::Gray).bg(BG));
let val = picker.field.value();
let w = val.chars().count().max(6) as u16;
let style = if parse_hex6(val).is_some() {
Style::default().fg(Color::White).bg(FIELD_BG)
} else {
Style::default().fg(Color::White).bg(INVALID_BG)
};
let box_x = x + 1;
2026-06-21 01:32:47 -05:00
buf.set_string(
box_x,
y,
format!("{val:<width$}", width = w as usize),
style,
);
2026-06-20 16:21:04 -05:00
focused.then(|| {
let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1));
Position::new(cur, y)
})
} else {
// Named slot: show the name + hex, read-only/dimmed.
let (name, c) = NAMED_COLORS[picker.selected];
let text = format!("= {name} #{}", hex6(c));
buf.set_string(x, y, text, Style::default().fg(Color::DarkGray).bg(BG));
None
2026-06-20 15:27:09 -05:00
}
}
2026-06-20 16:21:04 -05:00
/// Draws the `[enter] OK ▓ [esc] Cancel` footer: OK (dimmed when disabled), a live
/// preview of the selected glyph in the chosen colors, then Cancel.
2026-06-20 15:53:15 -05:00
fn draw_footer(&self, buf: &mut Buffer, area: Rect) {
2026-06-20 15:27:09 -05:00
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);
2026-06-20 16:21:04 -05:00
// Preview the chosen glyph in its fg/bg when both colors resolve, else gray-on-black.
let preview_style = match self.fg.color().zip(self.bg.color()) {
Some((fg, bg)) => Style::default().fg(to_color(fg)).bg(to_color(bg)),
None => Style::default().fg(Color::Gray).bg(Color::Black),
};
2026-06-20 15:27:09 -05:00
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
2026-06-20 16:21:04 -05:00
Span::styled("OK ", ok_style),
Span::styled(tile_to_char(self.tile).to_string(), preview_style),
Span::styled(" [esc] ", key_style),
2026-06-20 15:27:09 -05:00
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
2026-06-20 15:53:15 -05:00
Paragraph::new(footer).render(area, buf);
}
}
impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
/// Draws the picker centered over `area`: dims the background, then renders the
2026-06-20 16:21:04 -05:00
/// bordered window, the character grid, the fg/bg color strips + hex rows, and the
/// footer. Returns the focused custom field's caret position (or `None`) for the host
/// to place the terminal cursor.
2026-06-20 15:53:15 -05:00
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position> {
dim_area(area, buf);
// Size the window: wide enough for the 32-cell grid, tall enough for the grid
2026-06-20 16:21:04 -05:00
// (8) + a gap + the two color strips (each a swatch row + a hex row) + footer.
2026-06-20 15:53:15 -05:00
let want_w = 40u16;
2026-06-20 16:21:04 -05:00
let want_h = 16u16;
2026-06-20 15:53:15 -05:00
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.
Clear.render(rect, buf);
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);
block.render(rect, buf);
2026-06-20 16:21:04 -05:00
// Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer.
2026-06-21 01:32:47 -05:00
let [
grid_area,
_gap,
fg_strip,
fg_field,
bg_strip,
bg_field,
_filler,
footer_area,
] = Layout::vertical([
Constraint::Length(GRID_ROWS as u16),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
2026-06-20 16:21:04 -05:00
// Both colors must resolve for the grid to preview them; otherwise gray-on-black.
let colors = self.fg.color().zip(self.bg.color());
self.draw_grid(buf, grid_area, colors);
2026-06-20 15:53:15 -05:00
2026-06-20 16:21:04 -05:00
self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip);
2026-06-21 01:32:47 -05:00
let fg_cursor =
self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
2026-06-20 16:21:04 -05:00
self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip);
2026-06-21 01:32:47 -05:00
let bg_cursor =
self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
2026-06-20 15:53:15 -05:00
self.draw_footer(buf, footer_area);
2026-06-20 16:21:04 -05:00
// At most one custom field is focused at a time.
fg_cursor.or(bg_cursor)
2026-06-20 15:53:15 -05:00
}
}
/// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't
/// needed; the caret position from [`CursorOverlay::render`] is discarded. Use
2026-07-11 00:11:35 -05:00
/// [`render_overlay`](kiln_ui::render_overlay) when the terminal cursor must be placed.
2026-06-20 15:53:15 -05:00
impl<Ctx> Widget for &GlyphDialog<Ctx> {
fn render(self, area: Rect, buf: &mut Buffer) {
CursorOverlay::render(self, area, buf);
2026-06-20 15:27:09 -05:00
}
}
2026-06-20 16:21:04 -05:00
/// Draws a control label, highlighting its background when the control is focused.
fn draw_label(buf: &mut Buffer, x: u16, y: u16, text: &str, focused: bool) {
2026-06-20 15:27:09 -05:00
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);
}
/// 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};
2026-06-20 16:21:04 -05:00
/// A glyph with custom (non-named) colors for seeding tests.
2026-06-20 15:27:09 -05:00
fn sample() -> Glyph {
Glyph {
tile: 5,
2026-06-21 01:32:47 -05:00
fg: Rgba8 {
r: 0xFF,
g: 0x00,
b: 0x00,
a: 255,
}, // not a named color
bg: Rgba8 {
r: 0x00,
g: 0x00,
b: 0xFF,
a: 255,
}, // not a named color
2026-06-20 15:27:09 -05:00
}
}
/// Builds a key-press event with no modifiers.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
2026-06-20 16:21:04 -05:00
fn seed_custom_colors_go_to_custom_slot() {
2026-06-20 15:27:09 -05:00
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert_eq!(d.tile, 5);
2026-06-20 16:21:04 -05:00
assert!(d.fg.is_custom() && d.bg.is_custom());
assert_eq!(d.fg.field.value(), "FF0000");
assert_eq!(d.bg.field.value(), "0000FF");
2026-06-20 15:27:09 -05:00
assert!(d.ok_enabled());
}
#[test]
2026-06-20 16:21:04 -05:00
fn seed_matching_named_color_selects_its_swatch() {
let g = Glyph {
tile: 1,
fg: NAMED_COLORS[4].1, // Red
bg: NAMED_COLORS[0].1, // Black
};
let d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
assert_eq!(d.fg.selected, 4);
assert_eq!(d.bg.selected, 0);
assert!(!d.fg.is_custom());
}
#[test]
fn strip_left_right_selects_named_and_fills_hex() {
2026-06-20 15:27:09 -05:00
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
2026-06-20 16:21:04 -05:00
d.handle_event(&key(KeyCode::Tab)); // Grid -> FgStrip
assert!(d.fg.is_custom()); // seeded on the custom slot
// Left off the custom slot lands on the last named color (White, index 15).
d.handle_event(&key(KeyCode::Left));
assert_eq!(d.fg.selected, NAMED_COLORS.len() - 1);
assert!(!d.fg.is_custom());
assert_eq!(d.fg.field.value(), "FFFFFF");
assert_eq!(d.fg.color(), Some(NAMED_COLORS[15].1));
// Right clamps back to the custom slot.
d.handle_event(&key(KeyCode::Right));
assert!(d.fg.is_custom());
}
#[test]
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,
fg: NAMED_COLORS[1].1,
bg: NAMED_COLORS[2].1,
};
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
2026-06-20 15:27:09 -05:00
assert!(d.focus == Focus::Grid);
d.handle_event(&key(KeyCode::Tab));
2026-06-20 16:21:04 -05:00
assert!(d.focus == Focus::FgStrip);
2026-06-20 15:27:09 -05:00
d.handle_event(&key(KeyCode::Tab));
2026-06-20 16:21:04 -05:00
assert!(d.focus == Focus::BgStrip); // FgField skipped (fg not custom)
2026-06-20 15:27:09 -05:00
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::Grid);
2026-06-20 16:21:04 -05:00
// With a custom fg, its field becomes a Tab stop.
let mut d2: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
d2.handle_event(&key(KeyCode::Tab)); // FgStrip
d2.handle_event(&key(KeyCode::Tab)); // FgField (fg is custom)
assert!(d2.focus == Focus::FgField);
}
#[test]
fn down_enters_custom_field_up_returns() {
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
d.handle_event(&key(KeyCode::Tab)); // FgStrip
d.handle_event(&key(KeyCode::Down)); // -> FgField (custom slot selected)
assert!(d.focus == Focus::FgField);
d.handle_event(&key(KeyCode::Up)); // back to strip
assert!(d.focus == Focus::FgStrip);
2026-06-20 15:27:09 -05:00
}
#[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]
2026-06-20 16:21:04 -05:00
fn invalid_custom_hex_disables_ok_and_blocks_enter() {
2026-06-20 15:27:09 -05:00
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
2026-06-20 16:21:04 -05:00
d.handle_event(&key(KeyCode::Tab)); // FgStrip
d.handle_event(&key(KeyCode::Down)); // FgField (custom)
d.handle_event(&key(KeyCode::Backspace)); // "FF000" — invalid
assert_eq!(d.fg.field.value(), "FF000");
2026-06-20 15:27:09 -05:00
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')));
2026-06-20 16:21:04 -05:00
assert_eq!(d.fg.field.value(), "FF0000");
2026-06-20 15:27:09 -05:00
assert!(d.ok_enabled());
}
#[test]
fn submit_yields_glyph_cancel_yields_none() {
2026-06-20 16:21:04 -05:00
// Submit returns the chosen glyph (custom colors preserved).
2026-06-20 15:27:09 -05:00
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
}
2026-06-20 15:53:15 -05:00
#[test]
fn render_is_buffer_only_and_reports_cursor() {
// The split lets us render headless into a plain Buffer (no Frame/TTY).
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
2026-06-20 16:21:04 -05:00
// Grid / strip focus → no terminal cursor.
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert_eq!(CursorOverlay::render(&d, area, &mut buf), None);
d.handle_event(&key(KeyCode::Tab)); // FgStrip — still no caret
2026-06-20 15:53:15 -05:00
assert_eq!(CursorOverlay::render(&d, area, &mut buf), None);
2026-06-20 16:21:04 -05:00
// Entering the custom hex field requests a caret inside the window.
d.handle_event(&key(KeyCode::Down)); // FgField
2026-06-20 15:53:15 -05:00
let pos =
2026-06-20 16:21:04 -05:00
CursorOverlay::render(&d, area, &mut buf).expect("a focused field wants a cursor");
2026-06-20 15:53:15 -05:00
assert!(pos.x < area.width && pos.y < area.height);
}
2026-06-20 15:27:09 -05:00
}