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:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user