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