refactor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user