Extract kiln-ui crate

This commit is contained in:
2026-07-11 00:11:35 -05:00
parent ad95a9cd8d
commit cdeae455dc
17 changed files with 33 additions and 2380 deletions
+9 -3
View File
@@ -1,9 +1,11 @@
# kiln-tui
Module reference for the `kiln-tui` crate — a terminal **player + world
editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core`
and `kiln-ui`. See the repository-root `CLAUDE.md` for project-wide guidance,
code style, the world file format, and key design decisions.
editor** built on **ratatui 0.30 / crossterm**, depending on `kiln-core`
and the external, game-agnostic **`kiln-ui`** widget crate (a git dependency:
`Dialog`/`CodeEditor`/`TextField`, plus `render_overlay`/`dim_area`/`CursorOverlay`).
See the repository-root `CLAUDE.md` for project-wide guidance, code style, the
world file format, and key design decisions.
Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved. The app is one of two top-level [`Mode`]s — `Play` (live, ticking game) or `Edit` (a freshly reloaded, non-ticking world) — and the active mode plus a `Ui` value are threaded through the loop.
@@ -27,6 +29,10 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
**`kiln-tui/src/glyph_dialog.rs`** — the modal **glyph picker** (`GlyphDialog<Ctx>`):
- A modal picker for a `kiln_core::glyph::Glyph` (CP437 tile index + fg/bg colors), same API shape as `kiln_ui::dialog::Dialog`: `GlyphDialog::new(title, initial: Glyph, on_done)`, `handle_event(&Event) -> DialogResult`, `finish(result, &mut Ctx)`. A 32×8 char grid plus two `ColorPicker`s (a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches + a custom `#RRGGBB` `TextField` that is the source of truth). Draws via a `CursorOverlay` impl (host renders it through `kiln_ui::render_overlay`), rendering the grid with `kiln_core::cp437::tile_to_char`.
- **This is the one game-coupled UI widget.** It depends on `kiln-core` (`Glyph`/`Rgba8`, `NAMED_COLORS`, `tile_to_char`), so it lives here rather than in the game-agnostic external `kiln-ui` crate — it reuses `kiln-ui`'s public `DialogResult`, `TextField`, `CursorOverlay`, and `dim_area`. Seeded/read by the editor's `open_glyph_picker()` (bound to `g`); unit-tested with a dummy `Ctx`.
**`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`):
- `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation.
- `open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling.
+2 -1
View File
@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
kiln-core = { path = "../kiln-core" }
kiln-ui = { path = "../kiln-ui" }
# Reusable, game-agnostic ratatui widgets, now a standalone repo (tracks the default branch).
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
color = "0.3.3"
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
+1 -1
View File
@@ -10,6 +10,7 @@
use crate::editor_menu;
use crate::editor_menu::{MenuEntry, MenuLevel};
use crate::glyph_dialog::GlyphDialog;
use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui;
@@ -22,7 +23,6 @@ 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 ratatui::Frame;
use ratatui::crossterm::event::Event;
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
+752
View File
@@ -0,0 +1,752 @@
//! A modal **glyph picker** dialog: choose a [`Glyph`] (a CP437 tile index plus
//! 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.
//!
//! This mirrors the [`dialog`](kiln_ui::dialog) API — build a [`GlyphDialog`], route
//! events to [`handle_event`](GlyphDialog::handle_event), draw it via its
//! [`CursorOverlay`](kiln_ui::CursorOverlay) impl (see [`kiln_ui::render_overlay`]), and on
//! dismissal call [`finish`](GlyphDialog::finish), which fires a stored callback with
//! the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
//!
//! 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.
use color::Rgba8;
use kiln_core::colors::NAMED_COLORS;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use kiln_ui::dialog::DialogResult;
use kiln_ui::text_field::TextField;
use kiln_ui::{CursorOverlay, dim_area};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
/// Background color filling the dialog window (matches [`kiln_ui::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 control.
const LABEL_FOCUS_BG: Color = Color::Rgb(40, 60, 110);
/// 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);
/// 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;
/// 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));
}
}
}
/// 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 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,
}
/// 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 selector.
fg: ColorPicker,
/// Background color selector.
bg: ColorPicker,
/// 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`: 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`.
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: ColorPicker::new(initial.fg),
bg: ColorPicker::new(initial.bg),
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 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.
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 is routed to the focused control.
_ => {
match self.focus {
Focus::Grid => self.move_grid(key.code),
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),
}
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 is currently unresolved.
fn glyph(&self) -> Option<Glyph> {
Some(Glyph {
tile: self.tile,
fg: self.fg.color()?,
bg: self.bg.color()?,
})
}
/// Whether OK is currently allowed (both colors resolve).
fn ok_enabled(&self) -> bool {
self.fg.color().is_some() && self.bg.color().is_some()
}
/// Moves focus to the next (or previous) control, skipping a color's custom field
/// unless that color is on its custom slot.
fn cycle_focus(&mut self, forward: bool) -> DialogResult {
// 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();
let j = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
self.focus = stops[j];
DialogResult::Continue
}
/// 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() => {
self.focus = if is_fg {
Focus::FgField
} else {
Focus::BgField
};
}
_ => {}
}
}
/// 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) {
self.focus = if is_fg {
Focus::FgStrip
} else {
Focus::BgStrip
};
return;
}
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
picker.field.handle_key(key);
}
/// 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).
fn draw_grid(&self, buf: &mut Buffer, 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),
};
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 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 `+`.
fn draw_strip(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
picker: &ColorPicker,
focused: bool,
) {
draw_label(buf, area.x, area.y, label, focused);
let sx = area.x + 3;
let y = area.y;
// 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));
}
}
}
/// 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;
buf.set_string(
box_x,
y,
format!("{val:<width$}", width = w as usize),
style,
);
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
}
}
/// Draws the `[enter] OK ▓ [esc] Cancel` footer: OK (dimmed when disabled), a live
/// preview of the selected glyph in the chosen colors, then Cancel.
fn draw_footer(&self, buf: &mut Buffer, 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);
// 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),
};
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(" [esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
Paragraph::new(footer).render(area, buf);
}
}
impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
/// Draws the picker centered over `area`: dims the background, then renders the
/// 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.
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
// (8) + a gap + the two color strips (each a swatch row + a hex row) + footer.
let want_w = 40u16;
let want_h = 16u16;
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);
// Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer.
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);
// 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);
self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip);
let fg_cursor =
self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip);
let bg_cursor =
self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
self.draw_footer(buf, footer_area);
// At most one custom field is focused at a time.
fg_cursor.or(bg_cursor)
}
}
/// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't
/// needed; the caret position from [`CursorOverlay::render`] is discarded. Use
/// [`render_overlay`](kiln_ui::render_overlay) when the terminal cursor must be placed.
impl<Ctx> Widget for &GlyphDialog<Ctx> {
fn render(self, area: Rect, buf: &mut Buffer) {
CursorOverlay::render(self, area, buf);
}
}
/// 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) {
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};
/// A glyph with custom (non-named) colors for seeding tests.
fn sample() -> Glyph {
Glyph {
tile: 5,
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
}
}
/// Builds a key-press event with no modifiers.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn seed_custom_colors_go_to_custom_slot() {
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
assert_eq!(d.tile, 5);
assert!(d.fg.is_custom() && d.bg.is_custom());
assert_eq!(d.fg.field.value(), "FF0000");
assert_eq!(d.bg.field.value(), "0000FF");
assert!(d.ok_enabled());
}
#[test]
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() {
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
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, |_, _| {});
assert!(d.focus == Focus::Grid);
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::FgStrip);
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::BgStrip); // FgField skipped (fg not custom)
d.handle_event(&key(KeyCode::Tab));
assert!(d.focus == Focus::Grid);
// 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);
}
#[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_custom_hex_disables_ok_and_blocks_enter() {
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
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");
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.field.value(), "FF0000");
assert!(d.ok_enabled());
}
#[test]
fn submit_yields_glyph_cancel_yields_none() {
// Submit returns the chosen glyph (custom colors preserved).
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
}
#[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);
// 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
assert_eq!(CursorOverlay::render(&d, area, &mut buf), None);
// Entering the custom hex field requests a caret inside the window.
d.handle_event(&key(KeyCode::Down)); // FgField
let pos =
CursorOverlay::render(&d, area, &mut buf).expect("a focused field wants a cursor");
assert!(pos.x < area.width && pos.y < area.height);
}
}
+1
View File
@@ -10,6 +10,7 @@
mod animation;
mod editor;
mod editor_menu;
mod glyph_dialog;
mod input;
mod log;
mod menu;