diff --git a/CLAUDE.md b/CLAUDE.md index ae54f35..1b7c057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` (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` — 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, &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`; 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` — 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, &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`; 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. diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index abdb223..13f2c29 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -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); } } } diff --git a/kiln-ui/src/dialog.rs b/kiln-ui/src/dialog.rs index 2bb158c..238417b 100644 --- a/kiln-ui/src/dialog.rs +++ b/kiln-ui/src/dialog.rs @@ -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 Dialog { 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 CursorOverlay for Dialog { + /// 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 { + 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 Dialog { }; // 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 Dialog { 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 Dialog { // 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 Dialog { 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 Dialog { 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 Widget for &Dialog { + 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 { 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); + } } diff --git a/kiln-ui/src/glyph_dialog.rs b/kiln-ui/src/glyph_dialog.rs index d4bae98..3dee6e9 100644 --- a/kiln-ui/src/glyph_dialog.rs +++ b/kiln-ui/src/glyph_dialog.rs @@ -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 GlyphDialog { 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 GlyphDialog { } } - /// 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 { // 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 GlyphDialog { 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 GlyphDialog { // 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 GlyphDialog { 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 CursorOverlay for GlyphDialog { + /// 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 { + 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 Widget for &GlyphDialog { + 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); + } } diff --git a/kiln-ui/src/lib.rs b/kiln-ui/src/lib.rs index b5fd470..81c1f4c 100644 --- a/kiln-ui/src/lib.rs +++ b/kiln-ui/src/lib.rs @@ -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; +} + +/// 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); + } +}