@@ -0,0 +1,499 @@
//! 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.
//!
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route
//! events to [`handle_event`](GlyphDialog::handle_event), [`draw`](GlyphDialog::draw)
//! it, and on 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.
use color ::Rgba8 ;
use kiln_core ::cp437 ::tile_to_char ;
use kiln_core ::glyph ::Glyph ;
use ratatui ::Frame ;
use ratatui ::crossterm ::event ::{ Event , KeyCode , KeyModifiers } ;
use ratatui ::layout ::{ Constraint , Layout , Rect } ;
use ratatui ::style ::{ Color , Modifier , Style } ;
use ratatui ::text ::{ Line , Span } ;
use ratatui ::widgets ::{ Block , Clear , Paragraph } ;
use crate ::dialog ::DialogResult ;
use crate ::dim_area ;
use crate ::text_field ::TextField ;
/// 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 ) ;
/// Background highlight applied to the *label* of the currently focused color field.
const LABEL_FOCUS_BG : Color = Color ::Rgb ( 40 , 60 , 110 ) ;
/// 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 ;
/// 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 ,
}
/// Boxed callback fired once when the picker is dismissed: `Some(glyph)` on OK,
/// `None` on cancel.
type GlyphCallback < Ctx > = Box < dyn FnOnce ( Option < Glyph > , & mut Ctx ) > ;
/// An open glyph-picker dialog, generic over the host context `Ctx` its callback mutates.
pub struct GlyphDialog < Ctx > {
/// Title shown in the window's top border.
title : String ,
/// The currently selected tile index (`0..256`).
tile : u32 ,
/// Foreground color as bare hex digits (6 chars when valid, no `#`).
fg : TextField ,
/// Background color as bare hex digits (6 chars when valid, no `#`).
bg : TextField ,
/// Which sub-control currently has focus.
focus : Focus ,
/// The callback fired exactly once on dismissal.
callback : GlyphCallback < Ctx > ,
}
impl < Ctx > GlyphDialog < Ctx > {
/// Builds a glyph picker seeded from `initial`. 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`.
pub fn new (
title : impl Into < String > ,
initial : Glyph ,
on_done : impl FnOnce ( Option < Glyph > , & mut Ctx ) + 'static ,
) -> Self {
Self {
title : title . into ( ) ,
tile : initial . tile ,
fg : TextField ::sized ( hex6 ( initial . fg ) , 6 usize ) ,
bg : TextField ::sized ( hex6 ( initial . bg ) , 6 usize ) ,
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 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.
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 either moves the grid cursor or edits the focused field.
_ = > {
match self . focus {
Focus ::Grid = > self . move_grid ( key . code ) ,
Focus ::Fg = > {
self . fg . handle_key ( * key ) ;
}
Focus ::Bg = > {
self . bg . handle_key ( * key ) ;
}
}
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 field is currently invalid.
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 ,
} )
}
/// Whether OK is currently allowed (both color fields parse as valid hex).
fn ok_enabled ( & self ) -> bool {
parse_hex6 ( self . fg . value ( ) ) . is_some ( ) & & parse_hex6 ( self . bg . value ( ) ) . is_some ( )
}
/// Moves focus to the next (or previous) sub-control, wrapping around.
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 ,
} ;
DialogResult ::Continue
}
/// 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 picker centered over the whole frame: dims the background, then
/// renders the bordered window, the character grid, the two color fields, and the
/// OK/Cancel footer. Takes `&mut Frame` (not a `Widget`) so it can place a real
/// terminal cursor in the focused color field.
pub fn draw ( & self , frame : & mut Frame ) {
let area = frame . area ( ) ;
dim_area ( area , frame . buffer_mut ( ) ) ;
// 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.
let want_w = 40 u16 ;
let want_h = 14 u16 ;
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.
frame . render_widget ( Clear , rect ) ;
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 ) ;
frame . render_widget ( block , rect ) ;
// 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 ( ) ) ) ;
self . draw_grid ( frame , grid_area , colors ) ;
let field_cursor = self . draw_fields ( frame , fields_area ) ;
self . draw_footer ( frame , footer_area ) ;
// Place a real terminal cursor in the focused field (none when the grid is focused).
if let Some ( ( cx , cy ) ) = field_cursor {
frame . set_cursor_position ( ( cx , cy ) ) ;
}
}
/// 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 , frame : & mut Frame , 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 ) ,
} ;
let buf = frame . buffer_mut ( ) ;
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 the `fg`/`bg` labeled hex fields into `area`, centered. Returns the screen
/// position of the terminal cursor for the focused field (or `None` if the grid is
/// focused), so the caller can place it after dropping the buffer borrow.
fn draw_fields ( & self , frame : & mut Frame , area : Rect ) -> Option < ( u16 , u16 ) > {
// 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 ;
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 ;
let buf = frame . buffer_mut ( ) ;
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 ( ( fg_field_x + ( self . fg . display_cursor ( ) as u16 ) . min ( fg_w - 1 ) , y ) ) ,
Focus ::Bg = > Some ( ( bg_field_x + ( self . bg . display_cursor ( ) as u16 ) . min ( bg_w - 1 ) , y ) ) ,
Focus ::Grid = > None ,
}
}
/// Draws the `[enter] OK [esc] Cancel` footer (OK dimmed when disabled).
fn draw_footer ( & self , frame : & mut Frame , area : Rect ) {
let ok_style = if self . ok_enabled ( ) {
Style ::default ( ) . fg ( Color ::White ) . bg ( BG )
} else {
Style ::default ( ) . fg ( Color ::DarkGray ) . bg ( BG )
} ;
let key_style = Style ::default ( ) . fg ( KEY ) . bg ( BG ) ;
let footer = Line ::from ( vec! [
Span ::styled ( " [enter] " , key_style ) ,
Span ::styled ( " OK " , ok_style ) ,
Span ::styled ( " [esc] " , key_style ) ,
Span ::styled ( " Cancel " , Style ::default ( ) . fg ( Color ::White ) . bg ( BG ) ) ,
] ) ;
frame . render_widget ( Paragraph ::new ( footer ) , area ) ;
}
}
/// 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 ) {
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 ) ;
}
/// 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 )
}
/// Parses exactly 6 hex digits (no `#`) into an opaque [`Rgba8`], or `None` if the
/// string is not exactly six hex characters.
fn parse_hex6 ( s : & str ) -> Option < Rgba8 > {
if s . len ( ) ! = 6 | | ! s . bytes ( ) . all ( | b | b . is_ascii_hexdigit ( ) ) {
return None ;
}
Some ( Rgba8 {
r : u8 ::from_str_radix ( & s [ 0 .. 2 ] , 16 ) . ok ( ) ? ,
g : u8 ::from_str_radix ( & s [ 2 .. 4 ] , 16 ) . ok ( ) ? ,
b : u8 ::from_str_radix ( & s [ 4 .. 6 ] , 16 ) . ok ( ) ? ,
a : 255 ,
} )
}
/// Converts a core [`Rgba8`] to a ratatui [`Color`] (alpha dropped).
fn to_color ( c : Rgba8 ) -> Color {
Color ::Rgb ( c . r , c . g , c . b )
}
/// Picks black or white for legible text on a `c` background, by luminance.
fn contrast ( c : Rgba8 ) -> Color {
// Rec. 601 luma; > ~50% brightness reads better with black text.
let luma = 0.299 * c . r as f32 + 0.587 * c . g as f32 + 0.114 * c . b as f32 ;
if luma > 128.0 {
Color ::Black
} else {
Color ::White
}
}
#[ cfg(test) ]
mod tests {
use super ::* ;
use ratatui ::crossterm ::event ::{ KeyEvent , KeyModifiers } ;
/// A glyph with distinct, valid 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 ,
} ,
}
}
/// Builds a key-press event with no modifiers.
fn key ( code : KeyCode ) -> Event {
Event ::Key ( KeyEvent ::new ( code , KeyModifiers ::NONE ) )
}
#[ test ]
fn seed_round_trips_tile_and_colors ( ) {
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 . ok_enabled ( ) ) ;
}
#[ test ]
fn tab_cycles_focus_both_ways ( ) {
let mut d : GlyphDialog < ( ) > = GlyphDialog ::new ( " t " , sample ( ) , | _ , _ | { } ) ;
assert! ( d . focus = = Focus ::Grid ) ;
d . handle_event ( & key ( KeyCode ::Tab ) ) ;
assert! ( d . focus = = Focus ::Fg ) ;
d . handle_event ( & key ( KeyCode ::Tab ) ) ;
assert! ( d . focus = = Focus ::Bg ) ;
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 ) ;
}
#[ 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_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 " ) ;
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 . value ( ) , " FF0000 " ) ;
assert! ( d . ok_enabled ( ) ) ;
}
#[ test ]
fn submit_yields_glyph_cancel_yields_none ( ) {
// Submit returns the chosen glyph.
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
}
}