Extract kiln-ui: standalone game-agnostic ratatui widget toolkit

Widgets (CodeEditor, TextField, Dialog) plus clipboard/text internals,
extracted from the kiln workspace. No game-engine dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 23:42:48 -05:00
commit eabeaf1bf8
11 changed files with 2395 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
Cargo.lock
+43
View File
@@ -0,0 +1,43 @@
# kiln-ui
Module reference for the `kiln-ui` crate — reusable, **game-agnostic** terminal UI
widgets on **ratatui**. This is a standalone crate (its own git repo); it has **no
dependency on any game engine** and is intended to be reused by any ratatui front-end
(e.g. a text editor).
## Code style
- Add `///` rustdoc comments to every `pub` type, field, and function.
- Add inline `//` comments inside non-trivial function bodies to explain the *why* of
each logical step.
- Keep comments accurate: update them when the code they describe changes.
## Overview
A widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates *only through callbacks*, so a front-end decides what a choice does without the widget knowing about app/domain types. The front-end owns the event loop and routes input to the widget while it is focused. The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
Consumers exchange ratatui types with these widgets (`Dialog` is a `Widget`, `CursorOverlay` uses `Buffer`/`Rect`/`Position`), so a consuming crate's `ratatui` must unify with this crate's (pinned `0.30.x`).
**`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-borrowable by a host's own overlay renderer so 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`, which *also* `impl Widget for &Dialog` (forwarding to `CursorOverlay::render` and discarding the caret) so it 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;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below).
**`src/clipboard.rs` / `text.rs`** — `pub(crate)` shared internals for the text widgets:
- `clipboard::Clipboard` — system clipboard via `arboard` when available, always backed by an internal buffer (so copy/paste work headless / on the web); `set`/`get` write-through and prefer-system-on-read. Used by both `code_editor` and `text_field`.
- `text::{byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl, TAB_WIDTH}` — char↔byte index, display-width (tab stops + `unicode-width` wide chars), within-line word boundaries, and the `⌘``ctrl` key rewrite, shared by both editors.
**`src/dialog.rs`** — modal dialog widgets:
- `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`].
- `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` 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 with a dummy `Ctx`.
**`src/text_field.rs`** — a single-line editable text field (a one-line sibling of `code_editor`, used by `dialog`'s text/filter inputs):
- `TextField``text: String` + a char-indexed `cursor` + a `Clipboard` + an optional `max_length`. `new(initial)` (unbounded) or `sized(initial, max_length)` (truncates the seed and caps typed input); `value()`, `display_cursor()` (caret's display column), `set(text)` (replace contents, truncated to `max_length`), 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.
**`src/code_editor.rs`** — a hand-written **modeless, keyboard-only** code editor (hand-written; `edtui` was rejected for panicking on bare Shift and lacking line-wrap):
- `CodeEditor` — edits one script's text: `lines: Vec<String>`, a char-indexed `cursor`/`anchor` (selection), `desired_col` (vertical-move column), `scroll` (top row + left display-col, cursor-following), undo/redo `Snapshot` stacks, a cached `HighlightParts`, and a `Clipboard` (system via `arboard` when available, always backed by an internal buffer so copy/paste work headless / on the web). Public API: `new(name, text)`, `name()`, `text()` (lines joined with `\n`), `handle_event(&Event) -> CodeEditorOutcome { Continue, Exit }`, `draw(&mut self, frame, area)`.
- Input (`handle_event`): `⌘` is mapped to `ctrl` up front; then printable keys insert, Enter/Backspace/Delete edit (Backspace/Delete at a line edge join lines, Left/Right wrap across line ends), arrows move (`shift` extends the selection), **`ctrl`+Left/Right jump by word** (`text::{prev,next}_word`, wrapping at line ends) and **`ctrl`+Up/Down jump by paragraph** (to the nearest blank/whitespace-only line, via `is_blank_line`), Home/End/PageUp/PageDown, `ctrl`+`c`/`x`/`v` (system clipboard), `ctrl`+`a` (select all), `ctrl`+`z`/`y` (undo/redo), `Esc``Exit`. Bracketed `Event::Paste` inserts text. **Unknown keycodes are ignored** (the whole point — no panic on Shift/F-keys). Undo coalesces a run of typing (or deletes) into one step; movement/structural edits break the run.
- Rendering (`draw`): a titled border, a line-number gutter, and per-line styled text. One width model (`char_cells`: tabs → next 4-stop, wide chars via `unicode-width`) drives the cursor X, selection background, and horizontal scroll together. Highlighting: `build_highlight_parts()` parses the embedded `resources/rhai.sublime-syntax` into a one-syntax `SyntaxSet` + `base16-ocean.dark` theme (cached); `draw` runs `syntect::easy::HighlightLines` from line 0 to the viewport bottom each frame (small scripts), mapping syntect colors to `Color::Rgb`. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now. **Note:** highlighting is currently hardcoded to Rhai; making the syntax/theme pluggable is a known follow-up for non-Rhai hosts.
- Unit-tested (pure logic, no TTY).
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "kiln-ui"
version = "0.1.0"
edition = "2024"
[dependencies]
# text-only clipboard: drop the default `image-data` deps (image/tiff/jpeg), add Linux Wayland support
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
# Pinned to 0.30.x: consumers (e.g. kiln-tui) exchange ratatui types with these widgets,
# so their ratatui must unify with this one.
ratatui = "0.30.1"
syntect = "5.3.0"
unicode-width = "0.2"
+34
View File
@@ -0,0 +1,34 @@
# kiln-ui
Reusable, game-agnostic terminal UI widgets built on [ratatui](https://ratatui.rs).
Extracted from the [kiln](https://github.com/) project so the same widgets can drive
any ratatui front-end (a game editor, a text editor, …). The crate has **no game-engine
dependency**.
## Widgets
- **`code_editor::CodeEditor`** — a hand-written, modeless, keyboard-only full-screen
code editor: selection, undo/redo, word/paragraph motion, system clipboard, and
syntect syntax highlighting. (Highlighting is currently hardcoded to Rhai; a pluggable
syntax/theme is a planned follow-up.)
- **`text_field::TextField`** — a single-line editable field (the building block for
dialog inputs).
- **`dialog::Dialog<Ctx>`** — modal text-entry and filterable-list dialogs, generic over
a host context they mutate only through callbacks.
- **`CursorOverlay` / `render_overlay` / `dim_area`** — the modal-overlay scaffolding:
a widget paints into a buffer and returns where the caret goes; the host places it.
## Notes
- **Not WASM-compatible**: pulls in `syntect` (highlighting) and `arboard` (system
clipboard). The ratatui core itself is WASM-friendly.
- **ratatui version**: pinned to `0.30.x`. Consumers exchange ratatui types with these
widgets, so their `ratatui` must unify with this crate's.
## Development
```bash
cargo build
cargo test # pure-logic unit tests, no TTY required
```
+405
View File
@@ -0,0 +1,405 @@
%YAML 1.2
---
# http://www.sublimetext.com/docs/3/syntax.html
name: Rhai
file_extensions:
- rhai
scope: source.rhai
contexts:
main:
- include: core
brackets:
- include: round-brackets
- include: square-brackets
- include: curly-brackets
comments:
- match: '/\*\*(?![/|\*])'
captures:
0: punctuation.definition.comment.block.documentation.rhai
push:
- meta_scope: comment.block.documentation.rhai
- match: \*/
captures:
0: punctuation.definition.comment.block.documentation.rhai
pop: true
- include: comments
- match: /\*
captures:
0: punctuation.definition.comment.block.rhai
push:
- meta_scope: comment.block.rhai
- match: \*/
captures:
0: punctuation.definition.comment.block.rhai
pop: true
- include: comments
- match: '(///)[^/].*$\n?'
scope: comment.line.documentation.rhai
captures:
1: punctuation.definition.comment.documentation.rhai
- match: (//).*$\n?
scope: comment.line.double-slash.rhai
captures:
1: punctuation.definition.comment.double-slash.rhai
- match: ^(#!).*$\n?
scope: comment.line.shebang.rhai
captures:
1: punctuation.definition.comment.rhai
core:
- include: expression
curly-brackets:
- match: '\{'
captures:
0: meta.brace.curly.rhai
push:
- meta_scope: meta.group.braces.curly
- match: '\}'
captures:
0: meta.brace.curly.rhai
pop: true
- include: main
expression:
- include: literal-closure-labels
- include: literal-labels
- include: literal-keywords
- include: support
- include: literal-function
- include: literal-closure
- include: literal-constant
- include: literal-template-string
- include: literal-language-variable
- include: literal-module
- include: literal-method-call
- include: literal-function-call
- include: comments
- include: brackets
- include: literal-operators
- include: literal-namespace
- include: literal-variable
- include: literal-punctuation
function-declaration-parameters:
- match: \(
captures:
0: punctuation.definition.parameters.begin.rhai
push:
- match: \)
captures:
0: punctuation.definition.parameters.end.rhai
pop: true
- match: '[_a-zA-Z]\w*'
scope: variable.parameter.function.rhai
- match: \,
scope: punctuation.separator.parameter.function.rhai
- include: parameters-list
literal-closure:
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
- match: '(\b[_a-zA-Z]\w*)\s*(=)\s*(\|)'
captures:
1: entity.name.function.closure.rhai
2: keyword.operator.assignment.rhai
3: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
literal-closure-labels:
- match: '(\b[_a-zA-Z]\w*)\s*(\:)\s*(\|)'
captures:
1: string.unquoted.label.rhai entity.name.function.method.rhai
2: punctuation.separator.key-value.rhai
3: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
- match: '((\")((?:[^"]|\\")*)(\"))\s*(:)\s*(\|)'
captures:
1: string.quoted.double.rhai
2: punctuation.definition.string.begin.rhai
3: entity.name.function.method.rhai
4: punctuation.definition.string.end.rhai
5: punctuation.separator.key-value.rhai
6: punctuation.definition.parameters.closure.begin.rhai
push:
- meta_scope: meta.function.closure.rhai
- match: (\|)
captures:
1: punctuation.definition.parameters.closure.end.rhai
pop: true
- include: parameters-list
literal-constant:
- include: literal-number
- include: literal-string
- include: literal-language-constant
literal-function:
- match: |-
(?x) (?:\b(private)\s+)?
\s*(fn)
\s*([_a-zA-Z]\w*)\s*
captures:
1: storage.modifier.rhai
2: storage.type.function.rhai
3: entity.name.function.rhai
push:
- meta_scope: meta.function.rhai
- match: (?<=\))
pop: true
- include: parameters-list
literal-function-call:
- match: |-
(?x) ([_a-zA-Z]\w*)(!)?\s*
(\(\s*\))
scope: meta.function-call.without-arguments.rhai
captures:
1: entity.name.function.rhai
2: punctuation.function-call.capture.rhai
3: meta.group.braces.round.function.arguments.rhai
- match: |-
(?x) ([_a-zA-Z]\w*)(!)?\s*
(?=\()
scope: meta.function-call.with-arguments.rhai
captures:
1: entity.name.function.rhai
2: punctuation.function-call.capture.rhai
literal-keyword-storage:
- match: \b(const|let)\b
scope: storage.type.rhai
literal-keywords:
- include: literal-keyword-storage
- match: \b(return)\b
scope: keyword.control.flow.rhai
- match: \b(if|else|switch)\b
scope: keyword.control.conditional.rhai
- match: \b(throw|try|catch)\b
scope: keyword.control.trycatch.rhai
- match: \b(for|in|loop|do|while|until|break|continue)\b
scope: keyword.control.loop.rhai
literal-labels:
- match: |-
(?x) (?<!\?)(?<!\?\s)
(?=((")((?:[^\:"]|\\")*)("))\s*:)
push:
- match: ":"
captures:
0: punctuation.separator.key-value.rhai
pop: true
- include: literal-string
- match: '(?<!\.|\?|\?\s)([_a-zA-Z]\w*)\s*(:)(?!\:)'
scope: constant.other.object.key.rhai
captures:
1: string.unquoted.label.rhai
2: punctuation.separator.key-value.rhai
literal-language-constant:
- match: \btrue\b
scope: constant.language.boolean.true.rhai
- match: \bfalse\b
scope: constant.language.boolean.false.rhai
literal-language-namespace:
- match: '(?<!\:\:)\s*((global)\s*(\:\:))(?!<)'
captures:
1: meta.path.rhai
2: constant.language.namespace.global.rhai entity.name.namespace.rhai
3: punctuation.separator.namespace.rhai
literal-language-variable:
- match: \bthis\b
scope: variable.language.this.rhai
literal-method-call:
- match: |-
(?x) (?<=\.)
\s*([_a-zA-Z]\w*)\s*
(\(\s*\))
scope: meta.function-call.method.without-arguments.rhai
captures:
1: entity.name.function.rhai
2: meta.group.braces.round.function.arguments.rhai
- match: |-
(?x) (?<=\.)
\s*([_a-zA-Z]\w*)\s*
(?=\()
scope: meta.function-call.method.with-arguments.rhai
captures:
1: entity.name.function.rhai
literal-module:
- match: \b(import|export|as)\b
scope: keyword.control.import.rhai
literal-namespace:
- include: literal-language-namespace
- match: '([_a-zA-Z]\w*)\s*(\:\:)(?!<)'
scope: meta.path.rhai
captures:
1: entity.name.namespace.rhai
2: punctuation.separator.namespace.rhai
- match: '(?<=\:\:)(\s*([_a-zA-Z]\w*))\s*(?!\:\:)'
captures:
1: meta.path.rhai
2: variable.other.constant.rhai
literal-number:
- match: |-
(?xi) (?:
\b0b[0-1][_0-1]*| # binary
\b0o[0-7][_0-7]*| # octal
\b0x[\da-f][_\da-f]*| # hex
(\B[+\-])?\b\d[_\d]*\.\d[_\d]*(e[+\-]?\d[_\d]*)?| # e.g. 999.999, 999.99e+123
(\B[+\-])?\b\d[_\d]*\.\B| # e.g. 999.
(\B[+\-])?\b\d[_\d]*(e[+\-]?\d[_\d]*)? # e.g. 999, 999e+123
)
scope: constant.numeric.rhai
literal-operators:
- match: |-
(?x) !(?!=)| # logical-not right-to-left right
&& | # logical-and left-to-right both
\|\| # logical-or left-to-right both
scope: keyword.operator.logical.rhai
- match: "(?x) =(?![=>]) # assignment right-to-left both"
scope: keyword.operator.assignment.rhai
- match: |-
(?x) %= | # assignment right-to-left both
&= | # assignment right-to-left both
\*\*=| # assignment right-to-left both
(?<!\*)\*= | # assignment right-to-left both
\+= | # assignment right-to-left both
-= | # assignment right-to-left both
/= | # assignment right-to-left both
\^= | # assignment right-to-left both
\|= | # assignment right-to-left both
<<= | # assignment right-to-left both
>>= # assignment right-to-left both
scope: keyword.operator.assignment.augmented.rhai
- match: |-
(?x) << | # bitwise-shift left-to-right both
>> | # bitwise-shift left-to-right both
& | # bitwise-and left-to-right both
\^ | # bitwise-xor left-to-right both
\| # bitwise-or left-to-right both
scope: keyword.operator.bitwise.rhai
- match: |-
(?x) <= | # relational left-to-right both
>= | # relational left-to-right both
<(?!-) | # relational left-to-right both
(?<!-)> # relational left-to-right both
scope: keyword.operator.relational.rhai
- match: |-
(?x) ==(?!=) | # equality left-to-right both
!=(?!=) # equality left-to-right both
scope: keyword.operator.comparison.rhai
- match: |-
(?x) / | # division left-to-right both
% | # modulus left-to-right both
\*\* | # power left-to-right both
\*(?!\)) | # multiplication left-to-right both
\+(?!\+) | # addition left-to-right both
-(?![>-]) # subtraction left-to-right both
scope: keyword.operator.arithmetic.rhai
- match: \.(?!\.)
scope: keyword.operator.accessor.rhai punctuation.accessor.rhai
- match: "=>"
scope: punctuation.separator.switch.case.rhai
- match: '(\(\*|\*\)|\+\+|--|\.\.+|~|#(?!{)|@|\$(?!{)|->|<-|===|!==|\:=|\:\:<)'
scope: invalid.illegal.operator.rhai
literal-punctuation:
- match: \;
scope: punctuation.terminator.statement.rhai
- match: \,
scope: meta.delimiter.comma.rhai
literal-string:
- match: '(''([^''\\]|\\([tnr''\\]|x\h{2}|u\h{4}|U\h{8}))'')'
scope: string.quoted.single.rhai
- match: (")
captures:
1: punctuation.definition.string.begin.rhai
push:
- meta_scope: string.quoted.double.rhai
- match: (")
captures:
1: punctuation.definition.string.end.rhai
pop: true
- include: string-content
- match: (?<!\\)\n
scope: invalid.illegal.newline.rhai
- match: \\\n
scope: constant.character.escape.newline.rhai punctuation.separator.continuation
literal-template-string:
- match: "`"
captures:
0: punctuation.definition.string.begin.rhai
push:
- meta_scope: string.interpolated.rhai
- match: "`"
captures:
0: punctuation.definition.string.end.rhai
pop: true
- include: string-content
- match: '\${'
captures:
0: punctuation.section.interpolation.begin.rhai
push:
- meta_scope: meta.interpolation.rhai
- match: "}"
captures:
0: punctuation.section.interpolation.end.rhai
pop: true
- include: expression
literal-variable:
- match: '[A-Z][_\dA-Z]*\b'
scope: variable.other.constant.rhai
- match: '(?<!\.)\s*([_a-zA-Z]\w*)\s*(?=\.)'
captures:
1: variable.other.object.rhai
- match: '(?<=\.)\s*([_a-zA-Z]\w*)'
captures:
1: variable.other.property.rhai entity.name.property.rhai
- match: '[_a-zA-Z]\w*'
scope: variable.other.readwrite.rhai
parameters-list:
- match: '[_a-zA-Z]\w*'
scope: variable.parameter.function.rhai
- match: \,
scope: punctuation.separator.parameter.function.rhai
- include: comments
round-brackets:
- match: \((?!\*)
captures:
0: meta.brace.round.rhai
push:
- meta_scope: meta.group.braces.round
- match: (?<!\*)\)
captures:
0: meta.brace.round.rhai
pop: true
- include: expression
square-brackets:
- match: '\['
captures:
0: meta.brace.square.rhai
push:
- meta_scope: meta.group.braces.square
- match: '\]'
captures:
0: meta.brace.square.rhai
pop: true
- include: expression
string-content:
- match: '\\(x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8}|t|r|n|\\)'
scope: constant.character.escape.rhai
- match: '\\[^xuUtrn\\\n]'
scope: invalid.illegal.escape.rhai
support:
- match: \b(print|debug|call|curry|eval|type_of|is_def_var|is_def_fn|is_shared)\b
scope: support.function.rhai
- match: \b(var|static|shared|goto|exit|match|case|public|protected|new|use|with|module|package|super|thread|spawn|go|await|async|sync|yield|default|void|null|nil)\b
scope: invalid.illegal.keyword.rhai
+54
View File
@@ -0,0 +1,54 @@
//! A clipboard shared by the text widgets ([`code_editor`](crate::code_editor),
//! [`text_field`](crate::text_field)).
//!
//! It is backed by an always-present internal buffer, mirrored to the system clipboard
//! when one is available — so copy/paste works the same everywhere, degrading cleanly.
/// A clipboard backed by an always-present internal buffer, mirrored to the system
/// clipboard when one is available.
///
/// `arboard::Clipboard::new()` fails with no display (headless Linux, etc.) and isn't
/// available on the web; in those cases `system` is `None` and copy/paste still work
/// *within* the app via `internal`. When the system clipboard is present we write
/// through to it (so other apps see copies) and prefer it on read (so we see their
/// copies), falling back to `internal` if a read fails.
pub(crate) struct Clipboard {
system: Option<arboard::Clipboard>,
internal: String,
}
impl Clipboard {
/// Creates a clipboard, attaching the system one if it initializes.
///
/// Under `cfg(test)` the system clipboard is never attached: arboard isn't safe to
/// initialize from many threads at once (the parallel test harness would otherwise
/// crash on macOS), and tests exercise copy/paste through the internal buffer anyway.
pub(crate) fn new() -> Self {
#[cfg(not(test))]
let system = arboard::Clipboard::new().ok();
#[cfg(test)]
let system = None;
Self {
system,
internal: String::new(),
}
}
/// Stores `text` internally and (best-effort) on the system clipboard.
pub(crate) fn set(&mut self, text: String) {
if let Some(sys) = &mut self.system {
let _ = sys.set_text(&text);
}
self.internal = text;
}
/// Returns the system clipboard text if readable, else the internal buffer.
pub(crate) fn get(&mut self) -> String {
if let Some(sys) = &mut self.system
&& let Ok(text) = sys.get_text()
{
return text;
}
self.internal.clone()
}
}
+930
View File
@@ -0,0 +1,930 @@
//! A small, purpose-built **modeless, keyboard-only** code editor widget.
//!
//! It edits like an ordinary text box (no vim modes): arrow keys move (wrapping across line
//! ends), printable keys insert, `shift`+arrows select, `ctrl`/`⌘`+`c`/`x`/`v` use the system
//! clipboard, `ctrl`/`⌘`+`z`/`y` undo/redo, and `Esc` asks the host to close. It shows a
//! line-number gutter and syntect syntax highlighting (the embedded Rhai syntax).
//!
//! Mouse is intentionally out of scope for now (its click hit-testing — gutter + scroll +
//! unicode width + tab expansion — is the hard part); the same per-line width model here
//! would invert to support it later. Like [`dialog`](crate::dialog) this is presentation +
//! input only; the host owns the loop and decides what open/close means.
use std::sync::Arc;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::syntax_definition::SyntaxDefinition;
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
use crate::clipboard::Clipboard;
use crate::text::{
TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl,
};
/// The embedded Rhai Sublime-syntax definition used for highlighting.
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
/// A dark syntect theme that ships with `ThemeSet::load_defaults`.
const THEME_NAME: &str = "base16-ocean.dark";
/// Maximum retained undo (and redo) snapshots.
const UNDO_DEPTH: usize = 200;
/// Default text color when no syntax color applies.
const DEFAULT_FG: Color = Color::Rgb(220, 220, 220);
/// Background of selected text.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Line-number gutter color.
const GUTTER_FG: Color = Color::Rgb(90, 100, 120);
/// What a key/paste event did to the editor, reported back to the host.
pub enum CodeEditorOutcome {
/// The editor stays open.
Continue,
/// The user pressed `Esc`; the host should close the editor.
Exit,
}
/// A cursor/selection position: `col` is a **character** index within `row`'s line.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Pos {
row: usize,
col: usize,
}
/// Which kind of edit just happened, used to coalesce undo steps.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditKind {
/// A run of single-character insertions.
Insert,
/// A run of single-character deletions.
Delete,
/// Any structural edit (newline, paste, selection delete) — never coalesced.
Other,
}
/// A point-in-time buffer snapshot for undo/redo.
#[derive(Clone)]
struct Snapshot {
lines: Vec<String>,
cursor: Pos,
}
/// Cached syntect pieces for highlighting (reused across frames).
struct HighlightParts {
theme: Theme,
syntax_ref: SyntaxReference,
syntax_set: Arc<SyntaxSet>,
}
/// A modeless, keyboard-driven code editor over a single script's text.
pub struct CodeEditor {
/// The script name, shown in the title border and used by the host on save.
name: String,
/// The text buffer, one entry per line (always at least one line).
lines: Vec<String>,
/// The cursor position.
cursor: Pos,
/// Remembered display intent for vertical movement (so up/down keep a column).
desired_col: usize,
/// Selection anchor; the selection spans `anchor..cursor` when `Some`.
anchor: Option<Pos>,
/// Scroll offset as `(top row, left display column)`, kept following the cursor.
scroll: (usize, usize),
/// Visible text rows from the last `draw`, used by PageUp/PageDown.
view_rows: usize,
/// Syntax-highlighting data, or `None` if the syntax/theme failed to load.
highlight: Option<HighlightParts>,
/// Clipboard: the system one when available, always backed by an internal buffer.
clipboard: Clipboard,
/// Undo/redo snapshot stacks and the open-edit-group marker for coalescing.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
last_edit: Option<EditKind>,
}
impl CodeEditor {
/// Builds an editor for `name` pre-filled with `text`.
pub fn new(name: &str, text: &str) -> Self {
let lines: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(str::to_string).collect()
};
Self {
name: name.to_string(),
lines,
cursor: Pos { row: 0, col: 0 },
desired_col: 0,
anchor: None,
scroll: (0, 0),
view_rows: 0,
highlight: build_highlight_parts(),
clipboard: Clipboard::new(),
undo: Vec::new(),
redo: Vec::new(),
last_edit: None,
}
}
/// The script name (the host writes [`text`](CodeEditor::text) back under it).
pub fn name(&self) -> &str {
&self.name
}
/// The current buffer contents (lines joined with `\n`).
pub fn text(&self) -> String {
self.lines.join("\n")
}
/// Handles a key/paste event. `Esc` requests close; unrecognized keys are ignored.
pub fn handle_event(&mut self, event: &Event) -> CodeEditorOutcome {
match event {
// Terminal (bracketed) paste: insert the text as-is.
Event::Paste(text) => {
self.break_group();
self.replace_selection();
self.insert_text(text);
}
Event::Key(key) => {
// Treat ⌘ as ctrl (terminals report Super for Cmd under the kitty protocol).
let key = super_to_ctrl(*key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => return CodeEditorOutcome::Exit,
// Clipboard / undo / select-all chords.
KeyCode::Char(c) if ctrl => match c.to_ascii_lowercase() {
'c' => self.copy(),
'x' => self.cut(),
'v' => self.paste(),
'a' => self.select_all(),
'z' => {
if shift {
self.redo()
} else {
self.undo()
}
}
'y' => self.redo(),
_ => {}
},
// Printable text.
KeyCode::Char(c) if !alt => self.insert_char(c),
KeyCode::Tab => self.insert_tab(),
KeyCode::Enter => self.insert_newline(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete_forward(),
KeyCode::Left => self.moved(
shift,
if ctrl {
Self::move_word_left
} else {
Self::move_left
},
),
KeyCode::Right => self.moved(
shift,
if ctrl {
Self::move_word_right
} else {
Self::move_right
},
),
KeyCode::Up => self.moved(
shift,
if ctrl {
Self::move_paragraph_up
} else {
Self::move_up
},
),
KeyCode::Down => self.moved(
shift,
if ctrl {
Self::move_paragraph_down
} else {
Self::move_down
},
),
KeyCode::Home => self.moved(shift, Self::move_home),
KeyCode::End => self.moved(shift, Self::move_end),
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
KeyCode::PageDown => self.moved(shift, Self::move_page_down),
_ => {} // ignore anything else — never panic on an unknown key
}
}
_ => {} // mouse and other events are ignored for now
}
CodeEditorOutcome::Continue
}
// ── Movement ─────────────────────────────────────────────────────────────
/// Runs a movement, first setting/clearing the selection anchor per `shift`.
fn moved(&mut self, shift: bool, mv: fn(&mut Self)) {
self.break_group();
if shift {
// Begin (or continue) a selection from the pre-move cursor.
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
mv(self);
}
/// Number of characters in line `row`.
fn line_len(&self, row: usize) -> usize {
self.lines[row].chars().count()
}
fn move_left(&mut self) {
if self.cursor.col > 0 {
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Wrap to the end of the previous line.
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
}
self.desired_col = self.cursor.col;
}
fn move_right(&mut self) {
if self.cursor.col < self.line_len(self.cursor.row) {
self.cursor.col += 1;
} else if self.cursor.row + 1 < self.lines.len() {
// Wrap to the start of the next line.
self.cursor.row += 1;
self.cursor.col = 0;
}
self.desired_col = self.cursor.col;
}
fn move_up(&mut self) {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
fn move_down(&mut self) {
if self.cursor.row + 1 < self.lines.len() {
self.cursor.row += 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
/// ctrl+Left: jump to the previous word start (wrapping to the previous line's end).
fn move_word_left(&mut self) {
if self.cursor.col == 0 && self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
} else {
self.cursor.col = prev_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Right: jump to the next word start (wrapping to the next line's start).
fn move_word_right(&mut self) {
if self.cursor.col == self.line_len(self.cursor.row)
&& self.cursor.row + 1 < self.lines.len()
{
self.cursor.row += 1;
self.cursor.col = 0;
} else {
self.cursor.col = next_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
fn move_paragraph_up(&mut self) {
self.cursor.row = (0..self.cursor.row)
.rev()
.find(|&r| self.is_blank_line(r))
.unwrap_or(0);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// ctrl+Down: jump to the nearest blank/whitespace-only line below, else the last line.
fn move_paragraph_down(&mut self) {
let last = self.lines.len() - 1;
self.cursor.row = (self.cursor.row + 1..self.lines.len())
.find(|&r| self.is_blank_line(r))
.unwrap_or(last);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// Whether line `row` is empty or contains only whitespace.
fn is_blank_line(&self, row: usize) -> bool {
self.lines[row].trim().is_empty()
}
fn move_home(&mut self) {
self.cursor.col = 0;
self.desired_col = 0;
}
fn move_end(&mut self) {
self.cursor.col = self.line_len(self.cursor.row);
self.desired_col = self.cursor.col;
}
fn move_page_up(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = self.cursor.row.saturating_sub(step);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
fn move_page_down(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = (self.cursor.row + step).min(self.lines.len() - 1);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
// ── Editing ──────────────────────────────────────────────────────────────
fn insert_char(&mut self, c: char) {
self.replace_selection();
self.begin_edit(EditKind::Insert);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert(b, c);
self.cursor.col += 1;
self.desired_col = self.cursor.col;
}
/// Inserts spaces to advance to the next tab stop.
fn insert_tab(&mut self) {
self.replace_selection();
let pad = TAB_WIDTH - (self.display_col(self.cursor.row, self.cursor.col) % TAB_WIDTH);
for _ in 0..pad {
self.insert_char(' ');
}
}
fn insert_newline(&mut self) {
self.replace_selection();
self.begin_edit(EditKind::Other);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
self.desired_col = 0;
}
/// Inserts arbitrary text (possibly multi-line) at the cursor.
fn insert_text(&mut self, text: &str) {
self.begin_edit(EditKind::Other);
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
// Each '\n' splits the current line.
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
}
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert_str(b, part);
self.cursor.col += part.chars().count();
}
self.desired_col = self.cursor.col;
}
fn backspace(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col > 0 {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col - 1);
self.lines[self.cursor.row].remove(b);
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Join with the previous line.
let line = self.lines.remove(self.cursor.row);
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
self.lines[self.cursor.row].push_str(&line);
}
self.desired_col = self.cursor.col;
}
fn delete_forward(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col < self.line_len(self.cursor.row) {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].remove(b);
} else if self.cursor.row + 1 < self.lines.len() {
// Pull the next line up onto this one.
let next = self.lines.remove(self.cursor.row + 1);
self.lines[self.cursor.row].push_str(&next);
}
}
// ── Selection ──────────────────────────────────────────────────────────────
/// The selection as an ordered `(start, end)` pair, if any.
fn selection(&self) -> Option<(Pos, Pos)> {
let a = self.anchor?;
let c = self.cursor;
if (a.row, a.col) <= (c.row, c.col) {
Some((a, c))
} else {
Some((c, a))
}
}
/// Deletes the current selection (if any) and places the cursor at its start.
fn replace_selection(&mut self) {
let Some((a, b)) = self.selection() else {
return;
};
self.begin_edit(EditKind::Other);
if a.row == b.row {
let s = &mut self.lines[a.row];
let (ba, bb) = (byte_of(s, a.col), byte_of(s, b.col));
s.replace_range(ba..bb, "");
} else {
let bb = byte_of(&self.lines[b.row], b.col);
let tail = self.lines[b.row][bb..].to_string();
let ba = byte_of(&self.lines[a.row], a.col);
self.lines[a.row].truncate(ba);
self.lines[a.row].push_str(&tail);
self.lines.drain(a.row + 1..=b.row);
}
self.cursor = a;
self.desired_col = a.col;
self.anchor = None;
}
/// The text covered by an ordered selection `(a, b)`.
fn selected_text(&self, a: Pos, b: Pos) -> String {
if a.row == b.row {
let s = &self.lines[a.row];
return s[byte_of(s, a.col)..byte_of(s, b.col)].to_string();
}
let mut out = self.lines[a.row][byte_of(&self.lines[a.row], a.col)..].to_string();
for row in a.row + 1..b.row {
out.push('\n');
out.push_str(&self.lines[row]);
}
out.push('\n');
out.push_str(&self.lines[b.row][..byte_of(&self.lines[b.row], b.col)]);
out
}
fn select_all(&mut self) {
self.break_group();
let last = self.lines.len() - 1;
self.anchor = Some(Pos { row: 0, col: 0 });
self.cursor = Pos {
row: last,
col: self.line_len(last),
};
}
// ── Clipboard ──────────────────────────────────────────────────────────────
fn copy(&mut self) {
if let Some((a, b)) = self.selection() {
let text = self.selected_text(a, b);
self.clipboard.set(text);
}
}
fn cut(&mut self) {
self.copy();
self.replace_selection();
}
fn paste(&mut self) {
let text = self.clipboard.get();
if text.is_empty() {
return;
}
self.break_group();
self.replace_selection();
self.insert_text(&text);
}
// ── Undo / redo ──────────────────────────────────────────────────────────
/// Begins an edit of `kind`, snapshotting state unless it coalesces with the run in
/// progress (consecutive inserts, or consecutive deletes, collapse to one undo step).
fn begin_edit(&mut self, kind: EditKind) {
let coalesce = kind != EditKind::Other && self.last_edit == Some(kind);
if !coalesce {
self.push_snapshot();
}
self.last_edit = Some(kind);
}
/// Ends the current edit run so the next edit starts a fresh undo step.
fn break_group(&mut self) {
self.last_edit = None;
}
/// Pushes the current state to the undo stack and clears redo.
fn push_snapshot(&mut self) {
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.redo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(prev);
}
}
fn redo(&mut self) {
if let Some(next) = self.redo.pop() {
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(next);
}
}
/// Replaces the buffer/cursor with a snapshot, ending any edit run and selection.
fn restore(&mut self, snap: Snapshot) {
self.lines = snap.lines;
self.cursor = snap.cursor;
self.desired_col = self.cursor.col;
self.anchor = None;
self.last_edit = None;
}
// ── Rendering ──────────────────────────────────────────────────────────────
/// Display column (terminal cells) of character index `col` on line `row` (tabs to the
/// next [`TAB_WIDTH`] stop, wide chars as two cells).
fn display_col(&self, row: usize, col: usize) -> usize {
display_width(&self.lines[row], col)
}
/// Renders the editor (titled border, gutter, highlighted text, cursor) into `area`.
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
let block = Block::bordered().title(format!(" {} ", self.name));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let rows = inner.height as usize;
self.view_rows = rows;
// Gutter holds the largest line number plus a trailing space.
let gutter_w = (self.lines.len().to_string().len() + 1) as u16;
let text_x = inner.x + gutter_w;
let text_w = inner.width.saturating_sub(gutter_w);
if text_w == 0 {
return;
}
self.update_scroll(rows, text_w as usize);
let (top, left) = self.scroll;
let bottom = (top + rows).min(self.lines.len());
// Highlight from line 0 to the viewport bottom (syntect needs prior lines for context).
let colors = self.highlight_through(bottom);
let sel = self.selection();
for (i, row) in (top..bottom).enumerate() {
let y = inner.y + i as u16;
// Right-aligned line number in the gutter (temporary buffer borrow per call).
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
frame
.buffer_mut()
.set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
let rect = Rect {
x: text_x,
y,
width: text_w,
height: 1,
};
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
}
// Place the real terminal cursor if it's within the visible window.
let cx = self.display_col(self.cursor.row, self.cursor.col);
if (top..bottom).contains(&self.cursor.row) && cx >= left && cx - left < text_w as usize {
let x = text_x + (cx - left) as u16;
let y = inner.y + (self.cursor.row - top) as u16;
frame.set_cursor_position((x, y));
}
}
/// Adjusts `scroll` so the cursor stays visible (vertical by row, horizontal by cell).
fn update_scroll(&mut self, rows: usize, text_w: usize) {
let (mut top, mut left) = self.scroll;
if self.cursor.row < top {
top = self.cursor.row;
} else if self.cursor.row >= top + rows {
top = self.cursor.row + 1 - rows;
}
let cx = self.display_col(self.cursor.row, self.cursor.col);
if cx < left {
left = cx;
} else if cx >= left + text_w {
left = cx + 1 - text_w;
}
self.scroll = (top, left);
}
/// Per-character foreground colors for lines `0..upto`, via syntect (empty if disabled).
fn highlight_through(&self, upto: usize) -> Vec<Vec<Color>> {
let Some(hl) = &self.highlight else {
return Vec::new();
};
let mut h = HighlightLines::new(&hl.syntax_ref, &hl.theme);
let mut out = Vec::with_capacity(upto);
for line in self.lines.iter().take(upto) {
// Feed a trailing newline so multi-line constructs keep state, then drop it.
let with_nl = format!("{line}\n");
let regions = h
.highlight_line(&with_nl, &hl.syntax_set)
.unwrap_or_default();
let mut colors = Vec::with_capacity(line.chars().count());
for (style, text) in regions {
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
colors.extend(text.chars().map(|_| c));
}
colors.truncate(line.chars().count());
out.push(colors);
}
out
}
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
fn render_line(
&self,
row: usize,
colors: Option<&[Color]>,
sel: Option<(Pos, Pos)>,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut col_cells = 0; // running display column, for tab expansion
for (i, c) in self.lines[row].chars().enumerate() {
let fg = colors
.and_then(|cs| cs.get(i))
.copied()
.unwrap_or(DEFAULT_FG);
let mut style = Style::default().fg(fg);
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
style = style.bg(SELECT_BG);
}
if c == '\t' {
let pad = TAB_WIDTH - (col_cells % TAB_WIDTH);
spans.push(Span::styled(" ".repeat(pad), style));
col_cells += pad;
} else {
spans.push(Span::styled(c.to_string(), style));
col_cells += char_cells(c, col_cells);
}
}
Line::from(spans)
}
}
/// Whether character `(row, col)` lies within ordered selection `[a, b)`.
fn pos_in_range(row: usize, col: usize, a: Pos, b: Pos) -> bool {
let after_start = row > a.row || (row == a.row && col >= a.col);
let before_end = row < b.row || (row == b.row && col < b.col);
after_start && before_end
}
/// Parses the embedded Rhai syntax and a default theme into reusable highlight parts.
/// Returns `None` (highlighting disabled, editor still works) on any failure.
fn build_highlight_parts() -> Option<HighlightParts> {
let syntax = SyntaxDefinition::load_from_str(RHAI_SYNTAX, true, None).ok()?;
let mut builder = SyntaxSetBuilder::new();
builder.add(syntax);
let syntax_set = builder.build();
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
Some(HighlightParts {
theme,
syntax_ref,
syntax_set: Arc::new(syntax_set),
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyEvent;
/// Sends a bare key code to the editor.
fn send(ed: &mut CodeEditor, code: KeyCode) {
ed.handle_event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
}
/// Sends a key code with modifiers (e.g. ctrl/shift).
fn send_mod(ed: &mut CodeEditor, code: KeyCode, mods: KeyModifiers) {
ed.handle_event(&Event::Key(KeyEvent::new(code, mods)));
}
/// Types each character of `s` in order.
fn typ(ed: &mut CodeEditor, s: &str) {
for c in s.chars() {
send(ed, KeyCode::Char(c));
}
}
#[test]
fn embedded_rhai_syntax_parses_and_theme_loads() {
assert!(build_highlight_parts().is_some());
}
#[test]
fn text_round_trips_and_typing_inserts() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "let x = 1;");
assert_eq!(ed.text(), "let x = 1;");
assert_eq!(CodeEditor::new("s", "a\nb\nc").text(), "a\nb\nc");
}
#[test]
fn left_at_col0_wraps_to_previous_line_end() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Left); // should wrap to end of line 0 = (0,2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "abX\ncd");
}
#[test]
fn right_at_line_end_wraps_down() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2)
send(&mut ed, KeyCode::Right); // wrap to (1,0)
typ(&mut ed, "X");
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn backspace_at_col0_joins_lines() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "abcd");
typ(&mut ed, "X"); // cursor should sit at the join (0,2)
assert_eq!(ed.text(), "abXcd");
}
#[test]
fn shift_selection_then_delete_removes_it() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "cd");
}
#[test]
fn bracketed_paste_replaces_selection() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
ed.handle_event(&Event::Paste("XY\nZ".to_string()));
assert_eq!(ed.text(), "XY\nZcd");
}
#[test]
fn copy_then_paste_round_trips_via_internal_clipboard() {
// Works even headless: the internal buffer backs copy/paste when there's no display.
let mut ed = CodeEditor::new("s", "abc");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send_mod(&mut ed, KeyCode::Char('c'), KeyModifiers::CONTROL); // copy
send(&mut ed, KeyCode::End); // clear selection, go to end
send_mod(&mut ed, KeyCode::Char('v'), KeyModifiers::CONTROL); // paste
assert_eq!(ed.text(), "abcab");
}
#[test]
fn undo_and_redo_round_trip() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "abc"); // coalesces to one undo step
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "");
send_mod(
&mut ed,
KeyCode::Char('z'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(ed.text(), "abc");
}
#[test]
fn movement_breaks_undo_coalescing() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "ab");
send(&mut ed, KeyCode::Left); // breaks the typing group
typ(&mut ed, "c"); // "acb" (inserted before 'b')... actually at (0,1): "acb"
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "ab"); // only the post-move insert is undone
}
/// Sends a key code with modifiers and types a marker, returning where it landed.
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn ctrl_right_jumps_to_next_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz"); // cursor at (0,0) via Home
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "|"); // insert marker at the landing column
assert_eq!(ed.text(), "foo |bar baz"); // landed at start of "bar"
}
#[test]
fn ctrl_left_jumps_to_previous_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz");
send(&mut ed, KeyCode::End); // move to end (0,11) first
ed.handle_event(&Event::Key(ctrl(KeyCode::Left)));
typ(&mut ed, "|");
assert_eq!(ed.text(), "foo bar |baz"); // landed at start of "baz"
}
#[test]
fn ctrl_right_wraps_at_end_of_line() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2) end of first line
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "X"); // should land at start of line 2
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn ctrl_down_and_up_jump_to_blank_lines() {
let mut ed = CodeEditor::new("s", "alpha\nbeta\n\ngamma");
send(&mut ed, KeyCode::Home); // (0,0)
ed.handle_event(&Event::Key(ctrl(KeyCode::Down))); // → blank line (row 2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "alpha\nbeta\nX\ngamma");
// From the blank line, ctrl+Up returns to the top (no blank above).
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Up)));
typ(&mut ed, "Y");
assert_eq!(ed.text(), "Yalpha\nbeta\nX\ngamma");
}
#[test]
fn shift_ctrl_right_selects_a_word() {
let mut ed = CodeEditor::new("s", "foo bar");
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
))); // select "foo "
send(&mut ed, KeyCode::Backspace); // delete the selection
assert_eq!(ed.text(), "bar");
}
#[test]
fn esc_requests_exit() {
let mut ed = CodeEditor::new("s", "x");
assert!(matches!(
ed.handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
CodeEditorOutcome::Exit
));
}
#[test]
fn cmd_z_maps_to_ctrl_undo() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "hi");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::SUPER);
assert_eq!(ed.text(), "");
}
}
+508
View File
@@ -0,0 +1,508 @@
//! 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/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
//! single-line editor used elsewhere in kiln-ui).
//!
//! 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::text_field::TextField;
use crate::{CursorOverlay, dim_area};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
/// 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 { field: TextField },
/// A filter field above a selectable list. `selected` indexes the *filtered*
/// view (entries of `all` whose text starts with the filter).
List {
field: TextField,
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 {
field: TextField::new(initial),
},
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 {
field: TextField::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.
_ => {
if self.field_mut().handle_key(*key) {
// The filter may have changed, so the previous selection index may now
// be out of range — clamp it back (compute length first, then clamp).
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 { field }, DialogCallback::Text(cb)) => {
cb(submit.then(|| field.value().to_string()), ctx);
}
(
DialogBody::List {
field,
all,
selected,
allow_create,
},
DialogCallback::List(cb),
) => {
let resp = if !submit {
ListDialogResponse::Cancel
} else if let Some(entry) = filtered_items(&all, field.value())
.into_iter()
.nth(selected)
{
ListDialogResponse::Select(entry.clone())
} else if allow_create {
ListDialogResponse::Create(field.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 {
field,
allow_create,
..
} => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()),
}
}
/// Mutable access to whichever [`TextField`] this dialog owns.
fn field_mut(&mut self) -> &mut TextField {
match &mut self.body {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
}
}
/// Number of list entries currently passing the filter (0 for a text dialog).
fn filtered_len(&self) -> usize {
filtered_count(&self.body)
}
}
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 { .. });
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.
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);
// 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 field = match &self.body {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
};
// The body draws the field(s) and reports the caret position.
let cursor = if let DialogBody::List {
all,
selected,
field,
..
} = &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);
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);
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(buf, field_area, field)
};
// 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)),
]);
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);
}
}
/// 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, field, .. } => filtered_items(all, field.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()
}
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
/// (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 None;
}
// The paragraph's style fills the whole field area, including empty cells.
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));
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(buf: &mut Buffer, 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();
Paragraph::new(lines)
.style(Style::default().bg(BG))
.render(area, buf);
}
#[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));
}
#[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);
}
}
+67
View File
@@ -0,0 +1,67 @@
//! `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::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Style};
mod clipboard;
pub mod code_editor;
pub mod dialog;
mod text;
pub mod text_field;
/// 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);
}
}
}
}
/// 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);
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Small char/width/key helpers shared by the text widgets
//! ([`code_editor`](crate::code_editor), [`text_field`](crate::text_field)).
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use unicode_width::UnicodeWidthChar;
/// Columns a tab advances to (tab stop).
pub(crate) const TAB_WIDTH: usize = 4;
/// Byte offset of character index `col` within `s` (clamped to `s.len()`).
pub(crate) fn byte_of(s: &str, col: usize) -> usize {
s.char_indices().nth(col).map_or(s.len(), |(b, _)| b)
}
/// Display width of `c` at running display column `at` (tabs advance to the next
/// [`TAB_WIDTH`] stop; wide characters are two cells; zero-width control chars are 0).
pub(crate) fn char_cells(c: char, at: usize) -> usize {
if c == '\t' {
TAB_WIDTH - (at % TAB_WIDTH)
} else {
UnicodeWidthChar::width(c).unwrap_or(0)
}
}
/// Display width (terminal cells) of the first `col` characters of `s`.
pub(crate) fn display_width(s: &str, col: usize) -> usize {
let mut w = 0;
for c in s.chars().take(col) {
w += char_cells(c, w);
}
w
}
/// Character index after moving one word forward from `col` in `line`: skip the current
/// run of non-whitespace, then the following whitespace (lands on the next word's start).
pub(crate) fn next_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut i = col;
while i < n && !chars[i].is_whitespace() {
i += 1;
}
while i < n && chars[i].is_whitespace() {
i += 1;
}
i
}
/// Character index after moving one word backward from `col` in `line`: skip whitespace,
/// then the preceding word (lands on that word's start).
pub(crate) fn prev_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let mut i = col;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
i
}
/// Rewrites a `⌘`-modified key to its `ctrl` equivalent (terminals report Super for Cmd
/// under the kitty protocol; edtui/our bindings key off ctrl).
pub(crate) fn super_to_ctrl(mut key: KeyEvent) -> KeyEvent {
if key.modifiers.contains(KeyModifiers::SUPER) {
key.modifiers.remove(KeyModifiers::SUPER);
key.modifiers.insert(KeyModifiers::CONTROL);
}
key
}
+268
View File
@@ -0,0 +1,268 @@
//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
//!
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::clipboard::Clipboard;
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
/// A single-line text input with a character-indexed cursor.
pub struct TextField {
/// The current text (never contains newlines).
text: String,
/// Cursor position as a character index in `0..=text.chars().count()`.
cursor: usize,
/// Clipboard for `ctrl`/`⌘`+`v` paste.
clipboard: Clipboard,
/// How long we will allow the string to be:
max_length: Option<usize>,
}
impl TextField {
/// Creates a field pre-filled with `initial`, cursor at the end.
pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into();
let cursor = text.chars().count();
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: None,
}
}
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
let mut text = initial.into();
text.truncate(max_length);
let cursor = text.chars().count();
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: Some(max_length),
}
}
/// The current text.
pub fn value(&self) -> &str {
&self.text
}
/// Replaces the entire text (truncated to `max_length` if set), moving the cursor
/// to the end. Used to overwrite the field programmatically — e.g. when a color
/// picker selects a named swatch and fills in its hex.
pub fn set(&mut self, text: impl Into<String>) {
let mut text = text.into();
if let Some(max) = self.max_length {
text.truncate(max);
}
self.cursor = text.chars().count();
self.text = text;
}
/// The cursor's display column (terminal cells from the start), for placing the caret.
pub fn display_cursor(&self) -> usize {
display_width(&self.text, self.cursor)
}
/// Number of characters in the field.
fn len(&self) -> usize {
self.text.chars().count()
}
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
///
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let key = super_to_ctrl(key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('v') if ctrl => self.paste(),
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
KeyCode::Char(c) if !alt => self.insert(c),
KeyCode::Backspace => return self.backspace(),
KeyCode::Delete => return self.delete_forward(),
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
_ => return false,
}
true
}
fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor);
if self
.max_length
.is_none_or(|max_length| max_length > self.text.len())
{
self.text.insert(b, c);
}
if self
.max_length
.is_none_or(|max_length| max_length > self.cursor)
{
self.cursor += 1;
}
}
fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let b = byte_of(&self.text, self.cursor - 1);
self.text.remove(b);
self.cursor -= 1;
true
}
fn delete_forward(&mut self) -> bool {
if self.cursor >= self.len() {
return false;
}
let b = byte_of(&self.text, self.cursor);
self.text.remove(b);
true
}
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) {
let clean: String = self
.clipboard
.get()
.chars()
.filter(|c| !matches!(c, '\n' | '\r'))
.collect();
let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean);
self.cursor += clean.chars().count();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn typing_inserts_and_value_reflects_it() {
let mut f = TextField::new("");
for c in "abc".chars() {
assert!(f.handle_key(key(KeyCode::Char(c))));
}
assert_eq!(f.value(), "abc");
assert_eq!(f.display_cursor(), 3);
}
#[test]
fn backspace_and_delete() {
let mut f = TextField::new("abc"); // cursor at end (3)
assert!(f.handle_key(key(KeyCode::Backspace)));
assert_eq!(f.value(), "ab");
f.handle_key(key(KeyCode::Home));
assert!(f.handle_key(key(KeyCode::Delete)));
assert_eq!(f.value(), "b");
// Nothing to delete at the boundaries.
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
}
#[test]
fn cursor_movement_inserts_at_right_place() {
let mut f = TextField::new("ac");
f.handle_key(key(KeyCode::Left)); // between a and c
f.handle_key(key(KeyCode::Char('b')));
assert_eq!(f.value(), "abc");
f.handle_key(key(KeyCode::Home));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.value(), "Xabc");
}
#[test]
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
let mut f = TextField::new("");
f.clipboard.set("multi\nline".to_string());
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
assert_eq!(f.value(), "multiline");
}
#[test]
fn cmd_v_maps_to_ctrl_v() {
let mut f = TextField::new("");
f.clipboard.set("hi".to_string());
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
assert_eq!(f.value(), "hi");
}
#[test]
fn display_cursor_counts_wide_chars() {
let mut f = TextField::new(""); // one double-width char, cursor at end
assert_eq!(f.display_cursor(), 2);
f.handle_key(key(KeyCode::Home));
assert_eq!(f.display_cursor(), 0);
}
#[test]
fn sized_truncates_overlong_initial_value() {
// `sized` clamps the seed text to the max length up front.
let f = TextField::sized("FF0000extra", 6);
assert_eq!(f.value(), "FF0000");
}
#[test]
fn max_length_caps_typed_input() {
// Typing into a sized field fills up to the cap, then further input is dropped.
let mut f = TextField::sized("", 3);
for c in "abcd".chars() {
f.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(f.value(), "abc");
// A field seeded at its cap ignores further typing (the glyph picker's hex fields).
let mut g = TextField::sized("FF0000", 6);
g.handle_key(key(KeyCode::Char('9')));
assert_eq!(g.value(), "FF0000");
// An unsized field is unbounded.
let mut u = TextField::new("");
for c in "abcdef".chars() {
u.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(u.value(), "abcdef");
}
#[test]
fn set_replaces_text_and_respects_max_length() {
let mut f = TextField::sized("FF0000", 6);
f.set("00AA00");
assert_eq!(f.value(), "00AA00");
assert_eq!(f.display_cursor(), 6); // cursor at end
// Overlong replacements are truncated to the cap.
f.set("ABCDEFGH");
assert_eq!(f.value(), "ABCDEF");
// An unsized field accepts any length.
let mut u = TextField::new("x");
u.set("anything goes");
assert_eq!(u.value(), "anything goes");
}
#[test]
fn unhandled_keys_return_false() {
let mut f = TextField::new("x");
assert!(!f.handle_key(key(KeyCode::Enter)));
assert!(!f.handle_key(key(KeyCode::Up)));
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
}
}