editor ui

This commit is contained in:
2026-06-17 18:25:09 -05:00
parent 6464e47747
commit fb8f6df501
11 changed files with 982 additions and 135 deletions
+499
View File
@@ -0,0 +1,499 @@
//! Self-contained dialog overlays: a [text dialog](Dialog::text) (single free-text
//! field) and a [list dialog](Dialog::list) (a filterable, arrow-selectable list).
//!
//! Both are [`Dialog<Ctx>`] values generic over a host context type (the front-end
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
//! host routes all input to it; when dismissed it fires a stored callback with the
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
//! text field is backed by [`tui_input::Input`], driven through [`InputRequest`] so we
//! never depend on `tui-input`'s own event backend (avoiding any crossterm-version coupling).
//!
//! 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.
use crate::dim_area;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use tui_input::{Input, InputRequest};
/// Background color filling a dialog window.
const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for a dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210);
/// Color for the currently selected list entry's background.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Background color of an editable text field, so it reads as an input box.
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
/// The outcome a list dialog reports to its callback.
#[derive(Clone, Debug, PartialEq)]
pub enum ListDialogResponse {
/// The player chose an existing entry from the list.
Select(String),
/// The player typed a new value and confirmed it (only possible when the list
/// dialog was opened with `allow_create = true`). No current caller enables
/// creation, so the payload is unused for now — kept as part of the dialog API.
#[allow(dead_code)]
Create(String),
/// The player dismissed the dialog with `Esc`.
Cancel,
}
/// What kind of field a dialog shows, plus its associated state.
enum DialogBody {
/// A single free-text field.
Text { input: Input },
/// A filter field above a selectable list. `selected` indexes the *filtered*
/// view (entries of `all` whose text starts with the filter).
List {
input: Input,
all: Vec<String>,
selected: usize,
allow_create: bool,
},
}
/// Boxed callback for a text dialog: `Some(value)` on OK, `None` on Cancel.
type TextCallback<Ctx> = Box<dyn FnOnce(Option<String>, &mut Ctx)>;
/// Boxed callback for a list dialog: see [`ListDialogResponse`].
type ListCallback<Ctx> = Box<dyn FnOnce(ListDialogResponse, &mut Ctx)>;
/// The callback fired once when a dialog is dismissed, paired by body kind.
enum DialogCallback<Ctx> {
/// Text-dialog callback.
Text(TextCallback<Ctx>),
/// List-dialog callback.
List(ListCallback<Ctx>),
}
/// How a key event affected an open dialog.
pub enum DialogResult {
/// The dialog stays open; nothing more to do.
Continue,
/// The player confirmed (`Enter` with a valid choice) — fire the callback with the value.
Submit,
/// The player cancelled (`Esc`) — fire the callback with the cancel result.
Cancel,
}
/// An open dialog overlay, generic over the host context `Ctx` its callback mutates.
pub struct Dialog<Ctx> {
/// Title shown in the window's top border.
title: String,
/// The field kind + state.
body: DialogBody,
/// The callback fired exactly once on dismissal.
callback: DialogCallback<Ctx>,
}
impl<Ctx> Dialog<Ctx> {
/// Builds a text dialog. `initial` pre-fills the field; `on_done` receives
/// `Some(value)` on OK or `None` on Cancel, plus `&mut Ctx`.
pub fn text(
title: impl Into<String>,
initial: impl Into<String>,
on_done: impl FnOnce(Option<String>, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::Text {
input: Input::new(initial.into()),
},
callback: DialogCallback::Text(Box::new(on_done)),
}
}
/// Builds a list dialog over `items`. Typing filters the list to entries starting
/// with the typed text; arrow keys move the selection. When `allow_create` is true
/// the player may confirm typed text that matches nothing (→ [`ListDialogResponse::Create`]);
/// otherwise OK is disabled unless a list entry is selected. `on_done` receives the
/// [`ListDialogResponse`] plus `&mut Ctx`.
pub fn list(
title: impl Into<String>,
items: Vec<String>,
allow_create: bool,
on_done: impl FnOnce(ListDialogResponse, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::List {
input: Input::new(String::new()),
all: items,
selected: 0,
allow_create,
},
callback: DialogCallback::List(Box::new(on_done)),
}
}
/// Updates dialog state for an input event and reports whether it should close.
///
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when OK
/// is enabled (always for text; for a list, when a valid entry is selected or
/// `allow_create` and the field is non-empty). Arrow keys move a list's selection;
/// any other key edits the field (and re-clamps a list's selection to the new filter).
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
let Event::Key(key) = event else {
return DialogResult::Continue;
};
match key.code {
KeyCode::Esc => DialogResult::Cancel,
KeyCode::Enter => {
if self.ok_enabled() {
DialogResult::Submit
} else {
DialogResult::Continue
}
}
// Arrow up/down only mean something for a list (move selection within the filtered view).
KeyCode::Up | KeyCode::Down => {
// Compute the filtered length (immutable borrow) before taking the
// mutable borrow of `selected`.
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body
&& n > 0
{
// Wrap-free clamp: up decrements, down increments, bounded to [0, n-1].
if matches!(key.code, KeyCode::Up) {
*selected = selected.saturating_sub(1);
} else {
*selected = (*selected + 1).min(n - 1);
}
}
DialogResult::Continue
}
// Everything else edits the text/filter field.
code => {
if let Some(req) = key_to_request(code) {
self.input_mut().handle(req);
// The filter changed, so the previous selection index may now be
// out of range — clamp it back into the filtered list. Compute the
// new length first (immutable borrow), then clamp (mutable borrow).
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body {
*selected = (*selected).min(n.saturating_sub(1));
}
}
DialogResult::Continue
}
}
}
/// Consumes the dialog and fires its callback. `result` must be the
/// [`DialogResult::Submit`] or [`DialogResult::Cancel`] just returned by
/// [`handle_event`](Dialog::handle_event); `Continue` is treated as cancel.
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
let submit = matches!(result, DialogResult::Submit);
match (self.body, self.callback) {
(DialogBody::Text { input }, DialogCallback::Text(cb)) => {
cb(submit.then(|| input.value().to_string()), ctx);
}
(
DialogBody::List {
input,
all,
selected,
allow_create,
},
DialogCallback::List(cb),
) => {
let resp = if !submit {
ListDialogResponse::Cancel
} else if let Some(entry) = filtered_items(&all, input.value())
.into_iter()
.nth(selected)
{
ListDialogResponse::Select(entry.clone())
} else if allow_create {
ListDialogResponse::Create(input.value().to_string())
} else {
ListDialogResponse::Cancel
};
cb(resp, ctx);
}
// The body and callback are always constructed as a matching pair.
_ => unreachable!("dialog body/callback kinds always match"),
}
}
/// Whether the OK action is currently allowed (controls Enter + label styling).
fn ok_enabled(&self) -> bool {
match &self.body {
DialogBody::Text { .. } => true,
DialogBody::List {
input,
allow_create,
..
} => self.filtered_len() > 0 || (*allow_create && !input.value().is_empty()),
}
}
/// Mutable access to whichever [`Input`] field this dialog owns.
fn input_mut(&mut self) -> &mut Input {
match &mut self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
}
}
/// Number of list entries currently passing the filter (0 for a text dialog).
fn filtered_len(&self) -> usize {
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());
// Size the window: a list dialog is taller to show its entries.
let is_list = matches!(self.body, DialogBody::List { .. });
let want_w = 50u16;
let want_h = if is_list { 16 } else { 8 };
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);
// Carve the footer off the bottom; the rest is the body (field [+ list]).
let [body_area, footer_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let input = match &self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
};
if let DialogBody::List {
all,
selected,
input,
..
} = &self.body
{
// 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, input);
draw_list(frame, list_area, all, input.value(), *selected);
} else {
// Text dialog: a single field box, centered horizontally and vertically.
let fw = body_area.width.saturating_sub(4).max(1);
let field_area = Rect {
x: body_area.x + body_area.width.saturating_sub(fw) / 2,
y: body_area.y + body_area.height / 2,
width: fw,
height: 1,
};
draw_field(frame, field_area, input);
}
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
let ok_style = if self.ok_enabled() {
Style::default().fg(Color::White).bg(BG)
} else {
Style::default().fg(Color::DarkGray).bg(BG)
};
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(BG);
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_style),
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
frame.render_widget(Paragraph::new(footer), footer_area);
}
}
/// Counts the entries passing a list body's current filter (0 for a text body).
fn filtered_count(body: &DialogBody) -> usize {
match body {
DialogBody::List { all, input, .. } => filtered_items(all, input.value()).len(),
DialogBody::Text { .. } => 0,
}
}
/// Returns references to the entries of `all` that start with `filter` (prefix match).
fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
all.iter().filter(|s| s.starts_with(filter)).collect()
}
/// Maps a key code to a [`tui_input`] edit request, or `None` if it doesn't edit text.
fn key_to_request(code: KeyCode) -> Option<InputRequest> {
match code {
KeyCode::Char(c) => Some(InputRequest::InsertChar(c)),
KeyCode::Backspace => Some(InputRequest::DeletePrevChar),
KeyCode::Delete => Some(InputRequest::DeleteNextChar),
KeyCode::Left => Some(InputRequest::GoToPrevChar),
KeyCode::Right => Some(InputRequest::GoToNextChar),
KeyCode::Home => Some(InputRequest::GoToStart),
KeyCode::End => Some(InputRequest::GoToEnd),
_ => None,
}
}
/// 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 input's visual cursor column.
fn draw_field(frame: &mut Frame, area: Rect, input: &Input) {
if area.width == 0 {
return;
}
// The paragraph's style fills the whole field area, including empty cells.
frame.render_widget(
Paragraph::new(input.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
area,
);
let cur_x = area.x + (input.visual_cursor() as u16).min(area.width.saturating_sub(1));
frame.set_cursor_position((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) {
if area.height == 0 {
return;
}
let items = filtered_items(all, filter);
let rows = area.height as usize;
// Scroll so the selected row is within the visible window.
let top = selected
.saturating_sub(rows.saturating_sub(1))
.min(items.len().saturating_sub(rows.max(1)));
let lines: Vec<Line> = items
.iter()
.enumerate()
.skip(top)
.take(rows)
.map(|(i, s)| {
let style = if i == selected {
Style::default().fg(Color::White).bg(SELECT_BG)
} else {
Style::default().fg(Color::Gray).bg(BG)
};
Line::from(Span::styled(s.as_str(), style))
})
.collect();
frame.render_widget(Paragraph::new(lines).style(Style::default().bg(BG)), area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
/// Builds a key-press event for a given key code.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Three sample list entries; "alpha"/"alps" share the "al" prefix.
fn items() -> Vec<String> {
vec!["alpha".into(), "beta".into(), "alps".into()]
}
#[test]
fn typing_filters_by_prefix_and_clamps_selection() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
// No filter: all three entries pass.
assert_eq!(d.filtered_len(), 3);
// Move selection to the last entry, then filter so fewer remain.
d.handle_event(&key(KeyCode::Down));
d.handle_event(&key(KeyCode::Down)); // selected = 2
d.handle_event(&key(KeyCode::Char('a'))); // filter "a" -> alpha, alps
assert_eq!(d.filtered_len(), 2);
// The out-of-range selection is clamped back into the filtered list.
let DialogBody::List { selected, .. } = &d.body else {
unreachable!()
};
assert_eq!(*selected, 1);
}
#[test]
fn ok_disabled_when_no_match_and_create_not_allowed() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
for c in "zzz".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert_eq!(d.filtered_len(), 0);
assert!(!d.ok_enabled());
// Enter is ignored while OK is disabled.
assert!(matches!(
d.handle_event(&key(KeyCode::Enter)),
DialogResult::Continue
));
}
#[test]
fn list_submit_selects_highlighted_entry() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.handle_event(&key(KeyCode::Down)); // select index 1 ("beta")
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Select(s)) if s == "beta"));
}
#[test]
fn list_submit_creates_when_allowed_and_no_match() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), true, |r, c| *c = Some(r));
for c in "gamma".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert!(d.ok_enabled());
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Create(s)) if s == "gamma"));
}
#[test]
fn esc_yields_cancel_for_list() {
let mut out: Option<ListDialogResponse> = None;
let d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.finish(DialogResult::Cancel, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Cancel)));
}
#[test]
fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
// Submit returns the edited value.
let mut got: Option<Option<String>> = None;
let mut d: Dialog<Option<Option<String>>> =
Dialog::text("t", "hi", |v, c| *c = Some(v));
d.handle_event(&key(KeyCode::Char('!')));
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut got);
assert_eq!(got, Some(Some("hi!".to_string())));
// Cancel returns None.
let mut got2: Option<Option<String>> = None;
let d2: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
d2.finish(DialogResult::Cancel, &mut got2);
assert_eq!(got2, Some(None));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! `kiln-ui` — reusable, game-agnostic terminal UI widgets built on [`ratatui`].
//!
//! This crate is the home for kiln's front-end UI widgets, kept deliberately separate
//! from game concerns:
//!
//! - **Widget responsibilities only.** Each widget owns its *presentation* (drawing)
//! and *input handling*; it never references game/world types. No dependency on
//! `kiln-core`.
//! - **Generic over a host context.** A widget that needs to apply a result mutates a
//! caller-supplied context `Ctx` *only through callbacks* (e.g. [`dialog::Dialog<Ctx>`]),
//! so the front-end decides what a choice does without the widget knowing about it.
//! - **The host drives the loop.** Widgets expose plain `handle_event` / `draw` entry
//! points; the front-end owns the event loop, decides when a widget is focused, and
//! routes input to it.
//!
//! 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::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
pub mod dialog;
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
/// content beneath does not show through.
///
/// Shared scaffolding: used by [`dialog`] here, and re-borrowed by kiln-tui's
/// overlay-frame renderer so both modal styles dim the background identically.
pub fn dim_area(area: Rect, buf: &mut Buffer) {
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(dim_style);
}
}
}
}