diff --git a/CLAUDE.md b/CLAUDE.md index b7d652a..ed5f991 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,10 +40,10 @@ cargo fmt # format `kiln` is a Cargo **workspace** that separates the engine from its front-ends so the same game data can be driven by different UIs: - **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it. -- **`kiln-ui`** — reusable, **game-agnostic** terminal UI widgets on **ratatui** (no `kiln-core` dep). Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Currently holds the dialog/text-field system (`dialog::Dialog`) and the shared `dim_area` overlay helper; future home of the script text-editor wrapper. +- **`kiln-ui`** — reusable, **game-agnostic** terminal UI widgets on **ratatui** (no `kiln-core` dep). Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Holds the dialog/text-field system (`dialog::Dialog`), the shared `dim_area` overlay helper, and a hand-written modeless script code editor (`code_editor::CodeEditor`). The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core. - **`kiln-tui`** — a terminal **player + world editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core` and `kiln-ui`. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (`Esc` → menu → `e`). -Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui", "kiln-ui"]`, with `ratatui`/`tui-input` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types). +Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui", "kiln-ui"]`, with `ratatui` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types). ### kiln-core modules @@ -135,14 +135,27 @@ Reusable, **game-agnostic** terminal UI widgets on **ratatui** — no `kiln-core **`kiln-ui/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-borrowed by kiln-tui's `overlay::render_overlay_frame` so both modal styles dim identically. -- `pub mod dialog;` — see below. (Future home of a full-screen script text-editor wrapper.) +- Modules: `pub mod dialog; pub mod code_editor; pub mod text_field;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below). -**`kiln-ui/src/dialog.rs`** — modal dialog widgets, backed by [`tui-input`]: -- `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. The text field is a `tui_input::Input` driven through `InputRequest` (we don't use tui-input's own event backend, avoiding crossterm-version coupling). +**`kiln-ui/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. + +**`kiln-ui/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`] (the in-house single-line editor). - `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::draw(&self, &mut Frame)` — 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). Takes `&mut Frame` (not a `Widget`) because it needs to place a real terminal cursor. Unit-tested (filtering/clamping, OK-enabled rule, Select/Create/Cancel/text outcomes) with a dummy `Ctx`. +**`kiln-ui/src/text_field.rs`** — a single-line editable text field (a one-line sibling of `code_editor`, used by `dialog`'s text/filter inputs; replaced `tui-input`): +- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard`. `new(initial)`, `value()`, `display_cursor()` (caret's display column), 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 (typing, delete, movement, paste, wide-char cursor). + +**`kiln-ui/src/code_editor.rs`** — a hand-written **modeless, keyboard-only** code editor (no editor crate; `edtui` was tried and dropped — it panicked on bare Shift via an `unimplemented!()` keycode arm and lacked basics like 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 (unchanged across the edtui→custom swap, so kiln-tui needed no edits): `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. +- Unit-tested (pure logic, no TTY): syntax parses, text round-trips, typing inserts, Left/Right line-wrap, Backspace line-join, shift-select delete, bracketed-paste-over-selection, undo/redo + coalescing, ⌘→ctrl, Esc exits. + ### kiln-tui modules The terminal **player + world editor**, depending on both `kiln-core` and `kiln-ui`. Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved. The app is one of two top-level [`Mode`]s — `Play` (live, ticking game) or `Edit` (a freshly reloaded, non-ticking world) — and the active mode plus a `Ui` value are threaded through the loop. @@ -160,17 +173,17 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln- - `Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern. **`kiln-tui/src/editor.rs`** — world editor mode: -- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, a blinking `cursor`, `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), and editor-side `log`. +- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, `code_editor: Option` (the open script editor; replaces the board view), a blinking `cursor`, `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), and editor-side `log`. - `MenuLevel { World }` — a sidebar menu level; `title()` (breadcrumb segment) and `choices()` (static `(key, label)` list — `[n] Name`, `[e] Entry board`, `[s] Scripts`, `[b] Boards`). The menu is a stack so nested levels can be added later; today only `World` exists and every choice opens a dialog (no sub-menu). Per the design, menus are static letter-keyed lists — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. -- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → `open_*_dialog`), `menu_value(key)` (the "current value" shown beneath a choice in the sidebar — world name for `n`, start board for `e`), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; scripts/boards → select-only list dialogs for now). -- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — board (with blinking cursor + coord readout), a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. +- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → `open_*_dialog`), `menu_value(key)` (the "current value" shown beneath a choice in the sidebar — world name for `n`, start board for `e`), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). +- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. **`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`): - `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option>, menu: Option, should_quit, world_path, pending_mode }`. While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation. - `open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling. **`kiln-tui/src/input.rs`** — input mode + dispatch: -- `InputMode { Board, Scroll, Menu, Editor, Dialog, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `Dialog` when a dialog is open else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`). +- `InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `Dialog` when a dialog is open, else `CodeEditor` when a script editor is open, else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`). - `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `Esc` opens the `pause_menu_items()` overlay, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `Esc` → `menu_escape`, letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal. **`kiln-tui/src/animation.rs`** — the animation abstraction: diff --git a/Cargo.lock b/Cargo.lock index e1970f0..60636c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -46,6 +52,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "atomic" version = "0.6.1" @@ -67,6 +91,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -130,6 +163,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -142,6 +185,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "color" version = "0.3.3" @@ -200,6 +252,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -340,6 +401,16 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -349,6 +420,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.16.0" @@ -368,9 +445,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.14" @@ -407,6 +490,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -419,6 +508,22 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -471,6 +576,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -659,8 +774,10 @@ dependencies = [ name = "kiln-ui" version = "0.1.0" dependencies = [ + "arboard", "ratatui", - "tui-input", + "syntect", + "unicode-width", ] [[package]] @@ -702,6 +819,12 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -775,6 +898,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -784,7 +917,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -810,6 +943,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -845,6 +987,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -854,6 +1069,28 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -863,6 +1100,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "palette" version = "0.7.6" @@ -910,6 +1157,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.6" @@ -953,6 +1206,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -1011,6 +1275,25 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1042,6 +1325,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -1253,7 +1545,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1268,6 +1560,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1343,6 +1644,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1374,6 +1681,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.3" @@ -1458,6 +1771,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1465,7 +1799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf", "phf_codegen", ] @@ -1491,7 +1825,7 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", @@ -1574,12 +1908,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1588,6 +1924,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -1646,13 +1992,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "tui-input" -version = "0.15.3" +name = "tree_magic_mini" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ - "unicode-segmentation", - "unicode-width", + "memchr", + "nom 8.0.0", + "petgraph", ] [[package]] @@ -1735,6 +2082,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1838,6 +2195,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1936,6 +2363,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1948,6 +2384,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1957,6 +2402,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -2060,6 +2570,50 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 236330c..dde7ba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,3 @@ resolver = "2" [workspace.dependencies] ratatui = { version = "0.30.1" } -tui-input = { version = "0.15.3", default-features = false } diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 11bcb58..141e2e1 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -7,6 +7,7 @@ //! title and letter-keyed choices that open [dialogs](kiln_ui::dialog). `Esc` walks //! back up the tree, opening the overlay [editor menu](editor_menu_items) at the root. +use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome}; use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse}; use crate::log::{LogWidget, log_preview_line}; use crate::menu::{MenuItem, MenuKey}; @@ -84,6 +85,9 @@ pub(crate) struct EditorState { /// The currently open dialog overlay, if any. While `Some`, the editor is in /// [`InputMode::Dialog`](crate::input::InputMode::Dialog). pub(crate) dialog: Option>, + /// The open script code editor, if any. While `Some` it replaces the board view and + /// the editor is in [`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor). + pub(crate) code_editor: Option, /// Cursor position in board cells, clamped to the board bounds. cursor: (i32, i32), /// Width of the right-hand sidebar in terminal columns (mouse-resizable). @@ -109,6 +113,7 @@ impl EditorState { board_name, menu: vec![MenuLevel::World], dialog: None, + code_editor: None, cursor: (0, 0), sidebar_width: DEFAULT_SIDEBAR_WIDTH, blink_on: true, @@ -202,15 +207,31 @@ impl EditorState { )); } - /// Opens a list dialog of the world's scripts. Select-only for now (no side effect). + /// Opens a list dialog of the world's scripts; selecting one opens it in the code editor. fn open_scripts_dialog(&mut self) { let mut names: Vec = self.world.scripts.keys().cloned().collect(); names.sort(); self.dialog = Some(Dialog::list("Scripts", names, false, |resp, ed: &mut EditorState| { - ed.log.push(LogLine::raw(format!("Script dialog: {:?}", resp))) + if let ListDialogResponse::Select(name) = resp { + ed.open_script_editor(&name); + } })); } + /// Opens the code editor on the named script (empty if the name is unknown). + fn open_script_editor(&mut self, name: &str) { + let source = self.world.scripts.get(name).cloned().unwrap_or_default(); + self.code_editor = Some(CodeEditor::new(name, &source)); + } + + /// Closes the code editor, saving its text back into the in-memory script pool + /// (mirrors how the Name/Entry-board dialogs mutate the loaded `World`). + pub(crate) fn close_script_editor(&mut self) { + if let Some(editor) = self.code_editor.take() { + self.world.scripts.insert(editor.name().to_string(), editor.text()); + } + } + /// Opens a list dialog of the world's boards. Select-only for now (no side effect). fn open_boards_dialog(&mut self) { let mut names: Vec = self.world.boards.keys().cloned().collect(); @@ -298,43 +319,60 @@ pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) { } } +/// Handles an input event while the code editor is open ([`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor)). +/// +/// Forwards key/mouse events to the editor; on `Exit` (Esc) it closes and saves the script. +pub(crate) fn handle_code_editor_input(event: Event, ed: &mut EditorState) { + let Some(editor) = ed.code_editor.as_mut() else { return }; + if let CodeEditorOutcome::Exit = editor.handle_event(&event) { + ed.close_script_editor(); + } +} + /// Renders the editor: the board (with a blinking cursor and coordinate readout), /// the right-hand sidebar, and — when open — the bottom log panel spanning the /// full width (board and sidebar sit side-by-side above it). pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) { let (board_area, sidebar_area, log_area) = editor_layout(frame.area(), ui, ed.sidebar_width); - // ── Board, cursor, coordinate readout ──────────────────────────────────── - let inner = { - let board = ed.board(); - // Title carries the board name (top-left) and live cursor coords (top-right). - let mut block = Block::bordered() - .title(format!(" {} ", board.name)) - .title_top(Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned()) - .merge_borders(MergeStrategy::Exact); - // When the log is closed, preview the latest line in the bottom border. - if log_area.is_none() { - block = block.title_bottom(log_preview_line(&ed.log).left_aligned()); - } - let inner = block.inner(board_area); - frame.render_widget(block, board_area); - frame.render_widget(BoardWidget::new(&board), inner); - - // Overlay the cursor during its visible phase (a light-gray solid block). - if ed.blink_on { - let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize); - if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy) - && let Some(cell) = frame.buffer_mut().cell_mut((sx, sy)) - { - cell.set_char(CURSOR_CHAR) - .set_fg(Color::Rgb(180, 180, 180)) - .set_bg(Color::Black); + // ── Board area: the open script editor, else the board with its cursor ──── + if let Some(editor) = ed.code_editor.as_mut() { + // A script is being edited: the code editor takes the board's place. + editor.draw(frame, board_area); + } else { + let inner = { + let board = ed.board(); + // Title carries the board name (top-left) and live cursor coords (top-right). + let mut block = Block::bordered() + .title(format!(" {} ", board.name)) + .title_top( + Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned(), + ) + .merge_borders(MergeStrategy::Exact); + // When the log is closed, preview the latest line in the bottom border. + if log_area.is_none() { + block = block.title_bottom(log_preview_line(&ed.log).left_aligned()); } - } - inner - }; - // Record the board rect for right-click hit-testing (board borrow now dropped). - ed.last_board_area = inner; + let inner = block.inner(board_area); + frame.render_widget(block, board_area); + frame.render_widget(BoardWidget::new(&board), inner); + + // Overlay the cursor during its visible phase (a light-gray solid block). + if ed.blink_on { + let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize); + if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy) + && let Some(cell) = frame.buffer_mut().cell_mut((sx, sy)) + { + cell.set_char(CURSOR_CHAR) + .set_fg(Color::Rgb(180, 180, 180)) + .set_bg(Color::Black); + } + } + inner + }; + // Record the board rect for right-click hit-testing (board borrow now dropped). + ed.last_board_area = inner; + } // ── Sidebar: the current menu (breadcrumb title + letter-keyed choices) ─── let sb_block = Block::bordered() diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index a8fabb3..a559bf1 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -31,6 +31,8 @@ pub enum InputMode { Editor, /// A dialog overlay is open over the editor; all input drives the dialog. Dialog, + /// The script code editor is open; all input drives the editor. + CodeEditor, /// An animation is running and has not claimed any input mode; all input is discarded. Ignore, /// Reserved for a post-transition freeze; nothing currently produces this mode. @@ -54,6 +56,8 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode { Mode::Edit(ed) => { if ed.dialog.is_some() { InputMode::Dialog + } else if ed.code_editor.is_some() { + InputMode::CodeEditor } else { InputMode::Editor } diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 5e600a2..eeea5d9 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -24,7 +24,7 @@ mod ui; mod utils; use crate::animation::{AnimWidget, AnimationLayer}; -use crate::editor::{EditorState, draw_editor, handle_dialog_input}; +use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input}; use crate::input::{ InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input, handle_scroll_input, @@ -40,7 +40,8 @@ use kiln_core::log::LogLine; use kiln_core::world; use ratatui::Frame; use ratatui::crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Rect, Spacing}; @@ -77,6 +78,9 @@ fn main() -> ExitCode { // resized by dragging its divider. Disabled again before restoring. let _ = execute!(io::stdout(), EnableMouseCapture); + // Enable bracketed paste so the code editor receives terminal pastes as one event. + let _ = execute!(io::stdout(), EnableBracketedPaste); + // Detect terminal capabilities (now that we're in raw mode) and enable the // Kitty keyboard protocol when it's available, so scripts can rely on the // richer bindings it provides. Disabled again before restoring the terminal. @@ -105,6 +109,7 @@ fn main() -> ExitCode { if caps.keyboard_enhancement { let _ = term::pop_kitty_flags(); } + let _ = execute!(io::stdout(), DisableBracketedPaste); let _ = execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); @@ -164,6 +169,7 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> Mode::Edit(ed) => match input_mode { InputMode::Editor => handle_editor_input(event, size.width, ed, ui), InputMode::Dialog => handle_dialog_input(event, ed), + InputMode::CodeEditor => handle_code_editor_input(event, ed), _ => unreachable!( "Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode" ), diff --git a/kiln-ui/Cargo.toml b/kiln-ui/Cargo.toml index 9358ed2..5d77b00 100644 --- a/kiln-ui/Cargo.toml +++ b/kiln-ui/Cargo.toml @@ -4,5 +4,8 @@ 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"] } ratatui = { workspace = true } -tui-input = { workspace = true } +syntect = "5.3.0" +unicode-width = "0.2" diff --git a/kiln-ui/resources/rhai.sublime-syntax b/kiln-ui/resources/rhai.sublime-syntax new file mode 100644 index 0000000..e66d1a5 --- /dev/null +++ b/kiln-ui/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/kiln-ui/src/code_editor.rs b/kiln-ui/src/code_editor.rs new file mode 100644 index 0000000..db4bac6 --- /dev/null +++ b/kiln-ui/src/code_editor.rs @@ -0,0 +1,863 @@ +//! 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/kiln-ui/src/dialog.rs b/kiln-ui/src/dialog.rs index a0c6f8d..2bb158c 100644 --- a/kiln-ui/src/dialog.rs +++ b/kiln-ui/src/dialog.rs @@ -5,21 +5,21 @@ //! supplies its own; the editor uses its `EditorState`). While a dialog is open the //! host routes all input to it; when dismissed it fires a stored callback with the //! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The -//! text field is backed by [`tui_input::Input`], driven through [`InputRequest`] so we -//! never depend on `tui-input`'s own event backend (avoiding any crossterm-version coupling). +//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house +//! single-line editor used elsewhere in kiln-ui). //! //! Drawing is a method ([`Dialog::draw`]) taking `&mut Frame` rather than a ratatui //! `Widget` because it needs the [`Frame`] to place a real terminal cursor in the //! active text field. use crate::dim_area; +use crate::text_field::TextField; use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph}; -use tui_input::{Input, InputRequest}; /// Background color filling a dialog window. const BG: Color = Color::Rgb(10, 15, 35); @@ -47,11 +47,11 @@ pub enum ListDialogResponse { /// What kind of field a dialog shows, plus its associated state. enum DialogBody { /// A single free-text field. - Text { input: Input }, + 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 { - input: Input, + field: TextField, all: Vec, selected: usize, allow_create: bool, @@ -102,7 +102,7 @@ impl Dialog { Self { title: title.into(), body: DialogBody::Text { - input: Input::new(initial.into()), + field: TextField::new(initial), }, callback: DialogCallback::Text(Box::new(on_done)), } @@ -122,7 +122,7 @@ impl Dialog { Self { title: title.into(), body: DialogBody::List { - input: Input::new(String::new()), + field: TextField::new(""), all: items, selected: 0, allow_create, @@ -168,12 +168,10 @@ impl Dialog { DialogResult::Continue } // Everything else edits the text/filter field. - code => { - if let Some(req) = key_to_request(code) { - self.input_mut().handle(req); - // The filter changed, so the previous selection index may now be - // out of range — clamp it back into the filtered list. Compute the - // new length first (immutable borrow), then clamp (mutable borrow). + _ => { + 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)); @@ -190,12 +188,12 @@ impl Dialog { pub fn finish(self, result: DialogResult, ctx: &mut Ctx) { let submit = matches!(result, DialogResult::Submit); match (self.body, self.callback) { - (DialogBody::Text { input }, DialogCallback::Text(cb)) => { - cb(submit.then(|| input.value().to_string()), ctx); + (DialogBody::Text { field }, DialogCallback::Text(cb)) => { + cb(submit.then(|| field.value().to_string()), ctx); } ( DialogBody::List { - input, + field, all, selected, allow_create, @@ -204,13 +202,13 @@ impl Dialog { ) => { let resp = if !submit { ListDialogResponse::Cancel - } else if let Some(entry) = filtered_items(&all, input.value()) + } else if let Some(entry) = filtered_items(&all, field.value()) .into_iter() .nth(selected) { ListDialogResponse::Select(entry.clone()) } else if allow_create { - ListDialogResponse::Create(input.value().to_string()) + ListDialogResponse::Create(field.value().to_string()) } else { ListDialogResponse::Cancel }; @@ -226,17 +224,17 @@ impl Dialog { match &self.body { DialogBody::Text { .. } => true, DialogBody::List { - input, + field, allow_create, .. - } => self.filtered_len() > 0 || (*allow_create && !input.value().is_empty()), + } => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()), } } - /// Mutable access to whichever [`Input`] field this dialog owns. - fn input_mut(&mut self) -> &mut Input { + /// Mutable access to whichever [`TextField`] this dialog owns. + fn field_mut(&mut self) -> &mut TextField { match &mut self.body { - DialogBody::Text { input } | DialogBody::List { input, .. } => input, + DialogBody::Text { field } | DialogBody::List { field, .. } => field, } } @@ -281,22 +279,22 @@ impl Dialog { let [body_area, footer_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); - let input = match &self.body { - DialogBody::Text { input } | DialogBody::List { input, .. } => input, + let field = match &self.body { + DialogBody::Text { field } | DialogBody::List { field, .. } => field, }; if let DialogBody::List { all, selected, - input, + 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); - draw_field(frame, field_area, input); - draw_list(frame, list_area, all, input.value(), *selected); + draw_field(frame, field_area, field); + draw_list(frame, list_area, all, field.value(), *selected); } else { // Text dialog: a single field box, centered horizontally and vertically. let fw = body_area.width.saturating_sub(4).max(1); @@ -306,7 +304,7 @@ impl Dialog { width: fw, height: 1, }; - draw_field(frame, field_area, input); + draw_field(frame, field_area, field); } // Footer: [enter] OK (dimmed when disabled) and [esc] Cancel. @@ -329,7 +327,7 @@ impl Dialog { /// Counts the entries passing a list body's current filter (0 for a text body). fn filtered_count(body: &DialogBody) -> usize { match body { - DialogBody::List { all, input, .. } => filtered_items(all, input.value()).len(), + DialogBody::List { all, field, .. } => filtered_items(all, field.value()).len(), DialogBody::Text { .. } => 0, } } @@ -339,33 +337,19 @@ fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> { all.iter().filter(|s| s.starts_with(filter)).collect() } -/// Maps a key code to a [`tui_input`] edit request, or `None` if it doesn't edit text. -fn key_to_request(code: KeyCode) -> Option { - match code { - KeyCode::Char(c) => Some(InputRequest::InsertChar(c)), - KeyCode::Backspace => Some(InputRequest::DeletePrevChar), - KeyCode::Delete => Some(InputRequest::DeleteNextChar), - KeyCode::Left => Some(InputRequest::GoToPrevChar), - KeyCode::Right => Some(InputRequest::GoToNextChar), - KeyCode::Home => Some(InputRequest::GoToStart), - KeyCode::End => Some(InputRequest::GoToEnd), - _ => None, - } -} - /// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background /// (so it reads as an input box), the current value, and a real terminal cursor at -/// the input's visual cursor column. -fn draw_field(frame: &mut Frame, area: Rect, input: &Input) { +/// the field's display cursor column. +fn draw_field(frame: &mut Frame, area: Rect, field: &TextField) { if area.width == 0 { return; } // The paragraph's style fills the whole field area, including empty cells. frame.render_widget( - Paragraph::new(input.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)), + Paragraph::new(field.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)), area, ); - let cur_x = area.x + (input.visual_cursor() as u16).min(area.width.saturating_sub(1)); + let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1)); frame.set_cursor_position((cur_x, area.y)); } diff --git a/kiln-ui/src/lib.rs b/kiln-ui/src/lib.rs index 9afd6bd..0b8da9d 100644 --- a/kiln-ui/src/lib.rs +++ b/kiln-ui/src/lib.rs @@ -20,7 +20,11 @@ use ratatui::buffer::Buffer; use ratatui::layout::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. diff --git a/kiln-ui/src/text.rs b/kiln-ui/src/text.rs new file mode 100644 index 0000000..5cbc3cb --- /dev/null +++ b/kiln-ui/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/kiln-ui/src/text_field.rs b/kiln-ui/src/text_field.rs new file mode 100644 index 0000000..ffca8bd --- /dev/null +++ b/kiln-ui/src/text_field.rs @@ -0,0 +1,178 @@ +//! 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, +} + +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() } + } + + /// The current text. + pub fn value(&self) -> &str { + &self.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); + self.text.insert(b, c); + 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 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))); + } +}