diff --git a/CLAUDE.md b/CLAUDE.md index 1b7c057..d4723b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/cp437.rs`** — tile-index → character mapping (`pub`): - `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.). `tile_to_char(tile: u32) -> char` indexes it for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested. Lives in core (not a front-end) because it is the *meaning* of a tile index under the default font; used by kiln-tui's renderer (`render.rs`) and kiln-ui's glyph picker alike. +**`kiln-core/src/colors.rs`** — the named color palette (`pub`): +- `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift. + **`kiln-core/src/archetype.rs`** — element taxonomy: - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). @@ -152,12 +155,13 @@ Reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. A widget - `Dialog` draws via its `CursorOverlay` impl (`render(area, buf) -> Option`; host renders it through `render_overlay`) — dims the screen, draws a centered bordered box. A **text** dialog centers its field box (h+v) with a distinct `FIELD_BG` background so it reads as an input box; a **list** dialog puts the filter field atop the scrolling, selection-highlighted list. Both show an `[enter] OK [esc] Cancel` footer (OK dimmed when disabled). `render` returns the active field's caret position (the host places the real terminal cursor). Unit-tested (filtering/clamping, OK-enabled rule, Select/Create/Cancel/text outcomes, headless `render`) with a dummy `Ctx`. **`kiln-ui/src/glyph_dialog.rs`** — the glyph picker (the one kiln-core-dependent widget): -- `GlyphDialog` — a modal picker for a [`Glyph`] (CP437 tile index + fg/bg colors), same API shape as `Dialog`. Built via `GlyphDialog::new(title, initial: Glyph, on_done)`; `on_done: FnOnce(Option, &mut Ctx)` fires `Some(glyph)` on OK, `None` on cancel. Holds the selected `tile: u32`, two hex-color `TextField`s (`fg`/`bg`, 6 bare hex digits), and a `Focus` (`Grid`/`Fg`/`Bg`). -- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc`→`Cancel`; `Enter`→`Submit` only when both color fields parse (`parse_hex6`); `Tab`/`Shift-Tab`/`BackTab` cycle focus; when the grid is focused arrows move the selection in a 32×8 grid (clamped), otherwise keys edit the focused field. `finish(result, &mut Ctx)` fires the callback (a `Submit` with both colors valid yields `Some(Glyph{..})`). -- Drawing is its `CursorOverlay` impl (`render(area, buf) -> Option`; host renders it through `render_overlay`) — dims + centered bordered box; renders the 256-char grid via `kiln_core::cp437::tile_to_char` (each glyph drawn in the chosen fg/bg when both colors are valid, else gray-on-black; the selected cell is a bright cursor when the grid is focused, else reversed), the labeled `fg`/`bg` swatch fields (focused label's background highlighted; invalid field boxed red), and the standard footer. Returns the focused color field's caret position (or `None` when the grid is focused). Unit-tested (seed round-trip, focus cycling, grid clamp, invalid-hex disables OK, Submit/Cancel outcomes, headless `render`) with a dummy `Ctx`. +- `GlyphDialog` — a modal picker for a [`Glyph`] (CP437 tile index + fg/bg colors), same API shape as `Dialog`. Built via `GlyphDialog::new(title, initial: Glyph, on_done)`; `on_done: FnOnce(Option, &mut Ctx)` fires `Some(glyph)` on OK, `None` on cancel. Holds the selected `tile: u32`, two `ColorPicker`s (`fg`/`bg`), and a `Focus` (`Grid`/`FgStrip`/`FgField`/`BgStrip`/`BgField`). +- `ColorPicker` (private) — one color selector: a `selected` slot index over a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches plus a final **custom** slot (`CUSTOM == NAMED_COLORS.len()`), and a 6-hex `TextField` that is the **source of truth** (`color()` parses it). Picking a named swatch fills the field via `TextField::set`; the custom slot leaves it user-editable. Seeds from an `Rgba8` by matching a named color (else the custom slot). +- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc`→`Cancel`; `Enter`→`Submit` only when both colors resolve; `Tab`/`Shift-Tab`/`BackTab` cycle focus (skipping a color's `*Field` stop unless it's on the custom slot). On the grid, arrows move the 32×8 selection (clamped); 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. `finish(result, &mut Ctx)` fires the callback (a `Submit` with both colors valid yields `Some(Glyph{..})`). +- Drawing is its `CursorOverlay` impl (`render(area, buf) -> Option`; host renders it through `render_overlay`) — dims + centered bordered box; renders the 256-char grid via `kiln_core::cp437::tile_to_char` (chosen fg/bg when both colors resolve, else gray-on-black; selected cell is a bright cursor when the grid is focused, else reversed), then for each color a **swatch strip** (one solid cell per named color + the custom slot; selected slot marked `●`, custom slot otherwise `+`) above a **hex row** (an editable `#RRGGBB` box, red when invalid, when on the custom slot; else the dimmed name + hex of the selected named color), and the footer (which previews the chosen glyph). Returns the focused custom field's caret position (or `None`). Unit-tested (named/custom seeding, strip selection fills hex, focus cycling skips unreachable fields, Down/Up enters/leaves field, grid clamp, invalid-hex disables OK, Submit/Cancel, headless `render`) with a dummy `Ctx`. **`kiln-ui/src/text_field.rs`** — a single-line editable text field (a one-line sibling of `code_editor`, used by `dialog`'s text/filter inputs; replaced `tui-input`): -- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard`. `new(initial)`, `value()`, `display_cursor()` (caret's display column), and `handle_key(KeyEvent) -> bool` (`true` if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and `ctrl`/`⌘`+`v` paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads `value()`/`display_cursor()`). No selection yet. Unit-tested (typing, delete, movement, paste, wide-char cursor). +- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard` + an optional `max_length`. `new(initial)` (unbounded) or `sized(initial, max_length)` (truncates the seed and caps typed input); `value()`, `display_cursor()` (caret's display column), `set(text)` (replace contents, truncated to `max_length`), and `handle_key(KeyEvent) -> bool` (`true` if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and `ctrl`/`⌘`+`v` paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads `value()`/`display_cursor()`). No selection yet. Unit-tested (typing, delete, movement, paste, wide-char cursor, max-length cap, `set`). **`kiln-ui/src/code_editor.rs`** — a hand-written **modeless, keyboard-only** code editor (no editor crate; `edtui` was tried and dropped — it panicked on bare Shift via an `unimplemented!()` keycode arm and lacked basics like line-wrap): - `CodeEditor` — edits one script's text: `lines: Vec`, a char-indexed `cursor`/`anchor` (selection), `desired_col` (vertical-move column), `scroll` (top row + left display-col, cursor-following), undo/redo `Snapshot` stacks, a cached `HighlightParts`, and a `Clipboard` (system via `arboard` when available, always backed by an internal buffer so copy/paste work headless / on the web). Public API (unchanged across the edtui→custom swap, so kiln-tui needed no edits): `new(name, text)`, `name()`, `text()` (lines joined with `\n`), `handle_event(&Event) -> CodeEditorOutcome { Continue, Exit }`, `draw(&mut self, frame, area)`. diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 9c23b27..1dd6176 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -4,6 +4,8 @@ mod board; mod builtin_scripts; /// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font. pub mod cp437; +/// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor. +pub mod colors; /// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index fba068c..216d4f9 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -1021,23 +1021,11 @@ fn register_global_constants(engine: &mut Engine) { m.set_var("South", Direction::South); m.set_var("East", Direction::East); m.set_var("West", Direction::West); - // 16 EGA/VGA color constants as "#RRGGBB" strings. - m.set_var("Black", "#000000"); - m.set_var("Blue", "#0000AA"); - m.set_var("Green", "#00AA00"); - m.set_var("Cyan", "#00AAAA"); - m.set_var("Red", "#AA0000"); - m.set_var("Magenta", "#AA00AA"); - m.set_var("Brown", "#AA5500"); - m.set_var("LightGray", "#AAAAAA"); - m.set_var("DarkGray", "#555555"); - m.set_var("BrightBlue", "#5555FF"); - m.set_var("BrightGreen", "#55FF55"); - m.set_var("BrightCyan", "#55FFFF"); - m.set_var("BrightRed", "#FF5555"); - m.set_var("BrightMagenta", "#FF55FF"); - m.set_var("Yellow", "#FFFF55"); - m.set_var("White", "#FFFFFF"); + // 16 EGA/VGA color constants as "#RRGGBB" strings, from the shared palette table + // (the single source of truth, also used by the editor's color picker). + for (name, c) in crate::colors::NAMED_COLORS { + m.set_var(name, format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b)); + } engine.register_global_module(m.into()); } diff --git a/kiln-ui/src/glyph_dialog.rs b/kiln-ui/src/glyph_dialog.rs index 3dee6e9..553c877 100644 --- a/kiln-ui/src/glyph_dialog.rs +++ b/kiln-ui/src/glyph_dialog.rs @@ -1,5 +1,11 @@ //! 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. +//! 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`](crate::dialog) API — build a [`GlyphDialog`], route //! events to [`handle_event`](GlyphDialog::handle_event), draw it via its @@ -7,10 +13,11 @@ //! 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. +//! It is the first kiln-ui widget that depends on `kiln-core` (for [`Glyph`]/`Rgba8`, +//! the [`tile_to_char`] table, and the named colors); kiln-ui is otherwise game-agnostic. use color::Rgba8; +use kiln_core::colors::NAMED_COLORS; use kiln_core::cp437::tile_to_char; use kiln_core::glyph::Glyph; use ratatui::buffer::Buffer; @@ -28,8 +35,12 @@ use crate::{CursorOverlay, dim_area}; 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. +/// 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). @@ -40,15 +51,70 @@ 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 { + 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 hex field. - Fg, - /// The background-color hex field. - Bg, + /// 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, @@ -61,10 +127,10 @@ pub struct GlyphDialog { 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, + /// 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. @@ -72,10 +138,10 @@ pub struct GlyphDialog { } impl GlyphDialog { - /// 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`. + /// 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, initial: Glyph, @@ -84,8 +150,8 @@ impl GlyphDialog { Self { title: title.into(), tile: initial.tile, - fg: TextField::sized(hex6(initial.fg), 6usize), - bg: TextField::sized(hex6(initial.bg), 6usize), + fg: ColorPicker::new(initial.fg), + bg: ColorPicker::new(initial.bg), focus: Focus::Grid, callback: Box::new(on_done), } @@ -94,8 +160,9 @@ impl GlyphDialog { /// 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. + /// 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; @@ -114,16 +181,14 @@ impl GlyphDialog { 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. + // Everything else is routed to the focused control. _ => { match self.focus { Focus::Grid => self.move_grid(key.code), - Focus::Fg => { - self.fg.handle_key(*key); - } - Focus::Bg => { - self.bg.handle_key(*key); - } + 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 } @@ -142,35 +207,64 @@ impl GlyphDialog { (self.callback)(chosen, ctx); } - /// The chosen [`Glyph`], or `None` if either color field is currently invalid. + /// The chosen [`Glyph`], or `None` if either color is currently unresolved. fn glyph(&self) -> Option { - let fg = parse_hex6(self.fg.value())?; - let bg = parse_hex6(self.bg.value())?; Some(Glyph { tile: self.tile, - fg, - bg, + fg: self.fg.color()?, + bg: self.bg.color()?, }) } - /// Whether OK is currently allowed (both color fields parse as valid hex). + /// Whether OK is currently allowed (both colors resolve). fn ok_enabled(&self) -> bool { - parse_hex6(self.fg.value()).is_some() && parse_hex6(self.bg.value()).is_some() + self.fg.color().is_some() && self.bg.color().is_some() } - /// Moves focus to the next (or previous) sub-control, wrapping around. + /// 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 { - 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, - }; + // 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); @@ -218,43 +312,84 @@ impl GlyphDialog { } } - /// Draws the `fg`/`bg` labeled hex fields into `area`, centered. Returns the caret - /// position for the focused field (or `None` if the grid is focused) so the host - /// can place the terminal cursor. - fn draw_fields(&self, buf: &mut Buffer, area: Rect) -> Option { - // 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; + /// 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; - - 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; - - 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(Position::new( - fg_field_x + (self.fg.display_cursor() as u16).min(fg_w - 1), - y, - )), - Focus::Bg => Some(Position::new( - bg_field_x + (self.bg.display_cursor() as u16).min(bg_w - 1), - y, - )), - Focus::Grid => None, + // 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 the `[enter] OK [esc] Cancel` footer (OK dimmed when disabled). + /// 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 { + 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: GlyphDialog { 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("[esc] ", 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); @@ -274,16 +415,16 @@ impl GlyphDialog { impl CursorOverlay for GlyphDialog { /// Draws the picker centered over `area`: dims the background, then renders the - /// bordered window, the character grid, the two color fields, and the OK/Cancel - /// footer. Returns the focused color field's caret position (or `None` when the - /// grid is focused) for the host to place the terminal cursor. + /// 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 { dim_area(area, buf); // 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. + // (8) + a gap + the two color strips (each a swatch row + a hex row) + footer. let want_w = 40u16; - let want_h = 14u16; + 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 { @@ -302,24 +443,33 @@ impl CursorOverlay for GlyphDialog { let inner = block.inner(rect); block.render(rect, buf); - // 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())); + // 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); - let cursor = self.draw_fields(buf, fields_area); + + 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); - cursor + // At most one custom field is focused at a time. + fg_cursor.or(bg_cursor) } } @@ -332,8 +482,8 @@ impl Widget for &GlyphDialog { } } -/// 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) { +/// 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 { @@ -342,18 +492,6 @@ fn draw_label(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, text: &str, foc 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: String { format!("{:02X}{:02X}{:02X}", c.r, c.g, c.b) @@ -394,22 +532,12 @@ mod tests { use super::*; use ratatui::crossterm::event::{KeyEvent, KeyModifiers}; - /// A glyph with distinct, valid colors for seeding tests. + /// 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, - }, - bg: Rgba8 { - r: 0x00, - g: 0x00, - b: 0xFF, - a: 255, - }, + 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 } } @@ -419,27 +547,76 @@ mod tests { } #[test] - fn seed_round_trips_tile_and_colors() { + fn seed_custom_colors_go_to_custom_slot() { 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.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 tab_cycles_focus_both_ways() { + 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::Fg); + assert!(d.focus == Focus::FgStrip); d.handle_event(&key(KeyCode::Tab)); - assert!(d.focus == Focus::Bg); + assert!(d.focus == Focus::BgStrip); // FgField skipped (fg not custom) 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); + + // 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] @@ -462,12 +639,12 @@ mod tests { } #[test] - fn invalid_hex_disables_ok_and_blocks_enter() { + fn invalid_custom_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"); + 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)), @@ -475,13 +652,13 @@ mod tests { )); // Type the digit back → valid again. d.handle_event(&key(KeyCode::Char('0'))); - assert_eq!(d.fg.value(), "FF0000"); + assert_eq!(d.fg.field.value(), "FF0000"); assert!(d.ok_enabled()); } #[test] fn submit_yields_glyph_cancel_yields_none() { - // Submit returns the chosen glyph. + // Submit returns the chosen glyph (custom colors preserved). let mut out: Option> = None; let mut d: GlyphDialog>> = GlyphDialog::new("t", sample(), |g, c| *c = Some(g)); @@ -515,15 +692,16 @@ mod tests { let area = Rect::new(0, 0, 60, 25); let mut buf = Buffer::empty(area); - // Grid focused → no terminal cursor requested. - let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {}); + // 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); - // Focus a color field → a caret position inside the drawn window is returned. - let mut d2: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {}); - d2.handle_event(&key(KeyCode::Tab)); // focus fg + // Entering the custom hex field requests a caret inside the window. + d.handle_event(&key(KeyCode::Down)); // FgField let pos = - CursorOverlay::render(&d2, area, &mut buf).expect("a focused field wants a cursor"); + CursorOverlay::render(&d, area, &mut buf).expect("a focused field wants a cursor"); assert!(pos.x < area.width && pos.y < area.height); } } diff --git a/kiln-ui/src/text_field.rs b/kiln-ui/src/text_field.rs index 6d3a1c5..755aab9 100644 --- a/kiln-ui/src/text_field.rs +++ b/kiln-ui/src/text_field.rs @@ -42,6 +42,18 @@ impl TextField { &self.text } + /// Replaces the entire text (truncated to `max_length` if set), moving the cursor + /// to the end. Used to overwrite the field programmatically — e.g. when a color + /// picker selects a named swatch and fills in its hex. + pub fn set(&mut self, text: impl Into) { + let mut text = text.into(); + if let Some(max) = self.max_length { + text.truncate(max); + } + self.cursor = text.chars().count(); + self.text = text; + } + /// The cursor's display column (terminal cells from the start), for placing the caret. pub fn display_cursor(&self) -> usize { display_width(&self.text, self.cursor) @@ -211,6 +223,21 @@ mod tests { assert_eq!(u.value(), "abcdef"); } + #[test] + fn set_replaces_text_and_respects_max_length() { + let mut f = TextField::sized("FF0000", 6); + f.set("00AA00"); + assert_eq!(f.value(), "00AA00"); + assert_eq!(f.display_cursor(), 6); // cursor at end + // Overlong replacements are truncated to the cap. + f.set("ABCDEFGH"); + assert_eq!(f.value(), "ABCDEF"); + // An unsized field accepts any length. + let mut u = TextField::new("x"); + u.set("anything goes"); + assert_eq!(u.value(), "anything goes"); + } + #[test] fn unhandled_keys_return_false() { let mut f = TextField::new("x");