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
+4 -3
View File
@@ -138,6 +138,7 @@ Reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. A widget
**`kiln-ui/src/lib.rs`** — crate root:
- `dim_area(area, buf)` (`pub`) — dims every cell in `area` (background tint for a modal overlay). Shared scaffolding: used by [`dialog`] here and re-borrowed by kiln-tui's `overlay::render_overlay_frame` so both modal styles dim identically.
- `CursorOverlay` (`pub` trait) + `render_overlay(frame, &impl CursorOverlay)` (`pub`) — a modal overlay paints into a `&mut Buffer` and *returns* `Option<Position>` (where the terminal caret goes) from `render(area, buf)`, instead of placing the cursor itself. `render_overlay` is the one place that needs a `Frame`: it calls `render` over `frame.area()` then applies the returned position via `set_cursor_position`. The split exists because a ratatui `Widget` only sees a buffer (can't move the hardware cursor), and it makes `render` unit-testable headless. Implemented by `Dialog` and `GlyphDialog`, which *also* `impl Widget for &Dialog`/`&GlyphDialog` (forwarding to `CursorOverlay::render` and discarding the caret) so they can be drawn with `frame.render_widget` where the cursor isn't needed.
- Modules: `pub mod dialog; pub mod code_editor; pub mod text_field; pub mod glyph_dialog;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below).
**`kiln-ui/src/clipboard.rs` / `text.rs`** — `pub(crate)` shared internals for the text widgets:
@@ -148,12 +149,12 @@ Reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. A widget
- `Dialog<Ctx>` — an open dialog generic over the host context its callback mutates. Built via `Dialog::text(title, initial, on_done)` (a single free-text field; `on_done: FnOnce(Option<String>, &mut Ctx)`, `None` on cancel) or `Dialog::list(title, items, allow_create, on_done)` (a filter field over a selectable list; `on_done: FnOnce(ListDialogResponse, &mut Ctx)`). Holds a `DialogBody` (`Text`/`List`) plus the boxed callback. Each field is a [`TextField`] (the in-house single-line editor).
- `ListDialogResponse { Select(String), Create(String), Cancel }` — list outcome: an existing entry, a newly-typed value (only when `allow_create`), or cancel. `DialogResult { Continue, Submit, Cancel }` — what a key event did.
- `handle_event(&Event) -> DialogResult``Esc``Cancel`; `Enter``Submit` only when OK is enabled (always for text; for a list, a valid entry is selected *or* `allow_create` + non-empty); Up/Down move a list's selection; other keys edit the field (and re-clamp the list selection to the new prefix filter). `finish(result, &mut Ctx)` consumes the dialog and fires its callback. The host pattern: `take()` the dialog out of its slot on `Submit`/`Cancel`, then call `finish` so the callback gets an un-borrowed `&mut Ctx`.
- `Dialog::draw(&self, &mut Frame)` — 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). Takes `&mut Frame` (not a `Widget`) because it needs to place a real terminal cursor. Unit-tested (filtering/clamping, OK-enabled rule, Select/Create/Cancel/text outcomes) with a dummy `Ctx`.
- `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{..})`).
- `draw(&self, &mut Frame)` — 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. Places a real terminal cursor in the focused color field. Unit-tested (seed round-trip, focus cycling, grid clamp, invalid-hex disables OK, Submit/Cancel outcomes) with a dummy `Ctx`.
- 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`.
**`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).
@@ -175,7 +176,7 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln-
- `ratatui::init()``EnableMouseCapture` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `DisableMouseCapture``ratatui::restore()`. `game.run_init()` runs object `init()` hooks before the loop.
- `run` is a ~30 FPS real-time loop: `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed `dt` via `Ui::tick(dt, mode)`. Acts on `Press`/`Repeat` and ignores `Release` (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by `current_input_mode`: `Menu``handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode``Play``handle_board_input`/`handle_scroll_input`, `Edit``handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` and exits once `should_quit` is set and no close animation is running.
- `apply_pending_mode(mode, ui)` — applies a `PendingMode` a menu action requested: reloads the world from `world_path` and swaps `*mode` to `Mode::Edit(EditorState::new(world))` (enter editor) or a fresh `Mode::Play` (play world, re-running `init()`); a reload failure is logged to the play game and leaves the mode unchanged.
- `draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or the editor's glyph picker (`GlyphDialog::draw`, when open) / dialog (`Dialog::draw`). `draw_play` wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed). `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles.
- `draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or — via `kiln_ui::render_overlay` the editor's glyph picker (when open) or dialog (both `CursorOverlay`s). `draw_play` wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed). `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles.
**`kiln-tui/src/mode.rs`** — top-level application mode:
- `Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern.
+3 -2
View File
@@ -41,6 +41,7 @@ use crate::ui::Ui;
use kiln_core::game::GameState;
use kiln_core::log::LogLine;
use kiln_core::world;
use kiln_ui::render_overlay;
use ratatui::Frame;
use ratatui::crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
@@ -269,9 +270,9 @@ fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
// The dialog / glyph-picker overlays only exist while editing.
Mode::Edit(ed) => {
if let Some(g) = &ed.glyph_dialog {
g.draw(frame);
render_overlay(frame, g);
} else if let Some(d) = &ed.dialog {
d.draw(frame);
render_overlay(frame, d);
}
}
}
+62 -35
View File
@@ -8,18 +8,18 @@
//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
//! single-line editor used elsewhere in kiln-ui).
//!
//! Drawing is a method ([`Dialog::draw`]) taking `&mut Frame` rather than a ratatui
//! `Widget` because it needs the [`Frame`] to place a real terminal cursor in the
//! active text field.
//! Drawing is the [`CursorOverlay`] impl: it paints into a buffer and returns where
//! the active text field's caret should go, so the host can place the real terminal
//! cursor (see [`crate::render_overlay`]).
use crate::dim_area;
use crate::text_field::TextField;
use ratatui::Frame;
use crate::{CursorOverlay, dim_area};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
/// Background color filling a dialog window.
const BG: Color = Color::Rgb(10, 15, 35);
@@ -243,15 +243,15 @@ impl<Ctx> Dialog<Ctx> {
filtered_count(&self.body)
}
/// Draws the dialog centered over the whole frame: dims the background, then
/// renders the bordered window, the field (centered for a text dialog, atop the
/// list for a list dialog), and the OK/Cancel labels.
///
/// Takes `&mut Frame` (not a `Widget`) because the text field needs a real
/// terminal cursor placed via [`Frame::set_cursor_position`].
pub fn draw(&self, frame: &mut Frame) {
let area = frame.area();
dim_area(area, frame.buffer_mut());
}
impl<Ctx> CursorOverlay for Dialog<Ctx> {
/// Draws the dialog centered over `area`: dims the background, then renders the
/// bordered window, the field (centered for a text dialog, atop the list for a
/// list dialog), and the OK/Cancel labels. Returns the active field's caret
/// position so the host can place the real terminal cursor.
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position> {
dim_area(area, buf);
// Size the window: a list dialog is taller to show its entries.
let is_list = matches!(self.body, DialogBody::List { .. });
@@ -267,13 +267,13 @@ impl<Ctx> Dialog<Ctx> {
};
// Window frame: clear underlying content, then a bordered titled block.
frame.render_widget(Clear, rect);
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);
frame.render_widget(block, rect);
block.render(rect, buf);
// Carve the footer off the bottom; the rest is the body (field [+ list]).
let [body_area, footer_area] =
@@ -283,7 +283,8 @@ impl<Ctx> Dialog<Ctx> {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
};
if let DialogBody::List {
// The body draws the field(s) and reports the caret position.
let cursor = if let DialogBody::List {
all,
selected,
field,
@@ -293,8 +294,9 @@ impl<Ctx> Dialog<Ctx> {
// List dialog: the filter field sits atop the scrolling list.
let [field_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
draw_field(frame, field_area, field);
draw_list(frame, list_area, all, field.value(), *selected);
let cursor = draw_field(buf, field_area, field);
draw_list(buf, list_area, all, field.value(), *selected);
cursor
} else {
// Text dialog: a single field box, centered horizontally and vertically.
let fw = body_area.width.saturating_sub(4).max(1);
@@ -304,8 +306,8 @@ impl<Ctx> Dialog<Ctx> {
width: fw,
height: 1,
};
draw_field(frame, field_area, field);
}
draw_field(buf, field_area, field)
};
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
let ok_style = if self.ok_enabled() {
@@ -320,7 +322,18 @@ impl<Ctx> Dialog<Ctx> {
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
frame.render_widget(Paragraph::new(footer), footer_area);
Paragraph::new(footer).render(footer_area, buf);
cursor
}
}
/// Lets a `&Dialog` 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 &Dialog<Ctx> {
fn render(self, area: Rect, buf: &mut Buffer) {
CursorOverlay::render(self, area, buf);
}
}
@@ -338,24 +351,24 @@ fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
}
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
/// (so it reads as an input box), the current value, and a real terminal cursor at
/// the field's display cursor column.
fn draw_field(frame: &mut Frame, area: Rect, field: &TextField) {
/// (so it reads as an input box) and the current value. Returns the caret position
/// (the field's display cursor column) for the host to place the terminal cursor, or
/// `None` if the area has no width.
fn draw_field(buf: &mut Buffer, area: Rect, field: &TextField) -> Option<Position> {
if area.width == 0 {
return;
return None;
}
// The paragraph's style fills the whole field area, including empty cells.
frame.render_widget(
Paragraph::new(field.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
area,
);
Paragraph::new(field.value())
.style(Style::default().fg(Color::White).bg(FIELD_BG))
.render(area, buf);
let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1));
frame.set_cursor_position((cur_x, area.y));
Some(Position::new(cur_x, area.y))
}
/// Renders the filtered list into `area`, highlighting `selected` and scrolling so
/// the selection stays visible.
fn draw_list(frame: &mut Frame, area: Rect, all: &[String], filter: &str, selected: usize) {
fn draw_list(buf: &mut Buffer, area: Rect, all: &[String], filter: &str, selected: usize) {
if area.height == 0 {
return;
}
@@ -379,7 +392,9 @@ fn draw_list(frame: &mut Frame, area: Rect, all: &[String], filter: &str, select
Line::from(Span::styled(s.as_str(), style))
})
.collect();
frame.render_widget(Paragraph::new(lines).style(Style::default().bg(BG)), area);
Paragraph::new(lines)
.style(Style::default().bg(BG))
.render(area, buf);
}
#[cfg(test)]
@@ -480,4 +495,16 @@ mod tests {
d2.finish(DialogResult::Cancel, &mut got2);
assert_eq!(got2, Some(None));
}
#[test]
fn render_is_buffer_only_and_reports_cursor() {
use ratatui::buffer::Buffer;
// The split lets us render headless into a plain Buffer (no Frame/TTY); a text
// dialog always wants its caret shown, inside the drawn window.
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
let d: Dialog<()> = Dialog::text("t", "hi", |_, _| {});
let pos = CursorOverlay::render(&d, area, &mut buf).expect("text dialog wants a cursor");
assert!(pos.x < area.width && pos.y < area.height);
}
}
+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);
}
}
+25 -1
View File
@@ -16,8 +16,9 @@
//! This is also where a full-screen script text-editor widget will live (wrapping a
//! third-party editor crate) once that work begins.
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Style};
mod clipboard;
@@ -42,3 +43,26 @@ pub fn dim_area(area: Rect, buf: &mut Buffer) {
}
}
}
/// A modal overlay that paints itself into a [`Buffer`] and may want the terminal
/// cursor placed at a position (e.g. the caret in a dialog's text field).
///
/// Painting is split from cursor placement because only a [`Frame`] can move the
/// real hardware cursor (a [`Widget`](ratatui::widgets::Widget)'s `render` sees just
/// a buffer). So [`render`](CursorOverlay::render) is buffer-only — and therefore
/// unit-testable headless — and *returns* where the caret goes; the host applies it
/// (via [`render_overlay`]).
pub trait CursorOverlay {
/// Paints the overlay into `buf` within `area`, returning the desired terminal
/// cursor position, or `None` if no cursor should be shown.
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position>;
}
/// Paints a [`CursorOverlay`] over the whole frame and places the hardware cursor
/// if the overlay asked for one. The single place that needs a [`Frame`].
pub fn render_overlay(frame: &mut Frame, overlay: &impl CursorOverlay) {
let area = frame.area();
if let Some(pos) = overlay.render(area, frame.buffer_mut()) {
frame.set_cursor_position(pos);
}
}