commit eabeaf1bf8440e6dd784f6b3bc9fea855e8761e3 Author: Ross Andrews Date: Fri Jul 10 23:42:48 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ecd526b --- /dev/null +++ b/CLAUDE.md @@ -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` (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` — 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, &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`; 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`, 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). diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3823b04 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f303c9d --- /dev/null +++ b/README.md @@ -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`** — 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 +``` diff --git a/resources/rhai.sublime-syntax b/resources/rhai.sublime-syntax new file mode 100644 index 0000000..e66d1a5 --- /dev/null +++ b/resources/rhai.sublime-syntax @@ -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) (?]) # 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 + 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: (?, + 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() + } +} diff --git a/src/code_editor.rs b/src/code_editor.rs new file mode 100644 index 0000000..2fbe811 --- /dev/null +++ b/src/code_editor.rs @@ -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, + cursor: Pos, +} + +/// Cached syntect pieces for highlighting (reused across frames). +struct HighlightParts { + theme: Theme, + syntax_ref: SyntaxReference, + syntax_set: Arc, +} + +/// 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, + /// 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, + /// 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, + /// 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, + redo: Vec, + last_edit: Option, +} + +impl CodeEditor { + /// Builds an editor for `name` pre-filled with `text`. + pub fn new(name: &str, text: &str) -> Self { + let lines: Vec = 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> { + 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> = 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 { + 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(), ""); + } +} diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 0000000..3168eba --- /dev/null +++ b/src/dialog.rs @@ -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`] 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, + selected: usize, + allow_create: bool, + }, +} + +/// Boxed callback for a text dialog: `Some(value)` on OK, `None` on Cancel. +type TextCallback = Box, &mut Ctx)>; +/// Boxed callback for a list dialog: see [`ListDialogResponse`]. +type ListCallback = Box; + +/// The callback fired once when a dialog is dismissed, paired by body kind. +enum DialogCallback { + /// Text-dialog callback. + Text(TextCallback), + /// List-dialog callback. + List(ListCallback), +} + +/// 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 { + /// 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, +} + +impl Dialog { + /// 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, + initial: impl Into, + on_done: impl FnOnce(Option, &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, + items: Vec, + 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 CursorOverlay for Dialog { + /// 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 { + 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 Widget for &Dialog { + 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 { + 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 = 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 { + 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 = None; + let mut d: Dialog> = + 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 = None; + let mut d: Dialog> = + 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 = None; + let d: Dialog> = + 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> = None; + let mut d: Dialog>> = 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> = None; + let d2: Dialog>> = 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); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..12ec977 --- /dev/null +++ b/src/lib.rs @@ -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`]), +//! 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; +} + +/// 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); + } +} diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..5cbc3cb --- /dev/null +++ b/src/text.rs @@ -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 = 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 = 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 +} diff --git a/src/text_field.rs b/src/text_field.rs new file mode 100644 index 0000000..3e6aa19 --- /dev/null +++ b/src/text_field.rs @@ -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, +} + +impl TextField { + /// Creates a field pre-filled with `initial`, cursor at the end. + pub fn new(initial: impl Into) -> 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, 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) { + 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))); + } +}