This commit is contained in:
2026-06-20 15:53:15 -05:00
parent 05142e5712
commit 6816b1549f
5 changed files with 195 additions and 112 deletions
+101 -71
View File
@@ -2,9 +2,10 @@
//! 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`.
//! 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`.
//!
//! 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.
@@ -12,16 +13,16 @@
use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
use crate::dialog::DialogResult;
use crate::dim_area;
use crate::text_field::TextField;
use crate::{CursorOverlay, dim_area};
/// Background color filling the dialog window (matches [`crate::dialog`]).
const BG: Color = Color::Rgb(10, 15, 35);
@@ -183,70 +184,16 @@ impl<Ctx> GlyphDialog<Ctx> {
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 = 40u16;
let want_h = 14u16;
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)>) {
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),
};
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;
@@ -271,10 +218,10 @@ impl<Ctx> GlyphDialog<Ctx> {
}
}
/// 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)> {
/// 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;
@@ -288,7 +235,6 @@ impl<Ctx> GlyphDialog<Ctx> {
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);
@@ -296,14 +242,20 @@ impl<Ctx> GlyphDialog<Ctx> {
// 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::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,
}
}
/// Draws the `[enter] OK [esc] Cancel` footer (OK dimmed when disabled).
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
fn draw_footer(&self, buf: &mut Buffer, area: Rect) {
let ok_style = if self.ok_enabled() {
Style::default().fg(Color::White).bg(BG)
} else {
@@ -316,7 +268,67 @@ impl<Ctx> GlyphDialog<Ctx> {
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
frame.render_widget(Paragraph::new(footer), area);
Paragraph::new(footer).render(area, buf);
}
}
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.
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.
let want_w = 40u16;
let want_h = 14u16;
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 (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(buf, grid_area, colors);
let cursor = self.draw_fields(buf, fields_area);
self.draw_footer(buf, footer_area);
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`](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);
}
}
@@ -496,4 +508,22 @@ mod tests {
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 focused → no terminal cursor requested.
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
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
let pos =
CursorOverlay::render(&d2, area, &mut buf).expect("a focused field wants a cursor");
assert!(pos.x < area.width && pos.y < area.height);
}
}