Extract kiln-ui crate
This commit is contained in:
@@ -20,14 +20,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Finishing an epic
|
||||
|
||||
When the user says **"finish the epic"**, do all of the following in order:
|
||||
1. Update the relevant `CLAUDE.md` to reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's own `CLAUDE.md` (`kiln-core/CLAUDE.md`, `kiln-ui/CLAUDE.md`, `kiln-tui/CLAUDE.md`) for module-level changes.
|
||||
1. Update the relevant `CLAUDE.md` to reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's own `CLAUDE.md` (`kiln-core/CLAUDE.md`, `kiln-tui/CLAUDE.md`) for module-level changes. (`kiln-ui` is now a separate repo with its own `CLAUDE.md`.)
|
||||
2. Run the `/simplify` skill on changed code.
|
||||
3. Commit everything with a summary message.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo build # compile the workspace (kiln-core + kiln-ui + kiln-tui)
|
||||
cargo build # compile the workspace (kiln-core + kiln-tui; fetches the kiln-ui git dep)
|
||||
cargo run -p kiln-tui -- maps/start.toml # play/edit a world in the terminal
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name (substring match)
|
||||
@@ -37,15 +37,15 @@ cargo fmt # format
|
||||
|
||||
## Architecture
|
||||
|
||||
`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` is a Cargo **workspace** that separates the engine from its front-end, and depends on an external, game-agnostic UI toolkit:
|
||||
|
||||
- **`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 terminal UI widgets on **ratatui**, mostly **game-agnostic**. 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<Ctx>`), the shared `dim_area` overlay helper, a hand-written modeless script code editor (`code_editor::CodeEditor`), and the **glyph picker** (`glyph_dialog::GlyphDialog<Ctx>`). The glyph picker is the **one** widget that depends on `kiln-core` (for `Glyph`/`Rgba8` + the CP437 table); everything else is game-agnostic. 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`).
|
||||
- **`kiln-ui`** — reusable terminal UI widgets on **ratatui**, **game-agnostic** and now a **separate git repo** (`https://notpi.com/randrews/kiln-ui.git`), consumed by kiln-tui as a git dependency. 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<Ctx>`), the shared `dim_area`/`render_overlay`/`CursorOverlay` overlay scaffolding, and a hand-written modeless script code editor (`code_editor::CodeEditor`). It has **no `kiln-core` dependency** — the one kiln-core-coupled widget, the **glyph picker**, was left behind in kiln-tui (see below). The **syntect** (highlighting) and **arboard** (clipboard) deps make it desktop/terminal-only (not WASM), unlike its ratatui core.
|
||||
- **`kiln-tui`** — a terminal **player + world editor** built on **ratatui 0.30 / crossterm**, depending on `kiln-core` and the external `kiln-ui`. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (`Esc` → menu → `e`). Owns the **glyph picker** (`glyph_dialog::GlyphDialog<Ctx>`) — the one game-coupled UI widget (needs `Glyph`/`Rgba8` + the CP437 table), built on `kiln-ui`'s public dialog scaffolding.
|
||||
|
||||
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).
|
||||
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`, with `ratatui` pinned in `[workspace.dependencies]`. The external `kiln-ui` pins the same `ratatui` `0.30.x` so their public APIs (which exchange ratatui types) unify.
|
||||
|
||||
Module-by-module reference for each crate lives in that crate's own `CLAUDE.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md), [`kiln-ui/CLAUDE.md`](kiln-ui/CLAUDE.md), and [`kiln-tui/CLAUDE.md`](kiln-tui/CLAUDE.md) — which Claude Code loads automatically when you work in that crate.
|
||||
Module-by-module reference for each workspace crate lives in that crate's own `CLAUDE.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md) and [`kiln-tui/CLAUDE.md`](kiln-tui/CLAUDE.md) — which Claude Code loads automatically when you work in that crate. (`kiln-ui`'s reference lives in its own repo.)
|
||||
|
||||
### World file format (`maps/*.toml`)
|
||||
|
||||
|
||||
Generated
+1
-2
@@ -774,10 +774,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kiln-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://notpi.com/randrews/kiln-ui.git#eabeaf1bf8440e6dd784f6b3bc9fea855e8761e3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"color",
|
||||
"kiln-core",
|
||||
"ratatui",
|
||||
"syntect",
|
||||
"unicode-width",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["kiln-core", "kiln-tui", "kiln-ui"]
|
||||
members = ["kiln-core", "kiln-tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
+9
-3
@@ -1,9 +1,11 @@
|
||||
# kiln-tui
|
||||
|
||||
Module reference for the `kiln-tui` crate — a terminal **player + world
|
||||
editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core`
|
||||
and `kiln-ui`. See the repository-root `CLAUDE.md` for project-wide guidance,
|
||||
code style, the world file format, and key design decisions.
|
||||
editor** built on **ratatui 0.30 / crossterm**, depending on `kiln-core`
|
||||
and the external, game-agnostic **`kiln-ui`** widget crate (a git dependency:
|
||||
`Dialog`/`CodeEditor`/`TextField`, plus `render_overlay`/`dim_area`/`CursorOverlay`).
|
||||
See the repository-root `CLAUDE.md` for project-wide guidance, code style, the
|
||||
world file format, and key design decisions.
|
||||
|
||||
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.
|
||||
|
||||
@@ -27,6 +29,10 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
|
||||
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
|
||||
- `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_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `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/glyph_dialog.rs`** — the modal **glyph picker** (`GlyphDialog<Ctx>`):
|
||||
- A modal picker for a `kiln_core::glyph::Glyph` (CP437 tile index + fg/bg colors), same API shape as `kiln_ui::dialog::Dialog`: `GlyphDialog::new(title, initial: Glyph, on_done)`, `handle_event(&Event) -> DialogResult`, `finish(result, &mut Ctx)`. A 32×8 char grid plus two `ColorPicker`s (a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches + a custom `#RRGGBB` `TextField` that is the source of truth). Draws via a `CursorOverlay` impl (host renders it through `kiln_ui::render_overlay`), rendering the grid with `kiln_core::cp437::tile_to_char`.
|
||||
- **This is the one game-coupled UI widget.** It depends on `kiln-core` (`Glyph`/`Rgba8`, `NAMED_COLORS`, `tile_to_char`), so it lives here rather than in the game-agnostic external `kiln-ui` crate — it reuses `kiln-ui`'s public `DialogResult`, `TextField`, `CursorOverlay`, and `dim_area`. Seeded/read by the editor's `open_glyph_picker()` (bound to `g`); unit-tested with a dummy `Ctx`.
|
||||
|
||||
**`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`):
|
||||
- `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). 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.
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { path = "../kiln-core" }
|
||||
kiln-ui = { path = "../kiln-ui" }
|
||||
# Reusable, game-agnostic ratatui widgets, now a standalone repo (tracks the default branch).
|
||||
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
|
||||
color = "0.3.3"
|
||||
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use crate::editor_menu;
|
||||
use crate::editor_menu::{MenuEntry, MenuLevel};
|
||||
use crate::glyph_dialog::GlyphDialog;
|
||||
use crate::log::{LogWidget, log_preview_line};
|
||||
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
|
||||
use crate::ui::Ui;
|
||||
@@ -22,7 +23,6 @@ use kiln_core::world::World;
|
||||
use kiln_core::{Archetype, Board};
|
||||
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
|
||||
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
|
||||
use kiln_ui::glyph_dialog::GlyphDialog;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::Event;
|
||||
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
|
||||
|
||||
@@ -7,19 +7,24 @@
|
||||
//! pressing Down/Tab on the strip). The hex value is the source of truth — picking a
|
||||
//! named swatch fills it in.
|
||||
//!
|
||||
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route
|
||||
//! This mirrors the [`dialog`](kiln_ui::dialog) API — build a [`GlyphDialog`], route
|
||||
//! events to [`handle_event`](GlyphDialog::handle_event), draw it via its
|
||||
//! [`CursorOverlay`](crate::CursorOverlay) impl (see [`crate::render_overlay`]), and on
|
||||
//! [`CursorOverlay`](kiln_ui::CursorOverlay) impl (see [`kiln_ui::render_overlay`]), and on
|
||||
//! dismissal call [`finish`](GlyphDialog::finish), which fires a stored callback with
|
||||
//! the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
|
||||
//!
|
||||
//! It is the first kiln-ui widget that depends on `kiln-core` (for [`Glyph`]/`Rgba8`,
|
||||
//! the [`tile_to_char`] table, and the named colors); kiln-ui is otherwise game-agnostic.
|
||||
//! This is the glyph-picker widget that couples the game engine to the reusable UI: it
|
||||
//! depends on `kiln-core` (for [`Glyph`]/`Rgba8`, the [`tile_to_char`] table, and the
|
||||
//! named colors), so it lives here in kiln-tui rather than in the game-agnostic `kiln-ui`
|
||||
//! crate — building its dialog scaffolding on `kiln_ui`'s public widgets.
|
||||
|
||||
use color::Rgba8;
|
||||
use kiln_core::colors::NAMED_COLORS;
|
||||
use kiln_core::cp437::tile_to_char;
|
||||
use kiln_core::glyph::Glyph;
|
||||
use kiln_ui::dialog::DialogResult;
|
||||
use kiln_ui::text_field::TextField;
|
||||
use kiln_ui::{CursorOverlay, dim_area};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
@@ -27,11 +32,7 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
|
||||
use crate::dialog::DialogResult;
|
||||
use crate::text_field::TextField;
|
||||
use crate::{CursorOverlay, dim_area};
|
||||
|
||||
/// Background color filling the dialog window (matches [`crate::dialog`]).
|
||||
/// Background color filling the dialog window (matches [`kiln_ui::dialog`]).
|
||||
const BG: Color = Color::Rgb(10, 15, 35);
|
||||
/// Border color for the dialog window.
|
||||
const BORDER: Color = Color::Rgb(80, 130, 210);
|
||||
@@ -509,7 +510,7 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
||||
|
||||
/// Lets a `&GlyphDialog` be drawn with `frame.render_widget` where the caret isn't
|
||||
/// needed; the caret position from [`CursorOverlay::render`] is discarded. Use
|
||||
/// [`render_overlay`](crate::render_overlay) when the terminal cursor must be placed.
|
||||
/// [`render_overlay`](kiln_ui::render_overlay) when the terminal cursor must be placed.
|
||||
impl<Ctx> Widget for &GlyphDialog<Ctx> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
CursorOverlay::render(self, area, buf);
|
||||
@@ -10,6 +10,7 @@
|
||||
mod animation;
|
||||
mod editor;
|
||||
mod editor_menu;
|
||||
mod glyph_dialog;
|
||||
mod input;
|
||||
mod log;
|
||||
mod menu;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# kiln-ui
|
||||
|
||||
Module reference for the `kiln-ui` crate — reusable terminal UI widgets on
|
||||
**ratatui**, mostly **game-agnostic**. See the repository-root `CLAUDE.md` for
|
||||
project-wide guidance, code style, and key design decisions.
|
||||
|
||||
A widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates *only through callbacks*, so a front-end decides what a choice does without the widget knowing about game/world types. The front-end owns the event loop and routes input to the widget while it is focused. The lone exception is `glyph_dialog`, which depends on `kiln-core` for `Glyph`/`Rgba8` and `cp437::tile_to_char` (the accepted, documented first kiln-core dependency — it would matter only if kiln-ui were ever split into a standalone crate). The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui 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.
|
||||
- `CursorOverlay` (`pub` trait) + `render_overlay(frame, &impl CursorOverlay)` (`pub`) — a modal overlay paints into a `&mut Buffer` and *returns* `Option<Position>` (where the terminal caret goes) from `render(area, buf)`, instead of placing the cursor itself. `render_overlay` is the one place that needs a `Frame`: it calls `render` over `frame.area()` then applies the returned position via `set_cursor_position`. The split exists because a ratatui `Widget` only sees a buffer (can't move the hardware cursor), and it makes `render` unit-testable headless. Implemented by `Dialog` and `GlyphDialog`, which *also* `impl Widget for &Dialog`/`&GlyphDialog` (forwarding to `CursorOverlay::render` and discarding the caret) so they can be drawn with `frame.render_widget` where the cursor isn't needed.
|
||||
- Modules: `pub mod dialog; pub mod code_editor; pub mod text_field; pub mod glyph_dialog;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below).
|
||||
|
||||
**`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<Ctx>` — an open dialog generic over the host context its callback mutates. Built via `Dialog::text(title, initial, on_done)` (a single free-text field; `on_done: FnOnce(Option<String>, &mut Ctx)`, `None` on cancel) or `Dialog::list(title, items, allow_create, on_done)` (a filter field over a selectable list; `on_done: FnOnce(ListDialogResponse, &mut Ctx)`). Holds a `DialogBody` (`Text`/`List`) plus the boxed callback. Each field is a [`TextField`].
|
||||
- `ListDialogResponse { Select(String), Create(String), Cancel }` — list outcome: an existing entry, a newly-typed value (only when `allow_create`), or cancel. `DialogResult { Continue, Submit, Cancel }` — what a key event did.
|
||||
- `handle_event(&Event) -> DialogResult` — `Esc`→`Cancel`; `Enter`→`Submit` only when OK is enabled (always for text; for a list, a valid entry is selected *or* `allow_create` + non-empty); Up/Down move a list's selection; other keys edit the field (and re-clamp the list selection to the new prefix filter). `finish(result, &mut Ctx)` consumes the dialog and fires its callback. The host pattern: `take()` the dialog out of its slot on `Submit`/`Cancel`, then call `finish` so the callback gets an un-borrowed `&mut Ctx`.
|
||||
- `Dialog` draws via its `CursorOverlay` impl (`render(area, buf) -> Option<Position>`; host renders it through `render_overlay`) — dims the screen, draws a centered bordered box. A **text** dialog centers its field box (h+v) with a distinct `FIELD_BG` background so it reads as an input box; a **list** dialog puts the filter field atop the scrolling, selection-highlighted list. Both show an `[enter] OK [esc] Cancel` footer (OK dimmed when disabled). `render` returns the active field's caret position (the host places the real terminal cursor). Unit-tested with a dummy `Ctx`.
|
||||
|
||||
**`kiln-ui/src/glyph_dialog.rs`** — the glyph picker (the one kiln-core-dependent widget):
|
||||
- `GlyphDialog<Ctx>` — a modal picker for a [`Glyph`] (CP437 tile index + fg/bg colors), same API shape as `Dialog`. Built via `GlyphDialog::new(title, initial: Glyph, on_done)`; `on_done: FnOnce(Option<Glyph>, &mut Ctx)` fires `Some(glyph)` on OK, `None` on cancel. Holds the selected `tile: u32`, two `ColorPicker`s (`fg`/`bg`), and a `Focus` (`Grid`/`FgStrip`/`FgField`/`BgStrip`/`BgField`).
|
||||
- `ColorPicker` (private) — one color selector: a `selected` slot index over a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches plus a final **custom** slot (`CUSTOM == NAMED_COLORS.len()`), and a 6-hex `TextField` that is the **source of truth** (`color()` parses it). Picking a named swatch fills the field via `TextField::set`; the custom slot leaves it user-editable. Seeds from an `Rgba8` by matching a named color (else the custom slot).
|
||||
- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc`→`Cancel`; `Enter`→`Submit` only when both colors resolve; `Tab`/`Shift-Tab`/`BackTab` cycle focus (skipping a color's `*Field` stop unless it's on the custom slot). On the grid, arrows move the 32×8 selection (clamped); on a strip, Left/Right pick a swatch and Down enters the custom field; in a custom field, keys edit it and Up returns to the strip. `finish(result, &mut Ctx)` fires the callback (a `Submit` with both colors valid yields `Some(Glyph{..})`).
|
||||
- Drawing is its `CursorOverlay` impl (`render(area, buf) -> Option<Position>`; host renders it through `render_overlay`) — dims + centered bordered box; renders the 256-char grid via `kiln_core::cp437::tile_to_char` (chosen fg/bg when both colors resolve, else gray-on-black; selected cell is a bright cursor when the grid is focused, else reversed), then for each color a **swatch strip** (one solid cell per named color + the custom slot; selected slot marked `●`, custom slot otherwise `+`) above a **hex row** (an editable `#RRGGBB` box, red when invalid, when on the custom slot; else the dimmed name + hex of the selected named color), and the footer (which previews the chosen glyph). Returns the focused custom field's caret position (or `None`). Unit-tested 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):
|
||||
- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard` + an optional `max_length`. `new(initial)` (unbounded) or `sized(initial, max_length)` (truncates the seed and caps typed input); `value()`, `display_cursor()` (caret's display column), `set(text)` (replace contents, truncated to `max_length`), and `handle_key(KeyEvent) -> bool` (`true` if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and `ctrl`/`⌘`+`v` paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads `value()`/`display_cursor()`). No selection yet. Unit-tested.
|
||||
|
||||
**`kiln-ui/src/code_editor.rs`** — a hand-written **modeless, keyboard-only** code editor (hand-written; `edtui` was rejected for panicking on bare Shift and lacking line-wrap):
|
||||
- `CodeEditor` — edits one script's text: `lines: Vec<String>`, a char-indexed `cursor`/`anchor` (selection), `desired_col` (vertical-move column), `scroll` (top row + left display-col, cursor-following), undo/redo `Snapshot` stacks, a cached `HighlightParts`, and a `Clipboard` (system via `arboard` when available, always backed by an internal buffer so copy/paste work headless / on the web). Public API: `new(name, text)`, `name()`, `text()` (lines joined with `\n`), `handle_event(&Event) -> CodeEditorOutcome { Continue, Exit }`, `draw(&mut self, frame, area)`.
|
||||
- Input (`handle_event`): `⌘` is mapped to `ctrl` up front; then printable keys insert, Enter/Backspace/Delete edit (Backspace/Delete at a line edge join lines, Left/Right wrap across line ends), arrows move (`shift` extends the selection), **`ctrl`+Left/Right jump by word** (`text::{prev,next}_word`, wrapping at line ends) and **`ctrl`+Up/Down jump by paragraph** (to the nearest blank/whitespace-only line, via `is_blank_line`), Home/End/PageUp/PageDown, `ctrl`+`c`/`x`/`v` (system clipboard), `ctrl`+`a` (select all), `ctrl`+`z`/`y` (undo/redo), `Esc` → `Exit`. Bracketed `Event::Paste` inserts text. **Unknown keycodes are ignored** (the whole point — no panic on Shift/F-keys). Undo coalesces a run of typing (or deletes) into one step; movement/structural edits break the run.
|
||||
- Rendering (`draw`): a titled border, a line-number gutter, and per-line styled text. One width model (`char_cells`: tabs → next 4-stop, wide chars via `unicode-width`) drives the cursor X, selection background, and horizontal scroll together. Highlighting: `build_highlight_parts()` parses the embedded `resources/rhai.sublime-syntax` into a one-syntax `SyntaxSet` + `base16-ocean.dark` theme (cached); `draw` runs `syntect::easy::HighlightLines` from line 0 to the viewport bottom each frame (small scripts), mapping syntect colors to `Color::Rgb`. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now.
|
||||
- Unit-tested (pure logic, no TTY).
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "kiln-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# First kiln-core dependency in kiln-ui: the glyph picker needs `Glyph`/`Rgba8`.
|
||||
kiln-core = { path = "../kiln-core" }
|
||||
color = "0.3.3"
|
||||
# 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 }
|
||||
syntect = "5.3.0"
|
||||
unicode-width = "0.2"
|
||||
@@ -1,405 +0,0 @@
|
||||
%YAML 1.2
|
||||
---
|
||||
# http://www.sublimetext.com/docs/3/syntax.html
|
||||
name: Rhai
|
||||
file_extensions:
|
||||
- rhai
|
||||
scope: source.rhai
|
||||
contexts:
|
||||
main:
|
||||
- include: core
|
||||
brackets:
|
||||
- include: round-brackets
|
||||
- include: square-brackets
|
||||
- include: curly-brackets
|
||||
comments:
|
||||
- match: '/\*\*(?![/|\*])'
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.documentation.rhai
|
||||
push:
|
||||
- meta_scope: comment.block.documentation.rhai
|
||||
- match: \*/
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.documentation.rhai
|
||||
pop: true
|
||||
- include: comments
|
||||
- match: /\*
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.rhai
|
||||
push:
|
||||
- meta_scope: comment.block.rhai
|
||||
- match: \*/
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.rhai
|
||||
pop: true
|
||||
- include: comments
|
||||
- match: '(///)[^/].*$\n?'
|
||||
scope: comment.line.documentation.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.documentation.rhai
|
||||
- match: (//).*$\n?
|
||||
scope: comment.line.double-slash.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.double-slash.rhai
|
||||
- match: ^(#!).*$\n?
|
||||
scope: comment.line.shebang.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.rhai
|
||||
core:
|
||||
- include: expression
|
||||
curly-brackets:
|
||||
- match: '\{'
|
||||
captures:
|
||||
0: meta.brace.curly.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.curly
|
||||
- match: '\}'
|
||||
captures:
|
||||
0: meta.brace.curly.rhai
|
||||
pop: true
|
||||
- include: main
|
||||
expression:
|
||||
- include: literal-closure-labels
|
||||
- include: literal-labels
|
||||
- include: literal-keywords
|
||||
- include: support
|
||||
- include: literal-function
|
||||
- include: literal-closure
|
||||
- include: literal-constant
|
||||
- include: literal-template-string
|
||||
- include: literal-language-variable
|
||||
- include: literal-module
|
||||
- include: literal-method-call
|
||||
- include: literal-function-call
|
||||
- include: comments
|
||||
- include: brackets
|
||||
- include: literal-operators
|
||||
- include: literal-namespace
|
||||
- include: literal-variable
|
||||
- include: literal-punctuation
|
||||
function-declaration-parameters:
|
||||
- match: \(
|
||||
captures:
|
||||
0: punctuation.definition.parameters.begin.rhai
|
||||
push:
|
||||
- match: \)
|
||||
captures:
|
||||
0: punctuation.definition.parameters.end.rhai
|
||||
pop: true
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.parameter.function.rhai
|
||||
- match: \,
|
||||
scope: punctuation.separator.parameter.function.rhai
|
||||
- include: parameters-list
|
||||
literal-closure:
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
- match: '(\b[_a-zA-Z]\w*)\s*(=)\s*(\|)'
|
||||
captures:
|
||||
1: entity.name.function.closure.rhai
|
||||
2: keyword.operator.assignment.rhai
|
||||
3: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-closure-labels:
|
||||
- match: '(\b[_a-zA-Z]\w*)\s*(\:)\s*(\|)'
|
||||
captures:
|
||||
1: string.unquoted.label.rhai entity.name.function.method.rhai
|
||||
2: punctuation.separator.key-value.rhai
|
||||
3: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
- match: '((\")((?:[^"]|\\")*)(\"))\s*(:)\s*(\|)'
|
||||
captures:
|
||||
1: string.quoted.double.rhai
|
||||
2: punctuation.definition.string.begin.rhai
|
||||
3: entity.name.function.method.rhai
|
||||
4: punctuation.definition.string.end.rhai
|
||||
5: punctuation.separator.key-value.rhai
|
||||
6: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-constant:
|
||||
- include: literal-number
|
||||
- include: literal-string
|
||||
- include: literal-language-constant
|
||||
literal-function:
|
||||
- match: |-
|
||||
(?x) (?:\b(private)\s+)?
|
||||
\s*(fn)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
captures:
|
||||
1: storage.modifier.rhai
|
||||
2: storage.type.function.rhai
|
||||
3: entity.name.function.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.rhai
|
||||
- match: (?<=\))
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-function-call:
|
||||
- match: |-
|
||||
(?x) ([_a-zA-Z]\w*)(!)?\s*
|
||||
(\(\s*\))
|
||||
scope: meta.function-call.without-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: punctuation.function-call.capture.rhai
|
||||
3: meta.group.braces.round.function.arguments.rhai
|
||||
- match: |-
|
||||
(?x) ([_a-zA-Z]\w*)(!)?\s*
|
||||
(?=\()
|
||||
scope: meta.function-call.with-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: punctuation.function-call.capture.rhai
|
||||
literal-keyword-storage:
|
||||
- match: \b(const|let)\b
|
||||
scope: storage.type.rhai
|
||||
literal-keywords:
|
||||
- include: literal-keyword-storage
|
||||
- match: \b(return)\b
|
||||
scope: keyword.control.flow.rhai
|
||||
- match: \b(if|else|switch)\b
|
||||
scope: keyword.control.conditional.rhai
|
||||
- match: \b(throw|try|catch)\b
|
||||
scope: keyword.control.trycatch.rhai
|
||||
- match: \b(for|in|loop|do|while|until|break|continue)\b
|
||||
scope: keyword.control.loop.rhai
|
||||
literal-labels:
|
||||
- match: |-
|
||||
(?x) (?<!\?)(?<!\?\s)
|
||||
(?=((")((?:[^\:"]|\\")*)("))\s*:)
|
||||
push:
|
||||
- match: ":"
|
||||
captures:
|
||||
0: punctuation.separator.key-value.rhai
|
||||
pop: true
|
||||
- include: literal-string
|
||||
- match: '(?<!\.|\?|\?\s)([_a-zA-Z]\w*)\s*(:)(?!\:)'
|
||||
scope: constant.other.object.key.rhai
|
||||
captures:
|
||||
1: string.unquoted.label.rhai
|
||||
2: punctuation.separator.key-value.rhai
|
||||
literal-language-constant:
|
||||
- match: \btrue\b
|
||||
scope: constant.language.boolean.true.rhai
|
||||
- match: \bfalse\b
|
||||
scope: constant.language.boolean.false.rhai
|
||||
literal-language-namespace:
|
||||
- match: '(?<!\:\:)\s*((global)\s*(\:\:))(?!<)'
|
||||
captures:
|
||||
1: meta.path.rhai
|
||||
2: constant.language.namespace.global.rhai entity.name.namespace.rhai
|
||||
3: punctuation.separator.namespace.rhai
|
||||
literal-language-variable:
|
||||
- match: \bthis\b
|
||||
scope: variable.language.this.rhai
|
||||
literal-method-call:
|
||||
- match: |-
|
||||
(?x) (?<=\.)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
(\(\s*\))
|
||||
scope: meta.function-call.method.without-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: meta.group.braces.round.function.arguments.rhai
|
||||
- match: |-
|
||||
(?x) (?<=\.)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
(?=\()
|
||||
scope: meta.function-call.method.with-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
literal-module:
|
||||
- match: \b(import|export|as)\b
|
||||
scope: keyword.control.import.rhai
|
||||
literal-namespace:
|
||||
- include: literal-language-namespace
|
||||
- match: '([_a-zA-Z]\w*)\s*(\:\:)(?!<)'
|
||||
scope: meta.path.rhai
|
||||
captures:
|
||||
1: entity.name.namespace.rhai
|
||||
2: punctuation.separator.namespace.rhai
|
||||
- match: '(?<=\:\:)(\s*([_a-zA-Z]\w*))\s*(?!\:\:)'
|
||||
captures:
|
||||
1: meta.path.rhai
|
||||
2: variable.other.constant.rhai
|
||||
literal-number:
|
||||
- match: |-
|
||||
(?xi) (?:
|
||||
\b0b[0-1][_0-1]*| # binary
|
||||
\b0o[0-7][_0-7]*| # octal
|
||||
\b0x[\da-f][_\da-f]*| # hex
|
||||
(\B[+\-])?\b\d[_\d]*\.\d[_\d]*(e[+\-]?\d[_\d]*)?| # e.g. 999.999, 999.99e+123
|
||||
(\B[+\-])?\b\d[_\d]*\.\B| # e.g. 999.
|
||||
(\B[+\-])?\b\d[_\d]*(e[+\-]?\d[_\d]*)? # e.g. 999, 999e+123
|
||||
)
|
||||
scope: constant.numeric.rhai
|
||||
literal-operators:
|
||||
- match: |-
|
||||
(?x) !(?!=)| # logical-not right-to-left right
|
||||
&& | # logical-and left-to-right both
|
||||
\|\| # logical-or left-to-right both
|
||||
scope: keyword.operator.logical.rhai
|
||||
- match: "(?x) =(?![=>]) # assignment right-to-left both"
|
||||
scope: keyword.operator.assignment.rhai
|
||||
- match: |-
|
||||
(?x) %= | # assignment right-to-left both
|
||||
&= | # assignment right-to-left both
|
||||
\*\*=| # assignment right-to-left both
|
||||
(?<!\*)\*= | # assignment right-to-left both
|
||||
\+= | # assignment right-to-left both
|
||||
-= | # assignment right-to-left both
|
||||
/= | # assignment right-to-left both
|
||||
\^= | # assignment right-to-left both
|
||||
\|= | # assignment right-to-left both
|
||||
<<= | # assignment right-to-left both
|
||||
>>= # assignment right-to-left both
|
||||
scope: keyword.operator.assignment.augmented.rhai
|
||||
- match: |-
|
||||
(?x) << | # bitwise-shift left-to-right both
|
||||
>> | # bitwise-shift left-to-right both
|
||||
& | # bitwise-and left-to-right both
|
||||
\^ | # bitwise-xor left-to-right both
|
||||
\| # bitwise-or left-to-right both
|
||||
scope: keyword.operator.bitwise.rhai
|
||||
- match: |-
|
||||
(?x) <= | # relational left-to-right both
|
||||
>= | # relational left-to-right both
|
||||
<(?!-) | # relational left-to-right both
|
||||
(?<!-)> # relational left-to-right both
|
||||
scope: keyword.operator.relational.rhai
|
||||
- match: |-
|
||||
(?x) ==(?!=) | # equality left-to-right both
|
||||
!=(?!=) # equality left-to-right both
|
||||
scope: keyword.operator.comparison.rhai
|
||||
- match: |-
|
||||
(?x) / | # division left-to-right both
|
||||
% | # modulus left-to-right both
|
||||
\*\* | # power left-to-right both
|
||||
\*(?!\)) | # multiplication left-to-right both
|
||||
\+(?!\+) | # addition left-to-right both
|
||||
-(?![>-]) # subtraction left-to-right both
|
||||
scope: keyword.operator.arithmetic.rhai
|
||||
- match: \.(?!\.)
|
||||
scope: keyword.operator.accessor.rhai punctuation.accessor.rhai
|
||||
- match: "=>"
|
||||
scope: punctuation.separator.switch.case.rhai
|
||||
- match: '(\(\*|\*\)|\+\+|--|\.\.+|~|#(?!{)|@|\$(?!{)|->|<-|===|!==|\:=|\:\:<)'
|
||||
scope: invalid.illegal.operator.rhai
|
||||
literal-punctuation:
|
||||
- match: \;
|
||||
scope: punctuation.terminator.statement.rhai
|
||||
- match: \,
|
||||
scope: meta.delimiter.comma.rhai
|
||||
literal-string:
|
||||
- match: '(''([^''\\]|\\([tnr''\\]|x\h{2}|u\h{4}|U\h{8}))'')'
|
||||
scope: string.quoted.single.rhai
|
||||
- match: (")
|
||||
captures:
|
||||
1: punctuation.definition.string.begin.rhai
|
||||
push:
|
||||
- meta_scope: string.quoted.double.rhai
|
||||
- match: (")
|
||||
captures:
|
||||
1: punctuation.definition.string.end.rhai
|
||||
pop: true
|
||||
- include: string-content
|
||||
- match: (?<!\\)\n
|
||||
scope: invalid.illegal.newline.rhai
|
||||
- match: \\\n
|
||||
scope: constant.character.escape.newline.rhai punctuation.separator.continuation
|
||||
literal-template-string:
|
||||
- match: "`"
|
||||
captures:
|
||||
0: punctuation.definition.string.begin.rhai
|
||||
push:
|
||||
- meta_scope: string.interpolated.rhai
|
||||
- match: "`"
|
||||
captures:
|
||||
0: punctuation.definition.string.end.rhai
|
||||
pop: true
|
||||
- include: string-content
|
||||
- match: '\${'
|
||||
captures:
|
||||
0: punctuation.section.interpolation.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.interpolation.rhai
|
||||
- match: "}"
|
||||
captures:
|
||||
0: punctuation.section.interpolation.end.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
literal-variable:
|
||||
- match: '[A-Z][_\dA-Z]*\b'
|
||||
scope: variable.other.constant.rhai
|
||||
- match: '(?<!\.)\s*([_a-zA-Z]\w*)\s*(?=\.)'
|
||||
captures:
|
||||
1: variable.other.object.rhai
|
||||
- match: '(?<=\.)\s*([_a-zA-Z]\w*)'
|
||||
captures:
|
||||
1: variable.other.property.rhai entity.name.property.rhai
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.other.readwrite.rhai
|
||||
parameters-list:
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.parameter.function.rhai
|
||||
- match: \,
|
||||
scope: punctuation.separator.parameter.function.rhai
|
||||
- include: comments
|
||||
round-brackets:
|
||||
- match: \((?!\*)
|
||||
captures:
|
||||
0: meta.brace.round.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.round
|
||||
- match: (?<!\*)\)
|
||||
captures:
|
||||
0: meta.brace.round.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
square-brackets:
|
||||
- match: '\['
|
||||
captures:
|
||||
0: meta.brace.square.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.square
|
||||
- match: '\]'
|
||||
captures:
|
||||
0: meta.brace.square.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
string-content:
|
||||
- match: '\\(x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8}|t|r|n|\\)'
|
||||
scope: constant.character.escape.rhai
|
||||
- match: '\\[^xuUtrn\\\n]'
|
||||
scope: invalid.illegal.escape.rhai
|
||||
support:
|
||||
- match: \b(print|debug|call|curry|eval|type_of|is_def_var|is_def_fn|is_shared)\b
|
||||
scope: support.function.rhai
|
||||
- match: \b(var|static|shared|goto|exit|match|case|public|protected|new|use|with|module|package|super|thread|spawn|go|await|async|sync|yield|default|void|null|nil)\b
|
||||
scope: invalid.illegal.keyword.rhai
|
||||
@@ -1,54 +0,0 @@
|
||||
//! A clipboard shared by the text widgets ([`code_editor`](crate::code_editor),
|
||||
//! [`text_field`](crate::text_field)).
|
||||
//!
|
||||
//! It is backed by an always-present internal buffer, mirrored to the system clipboard
|
||||
//! when one is available — so copy/paste works the same everywhere, degrading cleanly.
|
||||
|
||||
/// A clipboard backed by an always-present internal buffer, mirrored to the system
|
||||
/// clipboard when one is available.
|
||||
///
|
||||
/// `arboard::Clipboard::new()` fails with no display (headless Linux, etc.) and isn't
|
||||
/// available on the web; in those cases `system` is `None` and copy/paste still work
|
||||
/// *within* the app via `internal`. When the system clipboard is present we write
|
||||
/// through to it (so other apps see copies) and prefer it on read (so we see their
|
||||
/// copies), falling back to `internal` if a read fails.
|
||||
pub(crate) struct Clipboard {
|
||||
system: Option<arboard::Clipboard>,
|
||||
internal: String,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
/// Creates a clipboard, attaching the system one if it initializes.
|
||||
///
|
||||
/// Under `cfg(test)` the system clipboard is never attached: arboard isn't safe to
|
||||
/// initialize from many threads at once (the parallel test harness would otherwise
|
||||
/// crash on macOS), and tests exercise copy/paste through the internal buffer anyway.
|
||||
pub(crate) fn new() -> Self {
|
||||
#[cfg(not(test))]
|
||||
let system = arboard::Clipboard::new().ok();
|
||||
#[cfg(test)]
|
||||
let system = None;
|
||||
Self {
|
||||
system,
|
||||
internal: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores `text` internally and (best-effort) on the system clipboard.
|
||||
pub(crate) fn set(&mut self, text: String) {
|
||||
if let Some(sys) = &mut self.system {
|
||||
let _ = sys.set_text(&text);
|
||||
}
|
||||
self.internal = text;
|
||||
}
|
||||
|
||||
/// Returns the system clipboard text if readable, else the internal buffer.
|
||||
pub(crate) fn get(&mut self) -> String {
|
||||
if let Some(sys) = &mut self.system
|
||||
&& let Ok(text) = sys.get_text()
|
||||
{
|
||||
return text;
|
||||
}
|
||||
self.internal.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,930 +0,0 @@
|
||||
//! A small, purpose-built **modeless, keyboard-only** code editor widget.
|
||||
//!
|
||||
//! It edits like an ordinary text box (no vim modes): arrow keys move (wrapping across line
|
||||
//! ends), printable keys insert, `shift`+arrows select, `ctrl`/`⌘`+`c`/`x`/`v` use the system
|
||||
//! clipboard, `ctrl`/`⌘`+`z`/`y` undo/redo, and `Esc` asks the host to close. It shows a
|
||||
//! line-number gutter and syntect syntax highlighting (the embedded Rhai syntax).
|
||||
//!
|
||||
//! Mouse is intentionally out of scope for now (its click hit-testing — gutter + scroll +
|
||||
//! unicode width + tab expansion — is the hard part); the same per-line width model here
|
||||
//! would invert to support it later. Like [`dialog`](crate::dialog) this is presentation +
|
||||
//! input only; the host owns the loop and decides what open/close means.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{Theme, ThemeSet};
|
||||
use syntect::parsing::syntax_definition::SyntaxDefinition;
|
||||
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
|
||||
|
||||
use crate::clipboard::Clipboard;
|
||||
use crate::text::{
|
||||
TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl,
|
||||
};
|
||||
|
||||
/// The embedded Rhai Sublime-syntax definition used for highlighting.
|
||||
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
|
||||
/// A dark syntect theme that ships with `ThemeSet::load_defaults`.
|
||||
const THEME_NAME: &str = "base16-ocean.dark";
|
||||
/// Maximum retained undo (and redo) snapshots.
|
||||
const UNDO_DEPTH: usize = 200;
|
||||
/// Default text color when no syntax color applies.
|
||||
const DEFAULT_FG: Color = Color::Rgb(220, 220, 220);
|
||||
/// Background of selected text.
|
||||
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
|
||||
/// Line-number gutter color.
|
||||
const GUTTER_FG: Color = Color::Rgb(90, 100, 120);
|
||||
|
||||
/// What a key/paste event did to the editor, reported back to the host.
|
||||
pub enum CodeEditorOutcome {
|
||||
/// The editor stays open.
|
||||
Continue,
|
||||
/// The user pressed `Esc`; the host should close the editor.
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// A cursor/selection position: `col` is a **character** index within `row`'s line.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct Pos {
|
||||
row: usize,
|
||||
col: usize,
|
||||
}
|
||||
|
||||
/// Which kind of edit just happened, used to coalesce undo steps.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EditKind {
|
||||
/// A run of single-character insertions.
|
||||
Insert,
|
||||
/// A run of single-character deletions.
|
||||
Delete,
|
||||
/// Any structural edit (newline, paste, selection delete) — never coalesced.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A point-in-time buffer snapshot for undo/redo.
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
lines: Vec<String>,
|
||||
cursor: Pos,
|
||||
}
|
||||
|
||||
/// Cached syntect pieces for highlighting (reused across frames).
|
||||
struct HighlightParts {
|
||||
theme: Theme,
|
||||
syntax_ref: SyntaxReference,
|
||||
syntax_set: Arc<SyntaxSet>,
|
||||
}
|
||||
|
||||
/// A modeless, keyboard-driven code editor over a single script's text.
|
||||
pub struct CodeEditor {
|
||||
/// The script name, shown in the title border and used by the host on save.
|
||||
name: String,
|
||||
/// The text buffer, one entry per line (always at least one line).
|
||||
lines: Vec<String>,
|
||||
/// The cursor position.
|
||||
cursor: Pos,
|
||||
/// Remembered display intent for vertical movement (so up/down keep a column).
|
||||
desired_col: usize,
|
||||
/// Selection anchor; the selection spans `anchor..cursor` when `Some`.
|
||||
anchor: Option<Pos>,
|
||||
/// Scroll offset as `(top row, left display column)`, kept following the cursor.
|
||||
scroll: (usize, usize),
|
||||
/// Visible text rows from the last `draw`, used by PageUp/PageDown.
|
||||
view_rows: usize,
|
||||
/// Syntax-highlighting data, or `None` if the syntax/theme failed to load.
|
||||
highlight: Option<HighlightParts>,
|
||||
/// Clipboard: the system one when available, always backed by an internal buffer.
|
||||
clipboard: Clipboard,
|
||||
/// Undo/redo snapshot stacks and the open-edit-group marker for coalescing.
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
last_edit: Option<EditKind>,
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Builds an editor for `name` pre-filled with `text`.
|
||||
pub fn new(name: &str, text: &str) -> Self {
|
||||
let lines: Vec<String> = if text.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
text.split('\n').map(str::to_string).collect()
|
||||
};
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
lines,
|
||||
cursor: Pos { row: 0, col: 0 },
|
||||
desired_col: 0,
|
||||
anchor: None,
|
||||
scroll: (0, 0),
|
||||
view_rows: 0,
|
||||
highlight: build_highlight_parts(),
|
||||
clipboard: Clipboard::new(),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
last_edit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The script name (the host writes [`text`](CodeEditor::text) back under it).
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The current buffer contents (lines joined with `\n`).
|
||||
pub fn text(&self) -> String {
|
||||
self.lines.join("\n")
|
||||
}
|
||||
|
||||
/// Handles a key/paste event. `Esc` requests close; unrecognized keys are ignored.
|
||||
pub fn handle_event(&mut self, event: &Event) -> CodeEditorOutcome {
|
||||
match event {
|
||||
// Terminal (bracketed) paste: insert the text as-is.
|
||||
Event::Paste(text) => {
|
||||
self.break_group();
|
||||
self.replace_selection();
|
||||
self.insert_text(text);
|
||||
}
|
||||
Event::Key(key) => {
|
||||
// Treat ⌘ as ctrl (terminals report Super for Cmd under the kitty protocol).
|
||||
let key = super_to_ctrl(*key);
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
match key.code {
|
||||
KeyCode::Esc => return CodeEditorOutcome::Exit,
|
||||
// Clipboard / undo / select-all chords.
|
||||
KeyCode::Char(c) if ctrl => match c.to_ascii_lowercase() {
|
||||
'c' => self.copy(),
|
||||
'x' => self.cut(),
|
||||
'v' => self.paste(),
|
||||
'a' => self.select_all(),
|
||||
'z' => {
|
||||
if shift {
|
||||
self.redo()
|
||||
} else {
|
||||
self.undo()
|
||||
}
|
||||
}
|
||||
'y' => self.redo(),
|
||||
_ => {}
|
||||
},
|
||||
// Printable text.
|
||||
KeyCode::Char(c) if !alt => self.insert_char(c),
|
||||
KeyCode::Tab => self.insert_tab(),
|
||||
KeyCode::Enter => self.insert_newline(),
|
||||
KeyCode::Backspace => self.backspace(),
|
||||
KeyCode::Delete => self.delete_forward(),
|
||||
KeyCode::Left => self.moved(
|
||||
shift,
|
||||
if ctrl {
|
||||
Self::move_word_left
|
||||
} else {
|
||||
Self::move_left
|
||||
},
|
||||
),
|
||||
KeyCode::Right => self.moved(
|
||||
shift,
|
||||
if ctrl {
|
||||
Self::move_word_right
|
||||
} else {
|
||||
Self::move_right
|
||||
},
|
||||
),
|
||||
KeyCode::Up => self.moved(
|
||||
shift,
|
||||
if ctrl {
|
||||
Self::move_paragraph_up
|
||||
} else {
|
||||
Self::move_up
|
||||
},
|
||||
),
|
||||
KeyCode::Down => self.moved(
|
||||
shift,
|
||||
if ctrl {
|
||||
Self::move_paragraph_down
|
||||
} else {
|
||||
Self::move_down
|
||||
},
|
||||
),
|
||||
KeyCode::Home => self.moved(shift, Self::move_home),
|
||||
KeyCode::End => self.moved(shift, Self::move_end),
|
||||
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
|
||||
KeyCode::PageDown => self.moved(shift, Self::move_page_down),
|
||||
_ => {} // ignore anything else — never panic on an unknown key
|
||||
}
|
||||
}
|
||||
_ => {} // mouse and other events are ignored for now
|
||||
}
|
||||
CodeEditorOutcome::Continue
|
||||
}
|
||||
|
||||
// ── Movement ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a movement, first setting/clearing the selection anchor per `shift`.
|
||||
fn moved(&mut self, shift: bool, mv: fn(&mut Self)) {
|
||||
self.break_group();
|
||||
if shift {
|
||||
// Begin (or continue) a selection from the pre-move cursor.
|
||||
if self.anchor.is_none() {
|
||||
self.anchor = Some(self.cursor);
|
||||
}
|
||||
} else {
|
||||
self.anchor = None;
|
||||
}
|
||||
mv(self);
|
||||
}
|
||||
|
||||
/// Number of characters in line `row`.
|
||||
fn line_len(&self, row: usize) -> usize {
|
||||
self.lines[row].chars().count()
|
||||
}
|
||||
|
||||
fn move_left(&mut self) {
|
||||
if self.cursor.col > 0 {
|
||||
self.cursor.col -= 1;
|
||||
} else if self.cursor.row > 0 {
|
||||
// Wrap to the end of the previous line.
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
if self.cursor.col < self.line_len(self.cursor.row) {
|
||||
self.cursor.col += 1;
|
||||
} else if self.cursor.row + 1 < self.lines.len() {
|
||||
// Wrap to the start of the next line.
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
if self.cursor.row > 0 {
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
if self.cursor.row + 1 < self.lines.len() {
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
}
|
||||
|
||||
/// ctrl+Left: jump to the previous word start (wrapping to the previous line's end).
|
||||
fn move_word_left(&mut self) {
|
||||
if self.cursor.col == 0 && self.cursor.row > 0 {
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
} else {
|
||||
self.cursor.col = prev_word(&self.lines[self.cursor.row], self.cursor.col);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// ctrl+Right: jump to the next word start (wrapping to the next line's start).
|
||||
fn move_word_right(&mut self) {
|
||||
if self.cursor.col == self.line_len(self.cursor.row)
|
||||
&& self.cursor.row + 1 < self.lines.len()
|
||||
{
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
} else {
|
||||
self.cursor.col = next_word(&self.lines[self.cursor.row], self.cursor.col);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
|
||||
fn move_paragraph_up(&mut self) {
|
||||
self.cursor.row = (0..self.cursor.row)
|
||||
.rev()
|
||||
.find(|&r| self.is_blank_line(r))
|
||||
.unwrap_or(0);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
/// ctrl+Down: jump to the nearest blank/whitespace-only line below, else the last line.
|
||||
fn move_paragraph_down(&mut self) {
|
||||
let last = self.lines.len() - 1;
|
||||
self.cursor.row = (self.cursor.row + 1..self.lines.len())
|
||||
.find(|&r| self.is_blank_line(r))
|
||||
.unwrap_or(last);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
/// Whether line `row` is empty or contains only whitespace.
|
||||
fn is_blank_line(&self, row: usize) -> bool {
|
||||
self.lines[row].trim().is_empty()
|
||||
}
|
||||
|
||||
fn move_home(&mut self) {
|
||||
self.cursor.col = 0;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
fn move_end(&mut self) {
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_page_up(&mut self) {
|
||||
let step = self.view_rows.max(1);
|
||||
self.cursor.row = self.cursor.row.saturating_sub(step);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
fn move_page_down(&mut self) {
|
||||
let step = self.view_rows.max(1);
|
||||
self.cursor.row = (self.cursor.row + step).min(self.lines.len() - 1);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
// ── Editing ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn insert_char(&mut self, c: char) {
|
||||
self.replace_selection();
|
||||
self.begin_edit(EditKind::Insert);
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].insert(b, c);
|
||||
self.cursor.col += 1;
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// Inserts spaces to advance to the next tab stop.
|
||||
fn insert_tab(&mut self) {
|
||||
self.replace_selection();
|
||||
let pad = TAB_WIDTH - (self.display_col(self.cursor.row, self.cursor.col) % TAB_WIDTH);
|
||||
for _ in 0..pad {
|
||||
self.insert_char(' ');
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_newline(&mut self) {
|
||||
self.replace_selection();
|
||||
self.begin_edit(EditKind::Other);
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
let tail = self.lines[self.cursor.row].split_off(b);
|
||||
self.lines.insert(self.cursor.row + 1, tail);
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
/// Inserts arbitrary text (possibly multi-line) at the cursor.
|
||||
fn insert_text(&mut self, text: &str) {
|
||||
self.begin_edit(EditKind::Other);
|
||||
for (i, part) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
// Each '\n' splits the current line.
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
let tail = self.lines[self.cursor.row].split_off(b);
|
||||
self.lines.insert(self.cursor.row + 1, tail);
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
}
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].insert_str(b, part);
|
||||
self.cursor.col += part.chars().count();
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
if self.anchor.is_some() {
|
||||
self.replace_selection();
|
||||
return;
|
||||
}
|
||||
self.begin_edit(EditKind::Delete);
|
||||
if self.cursor.col > 0 {
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col - 1);
|
||||
self.lines[self.cursor.row].remove(b);
|
||||
self.cursor.col -= 1;
|
||||
} else if self.cursor.row > 0 {
|
||||
// Join with the previous line.
|
||||
let line = self.lines.remove(self.cursor.row);
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
self.lines[self.cursor.row].push_str(&line);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) {
|
||||
if self.anchor.is_some() {
|
||||
self.replace_selection();
|
||||
return;
|
||||
}
|
||||
self.begin_edit(EditKind::Delete);
|
||||
if self.cursor.col < self.line_len(self.cursor.row) {
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].remove(b);
|
||||
} else if self.cursor.row + 1 < self.lines.len() {
|
||||
// Pull the next line up onto this one.
|
||||
let next = self.lines.remove(self.cursor.row + 1);
|
||||
self.lines[self.cursor.row].push_str(&next);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// The selection as an ordered `(start, end)` pair, if any.
|
||||
fn selection(&self) -> Option<(Pos, Pos)> {
|
||||
let a = self.anchor?;
|
||||
let c = self.cursor;
|
||||
if (a.row, a.col) <= (c.row, c.col) {
|
||||
Some((a, c))
|
||||
} else {
|
||||
Some((c, a))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the current selection (if any) and places the cursor at its start.
|
||||
fn replace_selection(&mut self) {
|
||||
let Some((a, b)) = self.selection() else {
|
||||
return;
|
||||
};
|
||||
self.begin_edit(EditKind::Other);
|
||||
if a.row == b.row {
|
||||
let s = &mut self.lines[a.row];
|
||||
let (ba, bb) = (byte_of(s, a.col), byte_of(s, b.col));
|
||||
s.replace_range(ba..bb, "");
|
||||
} else {
|
||||
let bb = byte_of(&self.lines[b.row], b.col);
|
||||
let tail = self.lines[b.row][bb..].to_string();
|
||||
let ba = byte_of(&self.lines[a.row], a.col);
|
||||
self.lines[a.row].truncate(ba);
|
||||
self.lines[a.row].push_str(&tail);
|
||||
self.lines.drain(a.row + 1..=b.row);
|
||||
}
|
||||
self.cursor = a;
|
||||
self.desired_col = a.col;
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
/// The text covered by an ordered selection `(a, b)`.
|
||||
fn selected_text(&self, a: Pos, b: Pos) -> String {
|
||||
if a.row == b.row {
|
||||
let s = &self.lines[a.row];
|
||||
return s[byte_of(s, a.col)..byte_of(s, b.col)].to_string();
|
||||
}
|
||||
let mut out = self.lines[a.row][byte_of(&self.lines[a.row], a.col)..].to_string();
|
||||
for row in a.row + 1..b.row {
|
||||
out.push('\n');
|
||||
out.push_str(&self.lines[row]);
|
||||
}
|
||||
out.push('\n');
|
||||
out.push_str(&self.lines[b.row][..byte_of(&self.lines[b.row], b.col)]);
|
||||
out
|
||||
}
|
||||
|
||||
fn select_all(&mut self) {
|
||||
self.break_group();
|
||||
let last = self.lines.len() - 1;
|
||||
self.anchor = Some(Pos { row: 0, col: 0 });
|
||||
self.cursor = Pos {
|
||||
row: last,
|
||||
col: self.line_len(last),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn copy(&mut self) {
|
||||
if let Some((a, b)) = self.selection() {
|
||||
let text = self.selected_text(a, b);
|
||||
self.clipboard.set(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn cut(&mut self) {
|
||||
self.copy();
|
||||
self.replace_selection();
|
||||
}
|
||||
|
||||
fn paste(&mut self) {
|
||||
let text = self.clipboard.get();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.break_group();
|
||||
self.replace_selection();
|
||||
self.insert_text(&text);
|
||||
}
|
||||
|
||||
// ── Undo / redo ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Begins an edit of `kind`, snapshotting state unless it coalesces with the run in
|
||||
/// progress (consecutive inserts, or consecutive deletes, collapse to one undo step).
|
||||
fn begin_edit(&mut self, kind: EditKind) {
|
||||
let coalesce = kind != EditKind::Other && self.last_edit == Some(kind);
|
||||
if !coalesce {
|
||||
self.push_snapshot();
|
||||
}
|
||||
self.last_edit = Some(kind);
|
||||
}
|
||||
|
||||
/// Ends the current edit run so the next edit starts a fresh undo step.
|
||||
fn break_group(&mut self) {
|
||||
self.last_edit = None;
|
||||
}
|
||||
|
||||
/// Pushes the current state to the undo stack and clears redo.
|
||||
fn push_snapshot(&mut self) {
|
||||
self.undo.push(Snapshot {
|
||||
lines: self.lines.clone(),
|
||||
cursor: self.cursor,
|
||||
});
|
||||
if self.undo.len() > UNDO_DEPTH {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
fn undo(&mut self) {
|
||||
if let Some(prev) = self.undo.pop() {
|
||||
self.redo.push(Snapshot {
|
||||
lines: self.lines.clone(),
|
||||
cursor: self.cursor,
|
||||
});
|
||||
self.restore(prev);
|
||||
}
|
||||
}
|
||||
|
||||
fn redo(&mut self) {
|
||||
if let Some(next) = self.redo.pop() {
|
||||
self.undo.push(Snapshot {
|
||||
lines: self.lines.clone(),
|
||||
cursor: self.cursor,
|
||||
});
|
||||
self.restore(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the buffer/cursor with a snapshot, ending any edit run and selection.
|
||||
fn restore(&mut self, snap: Snapshot) {
|
||||
self.lines = snap.lines;
|
||||
self.cursor = snap.cursor;
|
||||
self.desired_col = self.cursor.col;
|
||||
self.anchor = None;
|
||||
self.last_edit = None;
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Display column (terminal cells) of character index `col` on line `row` (tabs to the
|
||||
/// next [`TAB_WIDTH`] stop, wide chars as two cells).
|
||||
fn display_col(&self, row: usize, col: usize) -> usize {
|
||||
display_width(&self.lines[row], col)
|
||||
}
|
||||
|
||||
/// Renders the editor (titled border, gutter, highlighted text, cursor) into `area`.
|
||||
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::bordered().title(format!(" {} ", self.name));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = inner.height as usize;
|
||||
self.view_rows = rows;
|
||||
// Gutter holds the largest line number plus a trailing space.
|
||||
let gutter_w = (self.lines.len().to_string().len() + 1) as u16;
|
||||
let text_x = inner.x + gutter_w;
|
||||
let text_w = inner.width.saturating_sub(gutter_w);
|
||||
if text_w == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_scroll(rows, text_w as usize);
|
||||
let (top, left) = self.scroll;
|
||||
let bottom = (top + rows).min(self.lines.len());
|
||||
|
||||
// Highlight from line 0 to the viewport bottom (syntect needs prior lines for context).
|
||||
let colors = self.highlight_through(bottom);
|
||||
let sel = self.selection();
|
||||
|
||||
for (i, row) in (top..bottom).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
// Right-aligned line number in the gutter (temporary buffer borrow per call).
|
||||
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
|
||||
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
|
||||
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
|
||||
let rect = Rect {
|
||||
x: text_x,
|
||||
y,
|
||||
width: text_w,
|
||||
height: 1,
|
||||
};
|
||||
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
|
||||
}
|
||||
|
||||
// Place the real terminal cursor if it's within the visible window.
|
||||
let cx = self.display_col(self.cursor.row, self.cursor.col);
|
||||
if (top..bottom).contains(&self.cursor.row) && cx >= left && cx - left < text_w as usize {
|
||||
let x = text_x + (cx - left) as u16;
|
||||
let y = inner.y + (self.cursor.row - top) as u16;
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts `scroll` so the cursor stays visible (vertical by row, horizontal by cell).
|
||||
fn update_scroll(&mut self, rows: usize, text_w: usize) {
|
||||
let (mut top, mut left) = self.scroll;
|
||||
if self.cursor.row < top {
|
||||
top = self.cursor.row;
|
||||
} else if self.cursor.row >= top + rows {
|
||||
top = self.cursor.row + 1 - rows;
|
||||
}
|
||||
let cx = self.display_col(self.cursor.row, self.cursor.col);
|
||||
if cx < left {
|
||||
left = cx;
|
||||
} else if cx >= left + text_w {
|
||||
left = cx + 1 - text_w;
|
||||
}
|
||||
self.scroll = (top, left);
|
||||
}
|
||||
|
||||
/// Per-character foreground colors for lines `0..upto`, via syntect (empty if disabled).
|
||||
fn highlight_through(&self, upto: usize) -> Vec<Vec<Color>> {
|
||||
let Some(hl) = &self.highlight else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut h = HighlightLines::new(&hl.syntax_ref, &hl.theme);
|
||||
let mut out = Vec::with_capacity(upto);
|
||||
for line in self.lines.iter().take(upto) {
|
||||
// Feed a trailing newline so multi-line constructs keep state, then drop it.
|
||||
let with_nl = format!("{line}\n");
|
||||
let regions = h
|
||||
.highlight_line(&with_nl, &hl.syntax_set)
|
||||
.unwrap_or_default();
|
||||
let mut colors = Vec::with_capacity(line.chars().count());
|
||||
for (style, text) in regions {
|
||||
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
|
||||
colors.extend(text.chars().map(|_| c));
|
||||
}
|
||||
colors.truncate(line.chars().count());
|
||||
out.push(colors);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
|
||||
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
|
||||
fn render_line(
|
||||
&self,
|
||||
row: usize,
|
||||
colors: Option<&[Color]>,
|
||||
sel: Option<(Pos, Pos)>,
|
||||
) -> Line<'static> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut col_cells = 0; // running display column, for tab expansion
|
||||
for (i, c) in self.lines[row].chars().enumerate() {
|
||||
let fg = colors
|
||||
.and_then(|cs| cs.get(i))
|
||||
.copied()
|
||||
.unwrap_or(DEFAULT_FG);
|
||||
let mut style = Style::default().fg(fg);
|
||||
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
|
||||
style = style.bg(SELECT_BG);
|
||||
}
|
||||
if c == '\t' {
|
||||
let pad = TAB_WIDTH - (col_cells % TAB_WIDTH);
|
||||
spans.push(Span::styled(" ".repeat(pad), style));
|
||||
col_cells += pad;
|
||||
} else {
|
||||
spans.push(Span::styled(c.to_string(), style));
|
||||
col_cells += char_cells(c, col_cells);
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether character `(row, col)` lies within ordered selection `[a, b)`.
|
||||
fn pos_in_range(row: usize, col: usize, a: Pos, b: Pos) -> bool {
|
||||
let after_start = row > a.row || (row == a.row && col >= a.col);
|
||||
let before_end = row < b.row || (row == b.row && col < b.col);
|
||||
after_start && before_end
|
||||
}
|
||||
|
||||
/// Parses the embedded Rhai syntax and a default theme into reusable highlight parts.
|
||||
/// Returns `None` (highlighting disabled, editor still works) on any failure.
|
||||
fn build_highlight_parts() -> Option<HighlightParts> {
|
||||
let syntax = SyntaxDefinition::load_from_str(RHAI_SYNTAX, true, None).ok()?;
|
||||
let mut builder = SyntaxSetBuilder::new();
|
||||
builder.add(syntax);
|
||||
let syntax_set = builder.build();
|
||||
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
|
||||
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
|
||||
Some(HighlightParts {
|
||||
theme,
|
||||
syntax_ref,
|
||||
syntax_set: Arc::new(syntax_set),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::KeyEvent;
|
||||
|
||||
/// Sends a bare key code to the editor.
|
||||
fn send(ed: &mut CodeEditor, code: KeyCode) {
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
|
||||
}
|
||||
/// Sends a key code with modifiers (e.g. ctrl/shift).
|
||||
fn send_mod(ed: &mut CodeEditor, code: KeyCode, mods: KeyModifiers) {
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(code, mods)));
|
||||
}
|
||||
/// Types each character of `s` in order.
|
||||
fn typ(ed: &mut CodeEditor, s: &str) {
|
||||
for c in s.chars() {
|
||||
send(ed, KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_rhai_syntax_parses_and_theme_loads() {
|
||||
assert!(build_highlight_parts().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_round_trips_and_typing_inserts() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "let x = 1;");
|
||||
assert_eq!(ed.text(), "let x = 1;");
|
||||
assert_eq!(CodeEditor::new("s", "a\nb\nc").text(), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_at_col0_wraps_to_previous_line_end() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::Down); // (1,0)
|
||||
send(&mut ed, KeyCode::Left); // should wrap to end of line 0 = (0,2)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "abX\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_at_line_end_wraps_down() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::End); // (0,2)
|
||||
send(&mut ed, KeyCode::Right); // wrap to (1,0)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "ab\nXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_col0_joins_lines() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::Down); // (1,0)
|
||||
send(&mut ed, KeyCode::Backspace);
|
||||
assert_eq!(ed.text(), "abcd");
|
||||
typ(&mut ed, "X"); // cursor should sit at the join (0,2)
|
||||
assert_eq!(ed.text(), "abXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_selection_then_delete_removes_it() {
|
||||
let mut ed = CodeEditor::new("s", "abcd");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
send(&mut ed, KeyCode::Backspace);
|
||||
assert_eq!(ed.text(), "cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracketed_paste_replaces_selection() {
|
||||
let mut ed = CodeEditor::new("s", "abcd");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
ed.handle_event(&Event::Paste("XY\nZ".to_string()));
|
||||
assert_eq!(ed.text(), "XY\nZcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_then_paste_round_trips_via_internal_clipboard() {
|
||||
// Works even headless: the internal buffer backs copy/paste when there's no display.
|
||||
let mut ed = CodeEditor::new("s", "abc");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
send_mod(&mut ed, KeyCode::Char('c'), KeyModifiers::CONTROL); // copy
|
||||
send(&mut ed, KeyCode::End); // clear selection, go to end
|
||||
send_mod(&mut ed, KeyCode::Char('v'), KeyModifiers::CONTROL); // paste
|
||||
assert_eq!(ed.text(), "abcab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_round_trip() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "abc"); // coalesces to one undo step
|
||||
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
|
||||
assert_eq!(ed.text(), "");
|
||||
send_mod(
|
||||
&mut ed,
|
||||
KeyCode::Char('z'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
);
|
||||
assert_eq!(ed.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_breaks_undo_coalescing() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "ab");
|
||||
send(&mut ed, KeyCode::Left); // breaks the typing group
|
||||
typ(&mut ed, "c"); // "acb" (inserted before 'b')... actually at (0,1): "acb"
|
||||
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
|
||||
assert_eq!(ed.text(), "ab"); // only the post-move insert is undone
|
||||
}
|
||||
|
||||
/// Sends a key code with modifiers and types a marker, returning where it landed.
|
||||
fn ctrl(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_right_jumps_to_next_word_start() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar baz"); // cursor at (0,0) via Home
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
|
||||
typ(&mut ed, "|"); // insert marker at the landing column
|
||||
assert_eq!(ed.text(), "foo |bar baz"); // landed at start of "bar"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_left_jumps_to_previous_word_start() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar baz");
|
||||
send(&mut ed, KeyCode::End); // move to end (0,11) first
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Left)));
|
||||
typ(&mut ed, "|");
|
||||
assert_eq!(ed.text(), "foo bar |baz"); // landed at start of "baz"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_right_wraps_at_end_of_line() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::End); // (0,2) end of first line
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
|
||||
typ(&mut ed, "X"); // should land at start of line 2
|
||||
assert_eq!(ed.text(), "ab\nXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_down_and_up_jump_to_blank_lines() {
|
||||
let mut ed = CodeEditor::new("s", "alpha\nbeta\n\ngamma");
|
||||
send(&mut ed, KeyCode::Home); // (0,0)
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Down))); // → blank line (row 2)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "alpha\nbeta\nX\ngamma");
|
||||
// From the blank line, ctrl+Up returns to the top (no blank above).
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Up)));
|
||||
typ(&mut ed, "Y");
|
||||
assert_eq!(ed.text(), "Yalpha\nbeta\nX\ngamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_ctrl_right_selects_a_word() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar");
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(
|
||||
KeyCode::Right,
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
))); // select "foo "
|
||||
send(&mut ed, KeyCode::Backspace); // delete the selection
|
||||
assert_eq!(ed.text(), "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_requests_exit() {
|
||||
let mut ed = CodeEditor::new("s", "x");
|
||||
assert!(matches!(
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
|
||||
CodeEditorOutcome::Exit
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_z_maps_to_ctrl_undo() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "hi");
|
||||
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::SUPER);
|
||||
assert_eq!(ed.text(), "");
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
//! Self-contained dialog overlays: a [text dialog](Dialog::text) (single free-text
|
||||
//! field) and a [list dialog](Dialog::list) (a filterable, arrow-selectable list).
|
||||
//!
|
||||
//! Both are [`Dialog<Ctx>`] values generic over a host context type (the front-end
|
||||
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
|
||||
//! host routes all input to it; when dismissed it fires a stored callback with the
|
||||
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
|
||||
//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
|
||||
//! single-line editor used elsewhere in kiln-ui).
|
||||
//!
|
||||
//! Drawing is the [`CursorOverlay`] impl: it paints into a buffer and returns where
|
||||
//! the active text field's caret should go, so the host can place the real terminal
|
||||
//! cursor (see [`crate::render_overlay`]).
|
||||
|
||||
use crate::text_field::TextField;
|
||||
use crate::{CursorOverlay, dim_area};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::crossterm::event::{Event, KeyCode};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
|
||||
/// Background color filling a dialog window.
|
||||
const BG: Color = Color::Rgb(10, 15, 35);
|
||||
/// Border color for a dialog window.
|
||||
const BORDER: Color = Color::Rgb(80, 130, 210);
|
||||
/// Color for the currently selected list entry's background.
|
||||
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
|
||||
/// Background color of an editable text field, so it reads as an input box.
|
||||
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
|
||||
|
||||
/// The outcome a list dialog reports to its callback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ListDialogResponse {
|
||||
/// The player chose an existing entry from the list.
|
||||
Select(String),
|
||||
/// The player typed a new value and confirmed it (only possible when the list
|
||||
/// dialog was opened with `allow_create = true`). No current caller enables
|
||||
/// creation, so the payload is unused for now — kept as part of the dialog API.
|
||||
#[allow(dead_code)]
|
||||
Create(String),
|
||||
/// The player dismissed the dialog with `Esc`.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// What kind of field a dialog shows, plus its associated state.
|
||||
enum DialogBody {
|
||||
/// A single free-text field.
|
||||
Text { field: TextField },
|
||||
/// A filter field above a selectable list. `selected` indexes the *filtered*
|
||||
/// view (entries of `all` whose text starts with the filter).
|
||||
List {
|
||||
field: TextField,
|
||||
all: Vec<String>,
|
||||
selected: usize,
|
||||
allow_create: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Boxed callback for a text dialog: `Some(value)` on OK, `None` on Cancel.
|
||||
type TextCallback<Ctx> = Box<dyn FnOnce(Option<String>, &mut Ctx)>;
|
||||
/// Boxed callback for a list dialog: see [`ListDialogResponse`].
|
||||
type ListCallback<Ctx> = Box<dyn FnOnce(ListDialogResponse, &mut Ctx)>;
|
||||
|
||||
/// The callback fired once when a dialog is dismissed, paired by body kind.
|
||||
enum DialogCallback<Ctx> {
|
||||
/// Text-dialog callback.
|
||||
Text(TextCallback<Ctx>),
|
||||
/// List-dialog callback.
|
||||
List(ListCallback<Ctx>),
|
||||
}
|
||||
|
||||
/// How a key event affected an open dialog.
|
||||
pub enum DialogResult {
|
||||
/// The dialog stays open; nothing more to do.
|
||||
Continue,
|
||||
/// The player confirmed (`Enter` with a valid choice) — fire the callback with the value.
|
||||
Submit,
|
||||
/// The player cancelled (`Esc`) — fire the callback with the cancel result.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// An open dialog overlay, generic over the host context `Ctx` its callback mutates.
|
||||
pub struct Dialog<Ctx> {
|
||||
/// Title shown in the window's top border.
|
||||
title: String,
|
||||
/// The field kind + state.
|
||||
body: DialogBody,
|
||||
/// The callback fired exactly once on dismissal.
|
||||
callback: DialogCallback<Ctx>,
|
||||
}
|
||||
|
||||
impl<Ctx> Dialog<Ctx> {
|
||||
/// Builds a text dialog. `initial` pre-fills the field; `on_done` receives
|
||||
/// `Some(value)` on OK or `None` on Cancel, plus `&mut Ctx`.
|
||||
pub fn text(
|
||||
title: impl Into<String>,
|
||||
initial: impl Into<String>,
|
||||
on_done: impl FnOnce(Option<String>, &mut Ctx) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
body: DialogBody::Text {
|
||||
field: TextField::new(initial),
|
||||
},
|
||||
callback: DialogCallback::Text(Box::new(on_done)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a list dialog over `items`. Typing filters the list to entries starting
|
||||
/// with the typed text; arrow keys move the selection. When `allow_create` is true
|
||||
/// the player may confirm typed text that matches nothing (→ [`ListDialogResponse::Create`]);
|
||||
/// otherwise OK is disabled unless a list entry is selected. `on_done` receives the
|
||||
/// [`ListDialogResponse`] plus `&mut Ctx`.
|
||||
pub fn list(
|
||||
title: impl Into<String>,
|
||||
items: Vec<String>,
|
||||
allow_create: bool,
|
||||
on_done: impl FnOnce(ListDialogResponse, &mut Ctx) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
body: DialogBody::List {
|
||||
field: TextField::new(""),
|
||||
all: items,
|
||||
selected: 0,
|
||||
allow_create,
|
||||
},
|
||||
callback: DialogCallback::List(Box::new(on_done)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates dialog state for an input event and reports whether it should close.
|
||||
///
|
||||
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when OK
|
||||
/// is enabled (always for text; for a list, when a valid entry is selected or
|
||||
/// `allow_create` and the field is non-empty). Arrow keys move a list's selection;
|
||||
/// any other key edits the field (and re-clamps a list's selection to the new filter).
|
||||
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
|
||||
let Event::Key(key) = event else {
|
||||
return DialogResult::Continue;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => DialogResult::Cancel,
|
||||
KeyCode::Enter => {
|
||||
if self.ok_enabled() {
|
||||
DialogResult::Submit
|
||||
} else {
|
||||
DialogResult::Continue
|
||||
}
|
||||
}
|
||||
// Arrow up/down only mean something for a list (move selection within the filtered view).
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
// Compute the filtered length (immutable borrow) before taking the
|
||||
// mutable borrow of `selected`.
|
||||
let n = self.filtered_len();
|
||||
if let DialogBody::List { selected, .. } = &mut self.body
|
||||
&& n > 0
|
||||
{
|
||||
// Wrap-free clamp: up decrements, down increments, bounded to [0, n-1].
|
||||
if matches!(key.code, KeyCode::Up) {
|
||||
*selected = selected.saturating_sub(1);
|
||||
} else {
|
||||
*selected = (*selected + 1).min(n - 1);
|
||||
}
|
||||
}
|
||||
DialogResult::Continue
|
||||
}
|
||||
// Everything else edits the text/filter field.
|
||||
_ => {
|
||||
if self.field_mut().handle_key(*key) {
|
||||
// The filter may have changed, so the previous selection index may now
|
||||
// be out of range — clamp it back (compute length first, then clamp).
|
||||
let n = self.filtered_len();
|
||||
if let DialogBody::List { selected, .. } = &mut self.body {
|
||||
*selected = (*selected).min(n.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
DialogResult::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the dialog and fires its callback. `result` must be the
|
||||
/// [`DialogResult::Submit`] or [`DialogResult::Cancel`] just returned by
|
||||
/// [`handle_event`](Dialog::handle_event); `Continue` is treated as cancel.
|
||||
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
|
||||
let submit = matches!(result, DialogResult::Submit);
|
||||
match (self.body, self.callback) {
|
||||
(DialogBody::Text { field }, DialogCallback::Text(cb)) => {
|
||||
cb(submit.then(|| field.value().to_string()), ctx);
|
||||
}
|
||||
(
|
||||
DialogBody::List {
|
||||
field,
|
||||
all,
|
||||
selected,
|
||||
allow_create,
|
||||
},
|
||||
DialogCallback::List(cb),
|
||||
) => {
|
||||
let resp = if !submit {
|
||||
ListDialogResponse::Cancel
|
||||
} else if let Some(entry) = filtered_items(&all, field.value())
|
||||
.into_iter()
|
||||
.nth(selected)
|
||||
{
|
||||
ListDialogResponse::Select(entry.clone())
|
||||
} else if allow_create {
|
||||
ListDialogResponse::Create(field.value().to_string())
|
||||
} else {
|
||||
ListDialogResponse::Cancel
|
||||
};
|
||||
cb(resp, ctx);
|
||||
}
|
||||
// The body and callback are always constructed as a matching pair.
|
||||
_ => unreachable!("dialog body/callback kinds always match"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the OK action is currently allowed (controls Enter + label styling).
|
||||
fn ok_enabled(&self) -> bool {
|
||||
match &self.body {
|
||||
DialogBody::Text { .. } => true,
|
||||
DialogBody::List {
|
||||
field,
|
||||
allow_create,
|
||||
..
|
||||
} => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable access to whichever [`TextField`] this dialog owns.
|
||||
fn field_mut(&mut self) -> &mut TextField {
|
||||
match &mut self.body {
|
||||
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of list entries currently passing the filter (0 for a text dialog).
|
||||
fn filtered_len(&self) -> usize {
|
||||
filtered_count(&self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Ctx> CursorOverlay for Dialog<Ctx> {
|
||||
/// Draws the dialog centered over `area`: dims the background, then renders the
|
||||
/// bordered window, the field (centered for a text dialog, atop the list for a
|
||||
/// list dialog), and the OK/Cancel labels. Returns the active field's caret
|
||||
/// position so the host can place the real terminal cursor.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position> {
|
||||
dim_area(area, buf);
|
||||
|
||||
// Size the window: a list dialog is taller to show its entries.
|
||||
let is_list = matches!(self.body, DialogBody::List { .. });
|
||||
let want_w = 50u16;
|
||||
let want_h = if is_list { 16 } else { 8 };
|
||||
let w = want_w.min(area.width.saturating_sub(2)).max(8);
|
||||
let h = want_h.min(area.height.saturating_sub(2)).max(5);
|
||||
let rect = Rect {
|
||||
x: area.x + (area.width.saturating_sub(w)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(h)) / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
|
||||
// Window frame: clear underlying content, then a bordered titled block.
|
||||
Clear.render(rect, buf);
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {} ", self.title))
|
||||
.border_style(Style::default().fg(BORDER).bg(BG))
|
||||
.style(Style::default().bg(BG));
|
||||
let inner = block.inner(rect);
|
||||
block.render(rect, buf);
|
||||
|
||||
// Carve the footer off the bottom; the rest is the body (field [+ list]).
|
||||
let [body_area, footer_area] =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
|
||||
|
||||
let field = match &self.body {
|
||||
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
|
||||
};
|
||||
|
||||
// The body draws the field(s) and reports the caret position.
|
||||
let cursor = if let DialogBody::List {
|
||||
all,
|
||||
selected,
|
||||
field,
|
||||
..
|
||||
} = &self.body
|
||||
{
|
||||
// List dialog: the filter field sits atop the scrolling list.
|
||||
let [field_area, list_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
|
||||
let cursor = draw_field(buf, field_area, field);
|
||||
draw_list(buf, list_area, all, field.value(), *selected);
|
||||
cursor
|
||||
} else {
|
||||
// Text dialog: a single field box, centered horizontally and vertically.
|
||||
let fw = body_area.width.saturating_sub(4).max(1);
|
||||
let field_area = Rect {
|
||||
x: body_area.x + body_area.width.saturating_sub(fw) / 2,
|
||||
y: body_area.y + body_area.height / 2,
|
||||
width: fw,
|
||||
height: 1,
|
||||
};
|
||||
draw_field(buf, field_area, field)
|
||||
};
|
||||
|
||||
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
|
||||
let ok_style = if self.ok_enabled() {
|
||||
Style::default().fg(Color::White).bg(BG)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray).bg(BG)
|
||||
};
|
||||
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(BG);
|
||||
let footer = Line::from(vec![
|
||||
Span::styled("[enter] ", key_style),
|
||||
Span::styled("OK ", ok_style),
|
||||
Span::styled("[esc] ", key_style),
|
||||
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
|
||||
]);
|
||||
Paragraph::new(footer).render(footer_area, buf);
|
||||
|
||||
cursor
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a `&Dialog` be drawn with `frame.render_widget` where the caret isn't needed;
|
||||
/// the caret position from [`CursorOverlay::render`] is discarded. Use
|
||||
/// [`render_overlay`](crate::render_overlay) when the terminal cursor must be placed.
|
||||
impl<Ctx> Widget for &Dialog<Ctx> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
CursorOverlay::render(self, area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the entries passing a list body's current filter (0 for a text body).
|
||||
fn filtered_count(body: &DialogBody) -> usize {
|
||||
match body {
|
||||
DialogBody::List { all, field, .. } => filtered_items(all, field.value()).len(),
|
||||
DialogBody::Text { .. } => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns references to the entries of `all` that start with `filter` (prefix match).
|
||||
fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
|
||||
all.iter().filter(|s| s.starts_with(filter)).collect()
|
||||
}
|
||||
|
||||
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
|
||||
/// (so it reads as an input box) and the current value. Returns the caret position
|
||||
/// (the field's display cursor column) for the host to place the terminal cursor, or
|
||||
/// `None` if the area has no width.
|
||||
fn draw_field(buf: &mut Buffer, area: Rect, field: &TextField) -> Option<Position> {
|
||||
if area.width == 0 {
|
||||
return None;
|
||||
}
|
||||
// The paragraph's style fills the whole field area, including empty cells.
|
||||
Paragraph::new(field.value())
|
||||
.style(Style::default().fg(Color::White).bg(FIELD_BG))
|
||||
.render(area, buf);
|
||||
let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1));
|
||||
Some(Position::new(cur_x, area.y))
|
||||
}
|
||||
|
||||
/// Renders the filtered list into `area`, highlighting `selected` and scrolling so
|
||||
/// the selection stays visible.
|
||||
fn draw_list(buf: &mut Buffer, area: Rect, all: &[String], filter: &str, selected: usize) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let items = filtered_items(all, filter);
|
||||
let rows = area.height as usize;
|
||||
// Scroll so the selected row is within the visible window.
|
||||
let top = selected
|
||||
.saturating_sub(rows.saturating_sub(1))
|
||||
.min(items.len().saturating_sub(rows.max(1)));
|
||||
let lines: Vec<Line> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(top)
|
||||
.take(rows)
|
||||
.map(|(i, s)| {
|
||||
let style = if i == selected {
|
||||
Style::default().fg(Color::White).bg(SELECT_BG)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray).bg(BG)
|
||||
};
|
||||
Line::from(Span::styled(s.as_str(), style))
|
||||
})
|
||||
.collect();
|
||||
Paragraph::new(lines)
|
||||
.style(Style::default().bg(BG))
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
|
||||
|
||||
/// Builds a key-press event for a given key code.
|
||||
fn key(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||
}
|
||||
|
||||
/// Three sample list entries; "alpha"/"alps" share the "al" prefix.
|
||||
fn items() -> Vec<String> {
|
||||
vec!["alpha".into(), "beta".into(), "alps".into()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_filters_by_prefix_and_clamps_selection() {
|
||||
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
|
||||
// No filter: all three entries pass.
|
||||
assert_eq!(d.filtered_len(), 3);
|
||||
// Move selection to the last entry, then filter so fewer remain.
|
||||
d.handle_event(&key(KeyCode::Down));
|
||||
d.handle_event(&key(KeyCode::Down)); // selected = 2
|
||||
d.handle_event(&key(KeyCode::Char('a'))); // filter "a" -> alpha, alps
|
||||
assert_eq!(d.filtered_len(), 2);
|
||||
// The out-of-range selection is clamped back into the filtered list.
|
||||
let DialogBody::List { selected, .. } = &d.body else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(*selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_disabled_when_no_match_and_create_not_allowed() {
|
||||
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
|
||||
for c in "zzz".chars() {
|
||||
d.handle_event(&key(KeyCode::Char(c)));
|
||||
}
|
||||
assert_eq!(d.filtered_len(), 0);
|
||||
assert!(!d.ok_enabled());
|
||||
// Enter is ignored while OK is disabled.
|
||||
assert!(matches!(
|
||||
d.handle_event(&key(KeyCode::Enter)),
|
||||
DialogResult::Continue
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_submit_selects_highlighted_entry() {
|
||||
let mut out: Option<ListDialogResponse> = None;
|
||||
let mut d: Dialog<Option<ListDialogResponse>> =
|
||||
Dialog::list("t", items(), false, |r, c| *c = Some(r));
|
||||
d.handle_event(&key(KeyCode::Down)); // select index 1 ("beta")
|
||||
let res = d.handle_event(&key(KeyCode::Enter));
|
||||
d.finish(res, &mut out);
|
||||
assert!(matches!(out, Some(ListDialogResponse::Select(s)) if s == "beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_submit_creates_when_allowed_and_no_match() {
|
||||
let mut out: Option<ListDialogResponse> = None;
|
||||
let mut d: Dialog<Option<ListDialogResponse>> =
|
||||
Dialog::list("t", items(), true, |r, c| *c = Some(r));
|
||||
for c in "gamma".chars() {
|
||||
d.handle_event(&key(KeyCode::Char(c)));
|
||||
}
|
||||
assert!(d.ok_enabled());
|
||||
let res = d.handle_event(&key(KeyCode::Enter));
|
||||
d.finish(res, &mut out);
|
||||
assert!(matches!(out, Some(ListDialogResponse::Create(s)) if s == "gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_yields_cancel_for_list() {
|
||||
let mut out: Option<ListDialogResponse> = None;
|
||||
let d: Dialog<Option<ListDialogResponse>> =
|
||||
Dialog::list("t", items(), false, |r, c| *c = Some(r));
|
||||
d.finish(DialogResult::Cancel, &mut out);
|
||||
assert!(matches!(out, Some(ListDialogResponse::Cancel)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
|
||||
// Submit returns the edited value.
|
||||
let mut got: Option<Option<String>> = None;
|
||||
let mut d: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
|
||||
d.handle_event(&key(KeyCode::Char('!')));
|
||||
let res = d.handle_event(&key(KeyCode::Enter));
|
||||
d.finish(res, &mut got);
|
||||
assert_eq!(got, Some(Some("hi!".to_string())));
|
||||
|
||||
// Cancel returns None.
|
||||
let mut got2: Option<Option<String>> = None;
|
||||
let d2: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
|
||||
d2.finish(DialogResult::Cancel, &mut got2);
|
||||
assert_eq!(got2, Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_is_buffer_only_and_reports_cursor() {
|
||||
use ratatui::buffer::Buffer;
|
||||
// The split lets us render headless into a plain Buffer (no Frame/TTY); a text
|
||||
// dialog always wants its caret shown, inside the drawn window.
|
||||
let area = Rect::new(0, 0, 60, 25);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let d: Dialog<()> = Dialog::text("t", "hi", |_, _| {});
|
||||
let pos = CursorOverlay::render(&d, area, &mut buf).expect("text dialog wants a cursor");
|
||||
assert!(pos.x < area.width && pos.y < area.height);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! `kiln-ui` — reusable, game-agnostic terminal UI widgets built on [`ratatui`].
|
||||
//!
|
||||
//! This crate is the home for kiln's front-end UI widgets, kept deliberately separate
|
||||
//! from game concerns:
|
||||
//!
|
||||
//! - **Widget responsibilities only.** Each widget owns its *presentation* (drawing)
|
||||
//! and *input handling*; it never references game/world types. No dependency on
|
||||
//! `kiln-core`.
|
||||
//! - **Generic over a host context.** A widget that needs to apply a result mutates a
|
||||
//! caller-supplied context `Ctx` *only through callbacks* (e.g. [`dialog::Dialog<Ctx>`]),
|
||||
//! so the front-end decides what a choice does without the widget knowing about it.
|
||||
//! - **The host drives the loop.** Widgets expose plain `handle_event` / `draw` entry
|
||||
//! points; the front-end owns the event loop, decides when a widget is focused, and
|
||||
//! routes input to it.
|
||||
//!
|
||||
//! This is also where a full-screen script text-editor widget will live (wrapping a
|
||||
//! third-party editor crate) once that work begins.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
|
||||
mod clipboard;
|
||||
pub mod code_editor;
|
||||
pub mod dialog;
|
||||
pub mod glyph_dialog;
|
||||
mod text;
|
||||
pub mod text_field;
|
||||
|
||||
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
|
||||
/// content beneath does not show through.
|
||||
///
|
||||
/// Shared scaffolding: used by [`dialog`] here, and re-borrowed by kiln-tui's
|
||||
/// overlay-frame renderer so both modal styles dim the background identically.
|
||||
pub fn dim_area(area: Rect, buf: &mut Buffer) {
|
||||
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A modal overlay that paints itself into a [`Buffer`] and may want the terminal
|
||||
/// cursor placed at a position (e.g. the caret in a dialog's text field).
|
||||
///
|
||||
/// Painting is split from cursor placement because only a [`Frame`] can move the
|
||||
/// real hardware cursor (a [`Widget`](ratatui::widgets::Widget)'s `render` sees just
|
||||
/// a buffer). So [`render`](CursorOverlay::render) is buffer-only — and therefore
|
||||
/// unit-testable headless — and *returns* where the caret goes; the host applies it
|
||||
/// (via [`render_overlay`]).
|
||||
pub trait CursorOverlay {
|
||||
/// Paints the overlay into `buf` within `area`, returning the desired terminal
|
||||
/// cursor position, or `None` if no cursor should be shown.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) -> Option<Position>;
|
||||
}
|
||||
|
||||
/// Paints a [`CursorOverlay`] over the whole frame and places the hardware cursor
|
||||
/// if the overlay asked for one. The single place that needs a [`Frame`].
|
||||
pub fn render_overlay(frame: &mut Frame, overlay: &impl CursorOverlay) {
|
||||
let area = frame.area();
|
||||
if let Some(pos) = overlay.render(area, frame.buffer_mut()) {
|
||||
frame.set_cursor_position(pos);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Small char/width/key helpers shared by the text widgets
|
||||
//! ([`code_editor`](crate::code_editor), [`text_field`](crate::text_field)).
|
||||
|
||||
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Columns a tab advances to (tab stop).
|
||||
pub(crate) const TAB_WIDTH: usize = 4;
|
||||
|
||||
/// Byte offset of character index `col` within `s` (clamped to `s.len()`).
|
||||
pub(crate) fn byte_of(s: &str, col: usize) -> usize {
|
||||
s.char_indices().nth(col).map_or(s.len(), |(b, _)| b)
|
||||
}
|
||||
|
||||
/// Display width of `c` at running display column `at` (tabs advance to the next
|
||||
/// [`TAB_WIDTH`] stop; wide characters are two cells; zero-width control chars are 0).
|
||||
pub(crate) fn char_cells(c: char, at: usize) -> usize {
|
||||
if c == '\t' {
|
||||
TAB_WIDTH - (at % TAB_WIDTH)
|
||||
} else {
|
||||
UnicodeWidthChar::width(c).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Display width (terminal cells) of the first `col` characters of `s`.
|
||||
pub(crate) fn display_width(s: &str, col: usize) -> usize {
|
||||
let mut w = 0;
|
||||
for c in s.chars().take(col) {
|
||||
w += char_cells(c, w);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Character index after moving one word forward from `col` in `line`: skip the current
|
||||
/// run of non-whitespace, then the following whitespace (lands on the next word's start).
|
||||
pub(crate) fn next_word(line: &str, col: usize) -> usize {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = col;
|
||||
while i < n && !chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Character index after moving one word backward from `col` in `line`: skip whitespace,
|
||||
/// then the preceding word (lands on that word's start).
|
||||
pub(crate) fn prev_word(line: &str, col: usize) -> usize {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut i = col;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Rewrites a `⌘`-modified key to its `ctrl` equivalent (terminals report Super for Cmd
|
||||
/// under the kitty protocol; edtui/our bindings key off ctrl).
|
||||
pub(crate) fn super_to_ctrl(mut key: KeyEvent) -> KeyEvent {
|
||||
if key.modifiers.contains(KeyModifiers::SUPER) {
|
||||
key.modifiers.remove(KeyModifiers::SUPER);
|
||||
key.modifiers.insert(KeyModifiers::CONTROL);
|
||||
}
|
||||
key
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
|
||||
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
|
||||
//!
|
||||
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
|
||||
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
|
||||
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::clipboard::Clipboard;
|
||||
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
|
||||
|
||||
/// A single-line text input with a character-indexed cursor.
|
||||
pub struct TextField {
|
||||
/// The current text (never contains newlines).
|
||||
text: String,
|
||||
/// Cursor position as a character index in `0..=text.chars().count()`.
|
||||
cursor: usize,
|
||||
/// Clipboard for `ctrl`/`⌘`+`v` paste.
|
||||
clipboard: Clipboard,
|
||||
/// How long we will allow the string to be:
|
||||
max_length: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextField {
|
||||
/// Creates a field pre-filled with `initial`, cursor at the end.
|
||||
pub fn new(initial: impl Into<String>) -> Self {
|
||||
let text = initial.into();
|
||||
let cursor = text.chars().count();
|
||||
Self {
|
||||
text,
|
||||
cursor,
|
||||
clipboard: Clipboard::new(),
|
||||
max_length: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
|
||||
let mut text = initial.into();
|
||||
text.truncate(max_length);
|
||||
let cursor = text.chars().count();
|
||||
Self {
|
||||
text,
|
||||
cursor,
|
||||
clipboard: Clipboard::new(),
|
||||
max_length: Some(max_length),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text.
|
||||
pub fn value(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Replaces the entire text (truncated to `max_length` if set), moving the cursor
|
||||
/// to the end. Used to overwrite the field programmatically — e.g. when a color
|
||||
/// picker selects a named swatch and fills in its hex.
|
||||
pub fn set(&mut self, text: impl Into<String>) {
|
||||
let mut text = text.into();
|
||||
if let Some(max) = self.max_length {
|
||||
text.truncate(max);
|
||||
}
|
||||
self.cursor = text.chars().count();
|
||||
self.text = text;
|
||||
}
|
||||
|
||||
/// The cursor's display column (terminal cells from the start), for placing the caret.
|
||||
pub fn display_cursor(&self) -> usize {
|
||||
display_width(&self.text, self.cursor)
|
||||
}
|
||||
|
||||
/// Number of characters in the field.
|
||||
fn len(&self) -> usize {
|
||||
self.text.chars().count()
|
||||
}
|
||||
|
||||
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
|
||||
///
|
||||
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
|
||||
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
let key = super_to_ctrl(key);
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
match key.code {
|
||||
KeyCode::Char('v') if ctrl => self.paste(),
|
||||
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
|
||||
KeyCode::Char(c) if !alt => self.insert(c),
|
||||
KeyCode::Backspace => return self.backspace(),
|
||||
KeyCode::Delete => return self.delete_forward(),
|
||||
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
|
||||
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
|
||||
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
|
||||
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
|
||||
KeyCode::Home => self.cursor = 0,
|
||||
KeyCode::End => self.cursor = self.len(),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn insert(&mut self, c: char) {
|
||||
let b = byte_of(&self.text, self.cursor);
|
||||
if self
|
||||
.max_length
|
||||
.is_none_or(|max_length| max_length > self.text.len())
|
||||
{
|
||||
self.text.insert(b, c);
|
||||
}
|
||||
|
||||
if self
|
||||
.max_length
|
||||
.is_none_or(|max_length| max_length > self.cursor)
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
if self.cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
let b = byte_of(&self.text, self.cursor - 1);
|
||||
self.text.remove(b);
|
||||
self.cursor -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> bool {
|
||||
if self.cursor >= self.len() {
|
||||
return false;
|
||||
}
|
||||
let b = byte_of(&self.text, self.cursor);
|
||||
self.text.remove(b);
|
||||
true
|
||||
}
|
||||
|
||||
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
|
||||
fn paste(&mut self) {
|
||||
let clean: String = self
|
||||
.clipboard
|
||||
.get()
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '\n' | '\r'))
|
||||
.collect();
|
||||
let b = byte_of(&self.text, self.cursor);
|
||||
self.text.insert_str(b, &clean);
|
||||
self.cursor += clean.chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_inserts_and_value_reflects_it() {
|
||||
let mut f = TextField::new("");
|
||||
for c in "abc".chars() {
|
||||
assert!(f.handle_key(key(KeyCode::Char(c))));
|
||||
}
|
||||
assert_eq!(f.value(), "abc");
|
||||
assert_eq!(f.display_cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_and_delete() {
|
||||
let mut f = TextField::new("abc"); // cursor at end (3)
|
||||
assert!(f.handle_key(key(KeyCode::Backspace)));
|
||||
assert_eq!(f.value(), "ab");
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
assert!(f.handle_key(key(KeyCode::Delete)));
|
||||
assert_eq!(f.value(), "b");
|
||||
// Nothing to delete at the boundaries.
|
||||
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_movement_inserts_at_right_place() {
|
||||
let mut f = TextField::new("ac");
|
||||
f.handle_key(key(KeyCode::Left)); // between a and c
|
||||
f.handle_key(key(KeyCode::Char('b')));
|
||||
assert_eq!(f.value(), "abc");
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
f.handle_key(key(KeyCode::Char('X')));
|
||||
assert_eq!(f.value(), "Xabc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
|
||||
let mut f = TextField::new("");
|
||||
f.clipboard.set("multi\nline".to_string());
|
||||
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
|
||||
assert_eq!(f.value(), "multiline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_v_maps_to_ctrl_v() {
|
||||
let mut f = TextField::new("");
|
||||
f.clipboard.set("hi".to_string());
|
||||
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
|
||||
assert_eq!(f.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_cursor_counts_wide_chars() {
|
||||
let mut f = TextField::new("世"); // one double-width char, cursor at end
|
||||
assert_eq!(f.display_cursor(), 2);
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
assert_eq!(f.display_cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sized_truncates_overlong_initial_value() {
|
||||
// `sized` clamps the seed text to the max length up front.
|
||||
let f = TextField::sized("FF0000extra", 6);
|
||||
assert_eq!(f.value(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_length_caps_typed_input() {
|
||||
// Typing into a sized field fills up to the cap, then further input is dropped.
|
||||
let mut f = TextField::sized("", 3);
|
||||
for c in "abcd".chars() {
|
||||
f.handle_key(key(KeyCode::Char(c)));
|
||||
}
|
||||
assert_eq!(f.value(), "abc");
|
||||
|
||||
// A field seeded at its cap ignores further typing (the glyph picker's hex fields).
|
||||
let mut g = TextField::sized("FF0000", 6);
|
||||
g.handle_key(key(KeyCode::Char('9')));
|
||||
assert_eq!(g.value(), "FF0000");
|
||||
|
||||
// An unsized field is unbounded.
|
||||
let mut u = TextField::new("");
|
||||
for c in "abcdef".chars() {
|
||||
u.handle_key(key(KeyCode::Char(c)));
|
||||
}
|
||||
assert_eq!(u.value(), "abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_text_and_respects_max_length() {
|
||||
let mut f = TextField::sized("FF0000", 6);
|
||||
f.set("00AA00");
|
||||
assert_eq!(f.value(), "00AA00");
|
||||
assert_eq!(f.display_cursor(), 6); // cursor at end
|
||||
// Overlong replacements are truncated to the cap.
|
||||
f.set("ABCDEFGH");
|
||||
assert_eq!(f.value(), "ABCDEF");
|
||||
// An unsized field accepts any length.
|
||||
let mut u = TextField::new("x");
|
||||
u.set("anything goes");
|
||||
assert_eq!(u.value(), "anything goes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_keys_return_false() {
|
||||
let mut f = TextField::new("x");
|
||||
assert!(!f.handle_key(key(KeyCode::Enter)));
|
||||
assert!(!f.handle_key(key(KeyCode::Up)));
|
||||
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user