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