2026-06-20 15:27:09 -05:00
|
|
|
|
//! A modal **glyph picker** dialog: choose a [`Glyph`] (a CP437 tile index plus
|
2026-06-20 16:21:04 -05:00
|
|
|
|
//! foreground/background colors) from a 32×8 character grid and two color pickers.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Each color is chosen from a horizontal strip of the 16 EGA/VGA
|
|
|
|
|
|
//! [named colors](kiln_core::colors::NAMED_COLORS) followed by a **custom** slot; when
|
|
|
|
|
|
//! the custom slot is selected its hex field below becomes editable (focus it by
|
|
|
|
|
|
//! pressing Down/Tab on the strip). The hex value is the source of truth — picking a
|
|
|
|
|
|
//! named swatch fills it in.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
//!
|
|
|
|
|
|
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route
|
2026-06-20 15:53:15 -05:00
|
|
|
|
//! events to [`handle_event`](GlyphDialog::handle_event), draw it via its
|
|
|
|
|
|
//! [`CursorOverlay`](crate::CursorOverlay) impl (see [`crate::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`.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
//!
|
2026-06-20 16:21:04 -05:00
|
|
|
|
//! 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.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
|
|
|
|
|
|
use color::Rgba8;
|
2026-06-20 16:21:04 -05:00
|
|
|
|
use kiln_core::colors::NAMED_COLORS;
|
2026-06-20 15:27:09 -05:00
|
|
|
|
use kiln_core::cp437::tile_to_char;
|
|
|
|
|
|
use kiln_core::glyph::Glyph;
|
2026-06-20 15:53:15 -05:00
|
|
|
|
use ratatui::buffer::Buffer;
|
2026-06-20 15:27:09 -05:00
|
|
|
|
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
|
2026-06-20 15:53:15 -05:00
|
|
|
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
2026-06-20 15:27:09 -05:00
|
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
|
|
use ratatui::text::{Line, Span};
|
2026-06-20 15:53:15 -05:00
|
|
|
|
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
2026-06-20 15:27:09 -05:00
|
|
|
|
|
|
|
|
|
|
use crate::dialog::DialogResult;
|
|
|
|
|
|
use crate::text_field::TextField;
|
2026-06-20 15:53:15 -05:00
|
|
|
|
use crate::{CursorOverlay, dim_area};
|
2026-06-20 15:27:09 -05:00
|
|
|
|
|
|
|
|
|
|
/// Background color filling the dialog window (matches [`crate::dialog`]).
|
|
|
|
|
|
const BG: Color = Color::Rgb(10, 15, 35);
|
|
|
|
|
|
/// Border color for the dialog window.
|
|
|
|
|
|
const BORDER: Color = Color::Rgb(80, 130, 210);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Background highlight applied to the *label* of the currently focused control.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
const LABEL_FOCUS_BG: Color = Color::Rgb(40, 60, 110);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Background of an editable hex field (so it reads as an input box).
|
|
|
|
|
|
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
|
|
|
|
|
|
/// Background of an invalid hex field.
|
|
|
|
|
|
const INVALID_BG: Color = Color::Rgb(150, 30, 30);
|
2026-06-20 15:27:09 -05:00
|
|
|
|
/// Color for the bracketed key hints in the footer.
|
|
|
|
|
|
const KEY: Color = Color::Rgb(150, 200, 255);
|
|
|
|
|
|
/// Background of the selected grid cell while the grid is focused (a bright cursor).
|
|
|
|
|
|
const CURSOR_BG: Color = Color::Rgb(255, 215, 0);
|
|
|
|
|
|
|
|
|
|
|
|
/// Number of columns in the character grid (32 cols × 8 rows = 256 tiles).
|
|
|
|
|
|
const GRID_COLS: u32 = 32;
|
|
|
|
|
|
/// Number of rows in the character grid.
|
|
|
|
|
|
const GRID_ROWS: u32 = 8;
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// The strip slot index that means "custom color" (one past the named colors).
|
|
|
|
|
|
const CUSTOM: usize = NAMED_COLORS.len();
|
|
|
|
|
|
|
|
|
|
|
|
/// A single fg/bg color selector: a strip of the named swatches plus a custom slot,
|
|
|
|
|
|
/// with an editable hex field used when the custom slot is selected.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The hex `field` is the source of truth — [`color`](ColorPicker::color) parses it,
|
|
|
|
|
|
/// and selecting a named swatch fills it with that color's hex.
|
|
|
|
|
|
struct ColorPicker {
|
|
|
|
|
|
/// Selected slot: `0..NAMED_COLORS.len()` is a named color, [`CUSTOM`] is custom.
|
|
|
|
|
|
selected: usize,
|
|
|
|
|
|
/// Hex value (6 bare digits, no `#`). Holds the selected named color's hex, or the
|
|
|
|
|
|
/// user's entry while `selected == CUSTOM`.
|
|
|
|
|
|
field: TextField,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ColorPicker {
|
|
|
|
|
|
/// Builds a picker seeded from `initial`: selects the matching named swatch if one
|
|
|
|
|
|
/// exists, otherwise the custom slot, with the hex field pre-filled either way.
|
|
|
|
|
|
fn new(initial: Rgba8) -> Self {
|
|
|
|
|
|
let selected = NAMED_COLORS
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|(_, c)| *c == initial)
|
|
|
|
|
|
.unwrap_or(CUSTOM);
|
|
|
|
|
|
Self {
|
|
|
|
|
|
selected,
|
|
|
|
|
|
field: TextField::sized(hex6(initial), 6),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether the custom slot is selected (its hex field is then editable).
|
|
|
|
|
|
fn is_custom(&self) -> bool {
|
|
|
|
|
|
self.selected == CUSTOM
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The resolved color, or `None` if the (custom) hex is currently invalid.
|
|
|
|
|
|
fn color(&self) -> Option<Rgba8> {
|
|
|
|
|
|
parse_hex6(self.field.value())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Moves the strip selection by `delta`, clamped to `0..=CUSTOM`. Landing on a named
|
|
|
|
|
|
/// swatch fills the hex field with that color (keeping the hex the source of truth).
|
|
|
|
|
|
fn move_select(&mut self, delta: i32) {
|
|
|
|
|
|
let next = (self.selected as i32 + delta).clamp(0, CUSTOM as i32) as usize;
|
|
|
|
|
|
self.selected = next;
|
|
|
|
|
|
if next != CUSTOM {
|
|
|
|
|
|
self.field.set(hex6(NAMED_COLORS[next].1));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 15:27:09 -05:00
|
|
|
|
/// Which sub-control of the picker currently has keyboard focus.
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
enum Focus {
|
|
|
|
|
|
/// The 32×8 character grid (arrow keys move the selection).
|
|
|
|
|
|
Grid,
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// The foreground color strip (Left/Right select; Down enters its custom field).
|
|
|
|
|
|
FgStrip,
|
|
|
|
|
|
/// The foreground custom hex field (only reachable when fg is on the custom slot).
|
|
|
|
|
|
FgField,
|
|
|
|
|
|
/// The background color strip.
|
|
|
|
|
|
BgStrip,
|
|
|
|
|
|
/// The background custom hex field.
|
|
|
|
|
|
BgField,
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Boxed callback fired once when the picker is dismissed: `Some(glyph)` on OK,
|
|
|
|
|
|
/// `None` on cancel.
|
|
|
|
|
|
type GlyphCallback<Ctx> = Box<dyn FnOnce(Option<Glyph>, &mut Ctx)>;
|
|
|
|
|
|
|
|
|
|
|
|
/// An open glyph-picker dialog, generic over the host context `Ctx` its callback mutates.
|
|
|
|
|
|
pub struct GlyphDialog<Ctx> {
|
|
|
|
|
|
/// Title shown in the window's top border.
|
|
|
|
|
|
title: String,
|
|
|
|
|
|
/// The currently selected tile index (`0..256`).
|
|
|
|
|
|
tile: u32,
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Foreground color selector.
|
|
|
|
|
|
fg: ColorPicker,
|
|
|
|
|
|
/// Background color selector.
|
|
|
|
|
|
bg: ColorPicker,
|
2026-06-20 15:27:09 -05:00
|
|
|
|
/// Which sub-control currently has focus.
|
|
|
|
|
|
focus: Focus,
|
|
|
|
|
|
/// The callback fired exactly once on dismissal.
|
|
|
|
|
|
callback: GlyphCallback<Ctx>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl<Ctx> GlyphDialog<Ctx> {
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Builds a glyph picker seeded from `initial`: tile selection starts at
|
|
|
|
|
|
/// `initial.tile`, and each color picker is seeded from `initial.fg`/`initial.bg`
|
|
|
|
|
|
/// (a matching named swatch if any, else the custom slot). `on_done` receives
|
|
|
|
|
|
/// `Some(glyph)` on OK or `None` on cancel, plus `&mut Ctx`.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
pub fn new(
|
|
|
|
|
|
title: impl Into<String>,
|
|
|
|
|
|
initial: Glyph,
|
|
|
|
|
|
on_done: impl FnOnce(Option<Glyph>, &mut Ctx) + 'static,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
title: title.into(),
|
|
|
|
|
|
tile: initial.tile,
|
2026-06-20 16:21:04 -05:00
|
|
|
|
fg: ColorPicker::new(initial.fg),
|
|
|
|
|
|
bg: ColorPicker::new(initial.bg),
|
2026-06-20 15:27:09 -05:00
|
|
|
|
focus: Focus::Grid,
|
|
|
|
|
|
callback: Box::new(on_done),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Updates the picker for an input event and reports whether it should close.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// both colors resolve; `Tab`/`Shift-Tab` cycle focus. On the grid, arrows move the
|
|
|
|
|
|
/// selection; on a strip, Left/Right pick a swatch and Down enters the custom field;
|
|
|
|
|
|
/// in a custom field, keys edit it and Up returns to the strip.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
|
|
|
|
|
|
let Event::Key(key) = event else {
|
|
|
|
|
|
return DialogResult::Continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
|
|
|
|
match key.code {
|
|
|
|
|
|
KeyCode::Esc => DialogResult::Cancel,
|
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
|
if self.ok_enabled() {
|
|
|
|
|
|
DialogResult::Submit
|
|
|
|
|
|
} else {
|
|
|
|
|
|
DialogResult::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Tab cycles focus forward; Shift-Tab (or BackTab) cycles backward.
|
|
|
|
|
|
KeyCode::Tab if shift => self.cycle_focus(false),
|
|
|
|
|
|
KeyCode::Tab => self.cycle_focus(true),
|
|
|
|
|
|
KeyCode::BackTab => self.cycle_focus(false),
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Everything else is routed to the focused control.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
_ => {
|
|
|
|
|
|
match self.focus {
|
|
|
|
|
|
Focus::Grid => self.move_grid(key.code),
|
2026-06-20 16:21:04 -05:00
|
|
|
|
Focus::FgStrip => self.handle_strip(key.code, true),
|
|
|
|
|
|
Focus::BgStrip => self.handle_strip(key.code, false),
|
|
|
|
|
|
Focus::FgField => self.handle_field(*key, true),
|
|
|
|
|
|
Focus::BgField => self.handle_field(*key, false),
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
DialogResult::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Consumes the dialog and fires its callback. `result` must be the
|
|
|
|
|
|
/// [`DialogResult`] just returned by [`handle_event`](GlyphDialog::handle_event);
|
|
|
|
|
|
/// only [`DialogResult::Submit`] with both colors valid yields `Some(glyph)`.
|
|
|
|
|
|
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
|
|
|
|
|
|
let chosen = if matches!(result, DialogResult::Submit) {
|
|
|
|
|
|
self.glyph()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
(self.callback)(chosen, ctx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// The chosen [`Glyph`], or `None` if either color is currently unresolved.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
fn glyph(&self) -> Option<Glyph> {
|
|
|
|
|
|
Some(Glyph {
|
|
|
|
|
|
tile: self.tile,
|
2026-06-20 16:21:04 -05:00
|
|
|
|
fg: self.fg.color()?,
|
|
|
|
|
|
bg: self.bg.color()?,
|
2026-06-20 15:27:09 -05:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Whether OK is currently allowed (both colors resolve).
|
2026-06-20 15:27:09 -05:00
|
|
|
|
fn ok_enabled(&self) -> bool {
|
2026-06-20 16:21:04 -05:00
|
|
|
|
self.fg.color().is_some() && self.bg.color().is_some()
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Moves focus to the next (or previous) control, skipping a color's custom field
|
|
|
|
|
|
/// unless that color is on its custom slot.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
fn cycle_focus(&mut self, forward: bool) -> DialogResult {
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Build the live list of focus stops (custom fields only when reachable).
|
|
|
|
|
|
let mut stops = vec![Focus::Grid, Focus::FgStrip];
|
|
|
|
|
|
if self.fg.is_custom() {
|
|
|
|
|
|
stops.push(Focus::FgField);
|
|
|
|
|
|
}
|
|
|
|
|
|
stops.push(Focus::BgStrip);
|
|
|
|
|
|
if self.bg.is_custom() {
|
|
|
|
|
|
stops.push(Focus::BgField);
|
|
|
|
|
|
}
|
|
|
|
|
|
let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0);
|
|
|
|
|
|
let n = stops.len();
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let j = if forward {
|
|
|
|
|
|
(i + 1) % n
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(i + n - 1) % n
|
|
|
|
|
|
};
|
2026-06-20 16:21:04 -05:00
|
|
|
|
self.focus = stops[j];
|
2026-06-20 15:27:09 -05:00
|
|
|
|
DialogResult::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Handles a key on a color strip: Left/Right pick a swatch; Down enters the custom
|
|
|
|
|
|
/// field when the custom slot is selected.
|
|
|
|
|
|
fn handle_strip(&mut self, code: KeyCode, is_fg: bool) {
|
|
|
|
|
|
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
|
|
|
|
|
|
match code {
|
|
|
|
|
|
KeyCode::Left => picker.move_select(-1),
|
|
|
|
|
|
KeyCode::Right => picker.move_select(1),
|
|
|
|
|
|
KeyCode::Down if picker.is_custom() => {
|
2026-06-21 01:32:47 -05:00
|
|
|
|
self.focus = if is_fg {
|
|
|
|
|
|
Focus::FgField
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Focus::BgField
|
|
|
|
|
|
};
|
2026-06-20 16:21:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Handles a key in a custom hex field: Up returns to the strip; everything else
|
|
|
|
|
|
/// edits the field.
|
|
|
|
|
|
fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) {
|
|
|
|
|
|
if matches!(key.code, KeyCode::Up) {
|
2026-06-21 01:32:47 -05:00
|
|
|
|
self.focus = if is_fg {
|
|
|
|
|
|
Focus::FgStrip
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Focus::BgStrip
|
|
|
|
|
|
};
|
2026-06-20 16:21:04 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
|
|
|
|
|
|
picker.field.handle_key(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 15:27:09 -05:00
|
|
|
|
/// Moves the grid selection one cell for an arrow key, clamped at the edges.
|
|
|
|
|
|
fn move_grid(&mut self, code: KeyCode) {
|
|
|
|
|
|
let (col, row) = (self.tile % GRID_COLS, self.tile / GRID_COLS);
|
|
|
|
|
|
let (col, row) = match code {
|
|
|
|
|
|
KeyCode::Left => (col.saturating_sub(1), row),
|
|
|
|
|
|
KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row),
|
|
|
|
|
|
KeyCode::Up => (col, row.saturating_sub(1)),
|
|
|
|
|
|
KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)),
|
|
|
|
|
|
_ => (col, row),
|
|
|
|
|
|
};
|
|
|
|
|
|
self.tile = row * GRID_COLS + col;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Draws the 32×8 character grid into `area`, centered horizontally. When `colors`
|
|
|
|
|
|
/// is `Some((fg, bg))` each glyph is drawn in those colors; otherwise gray-on-black.
|
|
|
|
|
|
/// The selected cell is shown reversed (a bright cursor when the grid is focused).
|
2026-06-20 15:53:15 -05:00
|
|
|
|
fn draw_grid(&self, buf: &mut Buffer, area: Rect, colors: Option<(Rgba8, Rgba8)>) {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
// Center the fixed-width grid within whatever inner width we got.
|
|
|
|
|
|
let grid_x = area.x + area.width.saturating_sub(GRID_COLS as u16) / 2;
|
|
|
|
|
|
let base = match colors {
|
|
|
|
|
|
Some((fg, bg)) => Style::default().fg(to_color(fg)).bg(to_color(bg)),
|
|
|
|
|
|
None => Style::default().fg(Color::Gray).bg(Color::Black),
|
|
|
|
|
|
};
|
|
|
|
|
|
for tile in 0..(GRID_COLS * GRID_ROWS) {
|
|
|
|
|
|
let x = grid_x + (tile % GRID_COLS) as u16;
|
|
|
|
|
|
let y = area.y + (tile / GRID_COLS) as u16;
|
|
|
|
|
|
// Skip cells that would fall outside the (possibly clamped) grid area.
|
|
|
|
|
|
if x >= area.right() || y >= area.bottom() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let style = if tile == self.tile {
|
|
|
|
|
|
if self.focus == Focus::Grid {
|
|
|
|
|
|
// Bright cursor so the selection reads even against the colored grid.
|
|
|
|
|
|
Style::default().fg(Color::Black).bg(CURSOR_BG)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
base.add_modifier(Modifier::REVERSED)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
base
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
|
|
|
|
cell.set_symbol(&tile_to_char(tile).to_string());
|
|
|
|
|
|
cell.set_style(style);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Draws a color strip into `area`: a focusable label, then a solid swatch per named
|
|
|
|
|
|
/// color plus a final custom slot. The selected slot is marked with `●`; the custom
|
|
|
|
|
|
/// slot is otherwise marked `+`.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
fn draw_strip(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
buf: &mut Buffer,
|
|
|
|
|
|
area: Rect,
|
|
|
|
|
|
label: &str,
|
|
|
|
|
|
picker: &ColorPicker,
|
|
|
|
|
|
focused: bool,
|
|
|
|
|
|
) {
|
2026-06-20 16:21:04 -05:00
|
|
|
|
draw_label(buf, area.x, area.y, label, focused);
|
|
|
|
|
|
let sx = area.x + 3;
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let y = area.y;
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Iterate the named slots *and* one extra (the custom slot, == CUSTOM), so a
|
|
|
|
|
|
// plain range over slot indices is clearer than enumerating the array.
|
|
|
|
|
|
#[allow(clippy::needless_range_loop)]
|
|
|
|
|
|
for slot in 0..=CUSTOM {
|
|
|
|
|
|
let x = sx + slot as u16;
|
|
|
|
|
|
if x >= area.right() {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
// A named slot shows its fixed color; the custom slot shows the current
|
|
|
|
|
|
// custom color (a dark placeholder when its hex is invalid).
|
|
|
|
|
|
let slot_rgba = if slot < NAMED_COLORS.len() {
|
|
|
|
|
|
Some(NAMED_COLORS[slot].1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
picker.color()
|
|
|
|
|
|
};
|
|
|
|
|
|
let bg = slot_rgba.map(to_color).unwrap_or(Color::Rgb(40, 40, 40));
|
|
|
|
|
|
let marker_fg = slot_rgba.map(contrast).unwrap_or(Color::White);
|
|
|
|
|
|
let symbol = if slot == picker.selected {
|
|
|
|
|
|
"●" // current selection
|
|
|
|
|
|
} else if slot == CUSTOM {
|
|
|
|
|
|
"+" // denotes the custom slot
|
|
|
|
|
|
} else {
|
|
|
|
|
|
" "
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
|
|
|
|
cell.set_symbol(symbol);
|
|
|
|
|
|
cell.set_style(Style::default().fg(marker_fg).bg(bg));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-20 15:27:09 -05:00
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Draws a color's hex row into `area`. When the custom slot is selected this is an
|
|
|
|
|
|
/// editable `#RRGGBB` input box (red when invalid); otherwise it shows the selected
|
|
|
|
|
|
/// named color's name + hex, dimmed. Returns the caret position when the field is the
|
|
|
|
|
|
/// focused custom field.
|
|
|
|
|
|
fn draw_color_field(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
buf: &mut Buffer,
|
|
|
|
|
|
area: Rect,
|
|
|
|
|
|
picker: &ColorPicker,
|
|
|
|
|
|
focused: bool,
|
|
|
|
|
|
) -> Option<Position> {
|
|
|
|
|
|
let x = area.x + 3;
|
|
|
|
|
|
let y = area.y;
|
|
|
|
|
|
if picker.is_custom() {
|
|
|
|
|
|
// "#" prefix then a (≥6-wide) hex input box.
|
|
|
|
|
|
buf.set_string(x, y, "#", Style::default().fg(Color::Gray).bg(BG));
|
|
|
|
|
|
let val = picker.field.value();
|
|
|
|
|
|
let w = val.chars().count().max(6) as u16;
|
|
|
|
|
|
let style = if parse_hex6(val).is_some() {
|
|
|
|
|
|
Style::default().fg(Color::White).bg(FIELD_BG)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Color::White).bg(INVALID_BG)
|
|
|
|
|
|
};
|
|
|
|
|
|
let box_x = x + 1;
|
2026-06-21 01:32:47 -05:00
|
|
|
|
buf.set_string(
|
|
|
|
|
|
box_x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
format!("{val:<width$}", width = w as usize),
|
|
|
|
|
|
style,
|
|
|
|
|
|
);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
focused.then(|| {
|
|
|
|
|
|
let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1));
|
|
|
|
|
|
Position::new(cur, y)
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Named slot: show the name + hex, read-only/dimmed.
|
|
|
|
|
|
let (name, c) = NAMED_COLORS[picker.selected];
|
|
|
|
|
|
let text = format!("= {name} #{}", hex6(c));
|
|
|
|
|
|
buf.set_string(x, y, text, Style::default().fg(Color::DarkGray).bg(BG));
|
|
|
|
|
|
None
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Draws the `[enter] OK ▓ [esc] Cancel` footer: OK (dimmed when disabled), a live
|
|
|
|
|
|
/// preview of the selected glyph in the chosen colors, then Cancel.
|
2026-06-20 15:53:15 -05:00
|
|
|
|
fn draw_footer(&self, buf: &mut Buffer, area: Rect) {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let ok_style = if self.ok_enabled() {
|
|
|
|
|
|
Style::default().fg(Color::White).bg(BG)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Color::DarkGray).bg(BG)
|
|
|
|
|
|
};
|
|
|
|
|
|
let key_style = Style::default().fg(KEY).bg(BG);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Preview the chosen glyph in its fg/bg when both colors resolve, else gray-on-black.
|
|
|
|
|
|
let preview_style = match self.fg.color().zip(self.bg.color()) {
|
|
|
|
|
|
Some((fg, bg)) => Style::default().fg(to_color(fg)).bg(to_color(bg)),
|
|
|
|
|
|
None => Style::default().fg(Color::Gray).bg(Color::Black),
|
|
|
|
|
|
};
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let footer = Line::from(vec![
|
|
|
|
|
|
Span::styled("[enter] ", key_style),
|
2026-06-20 16:21:04 -05:00
|
|
|
|
Span::styled("OK ", ok_style),
|
|
|
|
|
|
Span::styled(tile_to_char(self.tile).to_string(), preview_style),
|
|
|
|
|
|
Span::styled(" [esc] ", key_style),
|
2026-06-20 15:27:09 -05:00
|
|
|
|
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
|
|
|
|
|
|
]);
|
2026-06-20 15:53:15 -05:00
|
|
|
|
Paragraph::new(footer).render(area, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
|
|
|
|
|
/// Draws the picker centered over `area`: dims the background, then renders the
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// bordered window, the character grid, the fg/bg color strips + hex rows, and the
|
|
|
|
|
|
/// footer. Returns the focused custom field's caret position (or `None`) for the host
|
|
|
|
|
|
/// to place the terminal cursor.
|
2026-06-20 15:53:15 -05:00
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position> {
|
|
|
|
|
|
dim_area(area, buf);
|
|
|
|
|
|
|
|
|
|
|
|
// Size the window: wide enough for the 32-cell grid, tall enough for the grid
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// (8) + a gap + the two color strips (each a swatch row + a hex row) + footer.
|
2026-06-20 15:53:15 -05:00
|
|
|
|
let want_w = 40u16;
|
2026-06-20 16:21:04 -05:00
|
|
|
|
let want_h = 16u16;
|
2026-06-20 15:53:15 -05:00
|
|
|
|
let w = want_w.min(area.width.saturating_sub(2)).max(8);
|
|
|
|
|
|
let h = want_h.min(area.height.saturating_sub(2)).max(5);
|
|
|
|
|
|
let rect = Rect {
|
|
|
|
|
|
x: area.x + area.width.saturating_sub(w) / 2,
|
|
|
|
|
|
y: area.y + area.height.saturating_sub(h) / 2,
|
|
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Window frame: clear underlying content, then a bordered titled block.
|
|
|
|
|
|
Clear.render(rect, buf);
|
|
|
|
|
|
let block = Block::bordered()
|
|
|
|
|
|
.title(format!(" {} ", self.title))
|
|
|
|
|
|
.border_style(Style::default().fg(BORDER).bg(BG))
|
|
|
|
|
|
.style(Style::default().bg(BG));
|
|
|
|
|
|
let inner = block.inner(rect);
|
|
|
|
|
|
block.render(rect, buf);
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let [
|
|
|
|
|
|
grid_area,
|
|
|
|
|
|
_gap,
|
|
|
|
|
|
fg_strip,
|
|
|
|
|
|
fg_field,
|
|
|
|
|
|
bg_strip,
|
|
|
|
|
|
bg_field,
|
|
|
|
|
|
_filler,
|
|
|
|
|
|
footer_area,
|
|
|
|
|
|
] = Layout::vertical([
|
|
|
|
|
|
Constraint::Length(GRID_ROWS as u16),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
Constraint::Min(0),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
])
|
|
|
|
|
|
.areas(inner);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
|
|
|
|
|
|
// Both colors must resolve for the grid to preview them; otherwise gray-on-black.
|
|
|
|
|
|
let colors = self.fg.color().zip(self.bg.color());
|
|
|
|
|
|
self.draw_grid(buf, grid_area, colors);
|
2026-06-20 15:53:15 -05:00
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let fg_cursor =
|
|
|
|
|
|
self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let bg_cursor =
|
|
|
|
|
|
self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
|
2026-06-20 15:53:15 -05:00
|
|
|
|
|
|
|
|
|
|
self.draw_footer(buf, footer_area);
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// At most one custom field is focused at a time.
|
|
|
|
|
|
fg_cursor.or(bg_cursor)
|
2026-06-20 15:53:15 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't
|
|
|
|
|
|
/// needed; the caret position from [`CursorOverlay::render`] is discarded. Use
|
|
|
|
|
|
/// [`render_overlay`](crate::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);
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// Draws a control label, highlighting its background when the control is focused.
|
|
|
|
|
|
fn draw_label(buf: &mut Buffer, x: u16, y: u16, text: &str, focused: bool) {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let style = if focused {
|
|
|
|
|
|
Style::default().fg(Color::White).bg(LABEL_FOCUS_BG)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Color::Gray).bg(BG)
|
|
|
|
|
|
};
|
|
|
|
|
|
buf.set_string(x, y, text, style);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Formats an [`Rgba8`] as 6 uppercase hex digits (no `#`); alpha is dropped.
|
|
|
|
|
|
fn hex6(c: Rgba8) -> String {
|
|
|
|
|
|
format!("{:02X}{:02X}{:02X}", c.r, c.g, c.b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Parses exactly 6 hex digits (no `#`) into an opaque [`Rgba8`], or `None` if the
|
|
|
|
|
|
/// string is not exactly six hex characters.
|
|
|
|
|
|
fn parse_hex6(s: &str) -> Option<Rgba8> {
|
|
|
|
|
|
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Rgba8 {
|
|
|
|
|
|
r: u8::from_str_radix(&s[0..2], 16).ok()?,
|
|
|
|
|
|
g: u8::from_str_radix(&s[2..4], 16).ok()?,
|
|
|
|
|
|
b: u8::from_str_radix(&s[4..6], 16).ok()?,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Converts a core [`Rgba8`] to a ratatui [`Color`] (alpha dropped).
|
|
|
|
|
|
fn to_color(c: Rgba8) -> Color {
|
|
|
|
|
|
Color::Rgb(c.r, c.g, c.b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Picks black or white for legible text on a `c` background, by luminance.
|
|
|
|
|
|
fn contrast(c: Rgba8) -> Color {
|
|
|
|
|
|
// Rec. 601 luma; > ~50% brightness reads better with black text.
|
|
|
|
|
|
let luma = 0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32;
|
|
|
|
|
|
if luma > 128.0 {
|
|
|
|
|
|
Color::Black
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Color::White
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
/// A glyph with custom (non-named) colors for seeding tests.
|
2026-06-20 15:27:09 -05:00
|
|
|
|
fn sample() -> Glyph {
|
|
|
|
|
|
Glyph {
|
|
|
|
|
|
tile: 5,
|
2026-06-21 01:32:47 -05:00
|
|
|
|
fg: Rgba8 {
|
|
|
|
|
|
r: 0xFF,
|
|
|
|
|
|
g: 0x00,
|
|
|
|
|
|
b: 0x00,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
}, // not a named color
|
|
|
|
|
|
bg: Rgba8 {
|
|
|
|
|
|
r: 0x00,
|
|
|
|
|
|
g: 0x00,
|
|
|
|
|
|
b: 0xFF,
|
|
|
|
|
|
a: 255,
|
|
|
|
|
|
}, // not a named color
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds a key-press event with no modifiers.
|
|
|
|
|
|
fn key(code: KeyCode) -> Event {
|
|
|
|
|
|
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-20 16:21:04 -05:00
|
|
|
|
fn seed_custom_colors_go_to_custom_slot() {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
|
|
|
|
|
assert_eq!(d.tile, 5);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
assert!(d.fg.is_custom() && d.bg.is_custom());
|
|
|
|
|
|
assert_eq!(d.fg.field.value(), "FF0000");
|
|
|
|
|
|
assert_eq!(d.bg.field.value(), "0000FF");
|
2026-06-20 15:27:09 -05:00
|
|
|
|
assert!(d.ok_enabled());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-20 16:21:04 -05:00
|
|
|
|
fn seed_matching_named_color_selects_its_swatch() {
|
|
|
|
|
|
let g = Glyph {
|
|
|
|
|
|
tile: 1,
|
|
|
|
|
|
fg: NAMED_COLORS[4].1, // Red
|
|
|
|
|
|
bg: NAMED_COLORS[0].1, // Black
|
|
|
|
|
|
};
|
|
|
|
|
|
let d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
|
|
|
|
|
|
assert_eq!(d.fg.selected, 4);
|
|
|
|
|
|
assert_eq!(d.bg.selected, 0);
|
|
|
|
|
|
assert!(!d.fg.is_custom());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn strip_left_right_selects_named_and_fills_hex() {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
2026-06-20 16:21:04 -05:00
|
|
|
|
d.handle_event(&key(KeyCode::Tab)); // Grid -> FgStrip
|
|
|
|
|
|
assert!(d.fg.is_custom()); // seeded on the custom slot
|
|
|
|
|
|
// Left off the custom slot lands on the last named color (White, index 15).
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Left));
|
|
|
|
|
|
assert_eq!(d.fg.selected, NAMED_COLORS.len() - 1);
|
|
|
|
|
|
assert!(!d.fg.is_custom());
|
|
|
|
|
|
assert_eq!(d.fg.field.value(), "FFFFFF");
|
|
|
|
|
|
assert_eq!(d.fg.color(), Some(NAMED_COLORS[15].1));
|
|
|
|
|
|
// Right clamps back to the custom slot.
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Right));
|
|
|
|
|
|
assert!(d.fg.is_custom());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn tab_cycles_focus_skipping_unreachable_custom_fields() {
|
|
|
|
|
|
// A named-only glyph: neither color is custom, so the field stops are skipped.
|
|
|
|
|
|
let g = Glyph {
|
|
|
|
|
|
tile: 0,
|
|
|
|
|
|
fg: NAMED_COLORS[1].1,
|
|
|
|
|
|
bg: NAMED_COLORS[2].1,
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
|
2026-06-20 15:27:09 -05:00
|
|
|
|
assert!(d.focus == Focus::Grid);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Tab));
|
2026-06-20 16:21:04 -05:00
|
|
|
|
assert!(d.focus == Focus::FgStrip);
|
2026-06-20 15:27:09 -05:00
|
|
|
|
d.handle_event(&key(KeyCode::Tab));
|
2026-06-20 16:21:04 -05:00
|
|
|
|
assert!(d.focus == Focus::BgStrip); // FgField skipped (fg not custom)
|
2026-06-20 15:27:09 -05:00
|
|
|
|
d.handle_event(&key(KeyCode::Tab));
|
|
|
|
|
|
assert!(d.focus == Focus::Grid);
|
2026-06-20 16:21:04 -05:00
|
|
|
|
|
|
|
|
|
|
// With a custom fg, its field becomes a Tab stop.
|
|
|
|
|
|
let mut d2: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
|
|
|
|
|
d2.handle_event(&key(KeyCode::Tab)); // FgStrip
|
|
|
|
|
|
d2.handle_event(&key(KeyCode::Tab)); // FgField (fg is custom)
|
|
|
|
|
|
assert!(d2.focus == Focus::FgField);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn down_enters_custom_field_up_returns() {
|
|
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Tab)); // FgStrip
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Down)); // -> FgField (custom slot selected)
|
|
|
|
|
|
assert!(d.focus == Focus::FgField);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Up)); // back to strip
|
|
|
|
|
|
assert!(d.focus == Focus::FgStrip);
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn arrows_move_grid_selection_and_clamp() {
|
|
|
|
|
|
let mut g = sample();
|
|
|
|
|
|
g.tile = 0;
|
|
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Right));
|
|
|
|
|
|
assert_eq!(d.tile, 1);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Down));
|
|
|
|
|
|
assert_eq!(d.tile, 1 + GRID_COLS);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Left));
|
|
|
|
|
|
assert_eq!(d.tile, GRID_COLS);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Up));
|
|
|
|
|
|
assert_eq!(d.tile, 0);
|
|
|
|
|
|
// Clamps at the top-left corner.
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Up));
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Left));
|
|
|
|
|
|
assert_eq!(d.tile, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-20 16:21:04 -05:00
|
|
|
|
fn invalid_custom_hex_disables_ok_and_blocks_enter() {
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
2026-06-20 16:21:04 -05:00
|
|
|
|
d.handle_event(&key(KeyCode::Tab)); // FgStrip
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Down)); // FgField (custom)
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Backspace)); // "FF000" — invalid
|
|
|
|
|
|
assert_eq!(d.fg.field.value(), "FF000");
|
2026-06-20 15:27:09 -05:00
|
|
|
|
assert!(!d.ok_enabled());
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Enter)),
|
|
|
|
|
|
DialogResult::Continue
|
|
|
|
|
|
));
|
|
|
|
|
|
// Type the digit back → valid again.
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Char('0')));
|
2026-06-20 16:21:04 -05:00
|
|
|
|
assert_eq!(d.fg.field.value(), "FF0000");
|
2026-06-20 15:27:09 -05:00
|
|
|
|
assert!(d.ok_enabled());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn submit_yields_glyph_cancel_yields_none() {
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Submit returns the chosen glyph (custom colors preserved).
|
2026-06-20 15:27:09 -05:00
|
|
|
|
let mut out: Option<Option<Glyph>> = None;
|
|
|
|
|
|
let mut d: GlyphDialog<Option<Option<Glyph>>> =
|
|
|
|
|
|
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Right)); // tile 5 -> 6
|
|
|
|
|
|
let res = d.handle_event(&key(KeyCode::Enter));
|
|
|
|
|
|
d.finish(res, &mut out);
|
|
|
|
|
|
let chosen = out.unwrap().unwrap();
|
|
|
|
|
|
assert_eq!(chosen.tile, 6);
|
|
|
|
|
|
assert_eq!(chosen.fg, sample().fg);
|
|
|
|
|
|
assert_eq!(chosen.bg, sample().bg);
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel returns None.
|
|
|
|
|
|
let mut out2: Option<Option<Glyph>> = None;
|
|
|
|
|
|
let d2: GlyphDialog<Option<Option<Glyph>>> =
|
|
|
|
|
|
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
|
|
|
|
|
|
d2.finish(DialogResult::Cancel, &mut out2);
|
|
|
|
|
|
assert!(out2.unwrap().is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn parse_hex6_validation() {
|
|
|
|
|
|
assert!(parse_hex6("FF0000").is_some());
|
|
|
|
|
|
assert!(parse_hex6("ff00 aa").is_none()); // space / wrong length
|
|
|
|
|
|
assert!(parse_hex6("FF00").is_none()); // too short
|
|
|
|
|
|
assert!(parse_hex6("GGGGGG").is_none()); // non-hex
|
|
|
|
|
|
}
|
2026-06-20 15:53:15 -05:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn render_is_buffer_only_and_reports_cursor() {
|
|
|
|
|
|
// The split lets us render headless into a plain Buffer (no Frame/TTY).
|
|
|
|
|
|
let area = Rect::new(0, 0, 60, 25);
|
|
|
|
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Grid / strip focus → no terminal cursor.
|
|
|
|
|
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
|
|
|
|
|
assert_eq!(CursorOverlay::render(&d, area, &mut buf), None);
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Tab)); // FgStrip — still no caret
|
2026-06-20 15:53:15 -05:00
|
|
|
|
assert_eq!(CursorOverlay::render(&d, area, &mut buf), None);
|
|
|
|
|
|
|
2026-06-20 16:21:04 -05:00
|
|
|
|
// Entering the custom hex field requests a caret inside the window.
|
|
|
|
|
|
d.handle_event(&key(KeyCode::Down)); // FgField
|
2026-06-20 15:53:15 -05:00
|
|
|
|
let pos =
|
2026-06-20 16:21:04 -05:00
|
|
|
|
CursorOverlay::render(&d, area, &mut buf).expect("a focused field wants a cursor");
|
2026-06-20 15:53:15 -05:00
|
|
|
|
assert!(pos.x < area.width && pos.y < area.height);
|
|
|
|
|
|
}
|
2026-06-20 15:27:09 -05:00
|
|
|
|
}
|