better color editor
This commit is contained in:
@@ -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<Position>`; 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<Ctx>` — 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<Glyph>, &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<Position>`; 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<Ctx>` — 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<Glyph>, &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<Position>`; 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<String>`, 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)`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-17
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+324
-146
@@ -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<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 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<Ctx> {
|
||||
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<Ctx> {
|
||||
}
|
||||
|
||||
impl<Ctx> GlyphDialog<Ctx> {
|
||||
/// Builds a glyph picker seeded from `initial`. The tile selection starts at
|
||||
/// `initial.tile` and the two hex fields are pre-filled from `initial.fg`/`initial.bg`
|
||||
/// (6 uppercase hex digits). `on_done` receives `Some(glyph)` on OK or `None` on
|
||||
/// cancel, plus `&mut Ctx`.
|
||||
/// 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,
|
||||
@@ -84,8 +150,8 @@ impl<Ctx> GlyphDialog<Ctx> {
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
/// 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<Ctx> GlyphDialog<Ctx> {
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
(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<Glyph> {
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Position> {
|
||||
// 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<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)
|
||||
@@ -262,10 +397,16 @@ impl<Ctx> GlyphDialog<Ctx> {
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
|
||||
impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
||||
/// 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<Position> {
|
||||
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<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
||||
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<Ctx> Widget for &GlyphDialog<Ctx> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:<width$}", width = w as usize);
|
||||
buf.set_string(x, y, shown, style);
|
||||
}
|
||||
|
||||
/// Formats an [`Rgba8`] as 6 uppercase hex digits (no `#`); alpha is dropped.
|
||||
fn hex6(c: Rgba8) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", c.r, c.g, c.b)
|
||||
@@ -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<Option<Glyph>> = None;
|
||||
let mut d: GlyphDialog<Option<Option<Glyph>>> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user