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