diff --git a/.claude/demo_content_reference.md b/.claude/demo_content_reference.md
index 09fc37d3..7f93dd97 100644
--- a/.claude/demo_content_reference.md
+++ b/.claude/demo_content_reference.md
@@ -11,7 +11,7 @@ vocabulary below so the docs, the playground scripts, and the screenshots all
read as one consistent world.
Why: the engine is application-agnostic. A neutral, friendly theme keeps the
-examples about the *widgets* rather than any particular software stack, and
+examples about the *fields* rather than any particular software stack, and
avoids dating the docs to a specific toolchain.
## The scenario
@@ -26,12 +26,12 @@ fruit, adding vegetables, choosing a quantity, and confirming.
- **Categories**: Fruit, Vegetable, Herb.
- **States**: Ripe / Unripe, Organic / Conventional.
-## Canonical widget values
+## Canonical field values
Use these exact ids, labels, options and defaults so the code and its screenshot
always match.
-| Widget | Label | Default | Options / bounds |
+| Field | Label | Default | Options / bounds |
| --- | --- | --- | --- |
| text | `Item` | `Pear` | complete: `Pear`, `Peach`, `Plum` |
| template | `Crate label` | `valley-pear-a` | pattern `{{orchard}}-{{fruit}}-{{grade}}`; slots Orchard, Fruit, Grade (a single letter a-c) |
diff --git a/.claude/skills/render-tui-diagrams/SKILL.md b/.claude/skills/render-tui-diagrams/SKILL.md
index 72cdd558..7409a233 100644
--- a/.claude/skills/render-tui-diagrams/SKILL.md
+++ b/.claude/skills/render-tui-diagrams/SKILL.md
@@ -39,7 +39,7 @@ The light render stays the single source of truth; the dark variant is derived f
## Task B - add a new data-flow diagram
-1. **Trace the flow from source.** Pick the entry method (e.g. `Engine::collect()`, `PanelController::run()`) and follow it through the classes it calls: `InputResolver`, the discovery specs, `Deriver` + `Derive` + `Transform`, `Condition`, `HandlerRegistry` (reusable static behaviour), then `Answers` / `Theme` / `WidgetFactory` on the way out.
+1. **Trace the flow from source.** Pick the entry method (e.g. `Engine::collect()`, `PanelController::run()`) and follow it through the classes it calls: `InputResolver`, the discovery specs, `Deriver` + `Derive` + `Transform`, `Condition`, `HandlerRegistry` (reusable static behaviour), then `Answers` / `Theme` / `FieldFactory` on the way out.
2. **Create** `docs/architecture/dataflow-.puml` from the template below.
3. **Fill** the participants and messages from the real call path: solid arrows (`->`) for the forward path, dashed (`-->`) for returns. Mirror `dataflow-collect.puml`.
4. **Render and derive** it: `plantuml -tsvg docs/architecture/dataflow-.puml`, then `node docs/util/derive-dark-diagram.js docs/architecture/dataflow-.svg` for the dark variant.
diff --git a/AGENTS.md b/AGENTS.md
index 9a9bb013..f5029ab1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -186,33 +186,33 @@ After a structural change, update them with the `render-tui-diagrams` skill.
### Terminal SVG assets
-Every widget and every primitive carries a full set of terminal SVGs under
+Every field and every primitive carries a full set of terminal SVGs under
`docs/assets/` - light and dark, in all four display modes (Unicode/ASCII,
colour on/off) - embedded in the README and the docs pages. Anything that
moves also carries an animated variant beside its static one; a subject with
no motion to record (the output primitives, which write finished lines and
return) is static-only by design. They render deterministically (no pty) from
the scripts in `docs/util/`:
-`render-widget-svgs.php` for widgets, `render-progress-svgs.php` for the
+`render-field-svgs.php` for fields, `render-progress-svgs.php` for the
progress primitive, `render-output-svgs.php` for the output primitives (static
only - they write finished lines, so there is no motion to record), and
`render-theme-svgs.php` for theme previews, all run by `update-assets.php`. The
naming convention lives in `docs/assets/README.md`.
-Whenever you add a widget or a primitive, do all of the following before
+Whenever you add a field or a primitive, do all of the following before
opening a PR:
- Add a spec to the matching renderer and generate its variants with it: a
- widget's spec (form, keystrokes, rows) goes in `widgetSpecs()` in
- `render-widget-svgs.php` for 16 variants; a primitive's goes in the renderer
+ field's spec (form, keystrokes, rows) goes in `fieldSpecs()` in
+ `render-field-svgs.php` for 16 variants; a primitive's goes in the renderer
that suits how it draws - `progressSpecs()` in `render-progress-svgs.php`
when it animates, `outputSpecs()` in `render-output-svgs.php` when it does
not. Run `php docs/util/render--svgs.php ` to generate them.
-- For a widget only, regenerate the all-widgets montage so the gallery includes
- it: `php docs/util/update-assets.php --record widgets`. A primitive is not in
+- For a field only, regenerate the all-fields montage so the gallery includes
+ it: `php docs/util/update-assets.php --record fields`. A primitive is not in
the montage, so it skips this step.
-- Add its documentation page and a `docs/sidebars.js` entry: a widget goes in
- `docs/content/widgets/.mdx` (mirror `pause.mdx`), a primitive at the
+- Add its documentation page and a `docs/sidebars.js` entry: a field goes in
+ `docs/content/fields/.mdx` (mirror `pause.mdx`), a primitive at the
top level in `docs/content/.mdx` (mirror `progress.mdx`).
- Run `php docs/util/audit-svgs.php` - it must stay green, and every dark asset
needs its light twin.
diff --git a/README.md b/README.md
index 5008e43f..4e8bf901 100644
--- a/README.md
+++ b/README.md
@@ -28,11 +28,11 @@
-`drevops/tui` is a PHP engine for panel-based terminal forms: keyboard-driven questionnaires that collect a set of answers and hand them back to your code as typed values.
+`drevops/tui` is a PHP library for panel-based terminal forms: keyboard-driven questionnaires that collect a set of answers and hand them back to your code as typed values.
-- **Declarative form model.** A form is declared with a fluent builder (`Form` / `PanelBuilder` / `FieldBuilder`): panels of typed fields, each field a widget with its own options, conditions, derivation rules and behavior.
+- **Declarative form model.** A form is declared with a fluent builder (`Form` / `PanelBuilder` / `FieldBuilder`): panels of typed fields, each with its own options, conditions, derivation rules and behavior.
- **Two collection modes, one declaration.** The same form runs as a full-screen interactive TUI on a terminal, or resolves non-interactively from a JSON payload, per-field environment variables, discovery rules and defaults.
-- **Application-agnostic.** The engine doesn't know (or care) what application it serves; questions and handlers live in your code, and applying the collected answers is your job. It collects; you apply.
+- **Application-agnostic.** The library doesn't know (or care) what application it serves; questions and handlers live in your code, and applying the collected answers is your job. It collects; you apply.
- **Dependency-light.** The runtime dependency surface is a single string-transform package.
The padded rounded border above is the default look. The same form explicitly opted out of the frame (`border` `none`, `normal` spacing):
@@ -48,6 +48,27 @@ The padded rounded border above is the default look. The same form explicitly op
Full documentation lives at **[phptui.dev](https://phptui.dev)**. The in-development build, rebuilt from `main` ahead of each release, is previewed at **[tui-docs.netlify.app](https://tui-docs.netlify.app/)**.
+## Core concepts
+
+A screen is built from four levels, and each owns a fixed set of capabilities. When something does not obviously fit, the question is never "where does this go" but **which level owns the capability it needs**.
+
+```
+Screen the root; occupies the terminal, or fits its contents
+└─ Layout arranges; reusable by name
+ └─ Region holds blocks and flows them; declares whether it scrolls
+ └─ Block drawn in a region
+```
+
+One kind of block - a **panel** - contains a layout, which starts the chain again. That is where depth comes from, rather than from a fifth level. Seven kinds of block exist: `Panel`, `Field`, `Markup`, `Breadcrumb`, `Legend`, `Actions` and `Progress`. Only a field collects, so only a field reaches the answers; everything else shows, focuses or activates.
+
+Three things follow, and they are what the rest of the library is shaped by:
+
+- **One tree.** The builder writes blocks directly, so `$form->root()` is what the interactive screen draws, what the headless collector reads, and what the JSON schema describes.
+- **Blocks say what they can do.** Each declares its capabilities as interfaces, so a driver asks "does this bind keys, does it collect" rather than "which class is this".
+- **Themes say how it looks.** A block asks the theme for one **element** at a time and hands it a plain string; order and spacing belong to the block, color and glyph to the theme.
+
+The whole model is written out at **[phptui.dev/specification](https://phptui.dev/specification)**.
+
## Features
Every feature has a reference page and a runnable, self-contained example in [`playground/`](playground):
@@ -56,10 +77,11 @@ Every feature has a reference page and a runnable, self-contained example in [`p
|---|---|---|---|
| 🧭 Full-screen TUI | Scrollable panel browser: hubs drill into sub-panels to any depth, contextual key-hint footer, ? help overlay | [panels](https://phptui.dev/panels) | [`03-panels-*`](playground) |
| 🪟 Modal panels | A panel marked `->modal()` opens as a centered dialog over its dimmed parent, with its own submit/cancel buttons | [panels](https://phptui.dev/panels#modal-panels) | [`03-panels-*`](playground) |
-| 🧱 Panel layouts | `->layout(1, 2)` arranges panels as a grid of side-by-side preview columns - rows of any width, recursively per level, with spatial arrow navigation | [panels](https://phptui.dev/panels#panel-layouts) | [`03-panels-*`](playground) |
+| 🧱 Panel grids | `->layout(1, 2)` arranges sub-panels as a grid of side-by-side preview columns - rows of any width, recursively per level, with spatial arrow navigation | [panels](https://phptui.dev/panels#panel-layouts) | [`03-panels-*`](playground) |
+| 🧭 Layouts | `->layout('two-column')` arranges a screen or a panel into named regions, each with its own size, flow and scrolling; register a layout class of your own and pick it by name | [layouts](https://phptui.dev/layouts) | [`20-layouts-*`](playground) |
| 🖥️ Fullscreen mode | `->fullscreen()` stretches the frame to the whole terminal; `halign`/`valign` anchor the content and min/max size options guard small or very wide terminals | [panels](https://phptui.dev/panels#fullscreen) | [`03-panels-*`](playground) |
| ⚡ Inline editing | A field's editor opens in place on the panel row; `->standalone()` opts a field out to full-screen | [panels](https://phptui.dev/panels#inline-editing) | [`04-inline-editing`](playground/04-inline-editing.php) |
-| 🧩 Widgets | 15 field types: text, template, number, rating, calendar, textarea, password, select, reorder, suggest, search, file picker, confirm, toggle, pause | [widgets](https://phptui.dev/widgets) | [`02-widgets-*`](playground) |
+| 🧩 Fields | 17 field types: text, template, number, rating, calendar, textarea, password, select, reorder, suggest, search, file picker, confirm, toggle, pause, plus note and progress rows that collect nothing | [fields](https://phptui.dev/fields) | [`02-fields-*`](playground) |
| 🏗️ Builder-driven | The form is declared in PHP with a fluent builder; the common cases need no code | [configuration](https://phptui.dev/configuration) | [`01-quickstart`](playground/01-quickstart.php) |
| 🎛️ Interactive or unattended | `run()` picks the mode: keyboard on a terminal, otherwise JSON payload + `TUI_` environment variables | [headless collection](https://phptui.dev/headless-collection) | [`08-headless-*`](playground) |
| 🔗 Derived values | Fields computed from other answers via `{{field}}` templates and str2name transforms, settling to a fixpoint | [configuration](https://phptui.dev/configuration#derived-values) | [`05-form-logic-*`](playground) |
@@ -71,10 +93,10 @@ Every feature has a reference page and a runnable, self-contained example in [`p
| 🌐 Remote-backed options | `->optionsFrom()` resolves a search or suggest field's candidates from the live query - a themed `Loading…` while it runs, a typing burst settling into one call, a per-query cache, and `->minQuery()` holding it back until the query is worth sending | [options from a query](https://phptui.dev/progress#options-from-a-query) | [`17-query-options`](playground/17-query-options.php) |
| 🧾 Output | An `output()` primitive draws the chrome around a form: boxes and cards, tables, five status lines, definition lists, wrapped text, rules and a banner - theme-drawn, dropping their color when piped or redirected | [output](https://phptui.dev/output) | [`18-output-*`](playground) |
| 📦 Self-describing answers | Answers carry provenance; `toSummary()` renders a badged, panel-grouped report and `toJson()` the machine result; `schema()`, `validate()` and `agentHelp()` describe the form itself | [self-describing answers](https://phptui.dev/headless-collection#self-describing-answers) | [`08-headless-*`](playground) |
-| 🎨 Themes | Six built-ins selected by name; a custom theme is a `DefaultTheme` subclass overriding palette atoms and render methods | [themes](https://phptui.dev/themes) | [`09-themes-*`](playground) |
-| ⌨️ Key bindings | Presets (`default`, `vim`, or a class) plus per-binding overrides scoped to navigation or a widget type; conflicts throw at setup | [key bindings](https://phptui.dev/key-bindings) | [`10-key-bindings-*`](playground) |
+| 🎨 Themes | Six built-ins selected by name; a custom theme is a `DefaultTheme` subclass repainting a handful of voices, and `->theme(fn(ThemeBuilder $t) => ...)` patches individual elements with no class at all | [themes](https://phptui.dev/themes) | [`09-themes-*`](playground) |
+| ⌨️ Key bindings | Presets (`default`, `vim`, or a class) plus per-binding overrides scoped to navigation or a field type; conflicts throw at setup | [key bindings](https://phptui.dev/key-bindings) | [`10-key-bindings-*`](playground) |
| ✨ Display modes | Dark/light follows the terminal background, glyphs follow the locale, color honors `NO_COLOR`; all three can be forced | [display modes](https://phptui.dev/display-modes) | [`11-display-modes-*`](playground) |
-| 🧪 Test harness | `TuiTester` drives the real panel loop from scripted keystrokes, no TTY; assert on answers, output and rendered frames | [testing](https://phptui.dev/testing) | [`13-testing`](playground/13-testing.php) |
+| 🧪 Test harness | `TuiTester` drives a whole form from scripted keystrokes and `ScreenTester` one screen frame by frame, no TTY; assert on answers, output and every frame drawn | [testing](https://phptui.dev/testing) | [`13-testing`](playground/13-testing.php) |
| 🌍 Translations | Bundled chrome catalogs load automatically; a directory, a single catalog file or an inline map layers your own strings and chrome overrides on top, falling back to English | [translations](https://phptui.dev/translations) | [`12-translations`](playground/12-translations.php) |
## Installation
@@ -85,7 +107,7 @@ composer require drevops/tui
## Quick start
-Declare a form with the `Form` builder, then drive it through the `Tui` facade - the one class that wires up the engine, resolver, schema tools and TUI for you:
+Declare a form with the `Form` builder, then drive it through the `Tui` facade - the one class that wires up collection, the input resolver, the schema tools and the interactive screen for you:
```php
use DrevOps\Tui\Builder\Form;
@@ -133,80 +155,81 @@ The facade's surface:
| `progress($total, $caption, $work)` | Show slow work running around the form: a spinner with no total, a determinate bar with one - a theme-drawn primitive |
| `output()` | Draw the chrome around the form: boxes and cards, tables, status lines, definition lists, text, rules and a banner - theme-drawn primitives |
| `schema()` / `validate($answers)` / `agentHelp()` | Describe the questions as structured metadata, validate an answer payload, emit the agent-facing answer schema |
-| `theme($theme, $options)` / `keys($preset, $overrides)` | Select the theme and key bindings |
-| `color($bool)` / `unicode($bool)` / `fullscreen($bool)` / `footer($bool)` / `clearOnExit($bool)` / `translator($t)` | Display and runtime switches |
-| `form()` / `engine()` / `registry()` | The internals, for finer control |
+| `theme($theme, $options)` | Select the theme by name or class, or pass a closure to patch individual elements |
+| `layout($layout)` / `keys($preset, $overrides)` | Arrange the screen into named regions; select the key bindings |
+| `color($bool)` / `unicode($bool)` / `markdown($bool)` / `fullscreen($bool)` / `footer($bool)` / `clearOnExit($bool)` / `translator($t)` | Display and runtime switches |
+| `root()` / `registry()` | The declared block tree, and the handler registry - for finer control |
Read the [full guide at phptui.dev](https://phptui.dev), and browse [`playground/`](playground) for complete, runnable examples - the numbered scripts for each feature in the table above.
-## Widgets
+## Fields
-There's a widget for most things you'd want to ask: text entry, numbers and dates, single and multiple choice, fuzzy search, filesystem browsing, and simple gates. Each one links to its full reference on [phptui.dev](https://phptui.dev/widgets), and every card below plays back the real interaction in whichever color scheme - light or dark - your reader is using.
+There's a field for most things you'd want to ask: text entry, numbers and dates, single and multiple choice, fuzzy search, filesystem browsing, and simple gates. Each one links to its full reference on [phptui.dev](https://phptui.dev/fields), and every card below plays back the real interaction in whichever color scheme - light or dark - your reader is using.
-
-
Calendar A month calendar returning a normalized ISO YYYY-MM-DD; arrows move by day and week.
+
+
Calendar A month calendar returning a normalized ISO YYYY-MM-DD; arrows move by day and week.
-
-
Confirm Yes/No toggle; arrows or Space switch, y/n set the choice directly, Enter accepts.
+
+
Confirm Yes/No toggle; arrows or Space switch, y/n set the choice directly, Enter accepts.
-
-
File picker Browse the filesystem for a path; arrows move, → enters a directory and ← returns to its parent. Add ->multiple() for several paths.
+
+
File picker Browse the filesystem for a path; arrows move, → enters a directory and ← returns to its parent. Add ->multiple() for several paths.
-
-
Number Integer entry (digits with an optional leading minus) accepted as an int, with optional min, max and step.
+
+
Number Integer entry (digits with an optional leading minus) accepted as an int, with optional min, max and step.
-
-
Password Text rendered as a mask in the editor, the field row and the summary; the accepted value stays plain for your code, and can be made revealable.
+
+
Password Text rendered as a mask in the editor, the field row and the summary; the accepted value stays plain for your code, and can be made revealable.
-
-
Pause An acknowledgment gate; Enter or Space accepts. Unattended runs auto-acknowledge it, so it never blocks automation.
+
+
Pause An acknowledgment gate; Enter or Space accepts. Unattended runs auto-acknowledge it, so it never blocks automation.
-
-
Progress A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself; it collects no value.
+
+
Progress A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself; it collects no value.
-
-
Rating A graded answer picked from a scale of points, accepted as an int; arrows walk the scale, a digit jumps to its point, and each point can carry a caption.
+
+
Rating A graded answer picked from a scale of points, accepted as an int; arrows walk the scale, a digit jumps to its point, and each point can carry a caption.
-
-
Reorder Rank a list by moving items into the order you want; Space picks an item up, arrows carry it through the list, Enter accepts.
+
+
Reorder Rank a list by moving items into the order you want; Space picks an item up, arrows carry it through the list, Enter accepts.
-
-
Search Single choice with a visible filter line; typing fuzzy-matches and ranks the labels, exact and prefix matches leading.
+
+
Search Single choice with a visible filter line; typing fuzzy-matches and ranks the labels, exact and prefix matches leading.
-
-
Select Single choice from a list; arrows move, Enter accepts the highlighted option, long lists page around the cursor.
+
+
Select Single choice from a list; arrows move, Enter accepts the highlighted option, long lists page around the cursor.
-
-
Suggest Free text with autocomplete over a fixed option set: as you type, suggestions are fuzzy-matched and ranked by relevance.
+
+
Suggest Free text with autocomplete over a fixed option set: as you type, suggestions are fuzzy-matched and ranked by relevance.
-
-
Template Fill the named slots of a fixed shape; the fixed text is context, Tab steps between slots and each one validates on its own.
+
+
Template Fill the named slots of a fixed shape; the fixed text is context, Tab steps between slots and each one validates on its own.
-
-
Text Single-line input with a movable caret; type to insert, arrows move, Backspace deletes, Enter accepts.
+
+
Text Single-line input with a movable caret; type to insert, arrows move, Backspace deletes, Enter accepts.
-
-
Textarea Multi-line input; Enter inserts a newline, arrows move between lines, Tab accepts, with an external-editor handoff.
+
+
Textarea Multi-line input; Enter inserts a newline, arrows move between lines, Tab accepts, with an external-editor handoff.
-
-
Toggle An inline switch between two labeled values; arrows or Space flip, the first letter of each label sets it directly.
+
+
Toggle An inline switch between two labeled values; arrows or Space flip, the first letter of each label sets it directly.
@@ -227,7 +250,7 @@ $tui = (new Tui($form))->theme('midnight');
| `mono` | Hue-free - bold weight, gray levels and reverse video for maximum compatibility. |
| `dos` | Retro MS-DOS: the bright white/cyan/yellow CGA palette in a double-line window, made for a blue terminal background. |
-Each renders across every widget and degrades to plain text without ANSI. Here the dark palette (left) and the light palette (right); the [themes docs](https://phptui.dev/themes) also show every theme inside the rounded border frame:
+Each renders across every field and degrades to plain text without ANSI. Here the dark palette (left) and the light palette (right); the [themes docs](https://phptui.dev/themes) also show every theme inside the rounded border frame:
**`midnight`**
@@ -264,7 +287,7 @@ Each renders across every widget and degrades to plain text without ANSI. Here t
-Write your own by subclassing `DefaultTheme` and overriding just its palette - see the [theming guide](https://phptui.dev/themes) and the playground's [`OceanTheme`](playground/themes/OceanTheme.php).
+Write your own by subclassing `DefaultTheme` and repainting just the voices a palette needs - see the [theming guide](https://phptui.dev/themes) and the playground's [`OceanTheme`](playground/themes/OceanTheme.php). To change a handful of glyphs and nothing else, skip the class: `->theme(fn(ThemeBuilder $t) => $t->field(fn(FieldOverrides $f) => $f->selector('▶', '=>')))` patches the selected theme in place, and anything it does not name keeps that theme's own answer.
## Contributing
diff --git a/docs/README.md b/docs/README.md
index 616f501b..030cd3ed 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,8 +3,10 @@
The [phptui.dev](https://phptui.dev) documentation site, built with
[Docusaurus](https://docusaurus.io/).
-The documentation content lives in [`content/`](content) as `.mdx` pages; the
-sidebar is generated from the folder structure.
+The documentation content lives in [`content/`](content) as `.mdx` pages, and
+[`sidebars.js`](sidebars.js) declares the sidebar - every page is listed there,
+in the order it appears, so pages carry no `sidebar_position` frontmatter. A
+page missing from the sidebar (or listed twice) fails the test suite.
## Local development
@@ -31,9 +33,13 @@ any static hosting service.
npm run test
```
-Runs the [Jest](https://jestjs.io/) component tests and the
-[CSpell](https://cspell.org/) spell check over the content. Add project-specific
-terms to [`cspell.json`](cspell.json).
+Runs the [Jest](https://jestjs.io/) component tests, the
+[CSpell](https://cspell.org/) spell check over the content, and a
+[Prettier](https://prettier.io/) formatting check. Add project-specific terms to
+[`cspell.json`](cspell.json), and reformat with `npm run format`.
+
+The architecture diagrams under [`architecture/`](architecture) are PlantUML
+sources rendered to SVG; see that directory's README for how to regenerate them.
## Publishing
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 62f32662..b950c861 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -1,67 +1,83 @@
# How the TUI works
-This is a walkthrough of the `drevops/tui` engine - what you assemble to build a form, and what happens when it runs. The diagrams are rendered from the PlantUML sources in this directory by the [`render-tui-diagrams`](../../.claude/skills/render-tui-diagrams/SKILL.md) skill; everything below is derived from `src/`, so if the prose and the code disagree, the code wins.
+This is a walkthrough of the `drevops/tui` library - what you assemble to build a form, and what happens when it runs. The diagrams are rendered from the PlantUML sources in this directory by the [`render-tui-diagrams`](../../.claude/skills/render-tui-diagrams/SKILL.md) skill; everything below is derived from `src/`, so if the prose and the code disagree, the code wins.
+
+The model the whole library is built on - four levels, seventeen capabilities, one canonical tree - is written out on the [specification](https://phptui.dev/specification) page. This walkthrough is the same thing seen from the outside: which class does which part, and in what order.
## The shape of it
-At the centre is the **Engine**. Everything else is either something you hand it (a configuration, a set of handlers, a theme) or something it produces (validated answers, a JSON schema). The packages below mirror the `src/` subdirectories, and the arrows are the main dependencies.
+At the center is the **block tree**. A form is declared as one, and everything else either writes into it (the builders), reads it (the collector, the schema tools) or draws it (the screen and its theme).
-Read it in three bands:
+Read it as three concerns around that tree:
-- **Left - what you provide.** A **FormDefinition** (assembled by the fluent `Form` builder into `FormDefinition` -> `Panel` -> `Field`) and, optionally, **Handlers** (classes that carry behaviour). The global TUI runtime - theme, key bindings, colour and language - is configured on the `Tui` facade and shared by every form. Together these declare the questions, how each one behaves, and how the TUI presents them.
-- **Middle - the Engine and its helpers.** The Engine drives collection, leaning on `InputResolver` (read a payload), `Discovery` (detect from the directory), `Deriver` + `Transform` (compute values), and the `Condition` rules (decide what is shown).
-- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), a `KeyMap` (resolved by preset through `KeyMapManager`), widgets, a `Navigator` and a `Terminal`.
+- **Declaring.** `Form`, `PanelBuilder` and `FieldBuilder` write the tree; `Tui` is the facade a consumer holds. Every runtime switch that describes the terminal rather than the questionnaire - the theme, the layout, the key bindings, color, Unicode, the footer - is set on the facade, so one declaration serves every way of running it.
+- **The tree itself.** `Panel`, `Field`, `Markup`, `Breadcrumb`, `Legend`, `Actions` and `Progress` all implement `BlockInterface`, and each declares what it can do as a capability interface (`Block\Capability`) and what it needs drawn as an elements interface (`Block\Element`).
+- **Running it.** `Collector` collects the tree with no screen at all. `ScreenController` drives it through a terminal, arranging it with a `Screen`, a layout and its regions, sending each key inward with `KeyRouter` and drawing outward with `ScreenRenderer`. Both paths settle the same rules and produce the same `Answers`.
## Step 1 - describe the questions
-You declare the questions in PHP with the fluent `Form` builder: panels holding fields. A field has an `id`, a `type` (text, select, suggest, search, file picker, confirm - the select, search and file picker types collecting a list with `->multiple()`) and optional rules - `default`, `required`, `options`, `when` (show it only when a condition holds), `derive` (compute it from other fields) and `discover` (detect it from the target directory). The builder validates the declaration - rejecting duplicate field ids - and builds the immutable `FormDefinition` model. The global TUI runtime is configured on the `Tui` facade instead, not the form. Nothing runs yet; this is pure description.
+You declare the questions in PHP with the fluent `Form` builder: panels holding fields. A field has an `id`, a type (`text`, `select`, `suggest`, `search`, `filepicker`, `confirm` and the rest; `select`, `search` and `filepicker` collect a list with `->multiple()`) and optional rules - `default`, `required`, `options`, `when` (show it only when a condition holds), `derive` (compute it from other fields) and `discover` (detect it from the target directory).
+
+What the builder produces is not a separate model: it writes the block tree directly. `$form->root()` hands back the root `Panel`, its regions hold the blocks, and a sub-panel is a block in a region like any other. There is one tree, and every operation on the facade reads that one - collection, the interactive session, the JSON schema, the validator. Duplicate field ids, an unknown transform name, a layout name nothing answers to and a modal declaring sub-panels are all rejected here, when the form is declared, rather than mid-session.
-## Step 2 - attach behaviour where you need it
+Nothing runs yet; this is pure description.
-Most fields need no code. When one does - a dynamic default, discovery, validation or a normalisation - declare it on the field itself: `->default(fn ...)`, `->validate(fn ...)`, `->transform(fn ...)`, `->discover(...)`. Reusable validators and transformers are public static methods on a consumer class named after the field id (`machine_name` -> `MachineName`) in a registered namespace - referenced explicitly as first-class callables or discovered by the engine as the fallback; the field declaration wins when both exist.
+## Step 2 - attach behavior where you need it
+
+Most fields need no code at all. When one does - a dynamic default, discovery, validation or a normalization - declare it on the field itself: `->default(fn ...)`, `->validate(fn ...)`, `->transform(fn ...)`, `->discover(...)`. Reusable validators and transformers are public static methods on a consumer class named after the field id (`red_apple` -> `RedApple`) in a registered namespace - referenced explicitly as first-class callables, or resolved through the `HandlerRegistry` as the fallback. When both exist, the field declaration wins.
## Step 3 - collect the answers
-`Engine::collect()` turns the config plus whatever the caller supplied into a settled set of answers. This is the heart of the engine:
+`Tui::collect()` turns the tree plus whatever the caller supplied into a settled set of answers, with no screen anywhere:
+Four capabilities survive here and thirteen do not, and the line between them is the useful part: collecting, constraining, refusing and depending on another answer are the form's meaning; the rest is how it looks. No `Screen`, no layout and no `Region` is built, and neither is any block that only shows.
+
Walking the sequence:
-1. **Resolve each field's starting value**, in priority order: an explicit input (from `--prompts` or the environment, via `InputResolver`) beats a discovered value (in update mode, adopted only when it passes the field's emptiness, type, bounds and options), which beats a handler's dynamic `default()`, which beats the static default in the config.
-2. **Transform each supplied input** through its declared or handler behaviour, so derivation, activation and fix-ups all evaluate the normalized value. Defaults and derived values are the configuration's own and skip the transformers.
-3. **Settle the derived and conditional fields.** `Deriver` recomputes `derive` values (with `Transform`) until they stop changing, the `Condition` rules decide which fields are active from their `when` declarations, and fix-ups reconcile dependents - repeated until the whole set is stable.
-4. **Validate each active supplied input** - an empty value on a required field is rejected first, then the type and bounds, and the first error throws.
+1. **Resolve each field's starting value**, in priority order: an explicit input (from a JSON payload or the environment, through `InputResolver`) beats a discovered value (in update mode, adopted only when it passes the field's emptiness, type, bounds and rows), which beats a dynamic default, which beats the static one.
+2. **Normalize each supplied value** through its declared or resolved transform, so derivation, conditions and fix-ups all see the final value. Defaults and derived values are the form's own and skip the transformers.
+3. **Settle.** `Deriver` recomputes `derive` values until they stop changing, the `Condition` rules decide which fields are there at all, rows that follow the answers re-resolve, and fix-ups reconcile dependents - repeated until nothing moves.
+4. **Measure each supplied value** that survived: emptiness on a required field first, then type, bounds and rows. A value the form refuses raises `CollectException` naming the field and the reason, because with no screen there is nobody to retype it.
5. **Emit `Answers`** - the values plus their provenance (default, detected, edited, derived, override).
-The same lifecycle runs whether the caller is a human at the TUI or a script passing JSON, which is why the engine is testable without a terminal.
+Only supplied values are measured, and only once the set has settled, because until then there is nothing final to measure them against.
## Step 4 - let a person answer (optional)
-For interactive use, `PanelController::run()` seeds itself with the engine's resolved answers and drives a panel TUI until the user is done:
+For interactive use, `ScreenController::run()` seeds itself from the same collector and drives a terminal session until the form ends:
-
+
-The theme instance comes from `ThemeManager` - a registry keyed by name ("default", a registered short name, or a theme class name directly). Colour, Unicode and the dark/light mode are display options: anything the form leaves unset is detected from the terminal, with the mode picked from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default). Each turn the controller asks the **Theme** to compose a frame (the theme owns colours, glyphs and layout - including the chrome-height budget that sizes the body viewport to the terminal), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A panel that declares `->layout()` renders its sub-panels as a grid of side-by-side preview columns instead of the row list - each level of the tree declares its own arrangement, and the arrows then navigate the grid spatially. With the `fullscreen` option on, the frame stretches to the whole terminal (capped by `max_width`/`max_height`): the theme aligns the body block inside it per the `halign`/`valign` options, the controller positions any smaller frame in the screen through the same `Overlay` placement the modals use, and below the minimum size (measured from the content unless `min_width`/`min_height` say otherwise) a centered resize notice takes the frame's place until the terminal grows. A key press is parsed by `KeyParser` into a `Key`, which a **KeyMap** resolves to a semantic action (move, accept, toggle, quit...) rather than a fixed key - the bindings behind each action are configurable per widget type, ship a vim preset alongside the default, and are validated when `->keys()` resolves the key map - at declaration time, not mid-session. Armed with the action, the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field - inline in the panel by default, the widget's view taking the place of the field's value in the row, or full-screen for a `->standalone()` field under a theme-composed underlined label header. The widget consults the same key map and renders through the theme, and an accept enforces the field's declared or handler-resolved `validate()`/`transform()` - the same behaviour the headless path applies - showing a rejection inline and writing the accepted value back marked "edited" (or "override" when it pins a derive rule). Every accepted edit then re-settles the form logic through the `Engine`: derive rules recompute, `when` conditions show and hide fields, and fix-ups re-apply - so the session honours exactly what a headless collection would. A panel can instead be declared modal with `->modal()`: activating it opens the panel as a centered dialog composited over the dimmed parent - the `Theme` boxes it narrower than the frame and `Overlay` splices it on top - with its own configurable submit/cancel buttons, where submit keeps the edits and cancel or Escape restores the answers the dialog opened with. When the user finishes, it returns the same active-field `Answers` a headless collection would produce: a condition-hidden field keeps its settled value internally - so a later activation change can surface it - but contributes no answer.
+**Assembling.** `Assembler` builds a `Screen` around the panel: a `Breadcrumb` in `header`, the panel and its `Actions` in `content`, a `Legend` in `footer` - wherever the named layout keeps a place for them. A layout naming its regions something else simply shows no trail rather than being refused, which is what keeps every layout usable. The layout itself comes from `LayoutManager`, by shipped name, by a name a consumer registered, or by the class itself.
+
+**Drawing runs outward.** The `Screen` gives its layout the terminal; `AbstractLayout` takes the fixed regions off the top and divides the remainder by the declared shares; each `Region` flows its blocks and scrolls them if it was declared to; each block's `render()` reaches the theme for elements; the theme returns styled strings. Every step hands down exactly one thing and knows nothing of the step after it, and nothing reaches back up.
+
+**Keys run inward.** `KeyParser` turns raw bytes into `Key` objects and a `KeyMap` resolves each to a semantic action rather than a fixed key. `KeyRouter` then sends the key to the innermost thing that binds it - the focused block if it binds that key, else the panel around it - which is why an open text field swallows `?` as a character while a closed one lets it travel outward and open help. Nobody wrote that exception; the key simply stops at a different level.
+
+Three kinds of key never reach the router, and all three for the same reason - they act on something outside the screen. Pressing a button ends the form or closes the dialog it belongs to; activating a `Progress` row runs its work against the terminal a step at a time, repainting between steps; and leaving is about the session rather than about anything in it. A block never learns where it is drawn, so whoever owns the terminal holds these.
+
+**Every answer re-settles the form.** An accepted edit goes back through `Collector::resettle()`: derive rules recompute, `when` conditions show and hide rows, answer-driven option lists re-resolve and fix-ups re-apply - so the session honors exactly what a headless collection would.
-No widget extends another widget. Each one composes its behaviour from the capabilities in `Widget\Capability` - an interface per capability (`OptionsCapableInterface`, `SelectionCapableInterface`, `FilterCapableInterface`, `SearchCapableInterface`, `QueryOptionsCapableInterface`, `PagingCapableInterface`, `TextEditCapableInterface`, `CompletionCapableInterface`, `StepCapableInterface`, `RevealCapableInterface`, `ExternalEditCapableInterface`) paired with the trait carrying its default implementation (`OptionsCapableTrait`, `FilterCapableTrait`, ...) - so the controller and the tests interact with a capability - "is this widget filterable, can it page" - rather than a concrete class.
+**The frame.** Border, spacing, alignment and the min/max sizes are theme options read through `OccupyCapableInterface`. In fullscreen the frame stretches to the terminal and the content anchors at `halign`/`valign`; below the minimum size a resize notice takes the frame's place and every key but the one that leaves is dropped. A panel declared `->modal()` is drawn as a centered dialog over the dimmed screen behind it - `DimCapableInterface` is what pushes the backdrop back - with its own submit/cancel pair, where submit keeps the edits and cancel restores the answers the dialog opened with.
-Blocking work inside the session goes through one seam. A widget never performs I/O, because only the loop may block and repaint: a query-driven widget (`QueryOptionsCapableInterface`) reports which query still needs answering, and the controller paints the loading frame, calls the field's source and hands the rows back. The same applies to a panel's option loaders and preload on entry, and to a progress row's work on activation - each is consumer code the controller runs between reads, repainting around it, so the single-threaded loop shows what it is waiting on without any of the widgets knowing there is a terminal. A query resolves once per read rather than once per key, so a burst of typing costs one call.
+**How it ends** is the whole of what a caller sees. Finishing hands back `Answers`; abandoning through the cancel button raises `CancelException`; the interrupt key raises `InterruptException` from anywhere, including from inside an open field. Partial answers are never mistaken for a completed form.
## Step 5 - apply the answers (the consumer's job)
-Collecting produces answers; acting on them - writing files, renaming directories - is the consumer's job, never the engine's. A consumer that processes answers defines its own processor contract with a `process()` hook, resolves each processor class by field id through the `HandlerRegistry`, and sequences the work by its own rules - ordering is a processing concern the form declaration does not carry. One class per field can carry both its `process()` and the reusable static `validate()`/`transform()` the engine discovers. This is the pattern a consumer CLI follows with its own `ProcessorInterface` and `Processor`.
+Collecting produces answers; acting on them - writing files, renaming directories - is the consumer's job, never the library's. A consumer that processes answers defines its own processor contract with a `process()` hook, resolves each processor class by field id through the `HandlerRegistry`, and sequences the work by its own rules - ordering is a processing concern the form declaration does not carry. One class per field can carry both its `process()` and the reusable static `validate()`/`transform()` the collector resolves.
## Regenerating this document
diff --git a/docs/architecture/architecture-dark.svg b/docs/architecture/architecture-dark.svg
index 69a3ea2f..2f69c60f 100644
--- a/docs/architecture/architecture-dark.svg
+++ b/docs/architecture/architecture-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/architecture.puml b/docs/architecture/architecture.puml
index 0141032f..95fed6da 100644
--- a/docs/architecture/architecture.puml
+++ b/docs/architecture/architecture.puml
@@ -9,84 +9,123 @@ skinparam shadowing false
skinparam componentStyle rectangle
title drevops/tui - component architecture
-package "Builder" #F0F4C3 {
+package "Declaring" #F0F4C3 {
+ [Tui]
[Form]
+ [PanelBuilder]
+ [FieldBuilder]
}
-package "Model" #E3F2FD {
- [FormDefinition]
+package "Block - the one tree" #E3F2FD {
+ interface BlockInterface
[Panel]
[Field]
+ [Markup]
+ [Breadcrumb]
+ [Legend]
+ [Actions]
+ [Progress]
+ [Block\\Capability]
+ [Block\\Element]
}
-package "Core" #FFF3E0 {
- [Engine]
+package "Arranging" #FFEBEE {
+ [Screen]
+ interface LayoutInterface
+ [AbstractLayout]
+ [LayoutManager]
+ [Region]
}
-package "Resolution" #E8F5E9 {
+package "Driving" #FFF3E0 {
+ [ScreenController]
+ [KeyRouter]
+ [ScreenRenderer]
+ [Assembler]
+ [KeyMap]
+ [Terminal]
+}
+
+package "Collecting" #E8F5E9 {
+ [Collector]
[InputResolver]
- [Discover specs]
+ [Discovery]
[Deriver]
- [Derive]
- [Transform]
[Condition]
+ [Answers]
+ [SchemaGenerator]
+ [SchemaValidator]
}
-package "Handlers" #F3E5F5 {
+package "Behaviour" #F3E5F5 {
[HandlerRegistry]
[Context]
}
-package "Output" #ECEFF1 {
- [Answers]
- [SchemaGenerator]
- [SchemaValidator]
-}
-
-package "Interactive TUI" #FFEBEE {
- [PanelController]
+package "Theme" #ECEFF1 {
+ interface ThemeInterface
+ [AbstractTheme]
+ [DefaultTheme]
+ [Theme\\Capability]
[ThemeManager]
- [Theme]
- [KeyMapManager]
- [KeyMap]
- [WidgetFactory]
- interface WidgetInterface
- [Navigator]
- [Terminal]
- [KeyParser]
+ [ThemeBuilder]
}
-[Form] --> [FormDefinition]
-[FormDefinition] *-- [Panel]
-[Panel] *-- [Field]
+[Tui] --> [Form]
+[Form] --> [PanelBuilder]
+[PanelBuilder] --> [FieldBuilder]
+[PanelBuilder] --> [Panel]
+[FieldBuilder] --> [Field]
+
+[Panel] --> BlockInterface
+[Field] --> BlockInterface
+[Markup] --> BlockInterface
+[Breadcrumb] --> BlockInterface
+[Legend] --> BlockInterface
+[Actions] --> BlockInterface
+[Progress] --> BlockInterface
+BlockInterface --> [Block\\Capability]
+BlockInterface --> [Block\\Element]
+
+[Panel] --> LayoutInterface
+[Screen] --> LayoutInterface
+[AbstractLayout] --> LayoutInterface
+[AbstractLayout] *-- [Region]
+[LayoutManager] --> LayoutInterface
+[Region] --> BlockInterface
+
+[Tui] --> [Collector]
+[Tui] --> [ScreenController]
+[Tui] --> [ThemeManager]
+[Tui] --> [InputResolver]
-[Engine] --> [FormDefinition]
-[Engine] --> [InputResolver]
-[Engine] --> [Discover specs]
-[Engine] --> [Deriver]
-[Deriver] --> [Derive]
-[Derive] --> [Transform]
-[Engine] --> [Condition]
-[Engine] --> [HandlerRegistry]
-[Engine] --> [Answers]
+[ScreenController] --> [Screen]
+[ScreenController] --> [Assembler]
+[ScreenController] --> [KeyRouter]
+[ScreenController] --> [ScreenRenderer]
+[ScreenController] --> [Collector]
+[ScreenController] --> [KeyMap]
+[ScreenController] --> [Terminal]
+[Assembler] --> [LayoutManager]
+[KeyRouter] --> [Panel]
+[ScreenRenderer] --> [Screen]
+[ScreenRenderer] --> ThemeInterface
-[SchemaGenerator] --> [FormDefinition]
-[SchemaValidator] --> [FormDefinition]
+[Collector] --> [Panel]
+[Collector] --> [Discovery]
+[Collector] --> [Deriver]
+[Collector] --> [Condition]
+[Collector] --> [HandlerRegistry]
+[Collector] --> [Context]
+[Collector] --> [Answers]
+[SchemaGenerator] --> [Panel]
+[SchemaValidator] --> [Panel]
-[PanelController] --> [FormDefinition]
-[PanelController] --> [Engine]
-[ThemeManager] --> [Theme]
-[PanelController] --> [Theme]
-[KeyMapManager] --> [KeyMap]
-[PanelController] --> [KeyMap]
-[PanelController] --> [WidgetFactory]
-[WidgetFactory] --> [KeyMap]
-[WidgetFactory] --> [HandlerRegistry]
-[WidgetFactory] --> WidgetInterface
-WidgetInterface --> [Theme]
-WidgetInterface --> [KeyMap]
-[PanelController] --> [Navigator]
-[PanelController] --> [Terminal]
-[PanelController] --> [KeyParser]
-[PanelController] --> [Answers]
+[AbstractTheme] --> ThemeInterface
+[AbstractTheme] --> [Block\\Element]
+[DefaultTheme] --> [AbstractTheme]
+[DefaultTheme] --> [Theme\\Capability]
+[ThemeManager] --> [DefaultTheme]
+[ThemeBuilder] --> [DefaultTheme]
+BlockInterface --> ThemeInterface
@enduml
diff --git a/docs/architecture/architecture.svg b/docs/architecture/architecture.svg
index 6de5c263..3120579f 100644
--- a/docs/architecture/architecture.svg
+++ b/docs/architecture/architecture.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-collect-dark.svg b/docs/architecture/dataflow-collect-dark.svg
index 55917d13..d1b63911 100644
--- a/docs/architecture/dataflow-collect-dark.svg
+++ b/docs/architecture/dataflow-collect-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-collect.puml b/docs/architecture/dataflow-collect.puml
index d90fda27..9c7e387f 100644
--- a/docs/architecture/dataflow-collect.puml
+++ b/docs/architecture/dataflow-collect.puml
@@ -1,68 +1,89 @@
@startuml
' drevops/tui - data flow for headless collection.
-' Traced from src/Engine/Engine.php (collect -> resolveAll -> transformInputs -> stabilize -> guardInputs).
+' Traced from src/Tui.php (collect) and src/Screen/Collector.php
+' (answers -> fetched -> settle -> resolveAll -> transformSupplied -> stabilize
+' -> refusal).
' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml
!theme plain
skinparam backgroundColor white
skinparam defaultFontName Helvetica
skinparam shadowing false
-title Data flow: headless collection (Engine::collect)
+title Data flow: headless collection (Tui::collect)
participant "Caller" as Caller
+participant "Tui" as Tui
participant "InputResolver" as IR
-participant "Engine" as Eng
+participant "Collector" as Col
+participant "Block tree\n(Panel -> Field)" as Tree
participant "Field behaviour\n(closure or static)" as B
participant "Discovery spec" as Disc
participant "Deriver\n+ Derive" as Der
participant "Condition" as CE
participant "Answers" as Ans
-Caller -> IR: resolve(fields, prompts, env)
+Caller -> Tui: collect(prompts, directory, update, version)
+activate Tui
+
+Tui -> Tree: root()
+Tree --> Tui: the declared Panel
+
+Tui -> IR: resolve(fields, prompts, env)
activate IR
-IR --> Caller: input values
+IR --> Tui: supplied values
deactivate IR
-Caller -> Eng: collect(inputs, context)
-activate Eng
+Tui -> Col: answers(panel, supplied, context)
+activate Col
-loop each field, in order
- alt update mode and no input
- Eng -> Disc: discover(directory)
- Disc --> Eng: discovered value
- note right of Eng: adopted only when it passes the field's\nemptiness, type, bounds and options
- end
- note right of Eng: input > discovered > declared default > static default
+Col -> Tree: fields(panel)
+note right of Col: no Screen, Layout or Region is built,\nand no block that only shows
+
+loop each field whose rows are loaded
+ Col -> B: options callback()
+ B --> Col: value => label rows
+ note right of Col: nothing would open a field, so a loader\nis asked here or its rows never arrive
end
-loop each field with a supplied input
- Eng -> B: transform(value)
- B --> Eng: normalized value
- note right of Eng: inputs normalize first, so derivation,\nactivation and fix-ups see the final value
+loop each field, in declaration order
+ alt update mode and nothing supplied
+ Col -> Disc: discover(directory)
+ Disc --> Col: detected value
+ note right of Col: adopted only when it passes the field's\nemptiness, type, bounds and rows
+ end
+ note right of Col: supplied > detected > declared default > static default
end
-loop each field whose options follow the answers
- Eng -> B: options callback(context)
- B --> Eng: value => label map
- note right of Eng: resolved before conditions evaluate; a value the\nset no longer holds is dropped unless it was supplied
+loop each field with a supplied value
+ Col -> B: transform(value)
+ B --> Col: normalized value
+ note right of Col: supplied values normalize first, so derivation,\nconditions and fix-ups see the final value
end
-Eng -> Der: derive(rules, values, pinned)
+Col -> Der: derive(rules, values, pinned)
activate Der
-Der --> Eng: derived values (fixpoint)
+Der --> Col: derived values (fixpoint)
deactivate Der
-Eng -> CE: matches(answers)
+Col -> CE: matches(answers)
activate CE
-CE --> Eng: active / inactive per field
+CE --> Col: there / not there, per field
deactivate CE
-note right of Eng: apply fix-ups; repeat until stable
+note right of Col: rows that follow the answers re-resolve,\nfix-ups apply; repeat until nothing moves
+
+loop each field that is there and was supplied
+ Col -> B: validate(value)
+ B --> Col: reason or null
+ note right of Col: emptiness on a required field first,\nthen type, bounds and rows
+end
-loop each active field with a supplied input
- Eng -> B: validate(value)
- B --> Eng: error or null
- note right of Eng: a required field's emptiness, then type\nand bounds; the first error throws
+alt a value was refused
+ Col --> Tui: CollectException naming the field and the reason
+else every value stands
+ Col -> Ans: forTree(panel, values, provenance)
+ Ans --> Col: the self-describing set
+ Col --> Tui: Answers
end
+deactivate Col
-Eng -> Ans: build(values, provenance)
-Eng --> Caller: Answers
-deactivate Eng
+Tui --> Caller: Answers
+deactivate Tui
@enduml
diff --git a/docs/architecture/dataflow-collect.svg b/docs/architecture/dataflow-collect.svg
index 1c329d3a..316d36e6 100644
--- a/docs/architecture/dataflow-collect.svg
+++ b/docs/architecture/dataflow-collect.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-tui-dark.svg b/docs/architecture/dataflow-tui-dark.svg
index 14db56e7..9cb45896 100644
--- a/docs/architecture/dataflow-tui-dark.svg
+++ b/docs/architecture/dataflow-tui-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-tui.puml b/docs/architecture/dataflow-tui.puml
index dc11b817..3679897b 100644
--- a/docs/architecture/dataflow-tui.puml
+++ b/docs/architecture/dataflow-tui.puml
@@ -1,74 +1,91 @@
@startuml
-' drevops/tui - data flow for the interactive panel TUI.
-' Traced from src/Render/PanelController.php (run -> frame/handle), src/Theme/
-' (frame, and renderModal composited via src/Render/Overlay), src/Input/
-' (KeyMap resolves a key press to a semantic action) and src/Engine/Engine.php
-' (settle re-resolves answer-driven options and re-runs derives, conditions and
-' fix-ups after an accepted edit).
+' drevops/tui - data flow for the interactive screen session.
+' Traced from src/Screen/ScreenController.php (run -> paint/handle), with
+' src/Screen/ScreenRenderer.php drawing outward from the Screen, src/Screen/
+' KeyRouter.php sending each key inward, src/Input/ resolving a key press to a
+' semantic action, and src/Screen/Collector.php re-settling after every answer.
' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml
!theme plain
skinparam backgroundColor white
skinparam defaultFontName Helvetica
skinparam shadowing false
-title Data flow: interactive panel TUI (PanelController::run)
+title Data flow: interactive session (ScreenController::run)
actor "User" as User
-participant "PanelController" as PC
+participant "ScreenController" as SC
+participant "Assembler" as Asm
participant "Terminal" as Term
-participant "Theme" as Theme
-participant "Navigator\n+ Scroller" as Nav
+participant "ScreenRenderer" as SR
+participant "Screen\n-> Layout -> Region" as Screen
+participant "Block\n(Panel, Field, ...)" as Block
+participant "Theme\n(elements)" as Theme
participant "KeyParser" as KP
-participant "KeyMap\n(scoped)" as KM
-participant "Widget\n(via factory)" as W
-participant "Engine" as Eng
+participant "KeyRouter" as KR
+participant "Collector" as Col
-PC -> Term: setup() raw mode, alt screen
-activate PC
-PC -> Theme: banner(logo, version)
-Theme --> PC: banner text
-PC -> Term: render(banner)
+SC -> Asm: assemble(panel, layout)
+activate SC
+Asm --> SC: Screen with the standard furniture
+note right of SC: a breadcrumb in 'header', the panel and its\nbuttons in 'content', a legend in 'footer' -\nwherever the named layout keeps a place
-loop until done
- alt fullscreen and terminal below the minimum size
- PC -> PC: centered "terminal too small" notice\n(only quit is handled)
+SC -> Term: setup() raw mode, alt screen
+SC -> Col: seed(panel, supplied, context)
+Col --> SC: the values the form opens on, and who is there
+
+loop until the form ends
+ alt the terminal cannot hold the frame
+ SC -> Term: render(resize notice)
+ note right of SC: every key but the one that leaves is dropped
else laid out
- PC -> Theme: body(panel, answers, cursor)
- Theme --> PC: rows + cursor line
- PC -> Nav: compute viewport\n(theme-owned chrome height)
- Nav --> PC: visible window
- alt current panel is a modal
- PC -> Theme: renderModal(dialog, dimmed backdrop)
- Theme --> PC: composited frame
- else hub or field editor
- PC -> Theme: frame(header, body, footer, viewport)
- Theme --> PC: frame text\n(fullscreen: stretched, block aligned)
- end
- PC -> PC: position frame in the screen\n(fullscreen: Overlay place by halign/valign)
+ SC -> SR: render(screen, rows, columns)
+ activate SR
+ SR -> Screen: arrange(available)
+ Screen --> SR: a size for each Region
+ SR -> Screen: flow the Region's blocks, scroll if declared
+ SR -> Block: render(theme)
+ Block -> Theme: elements for what it draws
+ Theme --> Block: styled strings
+ Block --> SR: its rows
+ SR --> SC: the frame
+ deactivate SR
+ SC -> Term: render(frame)
end
- PC -> Term: render(frame)
User -> Term: key press
- Term --> PC: raw bytes
- PC -> KP: parse(bytes)
- KP --> PC: Key list
+ Term --> SC: raw bytes
+ SC -> KP: parse(bytes)
+ KP --> SC: Key list
+
+ alt the interrupt key
+ note right of SC: aborts from anywhere, including an open field
+ else a button, work to run, or leaving
+ note right of SC: held here: each acts on something outside\nthe screen, which no block knows about
+ else anything else
+ SC -> KR: handle(key)
+ activate KR
+ KR -> Block: the focused block, if it binds that key
+ KR -> Block: else the panel around it
+ note right of KR: move the cursor, open a field, go into a panel,\ncome back out, show a field's help
+ KR --> SC: done
+ deactivate KR
- alt editing a field
- PC -> W: handle(key)
- W -> KM: matches(key, action)?
- KM --> W: bound action (accept, move, toggle...)
- W --> PC: value, complete or cancel
- opt edit accepted
- PC -> Eng: settle(values, pinned, context)
- Eng --> PC: settled values + active map\n(options resolve from the answers,\nderives recompute, conditions\nshow/hide fields, fix-ups apply)
+ opt an answer was just taken
+ SC -> Col: resettle(panel, values, pinned, context)
+ Col --> SC: settled values + who is there
+ note right of SC: rows that follow the answers re-resolve,\ncomputed values recompute, conditions\nshow and hide rows, fix-ups apply
end
- else navigating
- PC -> KM: matches(key, action)?
- KM --> PC: bound action (move, activate, back, quit)
- note right of PC: move cursor, drill panel, edit a field in place,\nor open a modal dialog over the dimmed parent;\na modal's submit keeps edits, cancel restores the opening answers
end
+
+ SC -> SC: resolve one query, once the whole read is spent
end
-PC -> Term: restore()
-PC --> User: Answers
-deactivate PC
+SC -> Term: restore(), and clear unless the session opted out
+alt interrupted
+ SC --> User: InterruptException
+else cancelled
+ SC --> User: CancelException
+else finished
+ SC --> User: Answers
+end
+deactivate SC
@enduml
diff --git a/docs/architecture/dataflow-tui.svg b/docs/architecture/dataflow-tui.svg
index 3d457a13..5e0ba3f9 100644
--- a/docs/architecture/dataflow-tui.svg
+++ b/docs/architecture/dataflow-tui.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/assets/README.md b/docs/assets/README.md
index 3c61653f..1b2cc104 100644
--- a/docs/assets/README.md
+++ b/docs/assets/README.md
@@ -15,7 +15,7 @@ alone:
| Segment | Values | Meaning |
|-------------|------------------------|----------------------------------------------------|
-| `subject` | e.g. `widget-text`, `theme-midnight`, `progress-bar` | What is shown (a widget, a panel demo, a theme preview, a primitive) |
+| `subject` | e.g. `field-text`, `theme-midnight`, `progress-bar` | What is shown (a field, a panel demo, a theme preview, a primitive) |
| `mode` | `dark` \| `light` | Colour scheme |
| `motion` | `animated` \| `static` | An animation, or a single frame |
| `-bordered` | present when set | Inside the rounded border frame (theme previews) |
@@ -24,9 +24,9 @@ alone:
A theme preview's subject carries the theme name (`theme-midnight`), so its
`mode` segment still reads dark or light: `theme-midnight-dark-static.svg`.
-Unicode and colour are the unmarked defaults, so `widget-text-dark-animated.svg`
+Unicode and colour are the unmarked defaults, so `field-text-dark-animated.svg`
is the dark, Unicode, colour animation, and
-`widget-text-dark-static-ascii-no-ansi.svg` is its ASCII, no-colour static twin.
+`field-text-dark-static-ascii-no-ansi.svg` is its ASCII, no-colour static twin.
Animated demos render inside the rounded border frame by design and carry no
marker; the `-bordered` marker distinguishes the theme previews' framed statics
from their borderless twins.
@@ -35,30 +35,30 @@ from their borderless twins.
`update-assets.php` is the single entry point: run without arguments it records every live-terminal job in parallel and spawns the four deterministic sibling generators alongside them, so one command regenerates the whole set.
-- **`update-assets.php`** - the full panel demos, the widget montage and the
+- **`update-assets.php`** - the full panel demos, the field montage and the
option-group / password-reveal / discovery frames, recorded from a live
terminal (`--record ` re-runs one job).
-- **`render-widget-svgs.php`** - every per-widget asset, driven deterministically
+- **`render-field-svgs.php`** - every per-field asset, driven deterministically
through the library's own keystroke harness with no terminal: the animated
- cards in all four display modes (`widget-*-dark-animated*.svg`, the unmarked one
+ cards in all four display modes (`field-*-dark-animated*.svg`, the unmarked one
being the hero, framed by the rounded border) and the matching static
- screenshots (`widget-*-dark-static*.svg`, borderless).
+ screenshots (`field-*-dark-static*.svg`, borderless).
- **`render-theme-svgs.php`** - the built-in theme previews, also through the
keystroke harness: `theme---static[-bordered].svg` for the
adaptive themes, and the dark/light pair for `dos` (which draws its own window
on its own surface, so it has no bordered twin).
- **`render-progress-svgs.php`** - the progress primitive's spinner and bar
assets (`progress-spinner-*`, `progress-bar-*`). The primitive is not a
- keystroke widget: it is a single line the theme redraws in place, so this
+ keystroke field: it is a single line the theme redraws in place, so this
drives the real primitive against an in-memory terminal, splits the output
into frames on the carriage return, and renders both the animation and a
single mid-run static frame in all four display modes, borderless like the
- widget statics.
+ field statics.
- **`render-output-svgs.php`** - the output primitives' assets (`output-box-*`,
`output-card-*`, `output-table-*`, `output-status-*`,
`output-definitions-*`, `output-text-*`), driven through the real primitive
against an in-memory terminal in all four display modes, borderless like the
- widget statics. These are the one subject with no `-animated` variant: the
+ field statics. These are the one subject with no `-animated` variant: the
output primitives write finished lines and return, so there is no motion to
record.
- **`render-social-card.php`** - the one non-SVG asset: `social-card.png`, the
diff --git a/docs/assets/anatomy-chrome-dark-static.svg b/docs/assets/anatomy-chrome-dark-static.svg
new file mode 100644
index 00000000..c41790bd
--- /dev/null
+++ b/docs/assets/anatomy-chrome-dark-static.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/anatomy-chrome-light-static.svg b/docs/assets/anatomy-chrome-light-static.svg
new file mode 100644
index 00000000..7b1ef6b5
--- /dev/null
+++ b/docs/assets/anatomy-chrome-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Delivery││❯Basketcontentsⁱapple,carrot││Picktheproduceforthisdelivery.││Basketweight1200││Weighedatthepackingbench.││Harvestdate2026-07-15││CourierValleyRuns││Organiconly?yes││▼││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit·?toshowhelp│╰──────────────────────────────────────────────────────────────────────────╯1 border2 breadcrumb3 breadcrumb separator4 overflow marker5 legend6 legend key7 legend description8 legend separator
\ No newline at end of file
diff --git a/docs/assets/anatomy-constraint-dark-static.svg b/docs/assets/anatomy-constraint-dark-static.svg
new file mode 100644
index 00000000..6a85efd5
--- /dev/null
+++ b/docs/assets/anatomy-constraint-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││❯baskets/││deliveries/││box.json││harvest.csv││pantry.yaml││README.md││Filesonly.Max64B.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-constraint-light-static.svg b/docs/assets/anatomy-constraint-light-static.svg
new file mode 100644
index 00000000..0808faf9
--- /dev/null
+++ b/docs/assets/anatomy-constraint-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││❯baskets/││deliveries/││box.json││harvest.csv││pantry.yaml││README.md││Filesonly.Max64B.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-editor-dark-static.svg b/docs/assets/anatomy-editor-dark-static.svg
new file mode 100644
index 00000000..3f2586b8
--- /dev/null
+++ b/docs/assets/anatomy-editor-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Basket││❯Basket◼Apple││❯◼Carrot││◻Tomato(outofseason)││Stayscrispforweekswhenkeptcold.││Selectbetween2and3items.││Picktheproduceforthisdelivery.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯1 entry2 entry selector3 entry marker4 entry note5 entry description6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-editor-light-static.svg b/docs/assets/anatomy-editor-light-static.svg
new file mode 100644
index 00000000..d0eb7c0c
--- /dev/null
+++ b/docs/assets/anatomy-editor-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Basket││❯Basket◼Apple││❯◼Carrot││◻Tomato(outofseason)││Stayscrispforweekswhenkeptcold.││Selectbetween2and3items.││Picktheproduceforthisdelivery.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯1 entry2 entry selector3 entry marker4 entry note5 entry description6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-error-dark-static.svg b/docs/assets/anatomy-error-dark-static.svg
new file mode 100644
index 00000000..1e55c364
--- /dev/null
+++ b/docs/assets/anatomy-error-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││baskets/││deliveries/││box.json││❯harvest.csv││pantry.yaml││README.md││Chooseafilenolargerthan64B.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯7 error
\ No newline at end of file
diff --git a/docs/assets/anatomy-error-light-static.svg b/docs/assets/anatomy-error-light-static.svg
new file mode 100644
index 00000000..fda35693
--- /dev/null
+++ b/docs/assets/anatomy-error-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││baskets/││deliveries/││box.json││❯harvest.csv││pantry.yaml││README.md││Chooseafilenolargerthan64B.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯7 error
\ No newline at end of file
diff --git a/docs/assets/anatomy-filepicker-dark-static.svg b/docs/assets/anatomy-filepicker-dark-static.svg
new file mode 100644
index 00000000..a395bf51
--- /dev/null
+++ b/docs/assets/anatomy-filepicker-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││baskets/││❯deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││TheCSVtheorchardsendseachweek.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯11 caption1 entry2 entry selector6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-filepicker-light-static.svg b/docs/assets/anatomy-filepicker-light-static.svg
new file mode 100644
index 00000000..2f358a8f
--- /dev/null
+++ b/docs/assets/anatomy-filepicker-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Pricelist││❯Pricelistsample-project││baskets/││❯deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││TheCSVtheorchardsendseachweek.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯11 caption1 entry2 entry selector6 constraint
\ No newline at end of file
diff --git a/docs/assets/anatomy-row-dark-static.svg b/docs/assets/anatomy-row-dark-static.svg
new file mode 100644
index 00000000..6870c4cb
--- /dev/null
+++ b/docs/assets/anatomy-row-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Delivery││❯Basketcontentsⁱapple,carrot││Picktheproduceforthisdelivery.││Basketweight1200││Weighedatthepackingbench.││Harvestdate2026-07-15││CourierValleyRuns││Organiconly?yes││NotesLeaveatthegate││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit·?toshowhelp│╰──────────────────────────────────────────────────────────────────────────╯1 field selector3 help marker5 value separator4 value6 description2 label
\ No newline at end of file
diff --git a/docs/assets/anatomy-row-light-static.svg b/docs/assets/anatomy-row-light-static.svg
new file mode 100644
index 00000000..62e5a6e2
--- /dev/null
+++ b/docs/assets/anatomy-row-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Delivery││❯Basketcontentsⁱapple,carrot││Picktheproduceforthisdelivery.││Basketweight1200││Weighedatthepackingbench.││Harvestdate2026-07-15││CourierValleyRuns││Organiconly?yes││NotesLeaveatthegate││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit·?toshowhelp│╰──────────────────────────────────────────────────────────────────────────╯1 field selector3 help marker5 value separator4 value6 description2 label
\ No newline at end of file
diff --git a/docs/assets/anatomy-text-dark-static.svg b/docs/assets/anatomy-text-dark-static.svg
new file mode 100644
index 00000000..04377868
--- /dev/null
+++ b/docs/assets/anatomy-text-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Crate││❯Cratelabelvalley-pear█-a││fillinginFruit││Identifiesthecrateontheloadingdock.││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯8 caret9 draft10 state
\ No newline at end of file
diff --git a/docs/assets/anatomy-text-light-static.svg b/docs/assets/anatomy-text-light-static.svg
new file mode 100644
index 00000000..792e0b1f
--- /dev/null
+++ b/docs/assets/anatomy-text-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Orchard›Crate││❯Cratelabelvalley-pear█-a││fillinginFruit││Identifiesthecrateontheloadingdock.││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯8 caret9 draft10 state
\ No newline at end of file
diff --git a/docs/assets/bordered-panels-dark-animated.svg b/docs/assets/bordered-panels-dark-animated.svg
index fa133b73..9b41c206 100644
--- a/docs/assets/bordered-panels-dark-animated.svg
+++ b/docs/assets/bordered-panels-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Fruitbasket│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Whatthebasketholds.││weekly·apple·6│weekly·apple·6││Delivery›││Howitarrives.││pickup·no││[Create][Cancel]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Fruitbasket›Basics││❯Basketnameweekly││Fruitapple│Fruitapple││Quantity6││Basketnameweekly││❯Fruitapple│Basics›││Whatthebasketholds.││weekly·apple·6│weekly·apple·6││❯Delivery›││Howitarrives.││pickup·no││Fruitbasket›Delivery││❯Methodpickup││Giftwrap?no│Giftwrap?no││Extras›││Medium│╰────────────────────────────────────────────────────────────────────────│Methodpickup││❯Giftwrap?no│❯Giftwrap?no││❯Extras›││Medium││Fruitbasket›Delivery›Extras││❯BagsizeMedium││[ Create ][Cancel]│BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium│❯Fruitapple││❯Quantity6│╰──────────────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────│↑/↓│↑/↓move·
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Fruitbasket││❯Basics›││Whatthebasketholds.││weekly·apple·6││││Delivery›││Howitarrives.│Howitarrives.││pickup·no││[ Create ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fruitbasket›Basics││❯Basketnameweekly││Fruitapple││Quantity6││Basketnameweekly││❯Fruitapple││❯Quantity6││Basics›││❯Delivery›││Fruitbasket›Delivery││❯Methodpickup││Giftwrap?no││Extras›││Medium│Medium││Methodpickup││❯Giftwrap?no││❯Extras›││Fruitbasket›Delivery›Extras││❯BagsizeMedium│BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium
\ No newline at end of file
diff --git a/docs/assets/bordered-panels-light-animated.svg b/docs/assets/bordered-panels-light-animated.svg
index 228e5c28..e52688ce 100644
--- a/docs/assets/bordered-panels-light-animated.svg
+++ b/docs/assets/bordered-panels-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Fruitbasket│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Whatthebasketholds.││weekly·apple·6│weekly·apple·6││Delivery›││Howitarrives.││pickup·no││[Create][Cancel]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Fruitbasket›Basics││❯Basketnameweekly││Fruitapple│Fruitapple││Quantity6││Basketnameweekly││❯Fruitapple│Basics›││Whatthebasketholds.││weekly·apple·6│weekly·apple·6││❯Delivery›││Howitarrives.││pickup·no││Fruitbasket›Delivery││❯Methodpickup││Giftwrap?no│Giftwrap?no││Extras›││Medium│╰────────────────────────────────────────────────────────────────────────│Methodpickup││❯Giftwrap?no│❯Giftwrap?no││❯Extras›││Medium││Fruitbasket›Delivery›Extras││❯BagsizeMedium││[ Create ][Cancel]│BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium│❯Fruitapple││❯Quantity6│╰──────────────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────│↑/↓│↑/↓move·
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Fruitbasket││❯Basics›││Whatthebasketholds.││weekly·apple·6││││Delivery›││Howitarrives.│Howitarrives.││pickup·no││[ Create ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fruitbasket›Basics││❯Basketnameweekly││Fruitapple││Quantity6││Basketnameweekly││❯Fruitapple││❯Quantity6││Basics›││❯Delivery›││Fruitbasket›Delivery││❯Methodpickup││Giftwrap?no││Extras›││Medium│Medium││Methodpickup││❯Giftwrap?no││❯Extras›││Fruitbasket›Delivery›Extras││❯BagsizeMedium│BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium
\ No newline at end of file
diff --git a/docs/assets/borderless-panels-dark-animated.svg b/docs/assets/borderless-panels-dark-animated.svg
index 1da49e7b..be07b52d 100644
--- a/docs/assets/borderless-panels-dark-animated.svg
+++ b/docs/assets/borderless-panels-dark-animated.svg
@@ -1 +1 @@
-Fruitbasket❯Basics›Whatthebasketholds.weekly·apple·6Delivery›Howitarrives.pickup·no[Create][Cancel]↑/↓move·↵select·escback·qquit·?helpFruitbasket›BasicsFruitappleQuantity6BasketnameweeklyBasics›Whatthebasketholds.weekly·apple·6❯Delivery›Howitarrives.pickup·noFruitbasket›Delivery❯MethodpickupGiftwrap?noExtras›MediumMethodpickupBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium❯Basketnameweekly❯Fruitapple❯Quantity6❯Giftwrap?no❯Extras›MediumFruitbasket›Delivery›Extras❯BagsizeMedium[ Create ][Cancel]
\ No newline at end of file
+Fruitbasket❯Basics›Whatthebasketholds.weekly·apple·6Delivery›Howitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·↵toselect↑/↓tomove·↵toselect·ESCtogoback·QtoquitFruitbasket›Basics❯BasketnameweeklyFruitappleQuantity6Basketnameweekly❯Fruitapple❯Quantity6Basics›❯Delivery›Fruitbasket›Delivery❯MethodpickupGiftwrap?noExtras›Medium↑/↓tomove·↵toselect·ESCtogoback·↑/↓tomove·↵toselect·ESCtogoback·QMethodpickup❯Giftwrap?no❯Extras›Fruitbasket›Delivery›Extras❯BagsizeMedium↑/↓tomove·↵toselect·↑/↓tomove·↵toselect·ESBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium
\ No newline at end of file
diff --git a/docs/assets/borderless-panels-light-animated.svg b/docs/assets/borderless-panels-light-animated.svg
index 270bc6f7..468c4277 100644
--- a/docs/assets/borderless-panels-light-animated.svg
+++ b/docs/assets/borderless-panels-light-animated.svg
@@ -1 +1 @@
-Fruitbasket❯Basics›Whatthebasketholds.weekly·apple·6Delivery›Howitarrives.pickup·no[Create][Cancel]↑/↓move·↵select·escback·qquit·?helpFruitbasket›BasicsFruitappleQuantity6BasketnameweeklyBasics›Whatthebasketholds.weekly·apple·6❯Delivery›Howitarrives.pickup·noFruitbasket›Delivery❯MethodpickupGiftwrap?noExtras›MediumMethodpickupBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium❯Basketnameweekly❯Fruitapple❯Quantity6❯Giftwrap?no❯Extras›MediumFruitbasket›Delivery›Extras❯BagsizeMedium[ Create ][Cancel]
\ No newline at end of file
+Fruitbasket❯Basics›Whatthebasketholds.weekly·apple·6Delivery›Howitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·↵toselect↑/↓tomove·↵toselect·ESCtogoback·QtoquitFruitbasket›Basics❯BasketnameweeklyFruitappleQuantity6Basketnameweekly❯Fruitapple❯Quantity6Basics›❯Delivery›Fruitbasket›Delivery❯MethodpickupGiftwrap?noExtras›Medium↑/↓tomove·↵toselect·ESCtogoback·↑/↓tomove·↵toselect·ESCtogoback·QMethodpickup❯Giftwrap?no❯Extras›Fruitbasket›Delivery›Extras❯BagsizeMedium↑/↓tomove·↵toselect·↑/↓tomove·↵toselect·ESBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:Medium
\ No newline at end of file
diff --git a/docs/assets/conditional-fields-dark-animated.svg b/docs/assets/conditional-fields-dark-animated.svg
index 72ad5689..3a62a188 100644
--- a/docs/assets/conditional-fields-dark-animated.svg
+++ b/docs/assets/conditional-fields-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Conditionalfields│├──────────────────────────────────────────────────────────────────────┤│││❯Packing›││Toggletheanswersandwatchfieldsappear.││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Conditionalfields›Packing││❯Contentsfruit││Boxsizemedium│││Stacktheboxes?no││❯Contents❯◼Fruit││◻Vegetables││◻Herbs││↑/↓move·↵accept·esccancel│╰─────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────│❯Contents◼Fruit││❯◻Vegetables││❯◻Herbs││❯◼Herbs│╰────────────────────────────────────────────────────────────│❯Contentsfruit,herbs edited ││Herbbundlemixed││Contentsfruit,herbs edited ││❯Boxsizemedium││❯Boxsize○Small││●Medium││○Large││↑/↓move·↵accept·esccancel│○Medium││●Large││❯Boxsizelarge edited ││Weeklyherbdelivery?yes││fruit,herbs·large·mixed·yes││Packing›││Toggletheanswersandwatchfieldsappear.││fruit,herbs·large·mixed·yes│PackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yes│fruit·medium·no│fruit·medium·no│╰──────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────│○Med│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Conditionalfields││❯Packing›││Toggletheanswersandwatchfieldsappear.││fruit·medium·no││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Conditionalfields›Packing││❯Contentsfruit││Boxsizemedium││Stacktheboxes?no││❯Contents❯◼Fruit││◻Vegetables││◻Herbs││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents◼Fruit││❯◻Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,herbs edited ││Herbbundlemixed││Contentsfruit,herbs edited ││❯Boxsizemedium││❯Boxsize○Small││●Medium││○Large││↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelarge edited ││Weeklyherbdelivery?yes││fruit,herbs·large·mixed·yes││Packing›│PackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yes
\ No newline at end of file
diff --git a/docs/assets/conditional-fields-light-animated.svg b/docs/assets/conditional-fields-light-animated.svg
index 23ca04db..1e422bbd 100644
--- a/docs/assets/conditional-fields-light-animated.svg
+++ b/docs/assets/conditional-fields-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Conditionalfields│├──────────────────────────────────────────────────────────────────────┤│││❯Packing›││Toggletheanswersandwatchfieldsappear.││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Conditionalfields›Packing││❯Contentsfruit││Boxsizemedium│││Stacktheboxes?no││❯Contents❯◼Fruit││◻Vegetables││◻Herbs││↑/↓move·↵accept·esccancel│╰─────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────│❯Contents◼Fruit││❯◻Vegetables││❯◻Herbs││❯◼Herbs│╰────────────────────────────────────────────────────────────│❯Contentsfruit,herbs edited ││Herbbundlemixed││Contentsfruit,herbs edited ││❯Boxsizemedium││❯Boxsize○Small││●Medium││○Large││↑/↓move·↵accept·esccancel│○Medium││●Large││❯Boxsizelarge edited ││Weeklyherbdelivery?yes││fruit,herbs·large·mixed·yes││Packing›││Toggletheanswersandwatchfieldsappear.││fruit,herbs·large·mixed·yes│PackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yes│fruit·medium·no│fruit·medium·no│╰──────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────│○Med│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Conditionalfields││❯Packing›││Toggletheanswersandwatchfieldsappear.││fruit·medium·no││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Conditionalfields›Packing││❯Contentsfruit││Boxsizemedium││Stacktheboxes?no││❯Contents❯◼Fruit││◻Vegetables││◻Herbs││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents◼Fruit││❯◻Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,herbs edited ││Herbbundlemixed││Contentsfruit,herbs edited ││❯Boxsizemedium││❯Boxsize○Small││●Medium││○Large││↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelarge edited ││Weeklyherbdelivery?yes││fruit,herbs·large·mixed·yes││Packing›│PackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yes
\ No newline at end of file
diff --git a/docs/assets/conditional-indent-dark-animated.svg b/docs/assets/conditional-indent-dark-animated.svg
index d6e0d749..86f6064c 100644
--- a/docs/assets/conditional-indent-dark-animated.svg
+++ b/docs/assets/conditional-indent-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Conditionalindentation│├──────────────────────────────────────────────────────────────────────┤│││❯Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││fruit·6│││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Conditionalindentation›Produceorder││❯Categoryfruit││Quantity6││❯Category●Fruit││○Vegetable││○Herb││↑/↓move·↵accept·esccancel││❯Category○Fruit││●Vegetable││❯Categoryvegetable edited ││Basketcarrot││Weeklydelivery?yes││CouriernoteLeaveatthegate││Categoryvegetable edited ││❯Basketcarrot││❯Basket❯◼Carrot││◻Potato││◻Tomato││❯Basket❯◻Carrot││❯Basket edited ││vegetable··6││Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││vegetable··6│ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6│❯Basket❯◼Carrot├────────────────────────────────────├───────────────────────────────────────├────────────────────────────────────────├───────────────────────────────────────────│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Conditionalindentation││❯Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││fruit·6││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Conditionalindentation›Produceorder││❯Categoryfruit││Quantity6││❯Category●Fruit││○Vegetable││○Herb││↑/↓tomove·↵toaccept·ESCtocancel││❯Category○Fruit││●Vegetable││❯Categoryvegetable edited ││Basketcarrot││Weeklydelivery?yes││CouriernoteLeaveatthegate││Categoryvegetable edited ││❯Basketcarrot││❯Basket❯◼Carrot││◻Potato││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket❯◻Carrot││❯Basket edited ││vegetable··6││Produceorder›│ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6
\ No newline at end of file
diff --git a/docs/assets/conditional-indent-light-animated.svg b/docs/assets/conditional-indent-light-animated.svg
index 405c9e3c..09fb983f 100644
--- a/docs/assets/conditional-indent-light-animated.svg
+++ b/docs/assets/conditional-indent-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Conditionalindentation│├──────────────────────────────────────────────────────────────────────┤│││❯Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││fruit·6│││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Conditionalindentation›Produceorder││❯Categoryfruit││Quantity6││❯Category●Fruit││○Vegetable││○Herb││↑/↓move·↵accept·esccancel││❯Category○Fruit││●Vegetable││❯Categoryvegetable edited ││Basketcarrot││Weeklydelivery?yes││CouriernoteLeaveatthegate││Categoryvegetable edited ││❯Basketcarrot││❯Basket❯◼Carrot││◻Potato││◻Tomato││❯Basket❯◻Carrot││❯Basket edited ││vegetable··6││Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││vegetable··6│ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6│❯Basket❯◼Carrot├────────────────────────────────────├───────────────────────────────────────├────────────────────────────────────────├───────────────────────────────────────────│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Conditionalindentation││❯Produceorder›││PickVegetable,thenaddCarrot,tostepthechainopen.││fruit·6││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Conditionalindentation›Produceorder││❯Categoryfruit││Quantity6││❯Category●Fruit││○Vegetable││○Herb││↑/↓tomove·↵toaccept·ESCtocancel││❯Category○Fruit││●Vegetable││❯Categoryvegetable edited ││Basketcarrot││Weeklydelivery?yes││CouriernoteLeaveatthegate││Categoryvegetable edited ││❯Basketcarrot││❯Basket❯◼Carrot││◻Potato││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket❯◻Carrot││❯Basket edited ││vegetable··6││Produceorder›│ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6
\ No newline at end of file
diff --git a/docs/assets/derived-values-dark-animated.svg b/docs/assets/derived-values-dark-animated.svg
index 604d4420..daa8ee0a 100644
--- a/docs/assets/derived-values-dark-animated.svg
+++ b/docs/assets/derived-values-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Derivedvalues│├──────────────────────────────────────────────────────────────────────┤│││❯Naming›││RedApple·red_apple·RED_APPLE·Sunny│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Derivedvalues›Naming││❯ProducenameRedApple││Slugred_apple derived ││Derivedfromthename.││CodeRED_APPLE derived ││Derivedfromtheslug.││GrowerSunny││Lotsunny/red_apple derived ││Derivedfromgrowerandslug.││❯ProducenameRedApple█│├──│↵accept·esccancel││❯ProducenameRedAppl█││❯ProducenameRedApp█││❯ProducenameRedAp█││❯ProducenameRedA█││❯ProducenameRed█││❯ProducenameRedP█││❯ProducenameRedPl█││❯ProducenameRedPlu█││❯ProducenameRedPlum█││❯ProducenameRedPlum edited ││Slugred_plum derived ││CodeRED_PLUM derived ││Lotsunny/red_plum derived ││RedPlum·red_plum·RED_PLUM·Sunny││Naming›││RedPlum·red_plum·RED_PLUM·Sunny│NamingProducename:RedPlum(edited)Slug:red_plum(derived)Code:RED_PLUM(derived)Grower:SunnyLot:sunny/red_plum(derived)│D├───│[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────╮│Derivedvalues││❯Naming›││RedApple·red_apple·RED_APPLE·Sunny││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯│Derivedvalues›Naming││❯ProducenameRedApple││Slugred_apple derived ││Derivedfromthename.││CodeRED_APPLE derived ││Derivedfromtheslug.││GrowerSunny││Lotsunny/red_apple derived ││Derivedfromgrowerandslug.││❯ProducenameRedApple█││Der│↵toaccept·ESCtocancel││❯ProducenameRedAppl█││❯ProducenameRedApp█││❯ProducenameRedAp█││❯ProducenameRedA█││❯ProducenameRed█││❯ProducenameRedP█││❯ProducenameRedPl█││❯ProducenameRedPlu█││❯ProducenameRedPlum█││❯ProducenameRedPlum edited ││Slugred_plum derived ││CodeRED_PLUM derived ││Lotsunny/red_plum derived ││RedPlum·red_plum·RED_PLUM·Sunny││Naming›│NamingProducename:RedPlum(edited)Slug:red_plum(derived)Code:RED_PLUM(derived)Grower:SunnyLot:sunny/red_plum(derived)│Derived
\ No newline at end of file
diff --git a/docs/assets/derived-values-light-animated.svg b/docs/assets/derived-values-light-animated.svg
index 2513fe32..7fe80dc6 100644
--- a/docs/assets/derived-values-light-animated.svg
+++ b/docs/assets/derived-values-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Derivedvalues│├──────────────────────────────────────────────────────────────────────┤│││❯Naming›││RedApple·red_apple·RED_APPLE·Sunny│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Derivedvalues›Naming││❯ProducenameRedApple││Slugred_apple derived ││Derivedfromthename.││CodeRED_APPLE derived ││Derivedfromtheslug.││GrowerSunny││Lotsunny/red_apple derived ││Derivedfromgrowerandslug.││❯ProducenameRedApple█│├──│↵accept·esccancel││❯ProducenameRedAppl█││❯ProducenameRedApp█││❯ProducenameRedAp█││❯ProducenameRedA█││❯ProducenameRed█││❯ProducenameRedP█││❯ProducenameRedPl█││❯ProducenameRedPlu█││❯ProducenameRedPlum█││❯ProducenameRedPlum edited ││Slugred_plum derived ││CodeRED_PLUM derived ││Lotsunny/red_plum derived ││RedPlum·red_plum·RED_PLUM·Sunny││Naming›││RedPlum·red_plum·RED_PLUM·Sunny│NamingProducename:RedPlum(edited)Slug:red_plum(derived)Code:RED_PLUM(derived)Grower:SunnyLot:sunny/red_plum(derived)│D├───│[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────╮│Derivedvalues││❯Naming›││RedApple·red_apple·RED_APPLE·Sunny││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯│Derivedvalues›Naming││❯ProducenameRedApple││Slugred_apple derived ││Derivedfromthename.││CodeRED_APPLE derived ││Derivedfromtheslug.││GrowerSunny││Lotsunny/red_apple derived ││Derivedfromgrowerandslug.││❯ProducenameRedApple█││Der│↵toaccept·ESCtocancel││❯ProducenameRedAppl█││❯ProducenameRedApp█││❯ProducenameRedAp█││❯ProducenameRedA█││❯ProducenameRed█││❯ProducenameRedP█││❯ProducenameRedPl█││❯ProducenameRedPlu█││❯ProducenameRedPlum█││❯ProducenameRedPlum edited ││Slugred_plum derived ││CodeRED_PLUM derived ││Lotsunny/red_plum derived ││RedPlum·red_plum·RED_PLUM·Sunny││Naming›│NamingProducename:RedPlum(edited)Slug:red_plum(derived)Code:RED_PLUM(derived)Grower:SunnyLot:sunny/red_plum(derived)│Derived
\ No newline at end of file
diff --git a/docs/assets/field-behaviour-dark-animated.svg b/docs/assets/field-behaviour-dark-animated.svg
index 8b19b9e4..cb6b3cf4 100644
--- a/docs/assets/field-behaviour-dark-animated.svg
+++ b/docs/assets/field-behaviour-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Fieldbehaviour│├──────────────────────────────────────────────────────────────────────┤│││❯Stall›││SampleProject··1200·GoldenDelicious│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Fieldbehaviour›Stall││❯StallnameSampleProject││Basket││Crateweight(g)1200││VarietyGoldenDelicious│╰──────────────────────────────────────────────────────────────────│❯StallnameSampleProject█││↵accept·esccancel│╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────│❯StallnameSampleProjec█││❯StallnameSampleProje█││❯StallnameSampleProj█││❯StallnameSamplePro█││❯StallnameSamplePr█││❯StallnameSampleP█││❯StallnameSample█││❯StallnameSample█││❯StallnameSampl█││❯StallnameSamp█││❯StallnameSam█││❯StallnameSa█││❯StallnameS█││❯Stallname█││Stallnameisrequired.││Basket╰─────────────────────────────────╰───────────────────────────────────│❯StallnameSe█││❯StallnameSea█││❯StallnameSeas█││❯StallnameSeasi█││❯StallnameSeasid█││❯StallnameSeaside█││❯StallnameSeaside█││❯StallnameSeasideS█││❯StallnameSeasideSt█││❯StallnameSeasideSta█││❯StallnameSeasideStal█││❯StallnameSeasideStall█││❯StallnameSeasideStall edited │╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────│SeasideStall··1200·GoldenDelicious││Stall›││SeasideStall··1200·GoldenDelicious││[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]││Addatleastoneitemtothebasket.││StallnameSeasideStall edited ││❯Basket││❯Basket❯◻Apple││◻Carrot││◻Tomato││▼││↑/↓move·↵accept·esccancel│↑/↓move·↵accept·esccancel││❯Basket❯◼Apple││❯Basketapple edited ││SeasideStall·apple·1200·GoldenDelicious││SeasideStall·apple·1200·GoldenDelicious│StallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious╰─────────────────────────────────────────────────────────────────────│Addatleastoneitemto│Addatleastoneitemtotheba│Addat╰────────────────────────────────────────────────────────────────│❯Basket❯◼Apple╰────────────────────────────────────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Fieldbehaviour││❯Stall›││SampleProject··1200·GoldenDelicious││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fieldbehaviour›Stall││❯StallnameSampleProject││Basket││Crateweight(g)1200││VarietyGoldenDelicious││❯StallnameSampleProject█││↵toaccept·ESCtocancel│╰───────────────────╰──────────────────────│❯StallnameSampleProjec█││❯StallnameSampleProje█││❯StallnameSampleProj█││❯StallnameSamplePro█││❯StallnameSamplePr█││❯StallnameSampleP█││❯StallnameSample█││❯StallnameSample█││❯StallnameSampl█││❯StallnameSamp█││❯StallnameSam█││❯StallnameSa█││❯StallnameS█││❯Stallname█││Stallnameisrequired.│╰────────────────│❯StallnameSe█││❯StallnameSea█││❯StallnameSeas█││❯StallnameSeasi█││❯StallnameSeasid█││❯StallnameSeaside█││❯StallnameSeaside█││❯StallnameSeasideS█││❯StallnameSeasideSt█││❯StallnameSeasideSta█││❯StallnameSeasideStal█││❯StallnameSeasideStall█││❯StallnameSeasideStall edited ││SeasideStall··1200·GoldenDelicious││Stall›││Addatleastoneitemtothebasket.││[ Submit ][Cancel]│StallnameSeasideStall edited ││❯Basket││❯Basket❯◻Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰────────────────────────│❯Basket❯◼Apple││❯Basketapple edited ││SeasideStall·apple·1200·GoldenDelicious│StallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious│Var│↑/↓tomove·↵toselect·ESCtogoback·Q╰───────────────────────────╰─────────────────────
\ No newline at end of file
diff --git a/docs/assets/field-behaviour-light-animated.svg b/docs/assets/field-behaviour-light-animated.svg
index dda87b2d..d601931a 100644
--- a/docs/assets/field-behaviour-light-animated.svg
+++ b/docs/assets/field-behaviour-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Fieldbehaviour│├──────────────────────────────────────────────────────────────────────┤│││❯Stall›││SampleProject··1200·GoldenDelicious│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Fieldbehaviour›Stall││❯StallnameSampleProject││Basket││Crateweight(g)1200││VarietyGoldenDelicious│╰──────────────────────────────────────────────────────────────────│❯StallnameSampleProject█││↵accept·esccancel│╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────│❯StallnameSampleProjec█││❯StallnameSampleProje█││❯StallnameSampleProj█││❯StallnameSamplePro█││❯StallnameSamplePr█││❯StallnameSampleP█││❯StallnameSample█││❯StallnameSample█││❯StallnameSampl█││❯StallnameSamp█││❯StallnameSam█││❯StallnameSa█││❯StallnameS█││❯Stallname█││Stallnameisrequired.││Basket╰─────────────────────────────────╰───────────────────────────────────│❯StallnameSe█││❯StallnameSea█││❯StallnameSeas█││❯StallnameSeasi█││❯StallnameSeasid█││❯StallnameSeaside█││❯StallnameSeaside█││❯StallnameSeasideS█││❯StallnameSeasideSt█││❯StallnameSeasideSta█││❯StallnameSeasideStal█││❯StallnameSeasideStall█││❯StallnameSeasideStall edited │╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────│SeasideStall··1200·GoldenDelicious││Stall›││SeasideStall··1200·GoldenDelicious││[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]││Addatleastoneitemtothebasket.││StallnameSeasideStall edited ││❯Basket││❯Basket❯◻Apple││◻Carrot││◻Tomato││▼││↑/↓move·↵accept·esccancel│↑/↓move·↵accept·esccancel││❯Basket❯◼Apple││❯Basketapple edited ││SeasideStall·apple·1200·GoldenDelicious││SeasideStall·apple·1200·GoldenDelicious│StallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious╰─────────────────────────────────────────────────────────────────────│Addatleastoneitemto│Addatleastoneitemtotheba│Addat╰────────────────────────────────────────────────────────────────│❯Basket❯◼Apple╰────────────────────────────────────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Fieldbehaviour││❯Stall›││SampleProject··1200·GoldenDelicious││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fieldbehaviour›Stall││❯StallnameSampleProject││Basket││Crateweight(g)1200││VarietyGoldenDelicious││❯StallnameSampleProject█││↵toaccept·ESCtocancel│╰───────────────────╰──────────────────────│❯StallnameSampleProjec█││❯StallnameSampleProje█││❯StallnameSampleProj█││❯StallnameSamplePro█││❯StallnameSamplePr█││❯StallnameSampleP█││❯StallnameSample█││❯StallnameSample█││❯StallnameSampl█││❯StallnameSamp█││❯StallnameSam█││❯StallnameSa█││❯StallnameS█││❯Stallname█││Stallnameisrequired.│╰────────────────│❯StallnameSe█││❯StallnameSea█││❯StallnameSeas█││❯StallnameSeasi█││❯StallnameSeasid█││❯StallnameSeaside█││❯StallnameSeaside█││❯StallnameSeasideS█││❯StallnameSeasideSt█││❯StallnameSeasideSta█││❯StallnameSeasideStal█││❯StallnameSeasideStall█││❯StallnameSeasideStall edited ││SeasideStall··1200·GoldenDelicious││Stall›││Addatleastoneitemtothebasket.││[ Submit ][Cancel]│StallnameSeasideStall edited ││❯Basket││❯Basket❯◻Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰────────────────────────│❯Basket❯◼Apple││❯Basketapple edited ││SeasideStall·apple·1200·GoldenDelicious│StallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious│Var│↑/↓tomove·↵toselect·ESCtogoback·Q╰───────────────────────────╰─────────────────────
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-animated-ascii-no-ansi.svg b/docs/assets/field-calendar-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..79e19151
--- /dev/null
+++ b/docs/assets/field-calendar-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendarfield||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-animated-ascii.svg b/docs/assets/field-calendar-dark-animated-ascii.svg
new file mode 100644
index 00000000..2094e83f
--- /dev/null
+++ b/docs/assets/field-calendar-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendarfield||>Calendar>||2026-07-15||[ Submit ][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-animated-no-ansi.svg b/docs/assets/field-calendar-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..daa42440
--- /dev/null
+++ b/docs/assets/field-calendar-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││Calendarfield││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-animated.svg b/docs/assets/field-calendar-dark-animated.svg
new file mode 100644
index 00000000..dd62b307
--- /dev/null
+++ b/docs/assets/field-calendar-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││Calendarfield││❯Calendar›││2026-07-15││[ Submit ][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-static-ascii-no-ansi.svg b/docs/assets/field-calendar-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..941ec524
--- /dev/null
+++ b/docs/assets/field-calendar-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-static-ascii.svg b/docs/assets/field-calendar-dark-static-ascii.svg
new file mode 100644
index 00000000..2529cb2c
--- /dev/null
+++ b/docs/assets/field-calendar-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-static-no-ansi.svg b/docs/assets/field-calendar-dark-static-no-ansi.svg
new file mode 100644
index 00000000..7c9c3d8f
--- /dev/null
+++ b/docs/assets/field-calendar-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-calendar-dark-static.svg b/docs/assets/field-calendar-dark-static.svg
new file mode 100644
index 00000000..1a2f88bd
--- /dev/null
+++ b/docs/assets/field-calendar-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-animated-ascii-no-ansi.svg b/docs/assets/field-calendar-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..f773dbde
--- /dev/null
+++ b/docs/assets/field-calendar-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendarfield||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-animated-ascii.svg b/docs/assets/field-calendar-light-animated-ascii.svg
new file mode 100644
index 00000000..eea71e25
--- /dev/null
+++ b/docs/assets/field-calendar-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendarfield||>Calendar>||2026-07-15||[ Submit ][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-animated-no-ansi.svg b/docs/assets/field-calendar-light-animated-no-ansi.svg
new file mode 100644
index 00000000..5ca1bb80
--- /dev/null
+++ b/docs/assets/field-calendar-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││Calendarfield││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-animated.svg b/docs/assets/field-calendar-light-animated.svg
new file mode 100644
index 00000000..ded998ef
--- /dev/null
+++ b/docs/assets/field-calendar-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││Calendarfield││❯Calendar›││2026-07-15││[ Submit ][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-static-ascii-no-ansi.svg b/docs/assets/field-calendar-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..accac1b6
--- /dev/null
+++ b/docs/assets/field-calendar-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-static-ascii.svg b/docs/assets/field-calendar-light-static-ascii.svg
new file mode 100644
index 00000000..e9e3bba4
--- /dev/null
+++ b/docs/assets/field-calendar-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Calendarfield>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-static-no-ansi.svg b/docs/assets/field-calendar-light-static-no-ansi.svg
new file mode 100644
index 00000000..05d8084a
--- /dev/null
+++ b/docs/assets/field-calendar-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-calendar-light-static.svg b/docs/assets/field-calendar-light-static.svg
new file mode 100644
index 00000000..923de657
--- /dev/null
+++ b/docs/assets/field-calendar-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Calendarfield›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-animated-ascii-no-ansi.svg b/docs/assets/field-confirm-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..d92f5ac5
--- /dev/null
+++ b/docs/assets/field-confirm-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Confirmfield>Confirm||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||>Organiconly?()Yes(*)No||Confirmfield||>Confirm>||yes||[Submit][Cancel]||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-animated-ascii.svg b/docs/assets/field-confirm-dark-animated-ascii.svg
new file mode 100644
index 00000000..507a1306
--- /dev/null
+++ b/docs/assets/field-confirm-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Confirmfield>Confirm||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||>Organiconly?()Yes(*)No||Confirmfield||>Confirm>||yes||[ Submit ][Cancel]||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-animated-no-ansi.svg b/docs/assets/field-confirm-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..5163e1ee
--- /dev/null
+++ b/docs/assets/field-confirm-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Confirmfield›Confirm││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?○Yes●No││Confirmfield││❯Confirm›││yes││[Submit][Cancel]││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-animated.svg b/docs/assets/field-confirm-dark-animated.svg
new file mode 100644
index 00000000..67c78e0a
--- /dev/null
+++ b/docs/assets/field-confirm-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Confirmfield›Confirm││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?○Yes●No││Confirmfield││❯Confirm›││yes││[ Submit ][Cancel]││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-static-ascii-no-ansi.svg b/docs/assets/field-confirm-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..3ef5243e
--- /dev/null
+++ b/docs/assets/field-confirm-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Confirmfield>Confirm||>Organiconly?(*)Yes()No||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-static-ascii.svg b/docs/assets/field-confirm-dark-static-ascii.svg
new file mode 100644
index 00000000..f1916bb7
--- /dev/null
+++ b/docs/assets/field-confirm-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Confirmfield>Confirm||>Organiconly?(*)Yes()No||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-static-no-ansi.svg b/docs/assets/field-confirm-dark-static-no-ansi.svg
new file mode 100644
index 00000000..cc2b7e4b
--- /dev/null
+++ b/docs/assets/field-confirm-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Confirmfield›Confirm││❯Organiconly?●Yes○No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-confirm-dark-static.svg b/docs/assets/field-confirm-dark-static.svg
new file mode 100644
index 00000000..b69f6878
--- /dev/null
+++ b/docs/assets/field-confirm-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Confirmfield›Confirm││❯Organiconly?●Yes○No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-animated-ascii-no-ansi.svg b/docs/assets/field-confirm-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..968a1576
--- /dev/null
+++ b/docs/assets/field-confirm-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Confirmfield>Confirm||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||>Organiconly?()Yes(*)No||Confirmfield||>Confirm>||yes||[Submit][Cancel]||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-animated-ascii.svg b/docs/assets/field-confirm-light-animated-ascii.svg
new file mode 100644
index 00000000..383cf1c8
--- /dev/null
+++ b/docs/assets/field-confirm-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Confirmfield>Confirm||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||>Organiconly?()Yes(*)No||Confirmfield||>Confirm>||yes||[ Submit ][Cancel]||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-animated-no-ansi.svg b/docs/assets/field-confirm-light-animated-no-ansi.svg
new file mode 100644
index 00000000..e8b7896c
--- /dev/null
+++ b/docs/assets/field-confirm-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Confirmfield›Confirm││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?○Yes●No││Confirmfield││❯Confirm›││yes││[Submit][Cancel]││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-animated.svg b/docs/assets/field-confirm-light-animated.svg
new file mode 100644
index 00000000..46027656
--- /dev/null
+++ b/docs/assets/field-confirm-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Confirmfield›Confirm││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?○Yes●No││Confirmfield││❯Confirm›││yes││[ Submit ][Cancel]││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-static-ascii-no-ansi.svg b/docs/assets/field-confirm-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..88548f3c
--- /dev/null
+++ b/docs/assets/field-confirm-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Confirmfield>Confirm||>Organiconly?(*)Yes()No||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-static-ascii.svg b/docs/assets/field-confirm-light-static-ascii.svg
new file mode 100644
index 00000000..546d9d04
--- /dev/null
+++ b/docs/assets/field-confirm-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Confirmfield>Confirm||>Organiconly?(*)Yes()No||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-static-no-ansi.svg b/docs/assets/field-confirm-light-static-no-ansi.svg
new file mode 100644
index 00000000..8c3ffb1e
--- /dev/null
+++ b/docs/assets/field-confirm-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Confirmfield›Confirm││❯Organiconly?●Yes○No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-confirm-light-static.svg b/docs/assets/field-confirm-light-static.svg
new file mode 100644
index 00000000..7db1e33b
--- /dev/null
+++ b/docs/assets/field-confirm-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Confirmfield›Confirm││❯Organiconly?●Yes○No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..f407d339
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistsample-project||harvest.csv||>Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden||baskets/||>deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-animated-ascii.svg b/docs/assets/field-filepicker-dark-animated-ascii.svg
new file mode 100644
index 00000000..1ddd3ad0
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden||baskets/||>deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-animated-no-ansi.svg b/docs/assets/field-filepicker-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..300f87ba
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistsample-project││harvest.csv││›Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden││baskets/││❯deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-animated.svg b/docs/assets/field-filepicker-dark-animated.svg
new file mode 100644
index 00000000..435c8d3e
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden││baskets/││❯deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..81bb25ba
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||>Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-static-ascii.svg b/docs/assets/field-filepicker-dark-static-ascii.svg
new file mode 100644
index 00000000..b5e07086
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-static-no-ansi.svg b/docs/assets/field-filepicker-dark-static-no-ansi.svg
new file mode 100644
index 00000000..5a086a6a
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││›Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-dark-static.svg b/docs/assets/field-filepicker-dark-static.svg
new file mode 100644
index 00000000..bc1a3a3b
--- /dev/null
+++ b/docs/assets/field-filepicker-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..701ce87a
--- /dev/null
+++ b/docs/assets/field-filepicker-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistsample-project||harvest.csv||>Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden||baskets/||>deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-animated-ascii.svg b/docs/assets/field-filepicker-light-animated-ascii.svg
new file mode 100644
index 00000000..3a8d2b1d
--- /dev/null
+++ b/docs/assets/field-filepicker-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden||baskets/||>deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-animated-no-ansi.svg b/docs/assets/field-filepicker-light-animated-no-ansi.svg
new file mode 100644
index 00000000..46d71e25
--- /dev/null
+++ b/docs/assets/field-filepicker-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistsample-project││harvest.csv││›Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden││baskets/││❯deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-animated.svg b/docs/assets/field-filepicker-light-animated.svg
new file mode 100644
index 00000000..7299360e
--- /dev/null
+++ b/docs/assets/field-filepicker-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden││baskets/││❯deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..d6d294a2
--- /dev/null
+++ b/docs/assets/field-filepicker-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||>Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-static-ascii.svg b/docs/assets/field-filepicker-light-static-ascii.svg
new file mode 100644
index 00000000..39faaf69
--- /dev/null
+++ b/docs/assets/field-filepicker-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/Vtomove*>toopen*<togoup*<toselect*TABtoshowhidden|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-static-no-ansi.svg b/docs/assets/field-filepicker-light-static-no-ansi.svg
new file mode 100644
index 00000000..4dcaaed6
--- /dev/null
+++ b/docs/assets/field-filepicker-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││›Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-light-static.svg b/docs/assets/field-filepicker-light-static.svg
new file mode 100644
index 00000000..3ffccd18
--- /dev/null
+++ b/docs/assets/field-filepicker-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓tomove·→toopen·←togoup·↵toselect·TABtoshowhidden│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..00be337d
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-animated-ascii.svg b/docs/assets/field-filepicker-multiple-dark-animated-ascii.svg
new file mode 100644
index 00000000..c60bb700
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-animated-no-ansi.svg b/docs/assets/field-filepicker-multiple-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..40d2d7c4
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-animated.svg b/docs/assets/field-filepicker-multiple-dark-animated.svg
new file mode 100644
index 00000000..2106d1d8
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..061d8cb4
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-static-ascii.svg b/docs/assets/field-filepicker-multiple-dark-static-ascii.svg
new file mode 100644
index 00000000..ec55dd03
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-static-no-ansi.svg b/docs/assets/field-filepicker-multiple-dark-static-no-ansi.svg
new file mode 100644
index 00000000..d6279cd2
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-dark-static.svg b/docs/assets/field-filepicker-multiple-dark-static.svg
new file mode 100644
index 00000000..d1674f2f
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..3cda7317
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-animated-ascii.svg b/docs/assets/field-filepicker-multiple-light-animated-ascii.svg
new file mode 100644
index 00000000..f6d10d96
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-animated-no-ansi.svg b/docs/assets/field-filepicker-multiple-light-animated-no-ansi.svg
new file mode 100644
index 00000000..82e14e67
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-animated.svg b/docs/assets/field-filepicker-multiple-light-animated.svg
new file mode 100644
index 00000000..905aeff4
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..b6313a04
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-static-ascii.svg b/docs/assets/field-filepicker-multiple-light-static-ascii.svg
new file mode 100644
index 00000000..f3611c15
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-static-no-ansi.svg b/docs/assets/field-filepicker-multiple-light-static-no-ansi.svg
new file mode 100644
index 00000000..16be69da
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-light-static.svg b/docs/assets/field-filepicker-multiple-light-static.svg
new file mode 100644
index 00000000..f60d3367
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..704833b2
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii.svg b/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii.svg
new file mode 100644
index 00000000..3a0ccb94
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..ea0988aa
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-animated.svg b/docs/assets/field-filepicker-multiple-limited-dark-animated.svg
new file mode 100644
index 00000000..38cfb4a5
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..3be45c1c
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-static-ascii.svg b/docs/assets/field-filepicker-multiple-limited-dark-static-ascii.svg
new file mode 100644
index 00000000..2be9c369
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-static-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-dark-static-no-ansi.svg
new file mode 100644
index 00000000..6947460f
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-dark-static.svg b/docs/assets/field-filepicker-multiple-limited-dark-static.svg
new file mode 100644
index 00000000..f08b74af
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..43b3ee7a
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-animated-ascii.svg b/docs/assets/field-filepicker-multiple-limited-light-animated-ascii.svg
new file mode 100644
index 00000000..27a31ea2
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Filepickerfield>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept||[x]baskets/||>[x]deliveries/||Filepickerfield||>Filepicker>||[ Submit ][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-animated-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-light-animated-no-ansi.svg
new file mode 100644
index 00000000..5636e33f
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-animated.svg b/docs/assets/field-filepicker-multiple-limited-light-animated.svg
new file mode 100644
index 00000000..187f9a11
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerfield›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept││◼baskets/││❯◼deliveries/││Filepickerfield││❯Filepicker›││[ Submit ][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..337be324
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-static-ascii.svg b/docs/assets/field-filepicker-multiple-limited-light-static-ascii.svg
new file mode 100644
index 00000000..897bc4ef
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Filepickerfield>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*>toopen*<togoup*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-static-no-ansi.svg b/docs/assets/field-filepicker-multiple-limited-light-static-no-ansi.svg
new file mode 100644
index 00000000..2e52fb2c
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-filepicker-multiple-limited-light-static.svg b/docs/assets/field-filepicker-multiple-limited-light-static.svg
new file mode 100644
index 00000000..8ac39627
--- /dev/null
+++ b/docs/assets/field-filepicker-multiple-limited-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerfield›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·→toopen·←togoup·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-animated-ascii-no-ansi.svg b/docs/assets/field-note-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..c0a3266e
--- /dev/null
+++ b/docs/assets/field-note-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notefield||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-animated-ascii.svg b/docs/assets/field-note-dark-animated-ascii.svg
new file mode 100644
index 00000000..ab63822b
--- /dev/null
+++ b/docs/assets/field-note-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notefield||>Note>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-animated-no-ansi.svg b/docs/assets/field-note-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..0cc07ae8
--- /dev/null
+++ b/docs/assets/field-note-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notefield││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-animated.svg b/docs/assets/field-note-dark-animated.svg
new file mode 100644
index 00000000..4ebf2855
--- /dev/null
+++ b/docs/assets/field-note-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notefield││❯Note›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-static-ascii-no-ansi.svg b/docs/assets/field-note-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..9a0c7094
--- /dev/null
+++ b/docs/assets/field-note-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-----------------------+||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-static-ascii.svg b/docs/assets/field-note-dark-static-ascii.svg
new file mode 100644
index 00000000..295295ed
--- /dev/null
+++ b/docs/assets/field-note-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-----------------------+||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-static-no-ansi.svg b/docs/assets/field-note-dark-static-no-ansi.svg
new file mode 100644
index 00000000..83155418
--- /dev/null
+++ b/docs/assets/field-note-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-dark-static.svg b/docs/assets/field-note-dark-static.svg
new file mode 100644
index 00000000..ac99f89d
--- /dev/null
+++ b/docs/assets/field-note-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-light-animated-ascii-no-ansi.svg b/docs/assets/field-note-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..848d6d68
--- /dev/null
+++ b/docs/assets/field-note-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notefield||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-light-animated-ascii.svg b/docs/assets/field-note-light-animated-ascii.svg
new file mode 100644
index 00000000..b247f126
--- /dev/null
+++ b/docs/assets/field-note-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notefield||>Note>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-light-animated-no-ansi.svg b/docs/assets/field-note-light-animated-no-ansi.svg
new file mode 100644
index 00000000..11ec998b
--- /dev/null
+++ b/docs/assets/field-note-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notefield││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-light-animated.svg b/docs/assets/field-note-light-animated.svg
new file mode 100644
index 00000000..389663fc
--- /dev/null
+++ b/docs/assets/field-note-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notefield││❯Note›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-light-static-ascii-no-ansi.svg b/docs/assets/field-note-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..69608361
--- /dev/null
+++ b/docs/assets/field-note-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-----------------------+||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-light-static-ascii.svg b/docs/assets/field-note-light-static-ascii.svg
new file mode 100644
index 00000000..0fb47c76
--- /dev/null
+++ b/docs/assets/field-note-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-----------------------+||Notefield>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-light-static-no-ansi.svg b/docs/assets/field-note-light-static-no-ansi.svg
new file mode 100644
index 00000000..e8935a4d
--- /dev/null
+++ b/docs/assets/field-note-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-light-static.svg b/docs/assets/field-note-light-static.svg
new file mode 100644
index 00000000..e7124e85
--- /dev/null
+++ b/docs/assets/field-note-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Notefield›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-animated-ascii-no-ansi.svg b/docs/assets/field-note-markdown-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..08e026e0
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Markdownnote>Note||+-----------------------------------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-animated-ascii.svg b/docs/assets/field-note-markdown-dark-animated-ascii.svg
new file mode 100644
index 00000000..49230bd8
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Markdownnote>Note||+--------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||Markdownnote||>Note>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-animated-no-ansi.svg b/docs/assets/field-note-markdown-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..28cbc592
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-animated.svg b/docs/assets/field-note-markdown-dark-animated.svg
new file mode 100644
index 00000000..2951c192
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││Markdownnote││❯Note›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-static-ascii-no-ansi.svg b/docs/assets/field-note-markdown-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..d02c09d1
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|+-----------------------------------------------------+||||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-static-ascii.svg b/docs/assets/field-note-markdown-dark-static-ascii.svg
new file mode 100644
index 00000000..74545182
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|+--------------------------+||||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-static-no-ansi.svg b/docs/assets/field-note-markdown-dark-static-no-ansi.svg
new file mode 100644
index 00000000..b653a6a1
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-dark-static.svg b/docs/assets/field-note-markdown-dark-static.svg
new file mode 100644
index 00000000..f6644abd
--- /dev/null
+++ b/docs/assets/field-note-markdown-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-animated-ascii-no-ansi.svg b/docs/assets/field-note-markdown-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..2d973264
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Markdownnote>Note||+-----------------------------------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-animated-ascii.svg b/docs/assets/field-note-markdown-light-animated-ascii.svg
new file mode 100644
index 00000000..e038ca58
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Markdownnote>Note||+--------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||Markdownnote||>Note>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-animated-no-ansi.svg b/docs/assets/field-note-markdown-light-animated-no-ansi.svg
new file mode 100644
index 00000000..9d703360
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-animated.svg b/docs/assets/field-note-markdown-light-animated.svg
new file mode 100644
index 00000000..c28acbe9
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││Markdownnote││❯Note›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-static-ascii-no-ansi.svg b/docs/assets/field-note-markdown-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..53f8e418
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|+-----------------------------------------------------+||||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-static-ascii.svg b/docs/assets/field-note-markdown-light-static-ascii.svg
new file mode 100644
index 00000000..baf577c6
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|+--------------------------+||||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-static-no-ansi.svg b/docs/assets/field-note-markdown-light-static-no-ansi.svg
new file mode 100644
index 00000000..dc0a8c4f
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-note-markdown-light-static.svg b/docs/assets/field-note-markdown-light-static.svg
new file mode 100644
index 00000000..d003182c
--- /dev/null
+++ b/docs/assets/field-note-markdown-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-animated-ascii-no-ansi.svg b/docs/assets/field-number-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..90e38211
--- /dev/null
+++ b/docs/assets/field-number-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Numberfield>Number||^/Vtoadjust*<toaccept*ESCtocancel||>Basketweight(g)4200|||Numberfield||>Number>||1200||[Submit][Cancel]||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-animated-ascii.svg b/docs/assets/field-number-dark-animated-ascii.svg
new file mode 100644
index 00000000..283c0fd3
--- /dev/null
+++ b/docs/assets/field-number-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Numberfield>Number||^/Vtoadjust*<toaccept*ESCtocancel||>Basketweight(g)4200|||Numberfield||>Number>||1200||[ Submit ][Cancel]||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-animated-no-ansi.svg b/docs/assets/field-number-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..aae9bf94
--- /dev/null
+++ b/docs/assets/field-number-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Numberfield›Number││↑/↓toadjust·↵toaccept·ESCtocancel││❯Basketweight(g)4200█││Numberfield││❯Number›││1200││[Submit][Cancel]││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-animated.svg b/docs/assets/field-number-dark-animated.svg
new file mode 100644
index 00000000..07741d8d
--- /dev/null
+++ b/docs/assets/field-number-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Numberfield›Number││↑/↓toadjust·↵toaccept·ESCtocancel││❯Basketweight(g)4200█││Numberfield││❯Number›││1200││[ Submit ][Cancel]││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-static-ascii-no-ansi.svg b/docs/assets/field-number-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..e49b57ac
--- /dev/null
+++ b/docs/assets/field-number-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Numberfield>Number||>Basketweight(g)1200|||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-static-ascii.svg b/docs/assets/field-number-dark-static-ascii.svg
new file mode 100644
index 00000000..c4704204
--- /dev/null
+++ b/docs/assets/field-number-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Numberfield>Number||>Basketweight(g)1200|||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-static-no-ansi.svg b/docs/assets/field-number-dark-static-no-ansi.svg
new file mode 100644
index 00000000..d6c648c1
--- /dev/null
+++ b/docs/assets/field-number-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Numberfield›Number││❯Basketweight(g)1200█││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-number-dark-static.svg b/docs/assets/field-number-dark-static.svg
new file mode 100644
index 00000000..d5bc2623
--- /dev/null
+++ b/docs/assets/field-number-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Numberfield›Number││❯Basketweight(g)1200█││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-number-light-animated-ascii-no-ansi.svg b/docs/assets/field-number-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..8e056936
--- /dev/null
+++ b/docs/assets/field-number-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Numberfield>Number||^/Vtoadjust*<toaccept*ESCtocancel||>Basketweight(g)4200|||Numberfield||>Number>||1200||[Submit][Cancel]||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/field-number-light-animated-ascii.svg b/docs/assets/field-number-light-animated-ascii.svg
new file mode 100644
index 00000000..99c585be
--- /dev/null
+++ b/docs/assets/field-number-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Numberfield>Number||^/Vtoadjust*<toaccept*ESCtocancel||>Basketweight(g)4200|||Numberfield||>Number>||1200||[ Submit ][Cancel]||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/field-number-light-animated-no-ansi.svg b/docs/assets/field-number-light-animated-no-ansi.svg
new file mode 100644
index 00000000..ebf6f660
--- /dev/null
+++ b/docs/assets/field-number-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Numberfield›Number││↑/↓toadjust·↵toaccept·ESCtocancel││❯Basketweight(g)4200█││Numberfield││❯Number›││1200││[Submit][Cancel]││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/field-number-light-animated.svg b/docs/assets/field-number-light-animated.svg
new file mode 100644
index 00000000..a47ea43e
--- /dev/null
+++ b/docs/assets/field-number-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Numberfield›Number││↑/↓toadjust·↵toaccept·ESCtocancel││❯Basketweight(g)4200█││Numberfield││❯Number›││1200││[ Submit ][Cancel]││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/field-number-light-static-ascii-no-ansi.svg b/docs/assets/field-number-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..c28cf2c7
--- /dev/null
+++ b/docs/assets/field-number-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Numberfield>Number||>Basketweight(g)1200|||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-number-light-static-ascii.svg b/docs/assets/field-number-light-static-ascii.svg
new file mode 100644
index 00000000..7581f2f3
--- /dev/null
+++ b/docs/assets/field-number-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Numberfield>Number||>Basketweight(g)1200|||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-number-light-static-no-ansi.svg b/docs/assets/field-number-light-static-no-ansi.svg
new file mode 100644
index 00000000..3fc1a6a6
--- /dev/null
+++ b/docs/assets/field-number-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Numberfield›Number││❯Basketweight(g)1200█││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-number-light-static.svg b/docs/assets/field-number-light-static.svg
new file mode 100644
index 00000000..ae9e3cb1
--- /dev/null
+++ b/docs/assets/field-number-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Numberfield›Number││❯Basketweight(g)1200█││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-animated-ascii-no-ansi.svg b/docs/assets/field-password-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..a031ccea
--- /dev/null
+++ b/docs/assets/field-password-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||Passwordfield||>Password>||********||[Submit][Cancel]||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-animated-ascii.svg b/docs/assets/field-password-dark-animated-ascii.svg
new file mode 100644
index 00000000..954c72c1
--- /dev/null
+++ b/docs/assets/field-password-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||Passwordfield||>Password>||********||[ Submit ][Cancel]||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-animated-no-ansi.svg b/docs/assets/field-password-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..c71f1862
--- /dev/null
+++ b/docs/assets/field-password-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││Passwordfield││❯Password›││••••••••││[Submit][Cancel]││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-animated.svg b/docs/assets/field-password-dark-animated.svg
new file mode 100644
index 00000000..70f9b105
--- /dev/null
+++ b/docs/assets/field-password-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││Passwordfield││❯Password›││••••••••││[ Submit ][Cancel]││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-static-ascii-no-ansi.svg b/docs/assets/field-password-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..a82f885d
--- /dev/null
+++ b/docs/assets/field-password-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-static-ascii.svg b/docs/assets/field-password-dark-static-ascii.svg
new file mode 100644
index 00000000..2fd8865f
--- /dev/null
+++ b/docs/assets/field-password-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-static-no-ansi.svg b/docs/assets/field-password-dark-static-no-ansi.svg
new file mode 100644
index 00000000..7867a995
--- /dev/null
+++ b/docs/assets/field-password-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-dark-static.svg b/docs/assets/field-password-dark-static.svg
new file mode 100644
index 00000000..0a7571d4
--- /dev/null
+++ b/docs/assets/field-password-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-light-animated-ascii-no-ansi.svg b/docs/assets/field-password-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..f9d96b13
--- /dev/null
+++ b/docs/assets/field-password-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||Passwordfield||>Password>||********||[Submit][Cancel]||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/field-password-light-animated-ascii.svg b/docs/assets/field-password-light-animated-ascii.svg
new file mode 100644
index 00000000..56f9e373
--- /dev/null
+++ b/docs/assets/field-password-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||Passwordfield||>Password>||********||[ Submit ][Cancel]||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/field-password-light-animated-no-ansi.svg b/docs/assets/field-password-light-animated-no-ansi.svg
new file mode 100644
index 00000000..af33d601
--- /dev/null
+++ b/docs/assets/field-password-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││Passwordfield││❯Password›││••••••••││[Submit][Cancel]││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/field-password-light-animated.svg b/docs/assets/field-password-light-animated.svg
new file mode 100644
index 00000000..795b9620
--- /dev/null
+++ b/docs/assets/field-password-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││Passwordfield││❯Password›││••••••••││[ Submit ][Cancel]││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/field-password-light-static-ascii-no-ansi.svg b/docs/assets/field-password-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..e1f7ff4f
--- /dev/null
+++ b/docs/assets/field-password-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-password-light-static-ascii.svg b/docs/assets/field-password-light-static-ascii.svg
new file mode 100644
index 00000000..fc6cbda9
--- /dev/null
+++ b/docs/assets/field-password-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Passwordfield>Password||>Ordercode******|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-password-light-static-no-ansi.svg b/docs/assets/field-password-light-static-no-ansi.svg
new file mode 100644
index 00000000..55af5e64
--- /dev/null
+++ b/docs/assets/field-password-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-light-static.svg b/docs/assets/field-password-light-static.svg
new file mode 100644
index 00000000..954b0462
--- /dev/null
+++ b/docs/assets/field-password-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercode••••••█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-reveal-dark-static.svg b/docs/assets/field-password-reveal-dark-static.svg
new file mode 100644
index 00000000..af7b2554
--- /dev/null
+++ b/docs/assets/field-password-reveal-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercodemelon7█││TABtoreveal·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-password-reveal-light-static.svg b/docs/assets/field-password-reveal-light-static.svg
new file mode 100644
index 00000000..3478b157
--- /dev/null
+++ b/docs/assets/field-password-reveal-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Passwordfield›Password││❯Ordercodemelon7█││TABtoreveal·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-animated-ascii-no-ansi.svg b/docs/assets/field-pause-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..72126a72
--- /dev/null
+++ b/docs/assets/field-pause-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Pausefield>Pause||>Reviewyourbasketyes||Pausefield||>Pause>||yes||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-animated-ascii.svg b/docs/assets/field-pause-dark-animated-ascii.svg
new file mode 100644
index 00000000..9c7cf1ad
--- /dev/null
+++ b/docs/assets/field-pause-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Pausefield>Pause||>Reviewyourbasketyes||Pausefield||>Pause>||yes||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-animated-no-ansi.svg b/docs/assets/field-pause-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..50608497
--- /dev/null
+++ b/docs/assets/field-pause-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Pausefield›Pause││❯Reviewyourbasketyes││Pausefield││❯Pause›││yes││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-animated.svg b/docs/assets/field-pause-dark-animated.svg
new file mode 100644
index 00000000..9dc9cdbe
--- /dev/null
+++ b/docs/assets/field-pause-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Pausefield›Pause││❯Reviewyourbasketyes││Pausefield││❯Pause›││yes││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-static-ascii-no-ansi.svg b/docs/assets/field-pause-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..39aaf417
--- /dev/null
+++ b/docs/assets/field-pause-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Pausefield>Pause||>Reviewyourbasketyes||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-static-ascii.svg b/docs/assets/field-pause-dark-static-ascii.svg
new file mode 100644
index 00000000..d8a05a1d
--- /dev/null
+++ b/docs/assets/field-pause-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Pausefield>Pause||>Reviewyourbasketyes||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-static-no-ansi.svg b/docs/assets/field-pause-dark-static-no-ansi.svg
new file mode 100644
index 00000000..0fd5cdb0
--- /dev/null
+++ b/docs/assets/field-pause-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Pausefield›Pause││❯Reviewyourbasketyes││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-pause-dark-static.svg b/docs/assets/field-pause-dark-static.svg
new file mode 100644
index 00000000..a326fde2
--- /dev/null
+++ b/docs/assets/field-pause-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Pausefield›Pause││❯Reviewyourbasketyes││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-animated-ascii-no-ansi.svg b/docs/assets/field-pause-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..acd555d9
--- /dev/null
+++ b/docs/assets/field-pause-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Pausefield>Pause||>Reviewyourbasketyes||Pausefield||>Pause>||yes||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-animated-ascii.svg b/docs/assets/field-pause-light-animated-ascii.svg
new file mode 100644
index 00000000..8f787aab
--- /dev/null
+++ b/docs/assets/field-pause-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Pausefield>Pause||>Reviewyourbasketyes||Pausefield||>Pause>||yes||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-animated-no-ansi.svg b/docs/assets/field-pause-light-animated-no-ansi.svg
new file mode 100644
index 00000000..690acca6
--- /dev/null
+++ b/docs/assets/field-pause-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Pausefield›Pause││❯Reviewyourbasketyes││Pausefield││❯Pause›││yes││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-animated.svg b/docs/assets/field-pause-light-animated.svg
new file mode 100644
index 00000000..4a976df6
--- /dev/null
+++ b/docs/assets/field-pause-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Pausefield›Pause││❯Reviewyourbasketyes││Pausefield││❯Pause›││yes││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-static-ascii-no-ansi.svg b/docs/assets/field-pause-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..3a278b1f
--- /dev/null
+++ b/docs/assets/field-pause-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Pausefield>Pause||>Reviewyourbasketyes||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-static-ascii.svg b/docs/assets/field-pause-light-static-ascii.svg
new file mode 100644
index 00000000..ac3b09bd
--- /dev/null
+++ b/docs/assets/field-pause-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Pausefield>Pause||>Reviewyourbasketyes||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-static-no-ansi.svg b/docs/assets/field-pause-light-static-no-ansi.svg
new file mode 100644
index 00000000..97d8d0d8
--- /dev/null
+++ b/docs/assets/field-pause-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Pausefield›Pause││❯Reviewyourbasketyes││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-pause-light-static.svg b/docs/assets/field-pause-light-static.svg
new file mode 100644
index 00000000..46f96a57
--- /dev/null
+++ b/docs/assets/field-pause-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Pausefield›Pause││❯Reviewyourbasketyes││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-animated-ascii-no-ansi.svg b/docs/assets/field-progress-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..31041909
--- /dev/null
+++ b/docs/assets/field-progress-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Progressfield>Progress||Packingthebox[----------]0/6||Packingthebox[##########]6/6||Progressfield||>Progress>||[Submit][Cancel]||Packingthebox[##--------]1/6||Packingthebox[###-------]2/6||Packingthebox[#####-----]3/6||Packingthebox[#######---]4/6||Packingthebox[########--]5/6|
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-animated-ascii.svg b/docs/assets/field-progress-dark-animated-ascii.svg
new file mode 100644
index 00000000..d6962c22
--- /dev/null
+++ b/docs/assets/field-progress-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Progressfield>Progress||Packingthebox[----------]0/6||Packingthebox[##########]6/6||Progressfield||>Progress>||[ Submit ][Cancel]||Packingthebox[##--------]1/6||Packingthebox[###-------]2/6||Packingthebox[#####-----]3/6||Packingthebox[#######---]4/6||Packingthebox[########--]5/6|
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-animated-no-ansi.svg b/docs/assets/field-progress-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..b0542ee6
--- /dev/null
+++ b/docs/assets/field-progress-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Progressfield›Progress││Packingthebox[░░░░░░░░░░]0/6││Packingthebox[██████████]6/6││Progressfield││❯Progress›││[Submit][Cancel]││Packingthebox[██░░░░░░░░]1/6││Packingthebox[███░░░░░░░]2/6││Packingthebox[█████░░░░░]3/6││Packingthebox[███████░░░]4/6││Packingthebox[████████░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-animated.svg b/docs/assets/field-progress-dark-animated.svg
new file mode 100644
index 00000000..5d662c58
--- /dev/null
+++ b/docs/assets/field-progress-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Progressfield›Progress││Packingthebox[░░░░░░░░░░]0/6││Packingthebox[██████████]6/6││Progressfield││❯Progress›││[ Submit ][Cancel]││Packingthebox[██░░░░░░░░]1/6││Packingthebox[███░░░░░░░]2/6││Packingthebox[█████░░░░░]3/6││Packingthebox[███████░░░]4/6││Packingthebox[████████░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-static-ascii-no-ansi.svg b/docs/assets/field-progress-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..cd954aa1
--- /dev/null
+++ b/docs/assets/field-progress-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Progressfield>Progress||Packingthebox[##########]6/6||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-static-ascii.svg b/docs/assets/field-progress-dark-static-ascii.svg
new file mode 100644
index 00000000..eb76ff7d
--- /dev/null
+++ b/docs/assets/field-progress-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Progressfield>Progress||Packingthebox[##########]6/6||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-static-no-ansi.svg b/docs/assets/field-progress-dark-static-no-ansi.svg
new file mode 100644
index 00000000..e3cac2c7
--- /dev/null
+++ b/docs/assets/field-progress-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Progressfield›Progress││Packingthebox[██████████]6/6││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-progress-dark-static.svg b/docs/assets/field-progress-dark-static.svg
new file mode 100644
index 00000000..d1da3b91
--- /dev/null
+++ b/docs/assets/field-progress-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Progressfield›Progress││Packingthebox[██████████]6/6││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-animated-ascii-no-ansi.svg b/docs/assets/field-progress-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..9926ddc2
--- /dev/null
+++ b/docs/assets/field-progress-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Progressfield>Progress||Packingthebox[----------]0/6||Packingthebox[##########]6/6||Progressfield||>Progress>||[Submit][Cancel]||Packingthebox[##--------]1/6||Packingthebox[###-------]2/6||Packingthebox[#####-----]3/6||Packingthebox[#######---]4/6||Packingthebox[########--]5/6|
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-animated-ascii.svg b/docs/assets/field-progress-light-animated-ascii.svg
new file mode 100644
index 00000000..f29b6ced
--- /dev/null
+++ b/docs/assets/field-progress-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Progressfield>Progress||Packingthebox[----------]0/6||Packingthebox[##########]6/6||Progressfield||>Progress>||[ Submit ][Cancel]||Packingthebox[##--------]1/6||Packingthebox[###-------]2/6||Packingthebox[#####-----]3/6||Packingthebox[#######---]4/6||Packingthebox[########--]5/6|
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-animated-no-ansi.svg b/docs/assets/field-progress-light-animated-no-ansi.svg
new file mode 100644
index 00000000..fb7ba933
--- /dev/null
+++ b/docs/assets/field-progress-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Progressfield›Progress││Packingthebox[░░░░░░░░░░]0/6││Packingthebox[██████████]6/6││Progressfield││❯Progress›││[Submit][Cancel]││Packingthebox[██░░░░░░░░]1/6││Packingthebox[███░░░░░░░]2/6││Packingthebox[█████░░░░░]3/6││Packingthebox[███████░░░]4/6││Packingthebox[████████░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-animated.svg b/docs/assets/field-progress-light-animated.svg
new file mode 100644
index 00000000..100a8671
--- /dev/null
+++ b/docs/assets/field-progress-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Progressfield›Progress││Packingthebox[░░░░░░░░░░]0/6││Packingthebox[██████████]6/6││Progressfield││❯Progress›││[ Submit ][Cancel]││Packingthebox[██░░░░░░░░]1/6││Packingthebox[███░░░░░░░]2/6││Packingthebox[█████░░░░░]3/6││Packingthebox[███████░░░]4/6││Packingthebox[████████░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-static-ascii-no-ansi.svg b/docs/assets/field-progress-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..8501646f
--- /dev/null
+++ b/docs/assets/field-progress-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Progressfield>Progress||Packingthebox[##########]6/6||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-static-ascii.svg b/docs/assets/field-progress-light-static-ascii.svg
new file mode 100644
index 00000000..da4dcdfe
--- /dev/null
+++ b/docs/assets/field-progress-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Progressfield>Progress||Packingthebox[##########]6/6||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-static-no-ansi.svg b/docs/assets/field-progress-light-static-no-ansi.svg
new file mode 100644
index 00000000..1eddbc12
--- /dev/null
+++ b/docs/assets/field-progress-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Progressfield›Progress││Packingthebox[██████████]6/6││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-progress-light-static.svg b/docs/assets/field-progress-light-static.svg
new file mode 100644
index 00000000..2cfd341c
--- /dev/null
+++ b/docs/assets/field-progress-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Progressfield›Progress││Packingthebox[██████████]6/6││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-animated-ascii-no-ansi.svg b/docs/assets/field-rating-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..9b611d13
--- /dev/null
+++ b/docs/assets/field-rating-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ratingfield>Rating||>Freshness****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Freshness***--3/5Fair||Ratingfield||>Rating>||****-4/5||[Submit][Cancel]||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-animated-ascii.svg b/docs/assets/field-rating-dark-animated-ascii.svg
new file mode 100644
index 00000000..b965ea58
--- /dev/null
+++ b/docs/assets/field-rating-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ratingfield>Rating||>Freshness****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Freshness***--3/5Fair||Ratingfield||>Rating>||****-4/5||[ Submit ][Cancel]||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-animated-no-ansi.svg b/docs/assets/field-rating-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..4d2df441
--- /dev/null
+++ b/docs/assets/field-rating-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ratingfield›Rating││❯Freshness●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Freshness●●●○○3/5Fair││Ratingfield││❯Rating›││●●●●○4/5││[Submit][Cancel]││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-animated.svg b/docs/assets/field-rating-dark-animated.svg
new file mode 100644
index 00000000..d1d1e28e
--- /dev/null
+++ b/docs/assets/field-rating-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ratingfield›Rating││❯Freshness●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Freshness●●●○○3/5Fair││Ratingfield││❯Rating›││●●●●○4/5││[ Submit ][Cancel]││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-static-ascii-no-ansi.svg b/docs/assets/field-rating-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..e0123841
--- /dev/null
+++ b/docs/assets/field-rating-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ratingfield>Rating||>Freshness***--3/5Fair||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-static-ascii.svg b/docs/assets/field-rating-dark-static-ascii.svg
new file mode 100644
index 00000000..0f93ae0e
--- /dev/null
+++ b/docs/assets/field-rating-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ratingfield>Rating||>Freshness***--3/5Fair||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-static-no-ansi.svg b/docs/assets/field-rating-dark-static-no-ansi.svg
new file mode 100644
index 00000000..9908c8d6
--- /dev/null
+++ b/docs/assets/field-rating-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ratingfield›Rating││❯Freshness●●●○○3/5Fair││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-rating-dark-static.svg b/docs/assets/field-rating-dark-static.svg
new file mode 100644
index 00000000..3c8f43d0
--- /dev/null
+++ b/docs/assets/field-rating-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ratingfield›Rating││❯Freshness●●●○○3/5Fair││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-animated-ascii-no-ansi.svg b/docs/assets/field-rating-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..1b102c36
--- /dev/null
+++ b/docs/assets/field-rating-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ratingfield>Rating||>Freshness****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Freshness***--3/5Fair||Ratingfield||>Rating>||****-4/5||[Submit][Cancel]||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-animated-ascii.svg b/docs/assets/field-rating-light-animated-ascii.svg
new file mode 100644
index 00000000..5188fb22
--- /dev/null
+++ b/docs/assets/field-rating-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ratingfield>Rating||>Freshness****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Freshness***--3/5Fair||Ratingfield||>Rating>||****-4/5||[ Submit ][Cancel]||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-animated-no-ansi.svg b/docs/assets/field-rating-light-animated-no-ansi.svg
new file mode 100644
index 00000000..d216292d
--- /dev/null
+++ b/docs/assets/field-rating-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ratingfield›Rating││❯Freshness●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Freshness●●●○○3/5Fair││Ratingfield││❯Rating›││●●●●○4/5││[Submit][Cancel]││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-animated.svg b/docs/assets/field-rating-light-animated.svg
new file mode 100644
index 00000000..feb3c6b0
--- /dev/null
+++ b/docs/assets/field-rating-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ratingfield›Rating││❯Freshness●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Freshness●●●○○3/5Fair││Ratingfield││❯Rating›││●●●●○4/5││[ Submit ][Cancel]││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-static-ascii-no-ansi.svg b/docs/assets/field-rating-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..0f9fbab1
--- /dev/null
+++ b/docs/assets/field-rating-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ratingfield>Rating||>Freshness***--3/5Fair||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-static-ascii.svg b/docs/assets/field-rating-light-static-ascii.svg
new file mode 100644
index 00000000..23999875
--- /dev/null
+++ b/docs/assets/field-rating-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ratingfield>Rating||>Freshness***--3/5Fair||^/Vtoadjust*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-static-no-ansi.svg b/docs/assets/field-rating-light-static-no-ansi.svg
new file mode 100644
index 00000000..741f22e8
--- /dev/null
+++ b/docs/assets/field-rating-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ratingfield›Rating││❯Freshness●●●○○3/5Fair││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-rating-light-static.svg b/docs/assets/field-rating-light-static.svg
new file mode 100644
index 00000000..2edd2350
--- /dev/null
+++ b/docs/assets/field-rating-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ratingfield›Rating││❯Freshness●●●○○3/5Fair││↑/↓toadjust·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-animated-ascii-no-ansi.svg b/docs/assets/field-reorder-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..623af131
--- /dev/null
+++ b/docs/assets/field-reorder-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Reorderfield>Reorder||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>BasketCarrot||>Apple||Reorderfield||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-animated-ascii.svg b/docs/assets/field-reorder-dark-animated-ascii.svg
new file mode 100644
index 00000000..bbf49202
--- /dev/null
+++ b/docs/assets/field-reorder-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Reorderfield>Reorder||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>BasketCarrot||>Apple||Reorderfield||>Reorder>||apple,carrot,tomato||[ Submit ][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-animated-no-ansi.svg b/docs/assets/field-reorder-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..2c6bb8d8
--- /dev/null
+++ b/docs/assets/field-reorder-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Reorderfield›Reorder││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯BasketCarrot││❯Apple││Reorderfield││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-animated.svg b/docs/assets/field-reorder-dark-animated.svg
new file mode 100644
index 00000000..a7cfd77e
--- /dev/null
+++ b/docs/assets/field-reorder-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Reorderfield›Reorder││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯BasketCarrot││❯Apple││Reorderfield││❯Reorder›││apple,carrot,tomato││[ Submit ][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-static-ascii-no-ansi.svg b/docs/assets/field-reorder-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..57c35456
--- /dev/null
+++ b/docs/assets/field-reorder-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Reorderfield>Reorder||>Basket>Apple||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-static-ascii.svg b/docs/assets/field-reorder-dark-static-ascii.svg
new file mode 100644
index 00000000..90fd36ee
--- /dev/null
+++ b/docs/assets/field-reorder-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Reorderfield>Reorder||>Basket>Apple||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-static-no-ansi.svg b/docs/assets/field-reorder-dark-static-no-ansi.svg
new file mode 100644
index 00000000..566e56b2
--- /dev/null
+++ b/docs/assets/field-reorder-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Reorderfield›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-dark-static.svg b/docs/assets/field-reorder-dark-static.svg
new file mode 100644
index 00000000..84282da2
--- /dev/null
+++ b/docs/assets/field-reorder-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Reorderfield›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/field-reorder-descriptions-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..0f4c87f3
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Reorder||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-animated-ascii.svg b/docs/assets/field-reorder-descriptions-dark-animated-ascii.svg
new file mode 100644
index 00000000..c6f273cc
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Reorder||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[ Submit ][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-animated-no-ansi.svg b/docs/assets/field-reorder-descriptions-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..ed9210c6
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-animated.svg b/docs/assets/field-reorder-descriptions-dark-animated.svg
new file mode 100644
index 00000000..49ee3b0d
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[ Submit ][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/field-reorder-descriptions-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..fdb63920
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-static-ascii.svg b/docs/assets/field-reorder-descriptions-dark-static-ascii.svg
new file mode 100644
index 00000000..7225468b
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-static-no-ansi.svg b/docs/assets/field-reorder-descriptions-dark-static-no-ansi.svg
new file mode 100644
index 00000000..c67ad6a2
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-dark-static.svg b/docs/assets/field-reorder-descriptions-dark-static.svg
new file mode 100644
index 00000000..fcbbbe22
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/field-reorder-descriptions-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..69865150
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Reorder||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-animated-ascii.svg b/docs/assets/field-reorder-descriptions-light-animated-ascii.svg
new file mode 100644
index 00000000..39e8c2a3
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Reorder||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[ Submit ][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-animated-no-ansi.svg b/docs/assets/field-reorder-descriptions-light-animated-no-ansi.svg
new file mode 100644
index 00000000..83dcf888
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-animated.svg b/docs/assets/field-reorder-descriptions-light-animated.svg
new file mode 100644
index 00000000..10a31c0d
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[ Submit ][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/field-reorder-descriptions-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..88ff3d8f
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-static-ascii.svg b/docs/assets/field-reorder-descriptions-light-static-ascii.svg
new file mode 100644
index 00000000..cf8f952f
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-static-no-ansi.svg b/docs/assets/field-reorder-descriptions-light-static-no-ansi.svg
new file mode 100644
index 00000000..6fc7e6e1
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-descriptions-light-static.svg b/docs/assets/field-reorder-descriptions-light-static.svg
new file mode 100644
index 00000000..4c4645de
--- /dev/null
+++ b/docs/assets/field-reorder-descriptions-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-animated-ascii-no-ansi.svg b/docs/assets/field-reorder-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..d6969688
--- /dev/null
+++ b/docs/assets/field-reorder-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Reorderfield>Reorder||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>BasketCarrot||>Apple||Reorderfield||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-animated-ascii.svg b/docs/assets/field-reorder-light-animated-ascii.svg
new file mode 100644
index 00000000..d07cf372
--- /dev/null
+++ b/docs/assets/field-reorder-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Reorderfield>Reorder||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>BasketCarrot||>Apple||Reorderfield||>Reorder>||apple,carrot,tomato||[ Submit ][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-animated-no-ansi.svg b/docs/assets/field-reorder-light-animated-no-ansi.svg
new file mode 100644
index 00000000..9acd0b16
--- /dev/null
+++ b/docs/assets/field-reorder-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Reorderfield›Reorder││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯BasketCarrot││❯Apple││Reorderfield││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-animated.svg b/docs/assets/field-reorder-light-animated.svg
new file mode 100644
index 00000000..ce5c4152
--- /dev/null
+++ b/docs/assets/field-reorder-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Reorderfield›Reorder││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯BasketCarrot││❯Apple││Reorderfield││❯Reorder›││apple,carrot,tomato││[ Submit ][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-static-ascii-no-ansi.svg b/docs/assets/field-reorder-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..0d748858
--- /dev/null
+++ b/docs/assets/field-reorder-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Reorderfield>Reorder||>Basket>Apple||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-static-ascii.svg b/docs/assets/field-reorder-light-static-ascii.svg
new file mode 100644
index 00000000..b456af61
--- /dev/null
+++ b/docs/assets/field-reorder-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Reorderfield>Reorder||>Basket>Apple||Carrot||Tomato||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-static-no-ansi.svg b/docs/assets/field-reorder-light-static-no-ansi.svg
new file mode 100644
index 00000000..66cf4b60
--- /dev/null
+++ b/docs/assets/field-reorder-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Reorderfield›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-reorder-light-static.svg b/docs/assets/field-reorder-light-static.svg
new file mode 100644
index 00000000..8b0c3b18
--- /dev/null
+++ b/docs/assets/field-reorder-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Reorderfield›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-animated-ascii-no-ansi.svg b/docs/assets/field-search-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..76746c7e
--- /dev/null
+++ b/docs/assets/field-search-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Searchfield>Search||()Potato||^/Vtomove*<toaccept*ESCtocancel||(*)Onion||>Vegetableon|||Searchfield||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Onion||()Pepper||>Vegetableo|||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-animated-ascii.svg b/docs/assets/field-search-dark-animated-ascii.svg
new file mode 100644
index 00000000..9961ed1b
--- /dev/null
+++ b/docs/assets/field-search-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Searchfield>Search||^/Vtomove*<toaccept*ESCtocancel||>Vegetableon|||(*)Onion||Searchfield||>Search>||carrot||[ Submit ][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||>Vegetableo|||(*)Onion||()Potato||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-animated-no-ansi.svg b/docs/assets/field-search-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..9c2f9fa8
--- /dev/null
+++ b/docs/assets/field-search-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Searchfield›Search││○Potato││↑/↓tomove·↵toaccept·ESCtocancel││●Onion││❯Vegetableon█││Searchfield││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Onion││○Pepper││❯Vegetableo█││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-animated.svg b/docs/assets/field-search-dark-animated.svg
new file mode 100644
index 00000000..aa977410
--- /dev/null
+++ b/docs/assets/field-search-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Searchfield›Search││↑/↓tomove·↵toaccept·ESCtocancel││❯Vegetableon█││●Onion││Searchfield││❯Search›││carrot││[ Submit ][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││❯Vegetableo█││●Onion││○Potato││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-static-ascii-no-ansi.svg b/docs/assets/field-search-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..dbaeca11
--- /dev/null
+++ b/docs/assets/field-search-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Searchfield>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-static-ascii.svg b/docs/assets/field-search-dark-static-ascii.svg
new file mode 100644
index 00000000..368f062d
--- /dev/null
+++ b/docs/assets/field-search-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Searchfield>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-static-no-ansi.svg b/docs/assets/field-search-dark-static-no-ansi.svg
new file mode 100644
index 00000000..b7550d3c
--- /dev/null
+++ b/docs/assets/field-search-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Searchfield›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-dark-static.svg b/docs/assets/field-search-dark-static.svg
new file mode 100644
index 00000000..e1d46908
--- /dev/null
+++ b/docs/assets/field-search-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Searchfield›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/field-search-descriptions-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..e2d10f9b
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-animated-ascii.svg b/docs/assets/field-search-descriptions-dark-animated-ascii.svg
new file mode 100644
index 00000000..d32aec0d
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[ Submit ][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-animated-no-ansi.svg b/docs/assets/field-search-descriptions-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..c1745e09
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-animated.svg b/docs/assets/field-search-descriptions-dark-animated.svg
new file mode 100644
index 00000000..1d6eafce
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[ Submit ][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/field-search-descriptions-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..42bcc6c1
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-static-ascii.svg b/docs/assets/field-search-descriptions-dark-static-ascii.svg
new file mode 100644
index 00000000..9c5fe515
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-static-no-ansi.svg b/docs/assets/field-search-descriptions-dark-static-no-ansi.svg
new file mode 100644
index 00000000..e975474f
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-dark-static.svg b/docs/assets/field-search-descriptions-dark-static.svg
new file mode 100644
index 00000000..8dde4775
--- /dev/null
+++ b/docs/assets/field-search-descriptions-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/field-search-descriptions-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..742d04a7
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-animated-ascii.svg b/docs/assets/field-search-descriptions-light-animated-ascii.svg
new file mode 100644
index 00000000..7a0f52c3
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[ Submit ][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-animated-no-ansi.svg b/docs/assets/field-search-descriptions-light-animated-no-ansi.svg
new file mode 100644
index 00000000..237d07db
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-animated.svg b/docs/assets/field-search-descriptions-light-animated.svg
new file mode 100644
index 00000000..5d46b48b
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[ Submit ][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/field-search-descriptions-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..6bc553f2
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-static-ascii.svg b/docs/assets/field-search-descriptions-light-static-ascii.svg
new file mode 100644
index 00000000..04d08635
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-static-no-ansi.svg b/docs/assets/field-search-descriptions-light-static-no-ansi.svg
new file mode 100644
index 00000000..056ebea7
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-descriptions-light-static.svg b/docs/assets/field-search-descriptions-light-static.svg
new file mode 100644
index 00000000..e181db5d
--- /dev/null
+++ b/docs/assets/field-search-descriptions-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-light-animated-ascii-no-ansi.svg b/docs/assets/field-search-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..7944405f
--- /dev/null
+++ b/docs/assets/field-search-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Searchfield>Search||()Potato||^/Vtomove*<toaccept*ESCtocancel||(*)Onion||>Vegetableon|||Searchfield||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Onion||()Pepper||>Vegetableo|||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-search-light-animated-ascii.svg b/docs/assets/field-search-light-animated-ascii.svg
new file mode 100644
index 00000000..682e3579
--- /dev/null
+++ b/docs/assets/field-search-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Searchfield>Search||^/Vtomove*<toaccept*ESCtocancel||>Vegetableon|||(*)Onion||Searchfield||>Search>||carrot||[ Submit ][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||>Vegetableo|||(*)Onion||()Potato||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-search-light-animated-no-ansi.svg b/docs/assets/field-search-light-animated-no-ansi.svg
new file mode 100644
index 00000000..fa6a0a75
--- /dev/null
+++ b/docs/assets/field-search-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Searchfield›Search││○Potato││↑/↓tomove·↵toaccept·ESCtocancel││●Onion││❯Vegetableon█││Searchfield││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Onion││○Pepper││❯Vegetableo█││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-search-light-animated.svg b/docs/assets/field-search-light-animated.svg
new file mode 100644
index 00000000..fdae42ad
--- /dev/null
+++ b/docs/assets/field-search-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Searchfield›Search││↑/↓tomove·↵toaccept·ESCtocancel││❯Vegetableon█││●Onion││Searchfield││❯Search›││carrot││[ Submit ][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││❯Vegetableo█││●Onion││○Potato││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-search-light-static-ascii-no-ansi.svg b/docs/assets/field-search-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..eb63c2e0
--- /dev/null
+++ b/docs/assets/field-search-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Searchfield>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-light-static-ascii.svg b/docs/assets/field-search-light-static-ascii.svg
new file mode 100644
index 00000000..31ce63cd
--- /dev/null
+++ b/docs/assets/field-search-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Searchfield>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-search-light-static-no-ansi.svg b/docs/assets/field-search-light-static-no-ansi.svg
new file mode 100644
index 00000000..40c413ca
--- /dev/null
+++ b/docs/assets/field-search-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Searchfield›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-light-static.svg b/docs/assets/field-search-light-static.svg
new file mode 100644
index 00000000..1abb4662
--- /dev/null
+++ b/docs/assets/field-search-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Searchfield›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/field-search-multiple-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..91f7dbfa
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSearchfield>MultiSearch||[]Carrot||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>[]Tomato||>Basketto|||>[x]Tomato||MultiSearchfield||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Tomato||>Baskett||
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-animated-ascii.svg b/docs/assets/field-search-multiple-dark-animated-ascii.svg
new file mode 100644
index 00000000..f356dd5e
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSearchfield>MultiSearch||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basketto|||>[x]Tomato||MultiSearchfield||>MultiSearch>||apple||[ Submit ][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||>Baskett|||>[]Tomato||[]Carrot||>[]Tomato|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-animated-no-ansi.svg b/docs/assets/field-search-multiple-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..e8c8a6d1
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchfield›MultiSearch││◻Carrot││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯◻Tomato││❯Basketto█││❯◼Tomato││MultiSearchfield││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Tomato││❯Baskett█│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-animated.svg b/docs/assets/field-search-multiple-dark-animated.svg
new file mode 100644
index 00000000..79e94ed7
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchfield›MultiSearch││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basketto█││❯◼Tomato││MultiSearchfield││❯MultiSearch›││apple││[ Submit ][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││❯Baskett█││❯◻Tomato││◻Carrot││❯◻Tomato│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/field-search-multiple-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..87feed26
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSearchfield>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-static-ascii.svg b/docs/assets/field-search-multiple-dark-static-ascii.svg
new file mode 100644
index 00000000..d8324a09
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSearchfield>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-static-no-ansi.svg b/docs/assets/field-search-multiple-dark-static-no-ansi.svg
new file mode 100644
index 00000000..579bc97b
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchfield›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-dark-static.svg b/docs/assets/field-search-multiple-dark-static.svg
new file mode 100644
index 00000000..6bc08515
--- /dev/null
+++ b/docs/assets/field-search-multiple-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchfield›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/field-search-multiple-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..19d35801
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSearchfield>MultiSearch||[]Carrot||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>[]Tomato||>Basketto|||>[x]Tomato||MultiSearchfield||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Tomato||>Baskett||
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-animated-ascii.svg b/docs/assets/field-search-multiple-light-animated-ascii.svg
new file mode 100644
index 00000000..24eec046
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSearchfield>MultiSearch||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basketto|||>[x]Tomato||MultiSearchfield||>MultiSearch>||apple||[ Submit ][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||>Baskett|||>[]Tomato||[]Carrot||>[]Tomato|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-animated-no-ansi.svg b/docs/assets/field-search-multiple-light-animated-no-ansi.svg
new file mode 100644
index 00000000..99e88072
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchfield›MultiSearch││◻Carrot││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯◻Tomato││❯Basketto█││❯◼Tomato││MultiSearchfield││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Tomato││❯Baskett█│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-animated.svg b/docs/assets/field-search-multiple-light-animated.svg
new file mode 100644
index 00000000..59ac1e72
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchfield›MultiSearch││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basketto█││❯◼Tomato││MultiSearchfield││❯MultiSearch›││apple││[ Submit ][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││❯Baskett█││❯◻Tomato││◻Carrot││❯◻Tomato│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-static-ascii-no-ansi.svg b/docs/assets/field-search-multiple-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..156d868d
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSearchfield>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-static-ascii.svg b/docs/assets/field-search-multiple-light-static-ascii.svg
new file mode 100644
index 00000000..7eed9a23
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSearchfield>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-static-no-ansi.svg b/docs/assets/field-search-multiple-light-static-no-ansi.svg
new file mode 100644
index 00000000..beb8d353
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchfield›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-light-static.svg b/docs/assets/field-search-multiple-light-static.svg
new file mode 100644
index 00000000..d3d4eb13
--- /dev/null
+++ b/docs/assets/field-search-multiple-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchfield›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/field-search-multiple-limited-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..55695d35
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-animated-ascii.svg b/docs/assets/field-search-multiple-limited-dark-animated-ascii.svg
new file mode 100644
index 00000000..d93d692e
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[ Submit ][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/field-search-multiple-limited-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..2067e062
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-animated.svg b/docs/assets/field-search-multiple-limited-dark-animated.svg
new file mode 100644
index 00000000..fc1ec258
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[ Submit ][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/field-search-multiple-limited-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..d35ea72f
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-static-ascii.svg b/docs/assets/field-search-multiple-limited-dark-static-ascii.svg
new file mode 100644
index 00000000..e36abe61
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-static-no-ansi.svg b/docs/assets/field-search-multiple-limited-dark-static-no-ansi.svg
new file mode 100644
index 00000000..e4a60cfb
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-dark-static.svg b/docs/assets/field-search-multiple-limited-dark-static.svg
new file mode 100644
index 00000000..76be65bb
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/field-search-multiple-limited-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..a3a1b13a
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-animated-ascii.svg b/docs/assets/field-search-multiple-limited-light-animated-ascii.svg
new file mode 100644
index 00000000..f4dc6fd2
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[ Submit ][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-animated-no-ansi.svg b/docs/assets/field-search-multiple-limited-light-animated-no-ansi.svg
new file mode 100644
index 00000000..05bb5d81
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-animated.svg b/docs/assets/field-search-multiple-limited-light-animated.svg
new file mode 100644
index 00000000..8eddb18c
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[ Submit ][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/field-search-multiple-limited-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..54f94469
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-static-ascii.svg b/docs/assets/field-search-multiple-limited-light-static-ascii.svg
new file mode 100644
index 00000000..f0264c5d
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-static-no-ansi.svg b/docs/assets/field-search-multiple-limited-light-static-no-ansi.svg
new file mode 100644
index 00000000..8cbfd826
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-search-multiple-limited-light-static.svg b/docs/assets/field-search-multiple-limited-light-static.svg
new file mode 100644
index 00000000..979baa2d
--- /dev/null
+++ b/docs/assets/field-search-multiple-limited-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-animated-ascii-no-ansi.svg b/docs/assets/field-select-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..b1095898
--- /dev/null
+++ b/docs/assets/field-select-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Selectfield>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Selectfield||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-animated-ascii.svg b/docs/assets/field-select-dark-animated-ascii.svg
new file mode 100644
index 00000000..58633bd5
--- /dev/null
+++ b/docs/assets/field-select-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Selectfield>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Selectfield||>Select>||apple||[ Submit ][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-animated-no-ansi.svg b/docs/assets/field-select-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..c694ad0c
--- /dev/null
+++ b/docs/assets/field-select-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Selectfield›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Selectfield││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-animated.svg b/docs/assets/field-select-dark-animated.svg
new file mode 100644
index 00000000..cd871693
--- /dev/null
+++ b/docs/assets/field-select-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Selectfield›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Selectfield││❯Select›││apple││[ Submit ][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..91c267b8
--- /dev/null
+++ b/docs/assets/field-select-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectfield>Select||>Fruit(*)Apple||()Banana||()Cherry||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-static-ascii.svg b/docs/assets/field-select-dark-static-ascii.svg
new file mode 100644
index 00000000..8d4a656f
--- /dev/null
+++ b/docs/assets/field-select-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectfield>Select||>Fruit(*)Apple||()Banana||()Cherry||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-static-no-ansi.svg b/docs/assets/field-select-dark-static-no-ansi.svg
new file mode 100644
index 00000000..2de37699
--- /dev/null
+++ b/docs/assets/field-select-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectfield›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-dark-static.svg b/docs/assets/field-select-dark-static.svg
new file mode 100644
index 00000000..c4375347
--- /dev/null
+++ b/docs/assets/field-select-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectfield›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/field-select-descriptions-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..09b9ebea
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-animated-ascii.svg b/docs/assets/field-select-descriptions-dark-animated-ascii.svg
new file mode 100644
index 00000000..cbb7ed7b
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[ Submit ][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-animated-no-ansi.svg b/docs/assets/field-select-descriptions-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..e5e5da96
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-animated.svg b/docs/assets/field-select-descriptions-dark-animated.svg
new file mode 100644
index 00000000..a4c10515
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[ Submit ][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-descriptions-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..3448c542
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-static-ascii.svg b/docs/assets/field-select-descriptions-dark-static-ascii.svg
new file mode 100644
index 00000000..93d6b3aa
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-static-no-ansi.svg b/docs/assets/field-select-descriptions-dark-static-no-ansi.svg
new file mode 100644
index 00000000..ecb1000a
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-dark-static.svg b/docs/assets/field-select-descriptions-dark-static.svg
new file mode 100644
index 00000000..28c72e61
--- /dev/null
+++ b/docs/assets/field-select-descriptions-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/field-select-descriptions-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..b6c87468
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-animated-ascii.svg b/docs/assets/field-select-descriptions-light-animated-ascii.svg
new file mode 100644
index 00000000..c72738e3
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[ Submit ][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-animated-no-ansi.svg b/docs/assets/field-select-descriptions-light-animated-no-ansi.svg
new file mode 100644
index 00000000..0d9f65e3
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-animated.svg b/docs/assets/field-select-descriptions-light-animated.svg
new file mode 100644
index 00000000..11c46ac2
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[ Submit ][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/field-select-descriptions-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..0e19d917
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-static-ascii.svg b/docs/assets/field-select-descriptions-light-static-ascii.svg
new file mode 100644
index 00000000..a3deb153
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-static-no-ansi.svg b/docs/assets/field-select-descriptions-light-static-no-ansi.svg
new file mode 100644
index 00000000..4c9a978e
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-descriptions-light-static.svg b/docs/assets/field-select-descriptions-light-static.svg
new file mode 100644
index 00000000..3927c407
--- /dev/null
+++ b/docs/assets/field-select-descriptions-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-groups-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..6601e03b
--- /dev/null
+++ b/docs/assets/field-select-groups-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||---------------------------------------------------------------||()Cherry(outofseason)||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-dark-static-ascii.svg b/docs/assets/field-select-groups-dark-static-ascii.svg
new file mode 100644
index 00000000..eb282854
--- /dev/null
+++ b/docs/assets/field-select-groups-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||---------------------------------------------------------------||()Cherry(outofseason)||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-dark-static-no-ansi.svg b/docs/assets/field-select-groups-dark-static-no-ansi.svg
new file mode 100644
index 00000000..0230ed4d
--- /dev/null
+++ b/docs/assets/field-select-groups-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────────────────────────────────────││○Cherry(outofseason)││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-dark-static.svg b/docs/assets/field-select-groups-dark-static.svg
new file mode 100644
index 00000000..c47305e3
--- /dev/null
+++ b/docs/assets/field-select-groups-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────────────────────────────────────││○Cherry(outofseason)││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-light-static-ascii-no-ansi.svg b/docs/assets/field-select-groups-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..bd929228
--- /dev/null
+++ b/docs/assets/field-select-groups-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||---------------------------------------------------------------||()Cherry(outofseason)||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-light-static-ascii.svg b/docs/assets/field-select-groups-light-static-ascii.svg
new file mode 100644
index 00000000..c15885e5
--- /dev/null
+++ b/docs/assets/field-select-groups-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||---------------------------------------------------------------||()Cherry(outofseason)||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-light-static-no-ansi.svg b/docs/assets/field-select-groups-light-static-no-ansi.svg
new file mode 100644
index 00000000..bdabd495
--- /dev/null
+++ b/docs/assets/field-select-groups-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────────────────────────────────────││○Cherry(outofseason)││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-groups-light-static.svg b/docs/assets/field-select-groups-light-static.svg
new file mode 100644
index 00000000..e01edef6
--- /dev/null
+++ b/docs/assets/field-select-groups-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────────────────────────────────────││○Cherry(outofseason)││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-light-animated-ascii-no-ansi.svg b/docs/assets/field-select-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..8690b2f6
--- /dev/null
+++ b/docs/assets/field-select-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Selectfield>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Selectfield||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/field-select-light-animated-ascii.svg b/docs/assets/field-select-light-animated-ascii.svg
new file mode 100644
index 00000000..3ce550d5
--- /dev/null
+++ b/docs/assets/field-select-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Selectfield>Select||()Cherry||^/Vtomove*<toaccept*ESCtocancel||>Fruit()Apple||(*)Banana||Selectfield||>Select>||apple||[ Submit ][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/field-select-light-animated-no-ansi.svg b/docs/assets/field-select-light-animated-no-ansi.svg
new file mode 100644
index 00000000..e9aa9591
--- /dev/null
+++ b/docs/assets/field-select-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Selectfield›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Selectfield││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/field-select-light-animated.svg b/docs/assets/field-select-light-animated.svg
new file mode 100644
index 00000000..bc36affb
--- /dev/null
+++ b/docs/assets/field-select-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Selectfield›Select││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││Selectfield││❯Select›││apple││[ Submit ][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/field-select-light-static-ascii-no-ansi.svg b/docs/assets/field-select-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..08f43305
--- /dev/null
+++ b/docs/assets/field-select-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectfield>Select||>Fruit(*)Apple||()Banana||()Cherry||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-light-static-ascii.svg b/docs/assets/field-select-light-static-ascii.svg
new file mode 100644
index 00000000..9642406b
--- /dev/null
+++ b/docs/assets/field-select-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Selectfield>Select||>Fruit(*)Apple||()Banana||()Cherry||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-select-light-static-no-ansi.svg b/docs/assets/field-select-light-static-no-ansi.svg
new file mode 100644
index 00000000..43f008db
--- /dev/null
+++ b/docs/assets/field-select-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectfield›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-light-static.svg b/docs/assets/field-select-light-static.svg
new file mode 100644
index 00000000..ab86d3ad
--- /dev/null
+++ b/docs/assets/field-select-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Selectfield›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/field-select-multiple-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..278d8628
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSelectfield>MultiSelect||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||MultiSelectfield||>MultiSelect>||apple||[Submit][Cancel]||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-animated-ascii.svg b/docs/assets/field-select-multiple-dark-animated-ascii.svg
new file mode 100644
index 00000000..7782fce2
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSelectfield>MultiSelect||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||MultiSelectfield||>MultiSelect>||apple||[ Submit ][Cancel]||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-animated-no-ansi.svg b/docs/assets/field-select-multiple-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..45929c4a
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectfield›MultiSelect││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││MultiSelectfield││❯MultiSelect›││apple││[Submit][Cancel]││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-animated.svg b/docs/assets/field-select-multiple-dark-animated.svg
new file mode 100644
index 00000000..03d279ac
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectfield›MultiSelect││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││MultiSelectfield││❯MultiSelect›││apple││[ Submit ][Cancel]││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..30d545fa
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectfield>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-static-ascii.svg b/docs/assets/field-select-multiple-dark-static-ascii.svg
new file mode 100644
index 00000000..1f77b86a
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectfield>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-static-no-ansi.svg b/docs/assets/field-select-multiple-dark-static-no-ansi.svg
new file mode 100644
index 00000000..28759b3a
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectfield›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-dark-static.svg b/docs/assets/field-select-multiple-dark-static.svg
new file mode 100644
index 00000000..34430699
--- /dev/null
+++ b/docs/assets/field-select-multiple-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectfield›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-groups-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..e3ff43b7
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||--------------------------------------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-dark-static-ascii.svg b/docs/assets/field-select-multiple-groups-dark-static-ascii.svg
new file mode 100644
index 00000000..fdbe2bef
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||--------------------------------------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-dark-static-no-ansi.svg b/docs/assets/field-select-multiple-groups-dark-static-no-ansi.svg
new file mode 100644
index 00000000..e8d2a0fc
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────────────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-dark-static.svg b/docs/assets/field-select-multiple-groups-dark-static.svg
new file mode 100644
index 00000000..0545cf84
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────────────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-light-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-groups-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..3b03d937
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||--------------------------------------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-light-static-ascii.svg b/docs/assets/field-select-multiple-groups-light-static-ascii.svg
new file mode 100644
index 00000000..79db58c0
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||--------------------------------------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-light-static-no-ansi.svg b/docs/assets/field-select-multiple-groups-light-static-no-ansi.svg
new file mode 100644
index 00000000..4cb5ab49
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────────────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-groups-light-static.svg b/docs/assets/field-select-multiple-groups-light-static.svg
new file mode 100644
index 00000000..0cd18ad4
--- /dev/null
+++ b/docs/assets/field-select-multiple-groups-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────────────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/field-select-multiple-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..70202287
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSelectfield>MultiSelect||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||MultiSelectfield||>MultiSelect>||apple||[Submit][Cancel]||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-animated-ascii.svg b/docs/assets/field-select-multiple-light-animated-ascii.svg
new file mode 100644
index 00000000..ed3dee4d
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||MultiSelectfield>MultiSelect||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||MultiSelectfield||>MultiSelect>||apple||[ Submit ][Cancel]||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-animated-no-ansi.svg b/docs/assets/field-select-multiple-light-animated-no-ansi.svg
new file mode 100644
index 00000000..93ac308a
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectfield›MultiSelect││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││MultiSelectfield││❯MultiSelect›││apple││[Submit][Cancel]││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-animated.svg b/docs/assets/field-select-multiple-light-animated.svg
new file mode 100644
index 00000000..79316e72
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectfield›MultiSelect││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││MultiSelectfield││❯MultiSelect›││apple││[ Submit ][Cancel]││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..fb6f1571
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectfield>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-static-ascii.svg b/docs/assets/field-select-multiple-light-static-ascii.svg
new file mode 100644
index 00000000..25407d74
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||MultiSelectfield>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-static-no-ansi.svg b/docs/assets/field-select-multiple-light-static-no-ansi.svg
new file mode 100644
index 00000000..1018de92
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectfield›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-light-static.svg b/docs/assets/field-select-multiple-light-static.svg
new file mode 100644
index 00000000..7b5b160e
--- /dev/null
+++ b/docs/assets/field-select-multiple-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectfield›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/field-select-multiple-limited-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..80bf5ba7
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-animated-ascii.svg b/docs/assets/field-select-multiple-limited-dark-animated-ascii.svg
new file mode 100644
index 00000000..6bca7185
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[ Submit ][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/field-select-multiple-limited-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..ddfa1d28
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-animated.svg b/docs/assets/field-select-multiple-limited-dark-animated.svg
new file mode 100644
index 00000000..6ea307b0
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[ Submit ][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-limited-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..512406bf
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-static-ascii.svg b/docs/assets/field-select-multiple-limited-dark-static-ascii.svg
new file mode 100644
index 00000000..aeb45727
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-static-no-ansi.svg b/docs/assets/field-select-multiple-limited-dark-static-no-ansi.svg
new file mode 100644
index 00000000..cc1eb6a3
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-dark-static.svg b/docs/assets/field-select-multiple-limited-dark-static.svg
new file mode 100644
index 00000000..147bc6f0
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/field-select-multiple-limited-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..9c4ada90
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-animated-ascii.svg b/docs/assets/field-select-multiple-limited-light-animated-ascii.svg
new file mode 100644
index 00000000..29fc8616
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[ Submit ][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-animated-no-ansi.svg b/docs/assets/field-select-multiple-limited-light-animated-no-ansi.svg
new file mode 100644
index 00000000..686cc19c
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-animated.svg b/docs/assets/field-select-multiple-limited-light-animated.svg
new file mode 100644
index 00000000..a8b6e943
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[ Submit ][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/field-select-multiple-limited-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..95b02fcf
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||>Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-static-ascii.svg b/docs/assets/field-select-multiple-limited-light-static-ascii.svg
new file mode 100644
index 00000000..f830e37a
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept|
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-static-no-ansi.svg b/docs/assets/field-select-multiple-limited-light-static-no-ansi.svg
new file mode 100644
index 00000000..fa5a746e
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││›Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-select-multiple-limited-light-static.svg b/docs/assets/field-select-multiple-limited-light-static.svg
new file mode 100644
index 00000000..a266a207
--- /dev/null
+++ b/docs/assets/field-select-multiple-limited-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..d9cff6fd
--- /dev/null
+++ b/docs/assets/field-suggest-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Suggestfield>Suggest||Apricot||Cherry||^/Vtomove*<toaccept*ESCtocancel||>FruitCh|||>Cherry||Suggestfield||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Banana||Mango||>FruitC||
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-animated-ascii.svg b/docs/assets/field-suggest-dark-animated-ascii.svg
new file mode 100644
index 00000000..15d41f0a
--- /dev/null
+++ b/docs/assets/field-suggest-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Suggestfield>Suggest||^/Vtomove*<toaccept*ESCtocancel||>FruitCh|||>Cherry||Suggestfield||>Suggest>||[ Submit ][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||>FruitC|||Cherry||Apricot||Cherry|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-animated-no-ansi.svg b/docs/assets/field-suggest-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..9f6ca798
--- /dev/null
+++ b/docs/assets/field-suggest-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Suggestfield›Suggest││Apricot││Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitCh█││❯Cherry││Suggestfield││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Banana││Mango││❯FruitC█│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-animated.svg b/docs/assets/field-suggest-dark-animated.svg
new file mode 100644
index 00000000..2545f5aa
--- /dev/null
+++ b/docs/assets/field-suggest-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Suggestfield›Suggest││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitCh█││❯Cherry││Suggestfield││❯Suggest›││[ Submit ][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││❯FruitC█││Cherry││Apricot││Cherry│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-static-ascii-no-ansi.svg b/docs/assets/field-suggest-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..95a3a908
--- /dev/null
+++ b/docs/assets/field-suggest-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Suggestfield>Suggest||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-static-ascii.svg b/docs/assets/field-suggest-dark-static-ascii.svg
new file mode 100644
index 00000000..4e017319
--- /dev/null
+++ b/docs/assets/field-suggest-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Suggestfield>Suggest||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-static-no-ansi.svg b/docs/assets/field-suggest-dark-static-no-ansi.svg
new file mode 100644
index 00000000..7b14f754
--- /dev/null
+++ b/docs/assets/field-suggest-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Suggestfield›Suggest││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-dark-static.svg b/docs/assets/field-suggest-dark-static.svg
new file mode 100644
index 00000000..86534fca
--- /dev/null
+++ b/docs/assets/field-suggest-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Suggestfield›Suggest││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-descriptions-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..3461a142
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-animated-ascii.svg b/docs/assets/field-suggest-descriptions-dark-animated-ascii.svg
new file mode 100644
index 00000000..d9e471cf
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[ Submit ][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-animated-no-ansi.svg b/docs/assets/field-suggest-descriptions-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..dfd98478
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-animated.svg b/docs/assets/field-suggest-descriptions-dark-animated.svg
new file mode 100644
index 00000000..218b6c0c
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[ Submit ][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/field-suggest-descriptions-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..19dac9c8
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-static-ascii.svg b/docs/assets/field-suggest-descriptions-dark-static-ascii.svg
new file mode 100644
index 00000000..d699111a
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-static-no-ansi.svg b/docs/assets/field-suggest-descriptions-dark-static-no-ansi.svg
new file mode 100644
index 00000000..26db19df
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-dark-static.svg b/docs/assets/field-suggest-descriptions-dark-static.svg
new file mode 100644
index 00000000..405988fc
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-descriptions-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..25518e02
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-animated-ascii.svg b/docs/assets/field-suggest-descriptions-light-animated-ascii.svg
new file mode 100644
index 00000000..cafd032c
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[ Submit ][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-animated-no-ansi.svg b/docs/assets/field-suggest-descriptions-light-animated-no-ansi.svg
new file mode 100644
index 00000000..b101a4d6
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-animated.svg b/docs/assets/field-suggest-descriptions-light-animated.svg
new file mode 100644
index 00000000..17e47ee2
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[ Submit ][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/field-suggest-descriptions-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..97814f05
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-static-ascii.svg b/docs/assets/field-suggest-descriptions-light-static-ascii.svg
new file mode 100644
index 00000000..3549eb45
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-static-no-ansi.svg b/docs/assets/field-suggest-descriptions-light-static-no-ansi.svg
new file mode 100644
index 00000000..71520a0d
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-descriptions-light-static.svg b/docs/assets/field-suggest-descriptions-light-static.svg
new file mode 100644
index 00000000..ca114ba8
--- /dev/null
+++ b/docs/assets/field-suggest-descriptions-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-ghost-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..52df33e3
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ghosttext>Suggest||Apple||Apricot||Banana||Mango||^/Vtomove*<toaccept*ESCtocancel||>FruitAp|||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Cherry||>FruitA||
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-animated-ascii.svg b/docs/assets/field-suggest-ghost-dark-animated-ascii.svg
new file mode 100644
index 00000000..e90db5c0
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ghosttext>Suggest||^/Vtomove*<toaccept*ESCtocancel||>FruitAp|ple||Apple||Apricot||Ghosttext||>Suggest>||[ Submit ][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||>FruitA|pple||Apple||Apricot||Banana||Mango|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-animated-no-ansi.svg b/docs/assets/field-suggest-ghost-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..dff49e7a
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││Apple││Apricot││Banana││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitAp█││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Cherry││❯FruitA█│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-animated.svg b/docs/assets/field-suggest-ghost-dark-animated.svg
new file mode 100644
index 00000000..3e24dc26
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitAp█ple││Apple││Apricot││Ghosttext││❯Suggest›││[ Submit ][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││❯FruitA█pple││Apple││Apricot││Banana││Mango│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-static-ascii-no-ansi.svg b/docs/assets/field-suggest-ghost-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..d3fcadff
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|||Apple||Apricot||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-static-ascii.svg b/docs/assets/field-suggest-ghost-dark-static-ascii.svg
new file mode 100644
index 00000000..d2893f93
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|ple||Apple||Apricot||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-static-no-ansi.svg b/docs/assets/field-suggest-ghost-dark-static-no-ansi.svg
new file mode 100644
index 00000000..f1156d8d
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█││Apple││Apricot││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-dark-static.svg b/docs/assets/field-suggest-ghost-dark-static.svg
new file mode 100644
index 00000000..ba1938a5
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█ple││Apple││Apricot││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-ghost-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..6ed2301b
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ghosttext>Suggest||Apple||Apricot||Banana||Mango||^/Vtomove*<toaccept*ESCtocancel||>FruitAp|||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Cherry||>FruitA||
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-animated-ascii.svg b/docs/assets/field-suggest-ghost-light-animated-ascii.svg
new file mode 100644
index 00000000..a192732a
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Ghosttext>Suggest||^/Vtomove*<toaccept*ESCtocancel||>FruitAp|ple||Apple||Apricot||Ghosttext||>Suggest>||[ Submit ][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||>FruitA|pple||Apple||Apricot||Banana||Mango|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-animated-no-ansi.svg b/docs/assets/field-suggest-ghost-light-animated-no-ansi.svg
new file mode 100644
index 00000000..e16858a5
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││Apple││Apricot││Banana││Mango││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitAp█││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Cherry││❯FruitA█│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-animated.svg b/docs/assets/field-suggest-ghost-light-animated.svg
new file mode 100644
index 00000000..609f8e26
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitAp█ple││Apple││Apricot││Ghosttext││❯Suggest›││[ Submit ][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││❯FruitA█pple││Apple││Apricot││Banana││Mango│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-static-ascii-no-ansi.svg b/docs/assets/field-suggest-ghost-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..cf33edc6
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|||Apple||Apricot||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-static-ascii.svg b/docs/assets/field-suggest-ghost-light-static-ascii.svg
new file mode 100644
index 00000000..0fe29f84
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|ple||Apple||Apricot||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-static-no-ansi.svg b/docs/assets/field-suggest-ghost-light-static-no-ansi.svg
new file mode 100644
index 00000000..c5b72f98
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█││Apple││Apricot││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-ghost-light-static.svg b/docs/assets/field-suggest-ghost-light-static.svg
new file mode 100644
index 00000000..a7305c5f
--- /dev/null
+++ b/docs/assets/field-suggest-ghost-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█ple││Apple││Apricot││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-animated-ascii-no-ansi.svg b/docs/assets/field-suggest-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..e37342ff
--- /dev/null
+++ b/docs/assets/field-suggest-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Suggestfield>Suggest||Apricot||Cherry||^/Vtomove*<toaccept*ESCtocancel||>FruitCh|||>Cherry||Suggestfield||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Banana||Mango||>FruitC||
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-animated-ascii.svg b/docs/assets/field-suggest-light-animated-ascii.svg
new file mode 100644
index 00000000..a8834004
--- /dev/null
+++ b/docs/assets/field-suggest-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Suggestfield>Suggest||^/Vtomove*<toaccept*ESCtocancel||>FruitCh|||>Cherry||Suggestfield||>Suggest>||[ Submit ][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||>FruitC|||Cherry||Apricot||Cherry|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-animated-no-ansi.svg b/docs/assets/field-suggest-light-animated-no-ansi.svg
new file mode 100644
index 00000000..3c687a0f
--- /dev/null
+++ b/docs/assets/field-suggest-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Suggestfield›Suggest││Apricot││Cherry││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitCh█││❯Cherry││Suggestfield││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Banana││Mango││❯FruitC█│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-animated.svg b/docs/assets/field-suggest-light-animated.svg
new file mode 100644
index 00000000..47d58500
--- /dev/null
+++ b/docs/assets/field-suggest-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Suggestfield›Suggest││↑/↓tomove·↵toaccept·ESCtocancel││❯FruitCh█││❯Cherry││Suggestfield││❯Suggest›││[ Submit ][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││❯FruitC█││Cherry││Apricot││Cherry│
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-static-ascii-no-ansi.svg b/docs/assets/field-suggest-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..b37ab58d
--- /dev/null
+++ b/docs/assets/field-suggest-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Suggestfield>Suggest||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-static-ascii.svg b/docs/assets/field-suggest-light-static-ascii.svg
new file mode 100644
index 00000000..2d079f30
--- /dev/null
+++ b/docs/assets/field-suggest-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Suggestfield>Suggest||>Fruit|||Apple||Apricot||Banana||Cherry||Mango||^/Vtomove*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-static-no-ansi.svg b/docs/assets/field-suggest-light-static-no-ansi.svg
new file mode 100644
index 00000000..27bfbc3b
--- /dev/null
+++ b/docs/assets/field-suggest-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Suggestfield›Suggest││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-suggest-light-static.svg b/docs/assets/field-suggest-light-static.svg
new file mode 100644
index 00000000..3c6d8f9b
--- /dev/null
+++ b/docs/assets/field-suggest-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Suggestfield›Suggest││❯Fruit█││Apple││Apricot││Banana││Cherry││Mango││↑/↓tomove·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-animated-ascii-no-ansi.svg b/docs/assets/field-table-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..598e9129
--- /dev/null
+++ b/docs/assets/field-table-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Tablefield>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablefield||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-animated-ascii.svg b/docs/assets/field-table-dark-animated-ascii.svg
new file mode 100644
index 00000000..f5d7d1ac
--- /dev/null
+++ b/docs/assets/field-table-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Tablefield>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablefield||>Stock>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-animated-no-ansi.svg b/docs/assets/field-table-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..76f01837
--- /dev/null
+++ b/docs/assets/field-table-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablefield││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-animated.svg b/docs/assets/field-table-dark-animated.svg
new file mode 100644
index 00000000..a05f5dff
--- /dev/null
+++ b/docs/assets/field-table-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablefield││❯Stock›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-static-ascii-no-ansi.svg b/docs/assets/field-table-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..652ec28c
--- /dev/null
+++ b/docs/assets/field-table-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablefield>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-static-ascii.svg b/docs/assets/field-table-dark-static-ascii.svg
new file mode 100644
index 00000000..d0002bd3
--- /dev/null
+++ b/docs/assets/field-table-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablefield>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-static-no-ansi.svg b/docs/assets/field-table-dark-static-no-ansi.svg
new file mode 100644
index 00000000..849b04b5
--- /dev/null
+++ b/docs/assets/field-table-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-table-dark-static.svg b/docs/assets/field-table-dark-static.svg
new file mode 100644
index 00000000..286758ef
--- /dev/null
+++ b/docs/assets/field-table-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-table-light-animated-ascii-no-ansi.svg b/docs/assets/field-table-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..e9a8376a
--- /dev/null
+++ b/docs/assets/field-table-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Tablefield>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablefield||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-table-light-animated-ascii.svg b/docs/assets/field-table-light-animated-ascii.svg
new file mode 100644
index 00000000..4dc53293
--- /dev/null
+++ b/docs/assets/field-table-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Tablefield>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablefield||>Stock>||[ Submit ][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/field-table-light-animated-no-ansi.svg b/docs/assets/field-table-light-animated-no-ansi.svg
new file mode 100644
index 00000000..ae4f722c
--- /dev/null
+++ b/docs/assets/field-table-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablefield││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-table-light-animated.svg b/docs/assets/field-table-light-animated.svg
new file mode 100644
index 00000000..5970f3ed
--- /dev/null
+++ b/docs/assets/field-table-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablefield││❯Stock›││[ Submit ][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/field-table-light-static-ascii-no-ansi.svg b/docs/assets/field-table-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..9a5b2042
--- /dev/null
+++ b/docs/assets/field-table-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablefield>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-table-light-static-ascii.svg b/docs/assets/field-table-light-static-ascii.svg
new file mode 100644
index 00000000..5b7333e8
--- /dev/null
+++ b/docs/assets/field-table-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablefield>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Color|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/Vtomove*<toselect*ESCtogoback*Qtoquit|
\ No newline at end of file
diff --git a/docs/assets/field-table-light-static-no-ansi.svg b/docs/assets/field-table-light-static-no-ansi.svg
new file mode 100644
index 00000000..e3428db5
--- /dev/null
+++ b/docs/assets/field-table-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-table-light-static.svg b/docs/assets/field-table-light-static.svg
new file mode 100644
index 00000000..8afd81a4
--- /dev/null
+++ b/docs/assets/field-table-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Tablefield›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Color│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-animated-ascii-no-ansi.svg b/docs/assets/field-template-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..86c36bfe
--- /dev/null
+++ b/docs/assets/field-template-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Templatefield>Template||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatefield||>Template>||valley-pear-a||[Submit][Cancel]||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-animated-ascii.svg b/docs/assets/field-template-dark-animated-ascii.svg
new file mode 100644
index 00000000..371bde0f
--- /dev/null
+++ b/docs/assets/field-template-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Templatefield>Template||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatefield||>Template>||valley-pear-a||[ Submit ][Cancel]||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-animated-no-ansi.svg b/docs/assets/field-template-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..dc607879
--- /dev/null
+++ b/docs/assets/field-template-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Templatefield›Template││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatefield││❯Template›││valley-pear-a││[Submit][Cancel]││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-animated.svg b/docs/assets/field-template-dark-animated.svg
new file mode 100644
index 00000000..5e98443c
--- /dev/null
+++ b/docs/assets/field-template-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Templatefield›Template││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatefield││❯Template›││valley-pear-a││[ Submit ][Cancel]││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-static-ascii-no-ansi.svg b/docs/assets/field-template-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..ddec2e16
--- /dev/null
+++ b/docs/assets/field-template-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Templatefield>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-static-ascii.svg b/docs/assets/field-template-dark-static-ascii.svg
new file mode 100644
index 00000000..f02db5ff
--- /dev/null
+++ b/docs/assets/field-template-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Templatefield>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-static-no-ansi.svg b/docs/assets/field-template-dark-static-no-ansi.svg
new file mode 100644
index 00000000..1b37f708
--- /dev/null
+++ b/docs/assets/field-template-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Templatefield›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-template-dark-static.svg b/docs/assets/field-template-dark-static.svg
new file mode 100644
index 00000000..c1079477
--- /dev/null
+++ b/docs/assets/field-template-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Templatefield›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-template-light-animated-ascii-no-ansi.svg b/docs/assets/field-template-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..683eb25c
--- /dev/null
+++ b/docs/assets/field-template-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Templatefield>Template||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatefield||>Template>||valley-pear-a||[Submit][Cancel]||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/field-template-light-animated-ascii.svg b/docs/assets/field-template-light-animated-ascii.svg
new file mode 100644
index 00000000..f360dc86
--- /dev/null
+++ b/docs/assets/field-template-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Templatefield>Template||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatefield||>Template>||valley-pear-a||[ Submit ][Cancel]||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/field-template-light-animated-no-ansi.svg b/docs/assets/field-template-light-animated-no-ansi.svg
new file mode 100644
index 00000000..dc09aa71
--- /dev/null
+++ b/docs/assets/field-template-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Templatefield›Template││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatefield││❯Template›││valley-pear-a││[Submit][Cancel]││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/field-template-light-animated.svg b/docs/assets/field-template-light-animated.svg
new file mode 100644
index 00000000..c5a95a01
--- /dev/null
+++ b/docs/assets/field-template-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Templatefield›Template││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatefield││❯Template›││valley-pear-a││[ Submit ][Cancel]││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/field-template-light-static-ascii-no-ansi.svg b/docs/assets/field-template-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..0ed97708
--- /dev/null
+++ b/docs/assets/field-template-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Templatefield>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-template-light-static-ascii.svg b/docs/assets/field-template-light-static-ascii.svg
new file mode 100644
index 00000000..b9cc041c
--- /dev/null
+++ b/docs/assets/field-template-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Templatefield>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||V/^tomovebetweenparts*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-template-light-static-no-ansi.svg b/docs/assets/field-template-light-static-no-ansi.svg
new file mode 100644
index 00000000..9bd30542
--- /dev/null
+++ b/docs/assets/field-template-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Templatefield›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-template-light-static.svg b/docs/assets/field-template-light-static.svg
new file mode 100644
index 00000000..5cddd86e
--- /dev/null
+++ b/docs/assets/field-template-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Templatefield›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-animated-ascii-no-ansi.svg b/docs/assets/field-text-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..5f585aaa
--- /dev/null
+++ b/docs/assets/field-text-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textfield>Text||<toaccept*ESCtocancel||>ItemApple|||Textfield||>Text>||Pear||[Submit][Cancel]||>ItemPear||>ItemPear|||>ItemPea|||>ItemPe|||>ItemP|||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-animated-ascii.svg b/docs/assets/field-text-dark-animated-ascii.svg
new file mode 100644
index 00000000..3d61e53a
--- /dev/null
+++ b/docs/assets/field-text-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textfield>Text||<toaccept*ESCtocancel||>ItemApple|||Textfield||>Text>||Pear||[ Submit ][Cancel]||>ItemPear||>ItemPear|||>ItemPea|r||>ItemPe|ar||>ItemP|ear||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-animated-no-ansi.svg b/docs/assets/field-text-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..e0a1c224
--- /dev/null
+++ b/docs/assets/field-text-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textfield›Text││↵toaccept·ESCtocancel││❯ItemApple█││Textfield││❯Text›││Pear││[Submit][Cancel]││❯ItemPear││❯ItemPear█││❯ItemPea█││❯ItemPe█││❯ItemP█││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-animated.svg b/docs/assets/field-text-dark-animated.svg
new file mode 100644
index 00000000..4b309891
--- /dev/null
+++ b/docs/assets/field-text-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textfield›Text││↵toaccept·ESCtocancel││❯ItemApple█││Textfield││❯Text›││Pear││[ Submit ][Cancel]││❯ItemPear││❯ItemPear█││❯ItemPea█r││❯ItemPe█ar││❯ItemP█ear││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-static-ascii-no-ansi.svg b/docs/assets/field-text-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..1473944d
--- /dev/null
+++ b/docs/assets/field-text-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textfield>Text||>ItemPear|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-static-ascii.svg b/docs/assets/field-text-dark-static-ascii.svg
new file mode 100644
index 00000000..8b1fca89
--- /dev/null
+++ b/docs/assets/field-text-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textfield>Text||>ItemPear|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-static-no-ansi.svg b/docs/assets/field-text-dark-static-no-ansi.svg
new file mode 100644
index 00000000..81e3dc57
--- /dev/null
+++ b/docs/assets/field-text-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textfield›Text││❯ItemPear█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-text-dark-static.svg b/docs/assets/field-text-dark-static.svg
new file mode 100644
index 00000000..c80219c0
--- /dev/null
+++ b/docs/assets/field-text-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textfield›Text││❯ItemPear█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-text-light-animated-ascii-no-ansi.svg b/docs/assets/field-text-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..4e83143b
--- /dev/null
+++ b/docs/assets/field-text-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textfield>Text||<toaccept*ESCtocancel||>ItemApple|||Textfield||>Text>||Pear||[Submit][Cancel]||>ItemPear||>ItemPear|||>ItemPea|||>ItemPe|||>ItemP|||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/field-text-light-animated-ascii.svg b/docs/assets/field-text-light-animated-ascii.svg
new file mode 100644
index 00000000..35691f87
--- /dev/null
+++ b/docs/assets/field-text-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textfield>Text||<toaccept*ESCtocancel||>ItemApple|||Textfield||>Text>||Pear||[ Submit ][Cancel]||>ItemPear||>ItemPear|||>ItemPea|r||>ItemPe|ar||>ItemP|ear||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/field-text-light-animated-no-ansi.svg b/docs/assets/field-text-light-animated-no-ansi.svg
new file mode 100644
index 00000000..e61f6e9e
--- /dev/null
+++ b/docs/assets/field-text-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textfield›Text││↵toaccept·ESCtocancel││❯ItemApple█││Textfield││❯Text›││Pear││[Submit][Cancel]││❯ItemPear││❯ItemPear█││❯ItemPea█││❯ItemPe█││❯ItemP█││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/field-text-light-animated.svg b/docs/assets/field-text-light-animated.svg
new file mode 100644
index 00000000..2f9b3c36
--- /dev/null
+++ b/docs/assets/field-text-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textfield›Text││↵toaccept·ESCtocancel││❯ItemApple█││Textfield││❯Text›││Pear││[ Submit ][Cancel]││❯ItemPear││❯ItemPear█││❯ItemPea█r││❯ItemPe█ar││❯ItemP█ear││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/field-text-light-static-ascii-no-ansi.svg b/docs/assets/field-text-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..d89dfa0d
--- /dev/null
+++ b/docs/assets/field-text-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textfield>Text||>ItemPear|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-text-light-static-ascii.svg b/docs/assets/field-text-light-static-ascii.svg
new file mode 100644
index 00000000..e3e41ae2
--- /dev/null
+++ b/docs/assets/field-text-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textfield>Text||>ItemPear|||<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-text-light-static-no-ansi.svg b/docs/assets/field-text-light-static-no-ansi.svg
new file mode 100644
index 00000000..61fef426
--- /dev/null
+++ b/docs/assets/field-text-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textfield›Text││❯ItemPear█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-text-light-static.svg b/docs/assets/field-text-light-static.svg
new file mode 100644
index 00000000..6c219ce4
--- /dev/null
+++ b/docs/assets/field-text-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textfield›Text││❯ItemPear█││↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-animated-ascii-no-ansi.svg b/docs/assets/field-textarea-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..0bb7f0a7
--- /dev/null
+++ b/docs/assets/field-textarea-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus||<toinsertanewline*TABtoaccept*ESCtocancel||Slightlytart|||Textareafield||>Textarea>||CrispandsweetHintofcitrus||[Submit][Cancel]||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-animated-ascii.svg b/docs/assets/field-textarea-dark-animated-ascii.svg
new file mode 100644
index 00000000..1c7e7a9a
--- /dev/null
+++ b/docs/assets/field-textarea-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textareafield>Textarea||>TastingnotesCrispandsweet||<toinsertanewline*TABtoaccept*ESCtocancel||Hintofcitrus||Slightlytart|||Textareafield||>Textarea>||CrispandsweetHintofcitrus||[ Submit ][Cancel]||>TastingnotesCrispandsweet||Hintofcitrus||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-animated-no-ansi.svg b/docs/assets/field-textarea-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..9d649ea3
--- /dev/null
+++ b/docs/assets/field-textarea-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus││↵toinsertanewline·TABtoaccept·ESCtocancel││Slightlytart█││Textareafield││❯Textarea›││CrispandsweetHintofcitrus││[Submit][Cancel]││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-animated.svg b/docs/assets/field-textarea-dark-animated.svg
new file mode 100644
index 00000000..a38b6f53
--- /dev/null
+++ b/docs/assets/field-textarea-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textareafield›Textarea││❯TastingnotesCrispandsweet││↵toinsertanewline·TABtoaccept·ESCtocancel││Hintofcitrus││Slightlytart█││Textareafield││❯Textarea›││CrispandsweetHintofcitrus││[ Submit ][Cancel]││❯TastingnotesCrispandsweet││Hintofcitrus││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-static-ascii-no-ansi.svg b/docs/assets/field-textarea-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..c89d2419
--- /dev/null
+++ b/docs/assets/field-textarea-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-static-ascii.svg b/docs/assets/field-textarea-dark-static-ascii.svg
new file mode 100644
index 00000000..4d1b4c0c
--- /dev/null
+++ b/docs/assets/field-textarea-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-static-no-ansi.svg b/docs/assets/field-textarea-dark-static-no-ansi.svg
new file mode 100644
index 00000000..6b544b03
--- /dev/null
+++ b/docs/assets/field-textarea-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-textarea-dark-static.svg b/docs/assets/field-textarea-dark-static.svg
new file mode 100644
index 00000000..c24d8e1d
--- /dev/null
+++ b/docs/assets/field-textarea-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-animated-ascii-no-ansi.svg b/docs/assets/field-textarea-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..372f2ff3
--- /dev/null
+++ b/docs/assets/field-textarea-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus||<toinsertanewline*TABtoaccept*ESCtocancel||Slightlytart|||Textareafield||>Textarea>||CrispandsweetHintofcitrus||[Submit][Cancel]||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-animated-ascii.svg b/docs/assets/field-textarea-light-animated-ascii.svg
new file mode 100644
index 00000000..841a81b9
--- /dev/null
+++ b/docs/assets/field-textarea-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Textareafield>Textarea||>TastingnotesCrispandsweet||<toinsertanewline*TABtoaccept*ESCtocancel||Hintofcitrus||Slightlytart|||Textareafield||>Textarea>||CrispandsweetHintofcitrus||[ Submit ][Cancel]||>TastingnotesCrispandsweet||Hintofcitrus||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-animated-no-ansi.svg b/docs/assets/field-textarea-light-animated-no-ansi.svg
new file mode 100644
index 00000000..a65823e2
--- /dev/null
+++ b/docs/assets/field-textarea-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus││↵toinsertanewline·TABtoaccept·ESCtocancel││Slightlytart█││Textareafield││❯Textarea›││CrispandsweetHintofcitrus││[Submit][Cancel]││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-animated.svg b/docs/assets/field-textarea-light-animated.svg
new file mode 100644
index 00000000..468c3b08
--- /dev/null
+++ b/docs/assets/field-textarea-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Textareafield›Textarea││❯TastingnotesCrispandsweet││↵toinsertanewline·TABtoaccept·ESCtocancel││Hintofcitrus││Slightlytart█││Textareafield││❯Textarea›││CrispandsweetHintofcitrus││[ Submit ][Cancel]││❯TastingnotesCrispandsweet││Hintofcitrus││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-static-ascii-no-ansi.svg b/docs/assets/field-textarea-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..ce42eb96
--- /dev/null
+++ b/docs/assets/field-textarea-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-static-ascii.svg b/docs/assets/field-textarea-light-static-ascii.svg
new file mode 100644
index 00000000..940ff3b9
--- /dev/null
+++ b/docs/assets/field-textarea-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Textareafield>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-static-no-ansi.svg b/docs/assets/field-textarea-light-static-no-ansi.svg
new file mode 100644
index 00000000..85e8970a
--- /dev/null
+++ b/docs/assets/field-textarea-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-textarea-light-static.svg b/docs/assets/field-textarea-light-static.svg
new file mode 100644
index 00000000..dc048a44
--- /dev/null
+++ b/docs/assets/field-textarea-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Textareafield›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-animated-ascii-no-ansi.svg b/docs/assets/field-toggle-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..d733c59c
--- /dev/null
+++ b/docs/assets/field-toggle-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Togglefield>Toggle||^totoggle*<toaccept*ESCtocancel||>Ripeness()Ripe(*)Unripe||Togglefield||>Toggle>||ripe||[Submit][Cancel]||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-animated-ascii.svg b/docs/assets/field-toggle-dark-animated-ascii.svg
new file mode 100644
index 00000000..12ce8806
--- /dev/null
+++ b/docs/assets/field-toggle-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Togglefield>Toggle||^totoggle*<toaccept*ESCtocancel||>Ripeness()Ripe(*)Unripe||Togglefield||>Toggle>||ripe||[ Submit ][Cancel]||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-animated-no-ansi.svg b/docs/assets/field-toggle-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..21e8fc91
--- /dev/null
+++ b/docs/assets/field-toggle-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Togglefield›Toggle││↑totoggle·↵toaccept·ESCtocancel││❯Ripeness○Ripe●Unripe││Togglefield││❯Toggle›││ripe││[Submit][Cancel]││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-animated.svg b/docs/assets/field-toggle-dark-animated.svg
new file mode 100644
index 00000000..bf40f120
--- /dev/null
+++ b/docs/assets/field-toggle-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Togglefield›Toggle││↑totoggle·↵toaccept·ESCtocancel││❯Ripeness○Ripe●Unripe││Togglefield││❯Toggle›││ripe││[ Submit ][Cancel]││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-static-ascii-no-ansi.svg b/docs/assets/field-toggle-dark-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..8d2c2d6d
--- /dev/null
+++ b/docs/assets/field-toggle-dark-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Togglefield>Toggle||>Ripeness(*)Ripe()Unripe||^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-static-ascii.svg b/docs/assets/field-toggle-dark-static-ascii.svg
new file mode 100644
index 00000000..aeb85000
--- /dev/null
+++ b/docs/assets/field-toggle-dark-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Togglefield>Toggle||>Ripeness(*)Ripe()Unripe||^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-static-no-ansi.svg b/docs/assets/field-toggle-dark-static-no-ansi.svg
new file mode 100644
index 00000000..17479e08
--- /dev/null
+++ b/docs/assets/field-toggle-dark-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Togglefield›Toggle││❯Ripeness●Ripe○Unripe││↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-toggle-dark-static.svg b/docs/assets/field-toggle-dark-static.svg
new file mode 100644
index 00000000..03851fe7
--- /dev/null
+++ b/docs/assets/field-toggle-dark-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Togglefield›Toggle││❯Ripeness●Ripe○Unripe││↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-animated-ascii-no-ansi.svg b/docs/assets/field-toggle-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..61b1d371
--- /dev/null
+++ b/docs/assets/field-toggle-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Togglefield>Toggle||^totoggle*<toaccept*ESCtocancel||>Ripeness()Ripe(*)Unripe||Togglefield||>Toggle>||ripe||[Submit][Cancel]||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-animated-ascii.svg b/docs/assets/field-toggle-light-animated-ascii.svg
new file mode 100644
index 00000000..a2a36f63
--- /dev/null
+++ b/docs/assets/field-toggle-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Togglefield>Toggle||^totoggle*<toaccept*ESCtocancel||>Ripeness()Ripe(*)Unripe||Togglefield||>Toggle>||ripe||[ Submit ][Cancel]||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-animated-no-ansi.svg b/docs/assets/field-toggle-light-animated-no-ansi.svg
new file mode 100644
index 00000000..05655e06
--- /dev/null
+++ b/docs/assets/field-toggle-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Togglefield›Toggle││↑totoggle·↵toaccept·ESCtocancel││❯Ripeness○Ripe●Unripe││Togglefield││❯Toggle›││ripe││[Submit][Cancel]││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-animated.svg b/docs/assets/field-toggle-light-animated.svg
new file mode 100644
index 00000000..358278b4
--- /dev/null
+++ b/docs/assets/field-toggle-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Togglefield›Toggle││↑totoggle·↵toaccept·ESCtocancel││❯Ripeness○Ripe●Unripe││Togglefield││❯Toggle›││ripe││[ Submit ][Cancel]││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-static-ascii-no-ansi.svg b/docs/assets/field-toggle-light-static-ascii-no-ansi.svg
new file mode 100644
index 00000000..71122508
--- /dev/null
+++ b/docs/assets/field-toggle-light-static-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Togglefield>Toggle||>Ripeness(*)Ripe()Unripe||^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-static-ascii.svg b/docs/assets/field-toggle-light-static-ascii.svg
new file mode 100644
index 00000000..c8022318
--- /dev/null
+++ b/docs/assets/field-toggle-light-static-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|||Togglefield>Toggle||>Ripeness(*)Ripe()Unripe||^totoggle*<toaccept*ESCtocancel|
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-static-no-ansi.svg b/docs/assets/field-toggle-light-static-no-ansi.svg
new file mode 100644
index 00000000..fa302677
--- /dev/null
+++ b/docs/assets/field-toggle-light-static-no-ansi.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Togglefield›Toggle││❯Ripeness●Ripe○Unripe││↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/field-toggle-light-static.svg b/docs/assets/field-toggle-light-static.svg
new file mode 100644
index 00000000..bd5c53b7
--- /dev/null
+++ b/docs/assets/field-toggle-light-static.svg
@@ -0,0 +1 @@
+││╭──────────────────────────────────────────────────────────────────────────╮│Togglefield›Toggle││❯Ripeness●Ripe○Unripe││↑totoggle·↵toaccept·ESCtocancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/fields-dark-animated-ascii-no-ansi.svg b/docs/assets/fields-dark-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..8d841dd6
--- /dev/null
+++ b/docs/assets/fields-dark-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|Fields||>Fields>||Pear*valley-pear-a*1200*****-4/5||||[Submit][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Fields>Fields||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200||Rating****-4/5||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||Selectapple||MultiSelectapplev||>TextPear|||<toaccept*ESCtocancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextAppleedited||TextAppleedited||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Rating****-4/5|v||V/^tomovebetweenparts*<toaccept*ESCtocancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-bedited||Templateridge-pear-bedited||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200edited||Number4200edited||>Rating****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Rating***--3/5Fair||>Rating***--3/5Fairedited||Rating***--3/5Fairedited||>|>CalendarJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendar2026-07-22edited||>TextareaCrispandsweet||<toinsertanewline*TABtoaccept*ESCtocancel||Slightlytart||TextareaCrispandsweetedited||Password********edited||^/Vtomove*<toaccept*ESCtocancel||Aread-onlycard-thecursorskipsitanditcollectsnothing.^||Calendar2026-07-22ed|Selectbananaedited||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>MultiSelect[x]Applev||TextAppleedited^||TextareaCrispandsweeted|MultiSelectapple,carrotedited||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>ReorderCarrotv||Templateridge-pear-bedited^||Slightlytart|Reordercarrot,apple,tomatoedited||>SuggestCh|v||Number4200edited^||Password********ed|SuggestCherryedited||Rating***--3/5Fairedited^||Selectbananaed|Searchonionedited||>MultiSearchto|v||Calendar2026-07-22edited^||MultiSelectapple,carroted|MultiSearchapple,tomatoedited||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||TextareaCrispandsweetedited^||Reordercarrot,apple,tomatoed|Confirmnoedited||^totoggle*<toaccept*ESCtocancel||Slightlytart^||SuggestCherryed|Toggleunripeedited||Apple*ridge-pear-b*4200****--3/5Fair||Fields>|FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)|>Calendar2026-07-15||>Calendar2026-07-22edited||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweetedited||>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password********edited||>Selectapple||>Select(*)Apple||()Bananav||>Select()Apple||(*)Bananav||>Selectbananaedited||>MultiSelectapplev||>MultiSelect>[x]Applev||>MultiSelectapple,carroteditedv||>Reorderapple,carrot,tomatov||>Reorder>Applev||>Reorder^vApplev||>Reordercarrot,apple,tomatoeditedv||>Suggestv||>Suggest|v||>SuggestC|v||>SuggestCherryeditedv||>Searchcarrotv||>Search|v||>Searcho|v||>Searchon|v||>Searchonioneditedv||>MultiSearchapplev||>MultiSearch|v||>MultiSearcht|v||>MultiSearchapple,tomatoeditedv||>Confirmyesv||>Confirm(*)Yes()Nov||>Confirm()Yes(*)Nov||>Confirmnoeditedv||>Toggleripev||>Toggle(*)Ripe()Unripev||>Toggle()Ripe(*)Unripev||>Toggleunripeeditedv||>Pauseyes||>PausePress<tocontinue||<tocontinue*ESCtocancel||>Pauseyesedited|
\ No newline at end of file
diff --git a/docs/assets/fields-dark-animated-ascii.svg b/docs/assets/fields-dark-animated-ascii.svg
new file mode 100644
index 00000000..dd96a4de
--- /dev/null
+++ b/docs/assets/fields-dark-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|Fields||>Fields>||Pear*valley-pear-a*1200*****-4/5||||[ Submit ][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Fields>Fields||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200|Number1200||Rating****-4/5||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||Selectapple||MultiSelectapplev||>TextPear|||<toaccept*ESCtocancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextApple edited ||TextApple edited ||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Password********|v||V/^tomovebetweenparts*<toaccept*ESCtocancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-b edited ||Templateridge-pear-b edited ||>Number1200|>Number1200||>Number1200||>Number1200|||>Number120||>Number120|||>Number12||>Number12|||>Number1||>Number1|||>Number||>Number|||>Number4||>Number4|||>Number42||>Number42|||>Number420||>Number420|||>Number4200||>Number4200|||>Number4200|>Number4200 edited ||Number4200|Number4200 e|Number4200 edited ||>Rating****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Rating***--3/5Fair||>Rating***--3/5Fair edited ||Rating***--3/5Fair edited ||>Calendar2026-07-15||>CalendarJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||>Calendar2026-07-22 edited ||Calendar2026-07-22 edited ||>TextareaCrispandsweet||>TextareaCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel||Hintofcitrus|||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweet edited ||Slightlytart||TextareaCrispandsweet edited ||>Password********|Password******** edited ||^/Vtomove*<toaccept*ESCtocancel||Aread-onlycard-thecursorskipsitanditcollectsnothing.^||Selectbanana edited ||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>MultiSelect[x]Applev||TextAppleedited^||MultiSelectapple,carrot edited ||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>ReorderCarrotv||Templateridge-pear-bedited^||Reordercarrot,apple,tomato edited ||>SuggestCh|v||Number4200edited^||Hintofcitrus|SuggestCherry edited ||Rating***--3/5Fairedited^||Searchonion edited ||>MultiSearchto|v||Calendar2026-07-22edited^||MultiSearchapple,tomato edited ||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||TextareaCrispandsweetedited^||Confirmno edited ||^totoggle*<toaccept*ESCtocancel||Slightlytart^||Toggleunripe edited ||Apple*ridge-pear-b*4200****--3/5Fair||Fields>|FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)|>Password********||>Password******|>Password******||>Password******|||>Password*******|>Password*******||>Password*******|||>Password********|>Password********||>Password********|||>Password*********|>Password*********||>Password*********|||>Password**********|>Password**********||>Password**********|||>Password***********|>Password***********||>Password***********|||>Password************|>Password************||>Password************|||>Password******** edited ||>Selectapple||>Select(*)Apple||()Bananav||>Select()Apple||(*)Bananav||>Selectbanana edited ||>MultiSelectapplev||>MultiSelect>[x]Applev||>MultiSelectapple,carroteditedv||>Reorderapple,carrot,tomatov||>Reorder>Applev||>Reorder^vApplev||>Reordercarrot,apple,tomatoeditedv||>Suggestv||>Suggest|v||>SuggestC|v||>SuggestCherryeditedv||>Searchcarrotv||>Search|v||>Searcho|v||>Searchon|v||>Searchonioneditedv||>MultiSearchapplev||>MultiSearch|v||>MultiSearcht|v||>MultiSearchapple,tomatoeditedv||>Confirmyesv||>Confirm(*)Yes()Nov||>Confirm()Yes(*)Nov||>Confirmnoeditedv||>Toggleripev||>Toggle(*)Ripe()Unripev||>Toggle()Ripe(*)Unripev||>Toggleunripeeditedv||>Pauseyes||>PausePress<tocontinue||<tocontinue*ESCtocancel||>Pauseyes edited |
\ No newline at end of file
diff --git a/docs/assets/fields-dark-animated-no-ansi.svg b/docs/assets/fields-dark-animated-no-ansi.svg
new file mode 100644
index 00000000..e4d54bc3
--- /dev/null
+++ b/docs/assets/fields-dark-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│Fields││❯Fields›││Pear·valley-pear-a·1200·●●●●○4/5││││[Submit][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fields›Fields││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││Selectapple││MultiSelectapple│MultiSelectapple▼││❯TextPear█││↵toaccept·ESCtocancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextAppleedited││TextAppleedited││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard││Number1200│▼││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-bedited││Templateridge-pear-bedited││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200edited││Number4200edited││❯Rating●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fairedited││Rating●●●○○3/5Fairedited││❯Calendar2026-07-15││❯CalendarJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22edited││Calendar2026-07-22edited││❯TextareaCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweetedited││Slightlytart││TextareaCrispandsweetedited││❯Password••••••••││❯Password••••••█││❯Password•••••••█││❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password••••••••edited││Password••••••••edited││❯Selectapple││❯Select●Apple││○Banana│↑/↓tomove·↵toaccept·ESCtocancel││❯Select○Apple││●Banana│❯Selectbananaedited││Aread-onlycard-thecursorskipsitanditcollectsnothing.▲││Rating●●●○○3/5F│Rating●●●○○3/5Fair│Selectbananaedited││❯MultiSelectapple│❯MultiSelect❯◼Apple│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯MultiSelect◼Apple│❯MultiSelect◼Apple▼││❯MultiSelectapple,carrot│TextAppleedited▲││Calendar2026-07-2│Calendar2026-07-22│MultiSelectapple,carrotedited││❯Reorderapple,carrot,tomato│❯Reorder❯Apple│↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯Reorder↑↓Apple│↑/↓toreorder·SPACEtodrop·ESCtocancel││❯ReorderCarrot│❯ReorderCarrot▼││❯Reordercarrot,apple,tomato│Templateridge-pear-bedited▲││TextareaCrispand│TextareaCrispandsweet│Reordercarrot,apple,tomatoedited││❯Suggest│❯Suggest█│❯SuggestC█│❯SuggestCh█│❯SuggestCh█▼││❯SuggestCherry│Number4200edited▲││Slightly│Slightlytart│SuggestCherryedited││❯Searchcarrot│❯Search█│❯Searcho█│❯Searchon█│❯Searchonion│Rating●●●○○3/5Fairedited▲││Password•••│Password••••••│Searchonionedited││❯MultiSearchapple│❯MultiSearch█│❯MultiSearcht█│❯MultiSearchto█│❯MultiSearchto█▼││❯MultiSearchapple,tomato│Calendar2026-07-22edited▲││Selectbanan│Selectbanana│MultiSearchapple,tomatoedited││❯Confirmyes│❯Confirm●Yes○No│Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Confirm○Yes●No│❯Confirmno│TextareaCrispandsweetedited▲││MultiSelect│MultiSelectapple,car│Confirmnoedited││❯Toggleripe│❯Toggle●Ripe○Unripe│↑totoggle·↵toaccept·ESCtocancel││❯Toggle○Ripe●Unripe│❯Toggleunripe│Slightlytart▲││Reordercarr│Reordercarrot,apple,│Toggleunripeedited││❯Pauseyes│❯PausePress↵tocontinue│Apple·ridge-pear-b·4200·●●●○○3/5Fair││Fields›│FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)│○Banana▼││●Banana▼││❯MultiSelectapple▼││❯MultiSelect❯◼Apple▼││❯MultiSelectapple,carrotedited▼││❯Reorderapple,carrot,tomato▼││❯Reorder❯Apple▼││❯Reorder↑↓Apple▼││❯Reordercarrot,apple,tomatoedited▼││❯Suggest▼││❯Suggest█▼││❯SuggestC█▼││❯SuggestCherryedited▼││❯Searchcarrot▼││❯Search█▼││❯Searcho█▼││❯Searchon█▼││❯Searchonionedited▼││❯MultiSearchapple▼││❯MultiSearch█▼││❯MultiSearcht█▼││❯MultiSearchapple,tomatoedited▼││❯Confirmyes▼││❯Confirm●Yes○No▼││❯Confirm○Yes●No▼││❯Confirmnoedited▼││❯Toggleripe▼││❯Toggle●Ripe○Unripe▼││❯Toggle○Ripe●Unripe▼││❯Toggleunripeedited▼││❯Pauseyes││❯PausePress↵tocontinue││↵tocontinue·ESCtocancel││❯Pauseyesedited│
\ No newline at end of file
diff --git a/docs/assets/fields-dark-animated.svg b/docs/assets/fields-dark-animated.svg
new file mode 100644
index 00000000..a3c41ecc
--- /dev/null
+++ b/docs/assets/fields-dark-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│Fields││❯Fields›││Pear·valley-pear-a·1200·●●●●○4/5││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fields›Fields││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││Selectapple││MultiSelectapple▼│╰───────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────│❯TextPear█││↵toaccept·ESCtocancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextApple edited │╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────│TextApple edited ││❯Templatevalley-pear-a││❯Templatevalley█-pear-a│❯Templatevalley█-pear-a││fillinginorchard││Hint│▼││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────────────│❯Templatevalle█-pear-a│❯Templatevalle█-pear-a││❯Templatevall█-pear-a│❯Templatevall█-pear-a││❯Templateval█-pear-a│❯Templateval█-pear-a││❯Templateva█-pear-a│❯Templateva█-pear-a││❯Templatev█-pear-a│❯Templatev█-pear-a││❯Template█-pear-a│❯Template█-pear-a││❯Templater█-pear-a│❯Templater█-pear-a││❯Templateri█-pear-a│❯Templateri█-pear-a││❯Templaterid█-pear-a│❯Templaterid█-pear-a││❯Templateridg█-pear-a│❯Templateridg█-pear-a││❯Templateridge█-pear-a│❯Templateridge█-pear-a││❯Templateridge-pear█-a│❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█│❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█│❯Templateridge-pear-█││❯Templateridge-pear-b█│❯Templateridge-pear-b█││❯Templateridge-pear-b edited │❯Templateridge-pear-b edited │╰─────────────────────────────────────────────────────────────│Templateridge-pear-b edited ││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200 edited │╰───────────────────────────────────────────────────────────│Number4200 edited ││❯Rating●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────────────────────│❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fair edited │╰────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────│Rating●●●○○3/5Fair edited ││❯Calendar2026-07-15││❯CalendarJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22 edited │╰─────────────────────────────────────────────────────│Calendar2026-07-22 edited ││❯TextareaCrispandsweet││❯TextareaCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────│Hintofcitrus││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly│Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweet edited ││Slightlytart│╰────────────────────────────────────────────────╰──────────────────────────────────────────────────│TextareaCrispandsweet edited ││❯Password••••••••││❯Password••••••█│╰────────────────────────────────────────────────────────────────────────│❯Password•••••••█│╰───────────────────────────────────────────────────────────────────────│❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password•••••••• edited │╰─────────────────────────────────────────────╰───────────────────────────────────────────────│Password•••••••• edited ││❯Selectapple││❯Select●Apple││○Banana▼││↑/↓tomove·↵toaccept·ESCtocancel│╰───────────────────────────────────────────────────│❯Select○Apple││●Banana▼│╰────────────────────────────────────────────────────────────│❯Selectbanana edited ││Aread-onlycard-thecursorskipsitanditcollectsnothing.▲││Selectbanana edited ││❯MultiSelectapple▼││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯MultiSelect◼Apple▼││❯MultiSelectapple,carrotedited▼││TextAppleedited▲││MultiSelectapple,carrot edited ││❯Reorderapple,carrot,tomato▼││❯Reorder❯Apple▼││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯Reorder↑↓Apple▼││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯ReorderCarrot▼││❯Reordercarrot,apple,tomatoedited▼││Templateridge-pear-bedited▲││Reordercarrot,apple,tomato edited ││❯Suggest▼││❯Suggest█▼││❯SuggestC█▼││❯SuggestCh█▼││❯SuggestCherryedited▼││Number4200edited▲││SuggestCherry edited ││❯Searchcarrot▼││❯Search█▼││❯Searcho█▼││❯Searchon█▼││❯Searchonionedited▼││Rating●●●○○3/5Fairedited▲││Hintofcitrus│Searchonion edited ││❯MultiSearchapple▼│╰────────────────────────────────────────────────────╰──────────────────────────────────────────────────────│❯MultiSearchto█▼││❯MultiSearchapple,tomatoedited▼││Calendar2026-07-22edited▲││MultiSearchapple,tomato edited ││❯Confirmyes▼│╰───────────────────────────────────────────────────────│❯Confirm●Yes○No▼││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────│❯Confirm○Yes●No▼││❯Confirmnoedited▼││TextareaCrispandsweetedited▲││Confirmno edited ││❯Toggleripe▼││❯Toggle●Ripe○Unripe▼││↑totoggle·↵toaccept·ESCtocancel││❯Toggle○Ripe●Unripe▼││❯Toggleunripeedited▼││Slightlytart▲││Toggleunripe edited ││❯Pauseyes││❯Pauseyes edited ││Apple·ridge-pear-b·4200·●●●○○3/5Fair││Fields›│FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)│Templatevalley-pear-a│❯Templatevalley-pear-a│█│S│S█│Sl│Sl█│Sli│Sli█│Slig│Slig█│Sligh│Sligh█│Slight│Slight█│Slightl│Slightl█│Slightly█│Slightlyt│Slightlyta│Slightlytar│Slightlytart╰──────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────│❯MultiSelect❯◼Apple▼│╰───────────────────────────────────────────────────────────────│❯MultiSearch█▼││❯MultiSearcht█▼││❯PausePress↵tocontinue││↵tocontinue·ESCtocancel│
\ No newline at end of file
diff --git a/docs/assets/fields-light-animated-ascii-no-ansi.svg b/docs/assets/fields-light-animated-ascii-no-ansi.svg
new file mode 100644
index 00000000..82e966d8
--- /dev/null
+++ b/docs/assets/fields-light-animated-ascii-no-ansi.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|Fields||>Fields>||Pear*valley-pear-a*1200*****-4/5||||[Submit][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Fields>Fields||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200||Rating****-4/5||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||Selectapple||MultiSelectapplev||>TextPear|||<toaccept*ESCtocancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextAppleedited||TextAppleedited||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Rating****-4/5|v||V/^tomovebetweenparts*<toaccept*ESCtocancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-bedited||Templateridge-pear-bedited||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200edited||Number4200edited||>Rating****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Rating***--3/5Fair||>Rating***--3/5Fairedited||Rating***--3/5Fairedited||>|>CalendarJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||Calendar2026-07-22edited||>TextareaCrispandsweet||<toinsertanewline*TABtoaccept*ESCtocancel||Slightlytart||TextareaCrispandsweetedited||Password********edited||^/Vtomove*<toaccept*ESCtocancel||Aread-onlycard-thecursorskipsitanditcollectsnothing.^||Calendar2026-07-22ed|Selectbananaedited||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>MultiSelect[x]Applev||TextAppleedited^||TextareaCrispandsweeted|MultiSelectapple,carrotedited||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>ReorderCarrotv||Templateridge-pear-bedited^||Slightlytart|Reordercarrot,apple,tomatoedited||>SuggestCh|v||Number4200edited^||Password********ed|SuggestCherryedited||Rating***--3/5Fairedited^||Selectbananaed|Searchonionedited||>MultiSearchto|v||Calendar2026-07-22edited^||MultiSelectapple,carroted|MultiSearchapple,tomatoedited||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||TextareaCrispandsweetedited^||Reordercarrot,apple,tomatoed|Confirmnoedited||^totoggle*<toaccept*ESCtocancel||Slightlytart^||SuggestCherryed|Toggleunripeedited||Apple*ridge-pear-b*4200****--3/5Fair||Fields>|FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)|>Calendar2026-07-15||>Calendar2026-07-22edited||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweetedited||>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password********edited||>Selectapple||>Select(*)Apple||()Bananav||>Select()Apple||(*)Bananav||>Selectbananaedited||>MultiSelectapplev||>MultiSelect>[x]Applev||>MultiSelectapple,carroteditedv||>Reorderapple,carrot,tomatov||>Reorder>Applev||>Reorder^vApplev||>Reordercarrot,apple,tomatoeditedv||>Suggestv||>Suggest|v||>SuggestC|v||>SuggestCherryeditedv||>Searchcarrotv||>Search|v||>Searcho|v||>Searchon|v||>Searchonioneditedv||>MultiSearchapplev||>MultiSearch|v||>MultiSearcht|v||>MultiSearchapple,tomatoeditedv||>Confirmyesv||>Confirm(*)Yes()Nov||>Confirm()Yes(*)Nov||>Confirmnoeditedv||>Toggleripev||>Toggle(*)Ripe()Unripev||>Toggle()Ripe(*)Unripev||>Toggleunripeeditedv||>Pauseyes||>PausePress<tocontinue||<tocontinue*ESCtocancel||>Pauseyesedited|
\ No newline at end of file
diff --git a/docs/assets/fields-light-animated-ascii.svg b/docs/assets/fields-light-animated-ascii.svg
new file mode 100644
index 00000000..d3165ce7
--- /dev/null
+++ b/docs/assets/fields-light-animated-ascii.svg
@@ -0,0 +1 @@
++--------------------------------------------------------------------------+|Fields||>Fields>||Pear*valley-pear-a*1200*****-4/5||||[ Submit ][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Fields>Fields||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200|Number1200||Rating****-4/5||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||Selectapple||MultiSelectapplev||>TextPear|||<toaccept*ESCtocancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextApple edited ||TextApple edited ||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Password********|v||V/^tomovebetweenparts*<toaccept*ESCtocancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-b edited ||Templateridge-pear-b edited ||>Number1200|>Number1200||>Number1200||>Number1200|||>Number120||>Number120|||>Number12||>Number12|||>Number1||>Number1|||>Number||>Number|||>Number4||>Number4|||>Number42||>Number42|||>Number420||>Number420|||>Number4200||>Number4200|||>Number4200|>Number4200 edited ||Number4200|Number4200 e|Number4200 edited ||>Rating****-4/5||^/Vtoadjust*<toaccept*ESCtocancel||>Rating***--3/5Fair||>Rating***--3/5Fair edited ||Rating***--3/5Fair edited ||>Calendar2026-07-15||>CalendarJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>tomovebyday*^/Vtomovebyweek*<toaccept*ESCtocancel||13141516171819||2021[22]23242526||>Calendar2026-07-22 edited ||Calendar2026-07-22 edited ||>TextareaCrispandsweet||>TextareaCrispandsweet||Hintofcitrus|||<toinsertanewline*TABtoaccept*ESCtocancel||Hintofcitrus|||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweet edited ||Slightlytart||TextareaCrispandsweet edited ||>Password********|Password******** edited ||^/Vtomove*<toaccept*ESCtocancel||Aread-onlycard-thecursorskipsitanditcollectsnothing.^||Selectbanana edited ||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>MultiSelect[x]Applev||TextAppleedited^||MultiSelectapple,carrot edited ||^/Vtomove*SPACEtograb*<toaccept*ESCtocancel||^/Vtoreorder*SPACEtodrop*ESCtocancel||>ReorderCarrotv||Templateridge-pear-bedited^||Reordercarrot,apple,tomato edited ||>SuggestCh|v||Number4200edited^||Hintofcitrus|SuggestCherry edited ||Rating***--3/5Fairedited^||Searchonion edited ||>MultiSearchto|v||Calendar2026-07-22edited^||MultiSearchapple,tomato edited ||Y/Ntoansweryesorno*^totoggle*<toaccept*ESCtocancel||TextareaCrispandsweetedited^||Confirmno edited ||^totoggle*<toaccept*ESCtocancel||Slightlytart^||Toggleunripe edited ||Apple*ridge-pear-b*4200****--3/5Fair||Fields>|FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)|>Password********||>Password******|>Password******||>Password******|||>Password*******|>Password*******||>Password*******|||>Password********|>Password********||>Password********|||>Password*********|>Password*********||>Password*********|||>Password**********|>Password**********||>Password**********|||>Password***********|>Password***********||>Password***********|||>Password************|>Password************||>Password************|||>Password******** edited ||>Selectapple||>Select(*)Apple||()Bananav||>Select()Apple||(*)Bananav||>Selectbanana edited ||>MultiSelectapplev||>MultiSelect>[x]Applev||>MultiSelectapple,carroteditedv||>Reorderapple,carrot,tomatov||>Reorder>Applev||>Reorder^vApplev||>Reordercarrot,apple,tomatoeditedv||>Suggestv||>Suggest|v||>SuggestC|v||>SuggestCherryeditedv||>Searchcarrotv||>Search|v||>Searcho|v||>Searchon|v||>Searchonioneditedv||>MultiSearchapplev||>MultiSearch|v||>MultiSearcht|v||>MultiSearchapple,tomatoeditedv||>Confirmyesv||>Confirm(*)Yes()Nov||>Confirm()Yes(*)Nov||>Confirmnoeditedv||>Toggleripev||>Toggle(*)Ripe()Unripev||>Toggle()Ripe(*)Unripev||>Toggleunripeeditedv||>Pauseyes||>PausePress<tocontinue||<tocontinue*ESCtocancel||>Pauseyes edited |
\ No newline at end of file
diff --git a/docs/assets/fields-light-animated-no-ansi.svg b/docs/assets/fields-light-animated-no-ansi.svg
new file mode 100644
index 00000000..7e00f0a4
--- /dev/null
+++ b/docs/assets/fields-light-animated-no-ansi.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│Fields││❯Fields›││Pear·valley-pear-a·1200·●●●●○4/5││││[Submit][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fields›Fields││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││Selectapple││MultiSelectapple│MultiSelectapple▼││❯TextPear█││↵toaccept·ESCtocancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextAppleedited││TextAppleedited││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard││Number1200│▼││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel││❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-bedited││Templateridge-pear-bedited││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200edited││Number4200edited││❯Rating●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel││❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fairedited││Rating●●●○○3/5Fairedited││❯Calendar2026-07-15││❯CalendarJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22edited││Calendar2026-07-22edited││❯TextareaCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweetedited││Slightlytart││TextareaCrispandsweetedited││❯Password••••••••││❯Password••••••█││❯Password•••••••█││❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password••••••••edited││Password••••••••edited││❯Selectapple││❯Select●Apple││○Banana│↑/↓tomove·↵toaccept·ESCtocancel││❯Select○Apple││●Banana│❯Selectbananaedited││Aread-onlycard-thecursorskipsitanditcollectsnothing.▲││Rating●●●○○3/5F│Rating●●●○○3/5Fair│Selectbananaedited││❯MultiSelectapple│❯MultiSelect❯◼Apple│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯MultiSelect◼Apple│❯MultiSelect◼Apple▼││❯MultiSelectapple,carrot│TextAppleedited▲││Calendar2026-07-2│Calendar2026-07-22│MultiSelectapple,carrotedited││❯Reorderapple,carrot,tomato│❯Reorder❯Apple│↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯Reorder↑↓Apple│↑/↓toreorder·SPACEtodrop·ESCtocancel││❯ReorderCarrot│❯ReorderCarrot▼││❯Reordercarrot,apple,tomato│Templateridge-pear-bedited▲││TextareaCrispand│TextareaCrispandsweet│Reordercarrot,apple,tomatoedited││❯Suggest│❯Suggest█│❯SuggestC█│❯SuggestCh█│❯SuggestCh█▼││❯SuggestCherry│Number4200edited▲││Slightly│Slightlytart│SuggestCherryedited││❯Searchcarrot│❯Search█│❯Searcho█│❯Searchon█│❯Searchonion│Rating●●●○○3/5Fairedited▲││Password•••│Password••••••│Searchonionedited││❯MultiSearchapple│❯MultiSearch█│❯MultiSearcht█│❯MultiSearchto█│❯MultiSearchto█▼││❯MultiSearchapple,tomato│Calendar2026-07-22edited▲││Selectbanan│Selectbanana│MultiSearchapple,tomatoedited││❯Confirmyes│❯Confirm●Yes○No│Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Confirm○Yes●No│❯Confirmno│TextareaCrispandsweetedited▲││MultiSelect│MultiSelectapple,car│Confirmnoedited││❯Toggleripe│❯Toggle●Ripe○Unripe│↑totoggle·↵toaccept·ESCtocancel││❯Toggle○Ripe●Unripe│❯Toggleunripe│Slightlytart▲││Reordercarr│Reordercarrot,apple,│Toggleunripeedited││❯Pauseyes│❯PausePress↵tocontinue│Apple·ridge-pear-b·4200·●●●○○3/5Fair││Fields›│FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)│○Banana▼││●Banana▼││❯MultiSelectapple▼││❯MultiSelect❯◼Apple▼││❯MultiSelectapple,carrotedited▼││❯Reorderapple,carrot,tomato▼││❯Reorder❯Apple▼││❯Reorder↑↓Apple▼││❯Reordercarrot,apple,tomatoedited▼││❯Suggest▼││❯Suggest█▼││❯SuggestC█▼││❯SuggestCherryedited▼││❯Searchcarrot▼││❯Search█▼││❯Searcho█▼││❯Searchon█▼││❯Searchonionedited▼││❯MultiSearchapple▼││❯MultiSearch█▼││❯MultiSearcht█▼││❯MultiSearchapple,tomatoedited▼││❯Confirmyes▼││❯Confirm●Yes○No▼││❯Confirm○Yes●No▼││❯Confirmnoedited▼││❯Toggleripe▼││❯Toggle●Ripe○Unripe▼││❯Toggle○Ripe●Unripe▼││❯Toggleunripeedited▼││❯Pauseyes││❯PausePress↵tocontinue││↵tocontinue·ESCtocancel││❯Pauseyesedited│
\ No newline at end of file
diff --git a/docs/assets/fields-light-animated.svg b/docs/assets/fields-light-animated.svg
new file mode 100644
index 00000000..e4d416be
--- /dev/null
+++ b/docs/assets/fields-light-animated.svg
@@ -0,0 +1 @@
+╭──────────────────────────────────────────────────────────────────────────╮│Fields││❯Fields›││Pear·valley-pear-a·1200·●●●●○4/5││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Fields›Fields││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││Selectapple││MultiSelectapple▼│╰───────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────│❯TextPear█││↵toaccept·ESCtocancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextApple edited │╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────│TextApple edited ││❯Templatevalley-pear-a││❯Templatevalley█-pear-a│❯Templatevalley█-pear-a││fillinginorchard││Hint│▼││↓/↑tomovebetweenparts·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────────────│❯Templatevalle█-pear-a│❯Templatevalle█-pear-a││❯Templatevall█-pear-a│❯Templatevall█-pear-a││❯Templateval█-pear-a│❯Templateval█-pear-a││❯Templateva█-pear-a│❯Templateva█-pear-a││❯Templatev█-pear-a│❯Templatev█-pear-a││❯Template█-pear-a│❯Template█-pear-a││❯Templater█-pear-a│❯Templater█-pear-a││❯Templateri█-pear-a│❯Templateri█-pear-a││❯Templaterid█-pear-a│❯Templaterid█-pear-a││❯Templateridg█-pear-a│❯Templateridg█-pear-a││❯Templateridge█-pear-a│❯Templateridge█-pear-a││❯Templateridge-pear█-a│❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█│❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█│❯Templateridge-pear-█││❯Templateridge-pear-b█│❯Templateridge-pear-b█││❯Templateridge-pear-b edited │❯Templateridge-pear-b edited │╰─────────────────────────────────────────────────────────────│Templateridge-pear-b edited ││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200 edited │╰───────────────────────────────────────────────────────────│Number4200 edited ││❯Rating●●●●○4/5││↑/↓toadjust·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────────────────────│❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fair edited │╰────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────│Rating●●●○○3/5Fair edited ││❯Calendar2026-07-15││❯CalendarJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22 edited │╰─────────────────────────────────────────────────────│Calendar2026-07-22 edited ││❯TextareaCrispandsweet││❯TextareaCrispandsweet││Hintofcitrus█││↵toinsertanewline·TABtoaccept·ESCtocancel│╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────│Hintofcitrus││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly│Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweet edited ││Slightlytart│╰────────────────────────────────────────────────╰──────────────────────────────────────────────────│TextareaCrispandsweet edited ││❯Password••••••••││❯Password••••••█│╰────────────────────────────────────────────────────────────────────────│❯Password•••••••█│╰───────────────────────────────────────────────────────────────────────│❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password•••••••• edited │╰─────────────────────────────────────────────╰───────────────────────────────────────────────│Password•••••••• edited ││❯Selectapple││❯Select●Apple││○Banana▼││↑/↓tomove·↵toaccept·ESCtocancel│╰───────────────────────────────────────────────────│❯Select○Apple││●Banana▼│╰────────────────────────────────────────────────────────────│❯Selectbanana edited ││Aread-onlycard-thecursorskipsitanditcollectsnothing.▲││Selectbanana edited ││❯MultiSelectapple▼││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯MultiSelect◼Apple▼││❯MultiSelectapple,carrotedited▼││TextAppleedited▲││MultiSelectapple,carrot edited ││❯Reorderapple,carrot,tomato▼││❯Reorder❯Apple▼││↑/↓tomove·SPACEtograb·↵toaccept·ESCtocancel││❯Reorder↑↓Apple▼││↑/↓toreorder·SPACEtodrop·ESCtocancel││❯ReorderCarrot▼││❯Reordercarrot,apple,tomatoedited▼││Templateridge-pear-bedited▲││Reordercarrot,apple,tomato edited ││❯Suggest▼││❯Suggest█▼││❯SuggestC█▼││❯SuggestCh█▼││❯SuggestCherryedited▼││Number4200edited▲││SuggestCherry edited ││❯Searchcarrot▼││❯Search█▼││❯Searcho█▼││❯Searchon█▼││❯Searchonionedited▼││Rating●●●○○3/5Fairedited▲││Hintofcitrus│Searchonion edited ││❯MultiSearchapple▼│╰────────────────────────────────────────────────────╰──────────────────────────────────────────────────────│❯MultiSearchto█▼││❯MultiSearchapple,tomatoedited▼││Calendar2026-07-22edited▲││MultiSearchapple,tomato edited ││❯Confirmyes▼│╰───────────────────────────────────────────────────────│❯Confirm●Yes○No▼││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel│╰─────────────────────────────────────────────────────────│❯Confirm○Yes●No▼││❯Confirmnoedited▼││TextareaCrispandsweetedited▲││Confirmno edited ││❯Toggleripe▼││❯Toggle●Ripe○Unripe▼││↑totoggle·↵toaccept·ESCtocancel││❯Toggle○Ripe●Unripe▼││❯Toggleunripeedited▼││Slightlytart▲││Toggleunripe edited ││❯Pauseyes││❯Pauseyes edited ││Apple·ridge-pear-b·4200·●●●○○3/5Fair││Fields›│FieldsText:Apple(edited)Template:ridge-pear-b(edited)Number:4200(edited)Rating:3(edited)Calendar:2026-07-22(edited)Textarea:CrispandsweetHintofcitrusSlightlytart(edited)Password:********(edited)Select:banana(edited)MultiSelect:apple,carrot(edited)Reorder:carrot,apple,tomato(edited)Suggest:Cherry(edited)Search:onion(edited)MultiSearch:apple,tomato(edited)Confirm:no(edited)Toggle:unripe(edited)Pause:yes(edited)│Templatevalley-pear-a│❯Templatevalley-pear-a│█│S│S█│Sl│Sl█│Sli│Sli█│Slig│Slig█│Sligh│Sligh█│Slight│Slight█│Slightl│Slightl█│Slightly█│Slightlyt│Slightlyta│Slightlytar│Slightlytart╰──────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────│❯MultiSelect❯◼Apple▼│╰───────────────────────────────────────────────────────────────│❯MultiSearch█▼││❯MultiSearcht█▼││❯PausePress↵tocontinue││↵tocontinue·ESCtocancel│
\ No newline at end of file
diff --git a/docs/assets/fullscreen-panels-dark-animated.svg b/docs/assets/fullscreen-panels-dark-animated.svg
index 2471883e..cd487ba0 100644
--- a/docs/assets/fullscreen-panels-dark-animated.svg
+++ b/docs/assets/fullscreen-panels-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────────╮│Marketstall│├──────────────────────────────────────────────────────────────────────────────┤││││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeor│[Placeorder]│[Placeorder][Cancel]││↑/↓/←/→move·↵select·escback·qquit·?help│╰────────────╰───────────────╰──────────────────────────────────────────────────────────────────────────────╯│Summary›││Theorderataglance.││OrdernameWeeklyBox││❯Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeorde╰─────────────│Produce›❯Delivery›││Fruit›Giftwrap?no││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│╰────────────────────────────────╰───────────────────────────────────│Fruit›❯Vegetables›││FruitappleVegetablescarrot││[ Place order ][Cancel]│[ Place order ][Cancel]│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────╰────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────────╮│Marketstall││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││││Produce›Delivery›││Fruit│Fruit›Giftwrap?no││apple││Vegetables›││carrot││[ Place order ][Cancel]│││↑/↓/←/→tomove·↵toselect·ESCtogoback·Qtoquit│╰─────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────────╯│Summary›││❯Produce›Delivery›││Produce›❯Delivery›││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot││Fruit›❯Vegetables›││❯Vegetables›││Fruit›SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────
\ No newline at end of file
diff --git a/docs/assets/fullscreen-panels-light-animated.svg b/docs/assets/fullscreen-panels-light-animated.svg
index 2aabb9d7..afafbb12 100644
--- a/docs/assets/fullscreen-panels-light-animated.svg
+++ b/docs/assets/fullscreen-panels-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────────╮│Marketstall│├──────────────────────────────────────────────────────────────────────────────┤││││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeor│[Placeorder]│[Placeorder][Cancel]││↑/↓/←/→move·↵select·escback·qquit·?help│╰────────────╰───────────────╰──────────────────────────────────────────────────────────────────────────────╯│Summary›││Theorderataglance.││OrdernameWeeklyBox││❯Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeorde╰─────────────│Produce›❯Delivery›││Fruit›Giftwrap?no││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│╰────────────────────────────────╰───────────────────────────────────│Fruit›❯Vegetables›││FruitappleVegetablescarrot││[ Place order ][Cancel]│[ Place order ][Cancel]│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────╰────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────────╮│Marketstall││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││││Produce›Delivery›││Fruit│Fruit›Giftwrap?no││apple││Vegetables›││carrot││[ Place order ][Cancel]│││↑/↓/←/→tomove·↵toselect·ESCtogoback·Qtoquit│╰─────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────────╯│Summary›││❯Produce›Delivery›││Produce›❯Delivery›││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot││Fruit›❯Vegetables›││❯Vegetables›││Fruit›SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────
\ No newline at end of file
diff --git a/docs/assets/inline-editing-dark-animated.svg b/docs/assets/inline-editing-dark-animated.svg
index 17261393..e9503815 100644
--- a/docs/assets/inline-editing-dark-animated.svg
+++ b/docs/assets/inline-editing-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────┤│││❯Orderoptions›││PressEnteronafieldtoedititinplace;Enteraccept││no·6·ripe·2026-07-15│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────╯│Produceorder›Orderoptions││❯Organiconly?no││Quantity6││Ripeness│Ripenessripe││Harvestdate2026-07-15││❯Organiconly?○Yes●No││R│y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?●Yes○No││❯Organiconly?yes edited ││Ripene│Organiconly?yes edited ││❯Quantity6││❯Quantity6█││↑/↓adjust·↵accept·esccancel││❯Quantity█││❯Quantity1█││❯Quantity12█││❯Quantity12 edited ││Quantity12 edited ││❯│❯Ripene│❯Ripeness●Ripe││○Unripe││○Mixed││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────╰─────────────────────────────────────────────────────│❯Ripeness○Ripe││●Unripe││Ripenessunripe│Ripenessunripe edited ││Harvestdate││July2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819│yes·12·unripe·2026-07-22││Orderoptions›││yes·12·unripe·2026-07-22│OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)│Ripenessr│❯Ripenessripe││❯Ripenessunripe edited ││❯Harvestdate2026-07-15││1314[15]1617│1314[15]16171819│1314[15]16171819││20212223242526││13141516171819││2021[22]23242526││❯Harvestdate2026-07-22 edited ││[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Orderoptions›││PressEnteronafieldtoedititinplace;Enteraccepts,Esccance││no·6·ripe·2026-07-15││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Orderoptions││❯Organiconly?no││Quantity6││Ripenessripe││Harvestdate2026-07-15││↑/↓│❯Organiconly?○Yes●No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?●Yes○No││❯Organiconly?yes edited ││Organiconly?yes edited ││❯Quantity6││❯Quantity6█││↑│↑/↓toadjust·↵toaccept·ESCtocancel││❯Quantity█││❯Quantity1█││❯Quantity12█││❯Quantity12 edited ││Quantity12 edited ││❯Ripenessripe││❯Ripeness●Ripe││○Unripe│○Unripe││○Mixed││↑/↓tomove·↵toaccept·ESCtocancel││❯Ripeness○Ripe││●Unripe│●Unripe││❯Ripenessunripe edited ││Ripenessunripe edited ││❯Harvestdate2026-07-15││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/│←/→│←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Harvestdate2026-07-22 edited ││yes·12·unripe·2026-07-22││Orderoptions›│OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)│Har│↑/
\ No newline at end of file
diff --git a/docs/assets/inline-editing-light-animated.svg b/docs/assets/inline-editing-light-animated.svg
index 3765cd70..dc765315 100644
--- a/docs/assets/inline-editing-light-animated.svg
+++ b/docs/assets/inline-editing-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────┤│││❯Orderoptions›││PressEnteronafieldtoedititinplace;Enteraccept││no·6·ripe·2026-07-15│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────╯│Produceorder›Orderoptions││❯Organiconly?no││Quantity6││Ripeness│Ripenessripe││Harvestdate2026-07-15││❯Organiconly?○Yes●No││R│y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?●Yes○No││❯Organiconly?yes edited ││Ripene│Organiconly?yes edited ││❯Quantity6││❯Quantity6█││↑/↓adjust·↵accept·esccancel││❯Quantity█││❯Quantity1█││❯Quantity12█││❯Quantity12 edited ││Quantity12 edited ││❯│❯Ripene│❯Ripeness●Ripe││○Unripe││○Mixed││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────╰─────────────────────────────────────────────────────│❯Ripeness○Ripe││●Unripe││Ripenessunripe│Ripenessunripe edited ││Harvestdate││July2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819│yes·12·unripe·2026-07-22││Orderoptions›││yes·12·unripe·2026-07-22│OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)│Ripenessr│❯Ripenessripe││❯Ripenessunripe edited ││❯Harvestdate2026-07-15││1314[15]1617│1314[15]16171819│1314[15]16171819││20212223242526││13141516171819││2021[22]23242526││❯Harvestdate2026-07-22 edited ││[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Orderoptions›││PressEnteronafieldtoedititinplace;Enteraccepts,Esccance││no·6·ripe·2026-07-15││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Orderoptions││❯Organiconly?no││Quantity6││Ripenessripe││Harvestdate2026-07-15││↑/↓│❯Organiconly?○Yes●No││Y/Ntoansweryesorno·↑totoggle·↵toaccept·ESCtocancel││❯Organiconly?●Yes○No││❯Organiconly?yes edited ││Organiconly?yes edited ││❯Quantity6││❯Quantity6█││↑│↑/↓toadjust·↵toaccept·ESCtocancel││❯Quantity█││❯Quantity1█││❯Quantity12█││❯Quantity12 edited ││Quantity12 edited ││❯Ripenessripe││❯Ripeness●Ripe││○Unripe│○Unripe││○Mixed││↑/↓tomove·↵toaccept·ESCtocancel││❯Ripeness○Ripe││●Unripe│●Unripe││❯Ripenessunripe edited ││Ripenessunripe edited ││❯Harvestdate2026-07-15││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/│←/→│←/→tomovebyday·↑/↓tomovebyweek·↵toaccept·ESCtocancel││13141516171819││2021[22]23242526││❯Harvestdate2026-07-22 edited ││yes·12·unripe·2026-07-22││Orderoptions›│OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)│Har│↑/
\ No newline at end of file
diff --git a/docs/assets/key-bindings-vim-dark-animated.svg b/docs/assets/key-bindings-vim-dark-animated.svg
index 66d25884..ad6cc9fd 100644
--- a/docs/assets/key-bindings-vim-dark-animated.svg
+++ b/docs/assets/key-bindings-vim-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Keybindingsdemo│├──────────────────────────────────────────────────────────────────────┤│││❯Order›││Weekly·apple··yes│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Keybindingsdemo›Order││❯OrdernameWeekly││Fruitapple││Vegetables││Organiconly?yes│╰──────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────│OrdernameWeekly││❯Fruitapple││❯Vegetables││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰│❯Fruit○Apple││●│●Banana││❯Fruitbanana edited ││Weekly·banana··yes││Order›││Weekly·banana··yes│OrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yesKeyboardhelpNavigation↑/↓move·↵select·escback·qquit·?helpText↵accept·esccancelSelect↑/↓move·↵accept·esccancelConfirmy/nyes/no·↑toggle·↵accept·esccancel?close╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────│[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────╮│Keybindingsdemo││❯Order›││Weekly·apple··yes││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯│Keybindingsdemo›Order││❯OrdernameWeekly││Fruitapple││Vegetables││Organiconly?yes│Organiconly?yes││OrdernameWeekly││❯Fruitapple││❯Vegetables││❯Fruit●Apple││○Banana││○Cherry││Vegetables│↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││❯Fruitbanana edited ││Weekly·banana··yes││Order›│OrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yes
\ No newline at end of file
diff --git a/docs/assets/key-bindings-vim-light-animated.svg b/docs/assets/key-bindings-vim-light-animated.svg
index ad734fa0..16d92f08 100644
--- a/docs/assets/key-bindings-vim-light-animated.svg
+++ b/docs/assets/key-bindings-vim-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────╮│Keybindingsdemo│├──────────────────────────────────────────────────────────────────────┤│││❯Order›││Weekly·apple··yes│││[Subm│[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯│Keybindingsdemo›Order││❯OrdernameWeekly││Fruitapple││Vegetables││Organiconly?yes│╰──────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────│OrdernameWeekly││❯Fruitapple││❯Vegetables││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰│❯Fruit○Apple││●│●Banana││❯Fruitbanana edited ││Weekly·banana··yes││Order›││Weekly·banana··yes│OrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yesKeyboardhelpNavigation↑/↓move·↵select·escback·qquit·?helpText↵accept·esccancelSelect↑/↓move·↵accept·esccancelConfirmy/nyes/no·↑toggle·↵accept·esccancel?close╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────│[ Submit ][│[ Submit ][Cancel│[ Submit ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────╮│Keybindingsdemo││❯Order›││Weekly·apple··yes││││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯│Keybindingsdemo›Order││❯OrdernameWeekly││Fruitapple││Vegetables││Organiconly?yes│Organiconly?yes││OrdernameWeekly││❯Fruitapple││❯Vegetables││❯Fruit●Apple││○Banana││○Cherry││Vegetables│↑/↓tomove·↵toaccept·ESCtocancel││❯Fruit○Apple││●Banana││❯Fruitbanana edited ││Weekly·banana··yes││Order›│OrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yes
\ No newline at end of file
diff --git a/docs/assets/modal-panels-dark-animated.svg b/docs/assets/modal-panels-dark-animated.svg
index aecac173..57b54f8e 100644
--- a/docs/assets/modal-panels-dark-animated.svg
+++ b/docs/assets/modal-panels-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────────────────┤│││❯Basket›││Yourproduceselection.││Pear·6·ripe│Pear·6·ripe││[Placeorder][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Basket││❯ItemPear││Quantity6│Quantity6││Ripenessripe││Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest││Emptythebasket›││Thisclearseveryitemfromyourbasket.Thereisnoundo.│││ItemPear││❯Quantity6│❯Quantity6││❯Ripenessripe││❯Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest│╭──────────────────────────────────────────────────────────────────────────╮│Produceorder›Basket›Giftoptions│├──────────────────────────────────────────────────────────────────────────┤│││Item╭────────────────────────────────────────────────────────╮│││Giftoptions│││Quant├────────────────────────────────────────────────────────┤││││││Ripen│Wrapthisorderasagift.│││Gift│❯Giftwrap?yes│││Wra││││yes│GiftmessageEnjoytheharvest│││││Empty├────────────────────────────────────────────────────────┤││Thi│[Save][Discard]│Thereis╰────────────────────────────────────────────────────────╯│↑/↓move·↵select│↑/↓move·↵select·esc│↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Gift│Giftwrap?yes│││yes│❯GiftmessageEnjoytheharvest│││Thi│[ Save ][Discard]││❯Emptythebasket›││Thisclearseveryitemfromyourbasket.Thereisnoundo.││Produceorder›Basket›Emptythebasket││ItemPear││╭────────────────────────────────────────────────────────╮││Quant││Quant│Empty│Quant│Emptythebasket│││├────────────────────────────────────────────────────────┤││Ripen│││││Thisclearseveryitemfromyourbasket.│││Gift│Thereisnoundo.│││yes├────────────────────────────────────────────────────────┤││││[ Empty it ][Keepit]│││Empty╰────────────────────────────────────────────────────────╯││Thisclearseveryitemfromyourbasket.Thereisnoundo.│╰──────────────────╰────────────────────││[Emptyit][ Keep it ]│││Basket›││Yourproduceselection.││Pear·6·ripe│BasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest│↑/↓move·↵select·escback·qquit│↑/↓move·↵select·escback·qquit·?h│Pear·6·ripe│[ Place order ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Basket›││Yourproduceselection.││Pear·6·ripe││││[ Place order ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Basket││❯ItemPear││Quantity6││Ripenessripe││Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest││Emptythebasket›││Thisclearseveryitemfromyourbasket.││Thereisnoundo.││ItemPear││❯Quantity6││❯Ripenessripe││❯│❯Giftoptions›│╭──────────────────────────────────────────────────────────────────────────╮│Produceorder›Basket›Giftoptions││ItemPear││││Quantity6││Ripen╭────────────────────────────────────────────────────────╮│││Giftoptions││Giftoptions│││❯Gift│Wrapthisorderasagift.│││Wra││││yes│❯Giftwrap?yes│││││││Empty│GiftmessageEnjoytheharvest│││Thi││││The│[ Save ][Discard]││││││││╰────────────────────────────────────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰───────────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────╯│yes│Giftwrap?yes│││Empty│❯GiftmessageEnjoytheharvest│││G│Giftopti│❯Emptythebasket›││Produceorder›Basket›Emptythebasket││Ripenessripe││Gift╭────────────────────────────────────│Gift╭───────────────────────────────────────│Gift╭────────────────────────────────────────────────────────╮││Wra│Emptythebasket│││yes│Thisclearseveryitemfromyourbasket.││││Thereisnoundo.│││❯Empty││││Thi│[ Empty it ][Keepit]│││The││││╰───────────────────────────────────────────────│╰──────────────────────────────────────────────────│Basket›│BasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest
\ No newline at end of file
diff --git a/docs/assets/modal-panels-light-animated.svg b/docs/assets/modal-panels-light-animated.svg
index 29d1fc0d..e9ab7ef3 100644
--- a/docs/assets/modal-panels-light-animated.svg
+++ b/docs/assets/modal-panels-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────────────────┤│││❯Basket›││Yourproduceselection.││Pear·6·ripe│Pear·6·ripe││[Placeorder][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Basket││❯ItemPear││Quantity6│Quantity6││Ripenessripe││Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest││Emptythebasket›││Thisclearseveryitemfromyourbasket.Thereisnoundo.│││ItemPear││❯Quantity6│❯Quantity6││❯Ripenessripe││❯Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest│╭──────────────────────────────────────────────────────────────────────────╮│Produceorder›Basket›Giftoptions│├──────────────────────────────────────────────────────────────────────────┤│││Item╭────────────────────────────────────────────────────────╮│││Giftoptions│││Quant├────────────────────────────────────────────────────────┤││││││Ripen│Wrapthisorderasagift.│││Gift│❯Giftwrap?yes│││Wra││││yes│GiftmessageEnjoytheharvest│││││Empty├────────────────────────────────────────────────────────┤││Thi│[Save][Discard]│Thereis╰────────────────────────────────────────────────────────╯│↑/↓move·↵select│↑/↓move·↵select·esc│↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Gift│Giftwrap?yes│││yes│❯GiftmessageEnjoytheharvest│││Thi│[ Save ][Discard]││❯Emptythebasket›││Thisclearseveryitemfromyourbasket.Thereisnoundo.││Produceorder›Basket›Emptythebasket││ItemPear││╭────────────────────────────────────────────────────────╮││Quant││Quant│Empty│Quant│Emptythebasket│││├────────────────────────────────────────────────────────┤││Ripen│││││Thisclearseveryitemfromyourbasket.│││Gift│Thereisnoundo.│││yes├────────────────────────────────────────────────────────┤││││[ Empty it ][Keepit]│││Empty╰────────────────────────────────────────────────────────╯││Thisclearseveryitemfromyourbasket.Thereisnoundo.│╰──────────────────╰────────────────────││[Emptyit][ Keep it ]│││Basket›││Yourproduceselection.││Pear·6·ripe│BasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest│↑/↓move·↵select·escback·qquit│↑/↓move·↵select·escback·qquit·?h│Pear·6·ripe│[ Place order ][Cancel]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Basket›││Yourproduceselection.││Pear·6·ripe││││[ Place order ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Basket││❯ItemPear││Quantity6││Ripenessripe││Giftoptions›││Wrapthisorderasagift.││yes·Enjoytheharvest││Emptythebasket›││Thisclearseveryitemfromyourbasket.││Thereisnoundo.││ItemPear││❯Quantity6││❯Ripenessripe││❯│❯Giftoptions›│╭──────────────────────────────────────────────────────────────────────────╮│Produceorder›Basket›Giftoptions││ItemPear││││Quantity6││Ripen╭────────────────────────────────────────────────────────╮│││Giftoptions││Giftoptions│││❯Gift│Wrapthisorderasagift.│││Wra││││yes│❯Giftwrap?yes│││││││Empty│GiftmessageEnjoytheharvest│││Thi││││The│[ Save ][Discard]││││││││╰────────────────────────────────────────────────────────╯││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰───────────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────╯│yes│Giftwrap?yes│││Empty│❯GiftmessageEnjoytheharvest│││G│Giftopti│❯Emptythebasket›││Produceorder›Basket›Emptythebasket││Ripenessripe││Gift╭────────────────────────────────────│Gift╭───────────────────────────────────────│Gift╭────────────────────────────────────────────────────────╮││Wra│Emptythebasket│││yes│Thisclearseveryitemfromyourbasket.││││Thereisnoundo.│││❯Empty││││Thi│[ Empty it ][Keepit]│││The││││╰───────────────────────────────────────────────│╰──────────────────────────────────────────────────│Basket›│BasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest
\ No newline at end of file
diff --git a/docs/assets/nested-panels-dark-animated.svg b/docs/assets/nested-panels-dark-animated.svg
index 842a85cc..ea04f202 100644
--- a/docs/assets/nested-panels-dark-animated.svg
+++ b/docs/assets/nested-panels-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────────────────┤│││❯Order›││Whothisorderisfor.││Weekly·weekly│Weekly·weekly││Delivery›││Howitarrives.││pickup·yes││[Save][Discard]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Order││❯OrdernameWeekly││OrdernameWeekly││Order›││Whothisorderisfor.││Weekly·weekly││❯Delivery›││Howitarrives.││pickup·yes││Produceorder›Delivery││❯Deliverypickup││Giftwrap?yes│Giftwrap?yes││Extras›││Optionaladd-ons.│╰────────────────────────────────────────────────────────────────────────│❯Delivery●Pickup││○Locker││○Doorstep│○Doorstep││↑/↓move·↵accept·esccancel│╰╰───│❯Delivery○Pickup││●Locker││●Doorstep│●Doorstep││❯Deliverydoorstep edited │╰─────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────────────│Deliverydoorstep edited ││❯Giftwrap?yes│❯Giftwrap?yes││❯Extras›││Optionaladd-ons.││Produceorder›Delivery›Extras││❯Add-ons││Packaging›│Packaging›││250g││❯Add-ons❯◻Herbs││◻Nuts││◻Seeds│◻Seeds││❯Add-ons❯◼Herbs│╰──────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────────│❯Add-ons◼Herbs││❯◻Nuts││❯◼Nuts││❯Add-onsherbs,nuts edited ││Herbnotemixed│Herbnotemixed││Add-onsherbs,nuts edited ││❯Herbnotemixed│❯Herbnotemixed││❯Packaging›││250g││Produceorder›Delivery›Extras›Packaging││❯Bagweight250g││❯Bagweight250g█││250g││❯Bagweight250█││250g││❯Bagweight25█││250g││❯Bagweight2█││250g││❯Bagweight█││250g││500g│500g││1kg││❯250g││❯500g│❯Bagweight1kg edited ││1kg││herbs,nuts·mixed││doorstep·yes││doorstep·yes││[ Save ][Discard]│OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)│Slugweekly│Slugweekly derive│Slugweekly derived ││Derivedfromtheordername.││❯Slugweekly│❯Slugweekly deri│❯Slugweekly derived ││Derivedfromtheordername.│╰──────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────│❯500g││❯1kg│├──────────────────────├────────────────────────╰────────────────────────────────╰───────────────────────────────────│↑/↓mo│↑/↓move·
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Order›││Whothisorderisfor.││Weekly·weekly││││Delivery›││Howitarrives.│Howitarrives.││pickup·yes││[ Save ][Discard]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Order││❯OrdernameWeekly││Slugweekly derived ││Derivedfromtheordername.││OrdernameWeekly││❯Slugweekly derived ││Order›││❯Delivery›││Produceorder›Delivery││❯Deliverypickup││Giftwrap?yes││Extras›││Optionaladd-ons.│Optionaladd-ons.││❯Delivery●Pickup││○Locker││○Doorstep││Atthestall││Giftwrap?yes│↑/↓tomove·↵toaccept·ESCtocancel││❯Delivery○Pickup││●Locker││Nearbylocker││●Doorstep││Toyourdoor││❯Deliverydoorstep edited ││Deliverydoorstep edited ││❯Giftwrap?yes││❯Extras›││Produceorder›Delivery›Extras││❯Add-ons││Packaging›││250g││❯Add-ons❯◻Herbs││◻Nuts││◻Seeds││250g│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Add-ons❯◼Herbs││❯Add-ons◼Herbs││❯◻Nuts││❯◼Nuts││❯Add-onsherbs,nuts edited ││Herbnotemixed││Add-onsherbs,nuts edited ││❯Herbnotemixed││❯Packaging›││Produceorder›Delivery›Extras›Packaging││❯Bagweight250g││❯Bagweight250g█││250g││❯Bagweight250█││250g││❯Bagweight25█││250g││❯Bagweight2█││250g││❯Bagweight█││250g││500g││1kg││❯250g││❯500g││❯1kg││❯Bagweight1kg edited ││1kg│1kg││herbs,nuts·mixed││doorstep·yes│OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)
\ No newline at end of file
diff --git a/docs/assets/nested-panels-light-animated.svg b/docs/assets/nested-panels-light-animated.svg
index 79477973..0f738ac8 100644
--- a/docs/assets/nested-panels-light-animated.svg
+++ b/docs/assets/nested-panels-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────────────────┤│││❯Order›││Whothisorderisfor.││Weekly·weekly│Weekly·weekly││Delivery›││Howitarrives.││pickup·yes││[Save][Discard]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Order││❯OrdernameWeekly││OrdernameWeekly││Order›││Whothisorderisfor.││Weekly·weekly││❯Delivery›││Howitarrives.││pickup·yes││Produceorder›Delivery││❯Deliverypickup││Giftwrap?yes│Giftwrap?yes││Extras›││Optionaladd-ons.│╰────────────────────────────────────────────────────────────────────────│❯Delivery●Pickup││○Locker││○Doorstep│○Doorstep││↑/↓move·↵accept·esccancel│╰╰───│❯Delivery○Pickup││●Locker││●Doorstep│●Doorstep││❯Deliverydoorstep edited │╰─────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────────────│Deliverydoorstep edited ││❯Giftwrap?yes│❯Giftwrap?yes││❯Extras›││Optionaladd-ons.││Produceorder›Delivery›Extras││❯Add-ons││Packaging›│Packaging›││250g││❯Add-ons❯◻Herbs││◻Nuts││◻Seeds│◻Seeds││❯Add-ons❯◼Herbs│╰──────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────────│❯Add-ons◼Herbs││❯◻Nuts││❯◼Nuts││❯Add-onsherbs,nuts edited ││Herbnotemixed│Herbnotemixed││Add-onsherbs,nuts edited ││❯Herbnotemixed│❯Herbnotemixed││❯Packaging›││250g││Produceorder›Delivery›Extras›Packaging││❯Bagweight250g││❯Bagweight250g█││250g││❯Bagweight250█││250g││❯Bagweight25█││250g││❯Bagweight2█││250g││❯Bagweight█││250g││500g│500g││1kg││❯250g││❯500g│❯Bagweight1kg edited ││1kg││herbs,nuts·mixed││doorstep·yes││doorstep·yes││[ Save ][Discard]│OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)│Slugweekly│Slugweekly derive│Slugweekly derived ││Derivedfromtheordername.││❯Slugweekly│❯Slugweekly deri│❯Slugweekly derived ││Derivedfromtheordername.│╰──────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────│❯500g││❯1kg│├──────────────────────├────────────────────────╰────────────────────────────────╰───────────────────────────────────│↑/↓mo│↑/↓move·
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Order›││Whothisorderisfor.││Weekly·weekly││││Delivery›││Howitarrives.│Howitarrives.││pickup·yes││[ Save ][Discard]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Order││❯OrdernameWeekly││Slugweekly derived ││Derivedfromtheordername.││OrdernameWeekly││❯Slugweekly derived ││Order›││❯Delivery›││Produceorder›Delivery││❯Deliverypickup││Giftwrap?yes││Extras›││Optionaladd-ons.│Optionaladd-ons.││❯Delivery●Pickup││○Locker││○Doorstep││Atthestall││Giftwrap?yes│↑/↓tomove·↵toaccept·ESCtocancel││❯Delivery○Pickup││●Locker││Nearbylocker││●Doorstep││Toyourdoor││❯Deliverydoorstep edited ││Deliverydoorstep edited ││❯Giftwrap?yes││❯Extras›││Produceorder›Delivery›Extras││❯Add-ons││Packaging›││250g││❯Add-ons❯◻Herbs││◻Nuts││◻Seeds││250g│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Add-ons❯◼Herbs││❯Add-ons◼Herbs││❯◻Nuts││❯◼Nuts││❯Add-onsherbs,nuts edited ││Herbnotemixed││Add-onsherbs,nuts edited ││❯Herbnotemixed││❯Packaging›││Produceorder›Delivery›Extras›Packaging││❯Bagweight250g││❯Bagweight250g█││250g││❯Bagweight250█││250g││❯Bagweight25█││250g││❯Bagweight2█││250g││❯Bagweight█││250g││500g││1kg││❯250g││❯500g││❯1kg││❯Bagweight1kg edited ││1kg│1kg││herbs,nuts·mixed││doorstep·yes│OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)
\ No newline at end of file
diff --git a/docs/assets/panel-layout-dark-animated.svg b/docs/assets/panel-layout-dark-animated.svg
index 15fb429e..33cf05b0 100644
--- a/docs/assets/panel-layout-dark-animated.svg
+++ b/docs/assets/panel-layout-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Marketstall│├──────────────────────────────────────────────────────────────────────────┤│││❯Summary›││Theorderataglance.││OrdernameWeeklyBox│OrdernameWeeklyBox││Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeorder][Cancel]│├───────────────────────────────────────────────────────────────├──────────────────────────────────────────────────────────────────│↑/↓/←/→move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Summary›││Theorderataglance.││OrdernameWeeklyBox│OrdernameWeeklyBox││❯Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›│├────────────────────────────────────────────────────────────────├───────────────────────────────────────────────────────────────────│Produce›❯Delivery›││Fruit›Giftwrap?no││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│││[ Place order ][Cancel]│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no├────────────────────────────────────────────────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Marketstall││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││││Produce›Delivery›││Fruit›│Fruit›Giftwrap?no││apple││Vegetables›││carrot││[ Place order ][Cancel]││↑/↓/←/→tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Summary›││❯Produce›Delivery›││Produce›❯Delivery›││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│││❯Fruit›│❯Fruit›Giftwrap?no│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/panel-layout-light-animated.svg b/docs/assets/panel-layout-light-animated.svg
index 30a3dfea..b1522c4b 100644
--- a/docs/assets/panel-layout-light-animated.svg
+++ b/docs/assets/panel-layout-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Marketstall│├──────────────────────────────────────────────────────────────────────────┤│││❯Summary›││Theorderataglance.││OrdernameWeeklyBox│OrdernameWeeklyBox││Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›││[Placeorder][Cancel]│├───────────────────────────────────────────────────────────────├──────────────────────────────────────────────────────────────────│↑/↓/←/→move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Summary›││Theorderataglance.││OrdernameWeeklyBox│OrdernameWeeklyBox││❯Produce›Delivery›││Fruit›Giftwrap?no││Vegetables›│├────────────────────────────────────────────────────────────────├───────────────────────────────────────────────────────────────────│Produce›❯Delivery›││Fruit›Giftwrap?no││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│││[ Place order ][Cancel]│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no├────────────────────────────────────────────────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Marketstall││❯Summary›││Theorderataglance.││OrdernameWeeklyBox││││Produce›Delivery›││Fruit›│Fruit›Giftwrap?no││apple││Vegetables›││carrot││[ Place order ][Cancel]││↑/↓/←/→tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Summary›││❯Produce›Delivery›││Produce›❯Delivery›││Marketstall›Produce││❯Fruit›Vegetables›││FruitappleVegetablescarrot│││❯Fruit›│❯Fruit›Giftwrap?no│SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-dark-animated-ascii-no-ansi.svg b/docs/assets/produce-box-dark-animated-ascii-no-ansi.svg
index 266ca690..b57e0fcb 100644
--- a/docs/assets/produce-box-dark-animated-ascii-no-ansi.svg
+++ b/docs/assets/produce-box-dark-animated-ascii-no-ansi.svg
@@ -1 +1 @@
-+--------------------------------------------------------------------------+|Producebox||||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||Contents&options>||Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]|[Submit][Cancel]|||^/vmove*<select*escback*qquit*?help||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1derived||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1der|Boxcodesunny/tui1derived||Derivedfromgrowerandslug.||LabelTui1derived||BoxnameTui1||>Slugtui1derived||>Growersunny||>Boxcodesunny/tui1derived||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no|+--|>Boxsize()Small||(*)Medium||()Large||^/vmove*<accept*esccancel||()Medium||(*)Large||>Boxsizelargeedited||Boxsizelargeedited||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||DeliverydayFriday|>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbsedited||Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbsedited||>Herbbundlemixed||>Weeklydelivery?yes||>|Friday||Monday||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>Boxcodesunny/tui1der|>LabelTui1derived||>DeliverydayFriday||>DeliverydayFriday|||>DeliverydayFrida|||>DeliverydayFrid|||>DeliverydayFri|||>DeliverydayFr|||>DeliverydayF|||>Deliveryday|||Wednesday||Saturday||v||>DeliverydayM|||>DeliverydayMo|||>DeliverydayMon|||>DeliverydayMond|||>DeliverydayMonda|||>DeliverydayMonday|||>DeliverydayMondayedited|
\ No newline at end of file
++--------------------------------------------------------------------------+|Producebox||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||||Contents&options>||Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1derived||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1derived||Derivedfromgrowerandslug.||LabelTui1derived||BoxnameTui1||>Slugtui1derived||>Growersunny||>Boxcodesunny/tui1derived||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||^/Vtomove*<toaccept*ESCtocancel||()Medium||(*)Large||>Boxsizelargeedited||Boxsizelargeedited||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||Giftwrap?no|SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbsedited||Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbsedited||>Herbbundlemixed||>Weeklydelivery?yes||>DeliverydayFriday||>DeliverydayFriday|||Friday|Friday||>DeliverydayFrida|||>DeliverydayFrid|||>DeliverydayFri|||>DeliverydayFr|||>DeliverydayF|||>Deliveryday|||Monday|Monday||>DeliverydayM|||>DeliverydayMo|||>DeliverydayMon|||>DeliverydayMond|||>DeliverydayMonda|||>DeliverydayMonday|||>DeliverydayMondayedited||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>|>LabelTui1derived||Wednesday||Saturday|
\ No newline at end of file
diff --git a/docs/assets/produce-box-dark-animated-ascii.svg b/docs/assets/produce-box-dark-animated-ascii.svg
index d8974c35..d3c93591 100644
--- a/docs/assets/produce-box-dark-animated-ascii.svg
+++ b/docs/assets/produce-box-dark-animated-ascii.svg
@@ -1 +1 @@
-+--------------------------------------------------------------------------+|Producebox||||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||Contents&options>||Whattheboxshipswith.|Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]||^/vmove*<select*escback*qquit*?help||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1 derived ||Derivedfromtheboxname.|||Growersunny||Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||LabelTui1 derived ||BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||>Slugtui1 derived ||Derivedfromtheboxname.||Grower|Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||>Contents&options>||Whattheboxshipswith.|Whattheboxshipswith.||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||^/vmove*<accept*esccancel||()Medium||(*)Large||>Boxsizelarge edited ||DeliverydayFriday|Boxsizelarge edited ||>Contents||Spacetotoggle,typetofilter.||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||>Contents>[x]Fruit||[]Salad|>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbs edited ||Herbbundlemixed|Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbs edited ||>Herbbundlemixed|>DeliverydayFriday|||Friday|+---+-------------|>DeliverydayFrida|||Friday||>DeliverydayFrid|||Friday||>DeliverydayFri|||Friday||>DeliverydayFr|||Friday||>DeliverydayF|||Friday||>Deliveryday|||Monday||Wednesday||Friday||Saturday||v||>DeliverydayM|||Monday||>DeliverydayMo|||Monday||>DeliverydayMon|||Monday||>DeliverydayMond|||Monday||>DeliverydayMonda|||Monday||>DeliverydayMonday|||Monday||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>|>Growersunny||>Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||>LabelTui1 derived ||medium**Friday*no||>Herbbundlemixed||>Weeklydelivery?yes||>DeliverydayFriday|+------------+----------------------|>DeliverydayMonday edited ||large*fruit,veg,herbs*mixed*yes||l|[ Submit ][Cancel]|
\ No newline at end of file
++--------------------------------------------------------------------------+|Producebox||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||||Contents&options>||Whattheboxshipswith.||medium**Friday*no|||[ Submit ][Cancel]||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1 derived ||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||LabelTui1 derived ||BoxnameTui1||>Slugtui1 derived ||>Growersunny||>Boxcodesunny/tui1 derived ||>LabelTui1 derived ||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no|Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||DeliverydayFriday|^/Vtomove*<toaccept*ESCtocancel||()Medium||(*)Large||>Boxsizelarge edited ||Boxsizelarge edited ||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbs edited ||Herbbundlemixed||Weeklydelivery?yes|Weeklydelivery?yes||Contentsfruit,veg,herbs edited ||>Herbbundlemixed||>Weeklydelivery?yes|>Weeklydelivery?yes||>DeliverydayFriday||>DeliverydayFriday|||Friday||>DeliverydayFrida|||Friday||>DeliverydayFrid|||Friday||>DeliverydayFri|||Friday||>DeliverydayFr|||Friday||>DeliverydayF|||Friday||>Deliveryday|||Monday||Wednesday||Friday||Saturday||>DeliverydayM|||Monday||>DeliverydayMo|||Monday||>DeliverydayMon|||Monday||>DeliverydayMond|||Monday||>DeliverydayMonda|||Monday||>DeliverydayMonday|||Monday||>DeliverydayMonday edited ||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-dark-animated-no-ansi.svg b/docs/assets/produce-box-dark-animated-no-ansi.svg
index 2352895f..1af877f3 100644
--- a/docs/assets/produce-box-dark-animated-no-ansi.svg
+++ b/docs/assets/produce-box-dark-animated-no-ansi.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Producebox│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││Contents&options›│Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1derived││Derivedfromtheboxname.│Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1derived││Derivedfromgrowerandslug.││LabelTui1derived│╰───╰───────│BoxnameTui1││❯Slugtui1derived││❯Growersunny││❯Boxcodesunny/tui1derived││❯LabelTui1derived││Basics›││❯Contents&options›│❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.│││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large││Contents│↑/↓move·↵accept·esccancel│╰─────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────│○Medium││●Large││❯Boxsizelargeedited││Boxsizelargeedited││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs│◻Herbs││◻Salad│╰────────────────────────────╰────────────────────────────────│❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs│❯◻Herbs││❯◼Herbs│❯◼Herbs││❯Contentsfruit,veg,herbsedited││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbsedited││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││↑/↓move·│↑/↓move·↵accept·│❯DeliverydayFrida█││❯DeliverydayFrid█││❯DeliverydayFri█││❯DeliverydayFr█││❯DeliverydayF█││❯Deliveryday█││Monday││Wednesday││Saturday││▼││❯DeliverydayM█││❯DeliverydayMo█││❯DeliverydayMon█││❯DeliverydayMond█││❯DeliverydayMonda█││❯DeliverydayMonday█││❯DeliverydayMondayedited││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no│Contents&opt├───────────────────────────├───────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Producebox││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││││Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]│[Submit][Cancel]││↑/↓tomove·↵toselect·ESCtogoback·Qtoqu│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1derived││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1│Boxcodesunny/tui1derived││Derivedfromgrowerandslug.││LabelTui1derived││BoxnameTui1││❯Slugtui1derived││❯Growersunny││❯Boxcodesunny/tui1│❯Boxcodesunny/tui1derived││❯LabelTui1derived││Basics›││❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no│││❯Boxsize○Small││●Medium││○Large││↑/↓tomove·↵toaccept·ESCtocancel│↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelargeedited││Boxsizelargeedited││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad││DeliverydayFrida│DeliverydayFriday│SPACEtoselect·↑/↓tomove·←/→toselectnon│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbsedited││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbsedited││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││❯DeliverydayFrida█││❯DeliverydayFrid█││❯DeliverydayFri█││❯DeliverydayFr█││❯DeliverydayF█││❯Deliveryday█││Monday││Wednesday││Saturday││❯DeliverydayM█││❯DeliverydayMo█││❯DeliverydayMon█││❯DeliverydayMond█││❯DeliverydayMonda█││❯DeliverydayMonday█││❯DeliverydayMondayedited││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-dark-animated.svg b/docs/assets/produce-box-dark-animated.svg
index 3affaed5..1d268965 100644
--- a/docs/assets/produce-box-dark-animated.svg
+++ b/docs/assets/produce-box-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Producebox│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1│Tui1·tui1·sunny·sunny/tui1││Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".│││Slugtui1 derived ││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││LabelTui1 derived ││BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││❯Slugtui1 derived ││Derivedfromtheboxname.││❯Growersunny││❯Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││❯LabelTui1 derived ││Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1│Tui1·tui1·sunny·sunny/tui1││❯Contents&options›││Whattheboxshipswith.││medium··Friday·no││Producebox›Contents&options││❯Boxsizemedium││Contents│Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large│○Large│├──────────────────────────────────────────────────├─────────────────────────────────────────────────────│↑/↓move·↵accept·esccancel││○Medium││●Large│●Large││❯Boxsizelarge edited │╰──│Boxsizelarge edited ││❯Contents│❯Contents││Spacetotoggle,typetofilter.││❯Contents❯◻Fruit│❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad││❯Contents❯◼Fruit│❯Contents❯◼Fruit│├────────├────────────│❯Contents◼Fruit│❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables│├─────├─────────│◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbs│❯Contentsfruit,veg,herbs edited ││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbs edited │Contentsfruit,veg,herbs edited ││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││Giftwrap?no│❯DeliverydayFrida█││Friday││❯DeliverydayFrid█││Friday││❯DeliverydayFri█││Friday││❯DeliverydayFr█││Friday││❯DeliverydayF█││Friday││❯Deliveryday█││Monday││Wednesday││Friday│❯DeliverydayM█││Monday││❯DeliverydayMo█││Monday││❯DeliverydayMon█││Monday││❯DeliverydayMond█││Monday││❯DeliverydayMonda█││Monday││❯DeliverydayMonday█││Monday││❯DeliverydayMonday edited ││large·fruit,veg,herbs·mixed·yes││large·fruit,veg,herbs·mixed·yes││[ Submit ][Cancel]│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no╰───╰──────├───────────├───────────────├──├──────│Friday││Saturday││▼││↑/│↑/↓move
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Producebox││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││││Contents&options›││Whattheboxshipswith.│Whattheboxshipswith.││medium··Friday·no││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1 derived ││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││LabelTui1 derived ││BoxnameTui1││❯Slugtui1 derived ││❯Growersunny││❯Boxcodesunny/tui1 derived ││❯LabelTui1 derived ││Basics›││❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large││Spacetotoggle,typetofilter.│↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelarge edited ││Boxsizelarge edited ││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad│◻Salad││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbs edited ││Herbbundlemixed│Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbs edited ││❯Herbbundlemixed│❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││❯DeliverydayFrida█││Friday││❯DeliverydayFrid█││Friday││❯DeliverydayFri█││Friday││❯DeliverydayFr█││Friday││❯DeliverydayF█││Friday││❯Deliveryday█││Monday││Wednesday││Friday││Saturday││Giftwrap?no│❯DeliverydayM█││Monday││❯DeliverydayMo█││Monday││❯DeliverydayMon█││Monday││❯DeliverydayMond█││Monday││❯DeliverydayMonda█││Monday││❯DeliverydayMonday█││Monday││❯DeliverydayMonday edited ││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-light-animated-ascii-no-ansi.svg b/docs/assets/produce-box-light-animated-ascii-no-ansi.svg
index 1aa5c561..5c96aae3 100644
--- a/docs/assets/produce-box-light-animated-ascii-no-ansi.svg
+++ b/docs/assets/produce-box-light-animated-ascii-no-ansi.svg
@@ -1 +1 @@
-+--------------------------------------------------------------------------+|Producebox||||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||Contents&options>||Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]|[Submit][Cancel]|||^/vmove*<select*escback*qquit*?help||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1derived||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1der|Boxcodesunny/tui1derived||Derivedfromgrowerandslug.||LabelTui1derived||BoxnameTui1||>Slugtui1derived||>Growersunny||>Boxcodesunny/tui1derived||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no|+--|>Boxsize()Small||(*)Medium||()Large||^/vmove*<accept*esccancel||()Medium||(*)Large||>Boxsizelargeedited||Boxsizelargeedited||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||DeliverydayFriday|>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbsedited||Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbsedited||>Herbbundlemixed||>Weeklydelivery?yes||>|Friday||Monday||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>Boxcodesunny/tui1der|>LabelTui1derived||>DeliverydayFriday||>DeliverydayFriday|||>DeliverydayFrida|||>DeliverydayFrid|||>DeliverydayFri|||>DeliverydayFr|||>DeliverydayF|||>Deliveryday|||Wednesday||Saturday||v||>DeliverydayM|||>DeliverydayMo|||>DeliverydayMon|||>DeliverydayMond|||>DeliverydayMonda|||>DeliverydayMonday|||>DeliverydayMondayedited|
\ No newline at end of file
++--------------------------------------------------------------------------+|Producebox||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||||Contents&options>||Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]|||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1derived||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1derived||Derivedfromgrowerandslug.||LabelTui1derived||BoxnameTui1||>Slugtui1derived||>Growersunny||>Boxcodesunny/tui1derived||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||^/Vtomove*<toaccept*ESCtocancel||()Medium||(*)Large||>Boxsizelargeedited||Boxsizelargeedited||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||Giftwrap?no|SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbsedited||Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbsedited||>Herbbundlemixed||>Weeklydelivery?yes||>DeliverydayFriday||>DeliverydayFriday|||Friday|Friday||>DeliverydayFrida|||>DeliverydayFrid|||>DeliverydayFri|||>DeliverydayFr|||>DeliverydayF|||>Deliveryday|||Monday|Monday||>DeliverydayM|||>DeliverydayMo|||>DeliverydayMon|||>DeliverydayMond|||>DeliverydayMonda|||>DeliverydayMonday|||>DeliverydayMondayedited||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>|>LabelTui1derived||Wednesday||Saturday|
\ No newline at end of file
diff --git a/docs/assets/produce-box-light-animated-ascii.svg b/docs/assets/produce-box-light-animated-ascii.svg
index fd5ced09..18606f32 100644
--- a/docs/assets/produce-box-light-animated-ascii.svg
+++ b/docs/assets/produce-box-light-animated-ascii.svg
@@ -1 +1 @@
-+--------------------------------------------------------------------------+|Producebox||||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||Contents&options>||Whattheboxshipswith.|Whattheboxshipswith.||medium**Friday*no||[Submit][Cancel]||^/vmove*<select*escback*qquit*?help||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1 derived ||Derivedfromtheboxname.|||Growersunny||Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||LabelTui1 derived ||BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||>Slugtui1 derived ||Derivedfromtheboxname.||Grower|Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||>Contents&options>||Whattheboxshipswith.|Whattheboxshipswith.||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||^/vmove*<accept*esccancel||()Medium||(*)Large||>Boxsizelarge edited ||DeliverydayFriday|Boxsizelarge edited ||>Contents||Spacetotoggle,typetofilter.||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||>Contents>[x]Fruit||[]Salad|>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbs edited ||Herbbundlemixed|Herbbundlemixed||Weeklydelivery?yes||Contentsfruit,veg,herbs edited ||>Herbbundlemixed|>DeliverydayFriday|||Friday|+---+-------------|>DeliverydayFrida|||Friday||>DeliverydayFrid|||Friday||>DeliverydayFri|||Friday||>DeliverydayFr|||Friday||>DeliverydayF|||Friday||>Deliveryday|||Monday||Wednesday||Friday||Saturday||v||>DeliverydayM|||Monday||>DeliverydayMo|||Monday||>DeliverydayMon|||Monday||>DeliverydayMond|||Monday||>DeliverydayMonda|||Monday||>DeliverydayMonday|||Monday||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no|>|>Growersunny||>Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||>LabelTui1 derived ||medium**Friday*no||>Herbbundlemixed||>Weeklydelivery?yes||>DeliverydayFriday|+------------+----------------------|>DeliverydayMonday edited ||large*fruit,veg,herbs*mixed*yes||l|[ Submit ][Cancel]|
\ No newline at end of file
++--------------------------------------------------------------------------+|Producebox||>Basics>||Namingandidentity.||Tui1*tui1*sunny*sunny/tui1||||Contents&options>||Whattheboxshipswith.||medium**Friday*no|||[ Submit ][Cancel]||^/Vtomove*<toselect*ESCtogoback*Qtoquit||Producebox>Basics||>BoxnameTui1||Ahuman-readablename,e.g."SummerBox".||Slugtui1 derived ||Derivedfromtheboxname.||Growersunny||Boxcodesunny/tui1 derived ||Derivedfromgrowerandslug.||LabelTui1 derived ||BoxnameTui1||>Slugtui1 derived ||>Growersunny||>Boxcodesunny/tui1 derived ||>LabelTui1 derived ||Basics>||>Contents&options>||Producebox>Contents&options||>Boxsizemedium||Contents||Spacetotoggle,typetofilter.||DeliverydayFriday||Giftwrap?no|Giftwrap?no||>Boxsize()Small||(*)Medium||()Large||DeliverydayFriday|^/Vtomove*<toaccept*ESCtocancel||()Medium||(*)Large||>Boxsizelarge edited ||Boxsizelarge edited ||>Contents||>Contents>[]Fruit||[]Vegetables||[]Herbs||[]Salad||SPACEtoselect*^/Vtomove*</>toselectnoneorall*<toaccept||>Contents>[x]Fruit||>Contents[x]Fruit||>[]Vegetables||>[x]Vegetables||[x]Vegetables||>[]Herbs||>[x]Herbs||>Contentsfruit,veg,herbs edited ||Herbbundlemixed||Weeklydelivery?yes|Weeklydelivery?yes||Contentsfruit,veg,herbs edited ||>Herbbundlemixed||>Weeklydelivery?yes|>Weeklydelivery?yes||>DeliverydayFriday||>DeliverydayFriday|||Friday||>DeliverydayFrida|||Friday||>DeliverydayFrid|||Friday||>DeliverydayFri|||Friday||>DeliverydayFr|||Friday||>DeliverydayF|||Friday||>Deliveryday|||Monday||Wednesday||Friday||Saturday||>DeliverydayM|||Monday||>DeliverydayMo|||Monday||>DeliverydayMon|||Monday||>DeliverydayMond|||Monday||>DeliverydayMonda|||Monday||>DeliverydayMonday|||Monday||>DeliverydayMonday edited ||large*fruit,veg,herbs*mixed*yes|BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-light-animated-no-ansi.svg b/docs/assets/produce-box-light-animated-no-ansi.svg
index 8da402df..be7ef44b 100644
--- a/docs/assets/produce-box-light-animated-no-ansi.svg
+++ b/docs/assets/produce-box-light-animated-no-ansi.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Producebox│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││Contents&options›│Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1derived││Derivedfromtheboxname.│Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1derived││Derivedfromgrowerandslug.││LabelTui1derived│╰───╰───────│BoxnameTui1││❯Slugtui1derived││❯Growersunny││❯Boxcodesunny/tui1derived││❯LabelTui1derived││Basics›││❯Contents&options›│❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.│││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large││Contents│↑/↓move·↵accept·esccancel│╰─────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────│○Medium││●Large││❯Boxsizelargeedited││Boxsizelargeedited││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs│◻Herbs││◻Salad│╰────────────────────────────╰────────────────────────────────│❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs│❯◻Herbs││❯◼Herbs│❯◼Herbs││❯Contentsfruit,veg,herbsedited││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbsedited││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││↑/↓move·│↑/↓move·↵accept·│❯DeliverydayFrida█││❯DeliverydayFrid█││❯DeliverydayFri█││❯DeliverydayFr█││❯DeliverydayF█││❯Deliveryday█││Monday││Wednesday││Saturday││▼││❯DeliverydayM█││❯DeliverydayMo█││❯DeliverydayMon█││❯DeliverydayMond█││❯DeliverydayMonda█││❯DeliverydayMonday█││❯DeliverydayMondayedited││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no│Contents&opt├───────────────────────────├───────────────────────────────
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Producebox││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││││Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]│[Submit][Cancel]││↑/↓tomove·↵toselect·ESCtogoback·Qtoqu│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1derived││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1│Boxcodesunny/tui1derived││Derivedfromgrowerandslug.││LabelTui1derived││BoxnameTui1││❯Slugtui1derived││❯Growersunny││❯Boxcodesunny/tui1│❯Boxcodesunny/tui1derived││❯LabelTui1derived││Basics›││❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no│││❯Boxsize○Small││●Medium││○Large││↑/↓tomove·↵toaccept·ESCtocancel│↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelargeedited││Boxsizelargeedited││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad││DeliverydayFrida│DeliverydayFriday│SPACEtoselect·↑/↓tomove·←/→toselectnon│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·│SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbsedited││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbsedited││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││❯DeliverydayFrida█││❯DeliverydayFrid█││❯DeliverydayFri█││❯DeliverydayFr█││❯DeliverydayF█││❯Deliveryday█││Monday││Wednesday││Saturday││❯DeliverydayM█││❯DeliverydayMo█││❯DeliverydayMon█││❯DeliverydayMond█││❯DeliverydayMonda█││❯DeliverydayMonday█││❯DeliverydayMondayedited││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/produce-box-light-animated.svg b/docs/assets/produce-box-light-animated.svg
index 64389663..fc8fe9f4 100644
--- a/docs/assets/produce-box-light-animated.svg
+++ b/docs/assets/produce-box-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Producebox│├──────────────────────────────────────────────────────────────────────────┤│││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1│Tui1·tui1·sunny·sunny/tui1││Contents&options›││Whattheboxshipswith.││medium··Friday·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".│││Slugtui1 derived ││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││LabelTui1 derived ││BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││❯Slugtui1 derived ││Derivedfromtheboxname.││❯Growersunny││❯Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││❯LabelTui1 derived ││Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1│Tui1·tui1·sunny·sunny/tui1││❯Contents&options›││Whattheboxshipswith.││medium··Friday·no││Producebox›Contents&options││❯Boxsizemedium││Contents│Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large│○Large│├──────────────────────────────────────────────────├─────────────────────────────────────────────────────│↑/↓move·↵accept·esccancel││○Medium││●Large│●Large││❯Boxsizelarge edited │╰──│Boxsizelarge edited ││❯Contents│❯Contents││Spacetotoggle,typetofilter.││❯Contents❯◻Fruit│❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad││❯Contents❯◼Fruit│❯Contents❯◼Fruit│├────────├────────────│❯Contents◼Fruit│❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables│├─────├─────────│◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbs│❯Contentsfruit,veg,herbs edited ││Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbs edited │Contentsfruit,veg,herbs edited ││❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││Giftwrap?no│❯DeliverydayFrida█││Friday││❯DeliverydayFrid█││Friday││❯DeliverydayFri█││Friday││❯DeliverydayFr█││Friday││❯DeliverydayF█││Friday││❯Deliveryday█││Monday││Wednesday││Friday│❯DeliverydayM█││Monday││❯DeliverydayMo█││Monday││❯DeliverydayMon█││Monday││❯DeliverydayMond█││Monday││❯DeliverydayMonda█││Monday││❯DeliverydayMonday█││Monday││❯DeliverydayMonday edited ││large·fruit,veg,herbs·mixed·yes││large·fruit,veg,herbs·mixed·yes││[ Submit ][Cancel]│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no╰───╰──────├───────────├───────────────├──├──────│Friday││Saturday││▼││↑/│↑/↓move
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Producebox││❯Basics›││Namingandidentity.││Tui1·tui1·sunny·sunny/tui1││││Contents&options›││Whattheboxshipswith.│Whattheboxshipswith.││medium··Friday·no││[ Submit ][Cancel]│││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Producebox›Basics││❯BoxnameTui1││Ahuman-readablename,e.g."SummerBox".││Slugtui1 derived ││Derivedfromtheboxname.││Growersunny││Boxcodesunny/tui1 derived ││Derivedfromgrowerandslug.││LabelTui1 derived ││BoxnameTui1││❯Slugtui1 derived ││❯Growersunny││❯Boxcodesunny/tui1 derived ││❯LabelTui1 derived ││Basics›││❯Contents&options›││Producebox›Contents&options││❯Boxsizemedium││Contents││Spacetotoggle,typetofilter.││DeliverydayFriday││Giftwrap?no││❯Boxsize○Small││●Medium││○Large││Spacetotoggle,typetofilter.│↑/↓tomove·↵toaccept·ESCtocancel││○Medium││●Large││❯Boxsizelarge edited ││Boxsizelarge edited ││❯Contents││❯Contents❯◻Fruit││◻Vegetables││◻Herbs││◻Salad│◻Salad││SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·↵toaccept││❯Contents❯◼Fruit││❯Contents◼Fruit││❯◻Vegetables││❯◼Vegetables││◼Vegetables││❯◻Herbs││❯◼Herbs││❯Contentsfruit,veg,herbs edited ││Herbbundlemixed│Herbbundlemixed││Weeklydelivery?yes││Contentsfruit,veg,herbs edited ││❯Herbbundlemixed│❯Herbbundlemixed││❯Weeklydelivery?yes││❯DeliverydayFriday││❯DeliverydayFriday█││Friday││❯DeliverydayFrida█││Friday││❯DeliverydayFrid█││Friday││❯DeliverydayFri█││Friday││❯DeliverydayFr█││Friday││❯DeliverydayF█││Friday││❯Deliveryday█││Monday││Wednesday││Friday││Saturday││Giftwrap?no│❯DeliverydayM█││Monday││❯DeliverydayMo█││Monday││❯DeliverydayMon█││Monday││❯DeliverydayMond█││Monday││❯DeliverydayMonda█││Monday││❯DeliverydayMonday█││Monday││❯DeliverydayMonday edited ││large·fruit,veg,herbs·mixed·yes│BasicsBoxname:Tui1Slug:tui1(derived)Grower:sunnyBoxcode:sunny/tui1(derived)Label:Tui1(derived)Contents&optionsBoxsize:large(edited)Contents:fruit,veg,herbs(edited)Herbbundle:mixedWeeklydelivery?:yesDeliveryday:Monday(edited)Giftwrap?:no
\ No newline at end of file
diff --git a/docs/assets/quickstart-dark-static.svg b/docs/assets/quickstart-dark-static.svg
index f37a1940..58bbc382 100644
--- a/docs/assets/quickstart-dark-static.svg
+++ b/docs/assets/quickstart-dark-static.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────╮│Quickstart›Neworder││❯Ordername││Fruitbanana││Vegetablescarrot││Quantity6││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────╮│Quickstart›Neworder││❯Ordername││Fruitbanana││Vegetablescarrot││Quantity6││Organiconly?no││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/quickstart-light-static.svg b/docs/assets/quickstart-light-static.svg
index c70bcbf3..449f29be 100644
--- a/docs/assets/quickstart-light-static.svg
+++ b/docs/assets/quickstart-light-static.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────╮│Quickstart›Neworder││❯Ordername││Fruitbanana││Vegetablescarrot││Quantity6││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────╮│Quickstart›Neworder││❯Ordername││Fruitbanana││Vegetablescarrot││Quantity6││Organiconly?no││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/social-card.png b/docs/assets/social-card.png
index 993c0575..f93358fb 100644
Binary files a/docs/assets/social-card.png and b/docs/assets/social-card.png differ
diff --git a/docs/assets/testing-dark-static.svg b/docs/assets/testing-dark-static.svg
index d9cf58a8..557b1eb5 100644
--- a/docs/assets/testing-dark-static.svg
+++ b/docs/assets/testing-dark-static.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││name='WeeklyBox'fruit='banana'organic=falsecancelled:falseinterrupted:falserenderedtheedit:true---finalframe---╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││Neworder›││WeeklyBox·banana·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││---finalframe---╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││Neworder›││WeeklyBox·banana·no││[Submit][Cancel]││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/testing-light-static.svg b/docs/assets/testing-light-static.svg
index 8cbac8dc..29f41f0d 100644
--- a/docs/assets/testing-light-static.svg
+++ b/docs/assets/testing-light-static.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││name='WeeklyBox'fruit='banana'organic=falsecancelled:falseinterrupted:falserenderedtheedit:true---finalframe---╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││Neworder›││WeeklyBox·banana·no││[Submit][Cancel]││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││---finalframe---╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││Neworder›││WeeklyBox·banana·no││[Submit][Cancel]││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-dos-dark-static.svg b/docs/assets/theme-dos-dark-static.svg
index 36e0b1e9..df3cb043 100644
--- a/docs/assets/theme-dos-dark-static.svg
+++ b/docs/assets/theme-dos-dark-static.svg
@@ -1 +1 @@
-╠══════════════════════════════════════════════════════════════════════════║╔══════════════════════════════════════════════════════════════════════════║Theme preview › Preview║❯Box nameWeekly Box║Shown in the header.║ Grade premium║Quality grade.║ Extras herbs, nuts║Added extras.║ ▼║↑/↓ move · ↵ select · esc back · q quit · ? help╚══════════════════════════════════════════════════════════════════════════
\ No newline at end of file
+║╔══════════════════════════════════════════════════════════════════════════║Theme preview › Preview║❯ Box name Weekly Box║Shown in the header.║ Grade premium║Quality grade.║ Extras herbs, nuts║Added extras.║ Gift wrap yes║Wrap the box as a gift.║↑/↓to move·↵to select·ESCto go back·Qto quit╚══════════════════════════════════════════════════════════════════════════
\ No newline at end of file
diff --git a/docs/assets/theme-dos-light-static.svg b/docs/assets/theme-dos-light-static.svg
index 36e0b1e9..df3cb043 100644
--- a/docs/assets/theme-dos-light-static.svg
+++ b/docs/assets/theme-dos-light-static.svg
@@ -1 +1 @@
-╠══════════════════════════════════════════════════════════════════════════║╔══════════════════════════════════════════════════════════════════════════║Theme preview › Preview║❯Box nameWeekly Box║Shown in the header.║ Grade premium║Quality grade.║ Extras herbs, nuts║Added extras.║ ▼║↑/↓ move · ↵ select · esc back · q quit · ? help╚══════════════════════════════════════════════════════════════════════════
\ No newline at end of file
+║╔══════════════════════════════════════════════════════════════════════════║Theme preview › Preview║❯ Box name Weekly Box║Shown in the header.║ Grade premium║Quality grade.║ Extras herbs, nuts║Added extras.║ Gift wrap yes║Wrap the box as a gift.║↑/↓to move·↵to select·ESCto go back·Qto quit╚══════════════════════════════════════════════════════════════════════════
\ No newline at end of file
diff --git a/docs/assets/theme-ember-dark-static-bordered.svg b/docs/assets/theme-ember-dark-static-bordered.svg
index 65ddaed3..cc01e416 100644
--- a/docs/assets/theme-ember-dark-static-bordered.svg
+++ b/docs/assets/theme-ember-dark-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-ember-dark-static.svg b/docs/assets/theme-ember-dark-static.svg
index 62be90e3..eb2f91ce 100644
--- a/docs/assets/theme-ember-dark-static.svg
+++ b/docs/assets/theme-ember-dark-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-ember-light-static-bordered.svg b/docs/assets/theme-ember-light-static-bordered.svg
index 2b18b504..8f95a387 100644
--- a/docs/assets/theme-ember-light-static-bordered.svg
+++ b/docs/assets/theme-ember-light-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-ember-light-static.svg b/docs/assets/theme-ember-light-static.svg
index d5b1abdb..7411993c 100644
--- a/docs/assets/theme-ember-light-static.svg
+++ b/docs/assets/theme-ember-light-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-frost-dark-static-bordered.svg b/docs/assets/theme-frost-dark-static-bordered.svg
index ce317e9c..471ea6a2 100644
--- a/docs/assets/theme-frost-dark-static-bordered.svg
+++ b/docs/assets/theme-frost-dark-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-frost-dark-static.svg b/docs/assets/theme-frost-dark-static.svg
index ea5c69bb..ce299d7a 100644
--- a/docs/assets/theme-frost-dark-static.svg
+++ b/docs/assets/theme-frost-dark-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-frost-light-static-bordered.svg b/docs/assets/theme-frost-light-static-bordered.svg
index 6a658945..a9e26509 100644
--- a/docs/assets/theme-frost-light-static-bordered.svg
+++ b/docs/assets/theme-frost-light-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-frost-light-static.svg b/docs/assets/theme-frost-light-static.svg
index 59c40193..ca892936 100644
--- a/docs/assets/theme-frost-light-static.svg
+++ b/docs/assets/theme-frost-light-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-midnight-dark-static-bordered.svg b/docs/assets/theme-midnight-dark-static-bordered.svg
index 15069c08..ac9b3983 100644
--- a/docs/assets/theme-midnight-dark-static-bordered.svg
+++ b/docs/assets/theme-midnight-dark-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-midnight-dark-static.svg b/docs/assets/theme-midnight-dark-static.svg
index 11d811a6..3ef1f793 100644
--- a/docs/assets/theme-midnight-dark-static.svg
+++ b/docs/assets/theme-midnight-dark-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-midnight-light-static-bordered.svg b/docs/assets/theme-midnight-light-static-bordered.svg
index 4e9fb576..2e718c41 100644
--- a/docs/assets/theme-midnight-light-static-bordered.svg
+++ b/docs/assets/theme-midnight-light-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-midnight-light-static.svg b/docs/assets/theme-midnight-light-static.svg
index c64996d2..2d2a881f 100644
--- a/docs/assets/theme-midnight-light-static.svg
+++ b/docs/assets/theme-midnight-light-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-mono-dark-static-bordered.svg b/docs/assets/theme-mono-dark-static-bordered.svg
index 55123df5..cd6737d4 100644
--- a/docs/assets/theme-mono-dark-static-bordered.svg
+++ b/docs/assets/theme-mono-dark-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-mono-dark-static.svg b/docs/assets/theme-mono-dark-static.svg
index 1801c5d5..54cff4b7 100644
--- a/docs/assets/theme-mono-dark-static.svg
+++ b/docs/assets/theme-mono-dark-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-mono-light-static-bordered.svg b/docs/assets/theme-mono-light-static-bordered.svg
index c7a63f71..92adcc55 100644
--- a/docs/assets/theme-mono-light-static-bordered.svg
+++ b/docs/assets/theme-mono-light-static-bordered.svg
@@ -1 +1 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││▼││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+││╭──────────────────────────────────────────────────────────────────────────╮│Themepreview›Preview││❯BoxnameWeeklyBox││Shownintheheader.││Gradepremium││Qualitygrade.││Extrasherbs,nuts││Addedextras.││Giftwrapyes││Wraptheboxasagift.││↑/↓tomove·↵toselect·ESCtogoback·Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/theme-mono-light-static.svg b/docs/assets/theme-mono-light-static.svg
index d3a5d86f..a3bfa888 100644
--- a/docs/assets/theme-mono-light-static.svg
+++ b/docs/assets/theme-mono-light-static.svg
@@ -1 +1 @@
-Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·↵select·escback·qquit·?help
\ No newline at end of file
+Themepreview›Preview❯BoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓tomove·↵toselect·ESCtogoback·Qtoquit
\ No newline at end of file
diff --git a/docs/assets/theme-ocean-dark-animated.svg b/docs/assets/theme-ocean-dark-animated.svg
index 5d93038c..b5db617a 100644
--- a/docs/assets/theme-ocean-dark-animated.svg
+++ b/docs/assets/theme-ocean-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────────────────╮│≈Oceanthemedemo│├──────────────────────────────────────────────────────────────────────────┤│││➤Seasidestall»3items││»Harbour/fruit/│││«Submit»«Cancel»││↑/↓move•select•escback•qquit•?help│╰──────────────────────────────────────────────────────────────────────────╯│≈Oceanthemedemo/Seasidestall││➤Stallname:Harbour││Stock:fruit│Stock:fruit││Crates:││➤StallnameHarbour▎││accept•esccancel││➤StallnameHarbou▎││➤StallnameHarbo▎││➤StallnameHarb▎││➤StallnameHar▎││➤StallnameHa▎││➤StallnameH▎││➤Stallname▎││➤StallnameS▎││➤StallnameSe▎││➤StallnameSea▎││➤StallnameSeav▎││➤StallnameSeavi▎││➤StallnameSeavie▎││➤StallnameSeaview▎││➤Stallname:Seaview││Stallname:Seaview││➤Stock:fruit││➤Stock◉Fruit│➤Stock◉Fruit││◯Vegetables││◯Herbs││↑/↓move•accept•esccancel│╰─────────────────────────────────────╰───────────────────────────────────────│➤Stock◯Fruit││◉Vegetables││➤Stock:veg││Stock:veg││➤Crates➤▢Apples││▢Pears││▢Plums│╰────────────────────────────────────│➤Crates➤▣Apples│╰─────────────────────────────────│➤Crates▣Apples││➤▢Pears││➤▣Pears││»Seaview/veg/apples,pears││Seasidestall»3items││»Seaview/veg/apples,pears│SeasidestallStallname:Seaview(edited)Stock:veg(edited)Crates:apples,pears(edited)~~~OCEAN~~~≈1.0.0≈Pressanykeytocontinue...│➤Stock:fruit│➤Stock◯Fruit│➤Stock:veg│➤Crates:│╰──────────────────────────────│➤Crates:apples,pears││« Submit »«Cancel»│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Oceanthemedemo││➤Seasidestall»││»Harbour/fruit/││││« Submit »«Cancel»│││↑/↓tomove•toselect•ESCtogoback•Qtoquit│╰──────────────────────────────────────────────────────────────────────────╯│Oceanthemedemo/Seasidestall││➤Stallname:Harbour││Stock:fruit││Crates:││➤Stallname:Harbour▎││toaccept•ESCtocancel││➤Stallname:Harbou▎││➤Stallname:Harbo▎││➤Stallname:Harb▎││➤Stallname:Har▎││➤Stallname:Ha▎││➤Stallname:H▎││➤Stallname:▎││➤Stallname:S▎││➤Stallname:Se▎││➤Stallname:Sea▎││➤Stallname:Seav▎││➤Stallname:Seavi▎││➤Stallname:Seavie▎││➤Stallname:Seaview▎││➤Stallname:Seaview edited ││Stallname:Seaview edited ││➤Stock:fruit││➤Stock:◉Fruit││◯Vegetables││◯Herbs││↑/↓tomove•toaccept•ESCtocancel││➤Stock:◯Fruit││◉Vegetables││➤Stock:veg edited ││Stock:veg edited ││➤Crates:││➤Crates:➤▢Apples││▢Pears│▢Pears││▢Plums││SPACEtoselect•↑/↓tomove•←/→toselectnoneorall•││➤Crates:➤▣Apples││➤Crates:▣Apples││➤▢Pears│➤▢Pears││➤▣Pears│➤▣Pears││➤Crates:apples,pears edited ││»Seaview/veg/apples,pears││Seasidestall»│SeasidestallStallname:Seaview(edited)Stock:veg(edited)Crates:apples,pears(edited)~~~OCEAN~~~≈1.0.0≈Pressanykeytocontinue...
\ No newline at end of file
diff --git a/docs/assets/translations-dark-animated.svg b/docs/assets/translations-dark-animated.svg
index 370325b0..a90fba21 100644
--- a/docs/assets/translations-dark-animated.svg
+++ b/docs/assets/translations-dark-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────┤│││❯Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly│││[Надіслати][Скасувати]││↑/↓перемістити·↵вибрати·escназад·qвийти·?довідк│╰──────────────────────────────────────────────────────────────╯│Produceorder›Новезамовлення││❯НазвазамовленняWeekly││Кошик›││Виберітьфрукти.│Виберітьфрукти.││НазвазамовленняWeekly││❯Кошик›││Виберітьфрукти.│Produceorder›Новезамовлення›Кошик││❯Фруктиapple,banana,cherry,pear││❯Фрукти❯◼Яблуко││◼Банан││◼Вишня││◼Груша││◻Виноград││↑/↓перемістити·↵прийняти·escскасувати││❯Фрукти❯◻Яблуко││◼Г│◼Груша│❯Фрукти◻Яблуко││❯◼Банан││❯◻Банан││◻Банан││❯◼Вишня││❯◻Вишня││❯Фруктиpear змінено ││Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly│НовезамовленняНазвазамовлення:WeeklyКошикФрукти:pear(змінено)│4елементивибрано││Виберітьфрукти.││4елементивибрано│├──────────────────────────────────────────────────────────├────────────────────────────────────────────────────────────│◼├────────────────────────────────────────────────────├──────────────────────────────────────────────────────│pear││[ Надіслати ][Скасувати]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly││││[ Надіслати ][Скасувати]│││↑/↓перемістити·↵вибрати·ESCназад·Qвийти│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Новезамовлення││❯НазвазамовленняWeekly││Кошик›││Виберітьфрукти.││4елементивибрано││НазвазамовленняWeekly││❯Кошик›││Produceorder›Новезамовлення›Кошик││❯Фруктиapple,banana,cherry,pear││❯Фрукти❯◼Яблуко││◼Банан││◼Вишня││◼Груша││◻Виноград│◻Виноград││ПРОБІЛвибрати·↑/↓перемістити·←/→нічого/усе·↵прийняти││❯Фрукти❯◻Яблуко││❯Фрукти◻Яблуко││❯◼Банан││❯◻Банан││◻Банан││❯◼Вишня││❯◻Вишня││❯Фруктиpear змінено ││pear││Новезамовлення›│НовезамовленняНазвазамовлення:WeeklyКошикФрукти:pear(змінено)│↑/
\ No newline at end of file
diff --git a/docs/assets/translations-light-animated.svg b/docs/assets/translations-light-animated.svg
index ef46cbba..9f99f9e1 100644
--- a/docs/assets/translations-light-animated.svg
+++ b/docs/assets/translations-light-animated.svg
@@ -1 +1 @@
-╭──────────────────────────────────────────────────────────────╮│Produceorder│├──────────────────────────────────────────────────────────────┤│││❯Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly│││[Надіслати][Скасувати]││↑/↓перемістити·↵вибрати·escназад·qвийти·?довідк│╰──────────────────────────────────────────────────────────────╯│Produceorder›Новезамовлення││❯НазвазамовленняWeekly││Кошик›││Виберітьфрукти.│Виберітьфрукти.││НазвазамовленняWeekly││❯Кошик›││Виберітьфрукти.│Produceorder›Новезамовлення›Кошик││❯Фруктиapple,banana,cherry,pear││❯Фрукти❯◼Яблуко││◼Банан││◼Вишня││◼Груша││◻Виноград││↑/↓перемістити·↵прийняти·escскасувати││❯Фрукти❯◻Яблуко││◼Г│◼Груша│❯Фрукти◻Яблуко││❯◼Банан││❯◻Банан││◻Банан││❯◼Вишня││❯◻Вишня││❯Фруктиpear змінено ││Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly│НовезамовленняНазвазамовлення:WeeklyКошикФрукти:pear(змінено)│4елементивибрано││Виберітьфрукти.││4елементивибрано│├──────────────────────────────────────────────────────────├────────────────────────────────────────────────────────────│◼├────────────────────────────────────────────────────├──────────────────────────────────────────────────────│pear││[ Надіслати ][Скасувати]│
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────╮│Produceorder││❯Новезамовлення›││Вашещотижневезамовленняпродукції.││Weekly││││[ Надіслати ][Скасувати]│││↑/↓перемістити·↵вибрати·ESCназад·Qвийти│╰──────────────────────────────────────────────────────────────────────────╯│Produceorder›Новезамовлення││❯НазвазамовленняWeekly││Кошик›││Виберітьфрукти.││4елементивибрано││НазвазамовленняWeekly││❯Кошик›││Produceorder›Новезамовлення›Кошик││❯Фруктиapple,banana,cherry,pear││❯Фрукти❯◼Яблуко││◼Банан││◼Вишня││◼Груша││◻Виноград│◻Виноград││ПРОБІЛвибрати·↑/↓перемістити·←/→нічого/усе·↵прийняти││❯Фрукти❯◻Яблуко││❯Фрукти◻Яблуко││❯◼Банан││❯◻Банан││◻Банан││❯◼Вишня││❯◻Вишня││❯Фруктиpear змінено ││pear││Новезамовлення›│НовезамовленняНазвазамовлення:WeeklyКошикФрукти:pear(змінено)│↑/
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-calendar-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 29881822..00000000
--- a/docs/assets/widget-calendar-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendarwidget||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-animated-ascii.svg b/docs/assets/widget-calendar-dark-animated-ascii.svg
deleted file mode 100644
index fc737c10..00000000
--- a/docs/assets/widget-calendar-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendarwidget||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-animated-no-ansi.svg b/docs/assets/widget-calendar-dark-animated-no-ansi.svg
deleted file mode 100644
index 251467e7..00000000
--- a/docs/assets/widget-calendar-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││Calendarwidget││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-animated.svg b/docs/assets/widget-calendar-dark-animated.svg
deleted file mode 100644
index 6e6a79ba..00000000
--- a/docs/assets/widget-calendar-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││Calendarwidget││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-static-ascii-no-ansi.svg b/docs/assets/widget-calendar-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 059b1d64..00000000
--- a/docs/assets/widget-calendar-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>day*^/vweek*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-static-ascii.svg b/docs/assets/widget-calendar-dark-static-ascii.svg
deleted file mode 100644
index 3c477208..00000000
--- a/docs/assets/widget-calendar-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>day*^/vweek*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-static-no-ansi.svg b/docs/assets/widget-calendar-dark-static-no-ansi.svg
deleted file mode 100644
index 883f79d5..00000000
--- a/docs/assets/widget-calendar-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-dark-static.svg b/docs/assets/widget-calendar-dark-static.svg
deleted file mode 100644
index d638763a..00000000
--- a/docs/assets/widget-calendar-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-animated-ascii-no-ansi.svg b/docs/assets/widget-calendar-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index eca4f4ac..00000000
--- a/docs/assets/widget-calendar-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendarwidget||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-animated-ascii.svg b/docs/assets/widget-calendar-light-animated-ascii.svg
deleted file mode 100644
index b7bc6a41..00000000
--- a/docs/assets/widget-calendar-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendarwidget||>Calendar>||2026-07-15||[Submit][Cancel]||>Harvestdate2026-07-15||1314[15]16171819||20212223242526|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-animated-no-ansi.svg b/docs/assets/widget-calendar-light-animated-no-ansi.svg
deleted file mode 100644
index 71c2bd42..00000000
--- a/docs/assets/widget-calendar-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││Calendarwidget││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-animated.svg b/docs/assets/widget-calendar-light-animated.svg
deleted file mode 100644
index e636d84d..00000000
--- a/docs/assets/widget-calendar-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││Calendarwidget││❯Calendar›││2026-07-15││[Submit][Cancel]││❯Harvestdate2026-07-15││1314[15]16171819││20212223242526│
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-static-ascii-no-ansi.svg b/docs/assets/widget-calendar-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 4950a43b..00000000
--- a/docs/assets/widget-calendar-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>day*^/vweek*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-static-ascii.svg b/docs/assets/widget-calendar-light-static-ascii.svg
deleted file mode 100644
index d2fc57a7..00000000
--- a/docs/assets/widget-calendar-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Calendarwidget>Calendar||>HarvestdateJuly2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031||</>day*^/vweek*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-static-no-ansi.svg b/docs/assets/widget-calendar-light-static-no-ansi.svg
deleted file mode 100644
index 6d843a79..00000000
--- a/docs/assets/widget-calendar-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-calendar-light-static.svg b/docs/assets/widget-calendar-light-static.svg
deleted file mode 100644
index 07cd2649..00000000
--- a/docs/assets/widget-calendar-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Calendarwidget›Calendar││❯HarvestdateJuly2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-confirm-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 984bdafe..00000000
--- a/docs/assets/widget-confirm-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Confirmwidget>Confirm||y/nyes/no*^toggle*<accept*esccancel||>Organiconly?()Yes(*)No||>Confirm>||yes||v||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-animated-ascii.svg b/docs/assets/widget-confirm-dark-animated-ascii.svg
deleted file mode 100644
index b6974eb3..00000000
--- a/docs/assets/widget-confirm-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Confirmwidget>Confirm||y/nyes/no*^toggle*<accept*esccancel||>Organiconly?()Yes(*)No||>Confirm>||yes||v||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-animated-no-ansi.svg b/docs/assets/widget-confirm-dark-animated-no-ansi.svg
deleted file mode 100644
index bb68341c..00000000
--- a/docs/assets/widget-confirm-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?○Yes●No││❯Confirm›││yes││▼││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-animated.svg b/docs/assets/widget-confirm-dark-animated.svg
deleted file mode 100644
index 2b9b8216..00000000
--- a/docs/assets/widget-confirm-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?○Yes●No││❯Confirm›││yes││▼││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-static-ascii-no-ansi.svg b/docs/assets/widget-confirm-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 00fd527d..00000000
--- a/docs/assets/widget-confirm-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Confirmwidget>Confirm||>Organiconly?(*)Yes()No||y/nyes/no*^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-static-ascii.svg b/docs/assets/widget-confirm-dark-static-ascii.svg
deleted file mode 100644
index a9453c00..00000000
--- a/docs/assets/widget-confirm-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Confirmwidget>Confirm||>Organiconly?(*)Yes()No||y/nyes/no*^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-static-no-ansi.svg b/docs/assets/widget-confirm-dark-static-no-ansi.svg
deleted file mode 100644
index af2d9fcc..00000000
--- a/docs/assets/widget-confirm-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││❯Organiconly?●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-dark-static.svg b/docs/assets/widget-confirm-dark-static.svg
deleted file mode 100644
index 63e4ae5e..00000000
--- a/docs/assets/widget-confirm-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││❯Organiconly?●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-animated-ascii-no-ansi.svg b/docs/assets/widget-confirm-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index d5792224..00000000
--- a/docs/assets/widget-confirm-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Confirmwidget>Confirm||y/nyes/no*^toggle*<accept*esccancel||>Organiconly?()Yes(*)No||>Confirm>||yes||v||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-animated-ascii.svg b/docs/assets/widget-confirm-light-animated-ascii.svg
deleted file mode 100644
index e852e9db..00000000
--- a/docs/assets/widget-confirm-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Confirmwidget>Confirm||y/nyes/no*^toggle*<accept*esccancel||>Organiconly?()Yes(*)No||>Confirm>||yes||v||>Organiconly?yes||>Organiconly?(*)Yes()No|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-animated-no-ansi.svg b/docs/assets/widget-confirm-light-animated-no-ansi.svg
deleted file mode 100644
index 51a767ec..00000000
--- a/docs/assets/widget-confirm-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?○Yes●No││❯Confirm›││yes││▼││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-animated.svg b/docs/assets/widget-confirm-light-animated.svg
deleted file mode 100644
index ecb619fb..00000000
--- a/docs/assets/widget-confirm-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││y/nyes/no·↑toggle·↵accept·esccancel││❯Organiconly?○Yes●No││❯Confirm›││yes││▼││❯Organiconly?yes││❯Organiconly?●Yes○No│
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-static-ascii-no-ansi.svg b/docs/assets/widget-confirm-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 2e85fcdf..00000000
--- a/docs/assets/widget-confirm-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Confirmwidget>Confirm||>Organiconly?(*)Yes()No||y/nyes/no*^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-static-ascii.svg b/docs/assets/widget-confirm-light-static-ascii.svg
deleted file mode 100644
index fc4f7343..00000000
--- a/docs/assets/widget-confirm-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Confirmwidget>Confirm||>Organiconly?(*)Yes()No||y/nyes/no*^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-static-no-ansi.svg b/docs/assets/widget-confirm-light-static-no-ansi.svg
deleted file mode 100644
index d8a3ae49..00000000
--- a/docs/assets/widget-confirm-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││❯Organiconly?●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-confirm-light-static.svg b/docs/assets/widget-confirm-light-static.svg
deleted file mode 100644
index f66b16b2..00000000
--- a/docs/assets/widget-confirm-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Confirmwidget›Confirm││❯Organiconly?●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index a5c6a134..00000000
--- a/docs/assets/widget-filepicker-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel||baskets/||>deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-animated-ascii.svg b/docs/assets/widget-filepicker-dark-animated-ascii.svg
deleted file mode 100644
index 1ed6209b..00000000
--- a/docs/assets/widget-filepicker-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel||baskets/||>deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-animated-no-ansi.svg b/docs/assets/widget-filepicker-dark-animated-no-ansi.svg
deleted file mode 100644
index 0d472b24..00000000
--- a/docs/assets/widget-filepicker-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel││baskets/││❯deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-animated.svg b/docs/assets/widget-filepicker-dark-animated.svg
deleted file mode 100644
index b9131c62..00000000
--- a/docs/assets/widget-filepicker-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel││baskets/││❯deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 90b3f0cf..00000000
--- a/docs/assets/widget-filepicker-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-static-ascii.svg b/docs/assets/widget-filepicker-dark-static-ascii.svg
deleted file mode 100644
index 40d1218c..00000000
--- a/docs/assets/widget-filepicker-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-static-no-ansi.svg b/docs/assets/widget-filepicker-dark-static-no-ansi.svg
deleted file mode 100644
index 11e4f72d..00000000
--- a/docs/assets/widget-filepicker-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-dark-static.svg b/docs/assets/widget-filepicker-dark-static.svg
deleted file mode 100644
index a3e3d24b..00000000
--- a/docs/assets/widget-filepicker-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index a19e9763..00000000
--- a/docs/assets/widget-filepicker-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel||baskets/||>deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-animated-ascii.svg b/docs/assets/widget-filepicker-light-animated-ascii.svg
deleted file mode 100644
index 9ee9d17a..00000000
--- a/docs/assets/widget-filepicker-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistsample-project||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel||baskets/||>deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelist||>baskets/||deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-animated-no-ansi.svg b/docs/assets/widget-filepicker-light-animated-no-ansi.svg
deleted file mode 100644
index 3e5b0c3b..00000000
--- a/docs/assets/widget-filepicker-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel││baskets/││❯deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-animated.svg b/docs/assets/widget-filepicker-light-animated.svg
deleted file mode 100644
index 7d24cf92..00000000
--- a/docs/assets/widget-filepicker-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistsample-project││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel││baskets/││❯deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelist││❯baskets/││deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 2d7f260a..00000000
--- a/docs/assets/widget-filepicker-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-static-ascii.svg b/docs/assets/widget-filepicker-light-static-ascii.svg
deleted file mode 100644
index edea9b52..00000000
--- a/docs/assets/widget-filepicker-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistsample-project||>baskets/||deliveries/||harvest.csv||Filesonly.Extensions:csv.Max2MB.||^/vmove*>open*<up*<select*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-static-no-ansi.svg b/docs/assets/widget-filepicker-light-static-no-ansi.svg
deleted file mode 100644
index 50ad6523..00000000
--- a/docs/assets/widget-filepicker-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-light-static.svg b/docs/assets/widget-filepicker-light-static.svg
deleted file mode 100644
index b708f008..00000000
--- a/docs/assets/widget-filepicker-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistsample-project││❯baskets/││deliveries/││harvest.csv││Filesonly.Extensions:csv.Max2MB.││↑/↓move·→open·←up·↵select·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 2b22d83e..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-animated-ascii.svg b/docs/assets/widget-filepicker-multiple-dark-animated-ascii.svg
deleted file mode 100644
index 90ba6fa1..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-animated-no-ansi.svg b/docs/assets/widget-filepicker-multiple-dark-animated-no-ansi.svg
deleted file mode 100644
index 38f8a12d..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-animated.svg b/docs/assets/widget-filepicker-multiple-dark-animated.svg
deleted file mode 100644
index 88c86c8c..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 0f20717c..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-static-ascii.svg b/docs/assets/widget-filepicker-multiple-dark-static-ascii.svg
deleted file mode 100644
index 17ca4cd2..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-static-no-ansi.svg b/docs/assets/widget-filepicker-multiple-dark-static-no-ansi.svg
deleted file mode 100644
index fcb2ec8d..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-dark-static.svg b/docs/assets/widget-filepicker-multiple-dark-static.svg
deleted file mode 100644
index 8aa63f54..00000000
--- a/docs/assets/widget-filepicker-multiple-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index beceb831..00000000
--- a/docs/assets/widget-filepicker-multiple-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-animated-ascii.svg b/docs/assets/widget-filepicker-multiple-light-animated-ascii.svg
deleted file mode 100644
index c5a61fc3..00000000
--- a/docs/assets/widget-filepicker-multiple-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-animated-no-ansi.svg b/docs/assets/widget-filepicker-multiple-light-animated-no-ansi.svg
deleted file mode 100644
index 97517e72..00000000
--- a/docs/assets/widget-filepicker-multiple-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-animated.svg b/docs/assets/widget-filepicker-multiple-light-animated.svg
deleted file mode 100644
index 9c04a851..00000000
--- a/docs/assets/widget-filepicker-multiple-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 4ff75937..00000000
--- a/docs/assets/widget-filepicker-multiple-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-static-ascii.svg b/docs/assets/widget-filepicker-multiple-light-static-ascii.svg
deleted file mode 100644
index 79e934a8..00000000
--- a/docs/assets/widget-filepicker-multiple-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||v||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-static-no-ansi.svg b/docs/assets/widget-filepicker-multiple-light-static-no-ansi.svg
deleted file mode 100644
index f361b193..00000000
--- a/docs/assets/widget-filepicker-multiple-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-light-static.svg b/docs/assets/widget-filepicker-multiple-light-static.svg
deleted file mode 100644
index f5ce2c13..00000000
--- a/docs/assets/widget-filepicker-multiple-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││▼││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 4d3f3723..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii.svg b/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii.svg
deleted file mode 100644
index 5947882c..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-dark-animated-no-ansi.svg
deleted file mode 100644
index 9c32dd01..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-animated.svg b/docs/assets/widget-filepicker-multiple-limited-dark-animated.svg
deleted file mode 100644
index 059695d7..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 3a7df481..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii.svg b/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii.svg
deleted file mode 100644
index f90e4dc5..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-static-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-dark-static-no-ansi.svg
deleted file mode 100644
index 28d59ac2..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-dark-static.svg b/docs/assets/widget-filepicker-multiple-limited-dark-static.svg
deleted file mode 100644
index 7c37cd52..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index a8577f99..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii.svg b/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii.svg
deleted file mode 100644
index 4127cb63..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Filepickerwidget>Filepicker||>Pricelistssample-project||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel||[x]baskets/||>[x]deliveries/||Filepickerwidget||>Filepicker>||[Submit][Cancel]||>Pricelists||>[]baskets/||>[x]baskets/||>[]deliveries/|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-animated-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-light-animated-no-ansi.svg
deleted file mode 100644
index 66297f4c..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-animated.svg b/docs/assets/widget-filepicker-multiple-limited-light-animated.svg
deleted file mode 100644
index 445cad47..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Filepickerwidget›Filepicker││❯Pricelistssample-project││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel││◼baskets/││❯◼deliveries/││Filepickerwidget││❯Filepicker›││[Submit][Cancel]││❯Pricelists││❯◻baskets/││❯◼baskets/││❯◻deliveries/│
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-light-static-ascii-no-ansi.svg
deleted file mode 100644
index c22ee379..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-static-ascii.svg b/docs/assets/widget-filepicker-multiple-limited-light-static-ascii.svg
deleted file mode 100644
index fd0cb025..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Filepickerwidget>Filepicker||>Pricelistssample-project||>[]baskets/||[]deliveries/||[]box.json||[]harvest.csv||[]pantry.yaml||[]README.md||Selectbetween2and3items.||^/vmove*>open*<up*<accept*tabhidden*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-static-no-ansi.svg b/docs/assets/widget-filepicker-multiple-limited-light-static-no-ansi.svg
deleted file mode 100644
index 8675b42e..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-filepicker-multiple-limited-light-static.svg b/docs/assets/widget-filepicker-multiple-limited-light-static.svg
deleted file mode 100644
index 0f4f83fc..00000000
--- a/docs/assets/widget-filepicker-multiple-limited-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Filepickerwidget›Filepicker││❯Pricelistssample-project││❯◻baskets/││◻deliveries/││◻box.json││◻harvest.csv││◻pantry.yaml││◻README.md││Selectbetween2and3items.││↑/↓move·→open·←up·↵accept·tabhidden·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-note-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index aecfec37..00000000
--- a/docs/assets/widget-note-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notewidget||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-animated-ascii.svg b/docs/assets/widget-note-dark-animated-ascii.svg
deleted file mode 100644
index 9aea416f..00000000
--- a/docs/assets/widget-note-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notewidget||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-animated-no-ansi.svg b/docs/assets/widget-note-dark-animated-no-ansi.svg
deleted file mode 100644
index d89ccff3..00000000
--- a/docs/assets/widget-note-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notewidget││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-animated.svg b/docs/assets/widget-note-dark-animated.svg
deleted file mode 100644
index 12252b16..00000000
--- a/docs/assets/widget-note-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notewidget││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-static-ascii-no-ansi.svg b/docs/assets/widget-note-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index c0ec5be4..00000000
--- a/docs/assets/widget-note-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------+||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-static-ascii.svg b/docs/assets/widget-note-dark-static-ascii.svg
deleted file mode 100644
index 7fe92bd2..00000000
--- a/docs/assets/widget-note-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------+||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-static-no-ansi.svg b/docs/assets/widget-note-dark-static-no-ansi.svg
deleted file mode 100644
index f07c8dab..00000000
--- a/docs/assets/widget-note-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-dark-static.svg b/docs/assets/widget-note-dark-static.svg
deleted file mode 100644
index d2bede12..00000000
--- a/docs/assets/widget-note-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-animated-ascii-no-ansi.svg b/docs/assets/widget-note-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 6dd8c79e..00000000
--- a/docs/assets/widget-note-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notewidget||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-animated-ascii.svg b/docs/assets/widget-note-light-animated-ascii.svg
deleted file mode 100644
index b9e28b66..00000000
--- a/docs/assets/widget-note-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.||+-----------------------+|||Readytopack||||Framedwithaborder.|||Notewidget||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-animated-no-ansi.svg b/docs/assets/widget-note-light-animated-no-ansi.svg
deleted file mode 100644
index af3453ce..00000000
--- a/docs/assets/widget-note-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notewidget││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-animated.svg b/docs/assets/widget-note-light-animated.svg
deleted file mode 100644
index 1a615ad7..00000000
--- a/docs/assets/widget-note-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││Notewidget││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-static-ascii-no-ansi.svg b/docs/assets/widget-note-light-static-ascii-no-ansi.svg
deleted file mode 100644
index ee3acc85..00000000
--- a/docs/assets/widget-note-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------+||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-static-ascii.svg b/docs/assets/widget-note-light-static-ascii.svg
deleted file mode 100644
index 3b87d033..00000000
--- a/docs/assets/widget-note-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------+||Notewidget>Note||Freshproduceorder||Aread-onlycard-thecursorskipsit.|||Readytopack||||Framedwithaborder.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-static-no-ansi.svg b/docs/assets/widget-note-light-static-no-ansi.svg
deleted file mode 100644
index 098b8866..00000000
--- a/docs/assets/widget-note-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-light-static.svg b/docs/assets/widget-note-light-static.svg
deleted file mode 100644
index f8e73ee2..00000000
--- a/docs/assets/widget-note-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Notewidget›Note││Freshproduceorder││Aread-onlycard-thecursorskipsit.││╭───────────────────────╮│││Readytopack││││Framedwithaborder.│││╰───────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-note-markdown-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 473dbb0c..00000000
--- a/docs/assets/widget-note-markdown-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Markdownnote>Note||+-----------------------------------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-animated-ascii.svg b/docs/assets/widget-note-markdown-dark-animated-ascii.svg
deleted file mode 100644
index 122917e0..00000000
--- a/docs/assets/widget-note-markdown-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Markdownnote>Note||+--------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-animated-no-ansi.svg b/docs/assets/widget-note-markdown-dark-animated-no-ansi.svg
deleted file mode 100644
index 51090cb5..00000000
--- a/docs/assets/widget-note-markdown-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-animated.svg b/docs/assets/widget-note-markdown-dark-animated.svg
deleted file mode 100644
index 4281379f..00000000
--- a/docs/assets/widget-note-markdown-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-static-ascii-no-ansi.svg b/docs/assets/widget-note-markdown-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 775fc36a..00000000
--- a/docs/assets/widget-note-markdown-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------------------------------------+||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-static-ascii.svg b/docs/assets/widget-note-markdown-dark-static-ascii.svg
deleted file mode 100644
index e99c72ac..00000000
--- a/docs/assets/widget-note-markdown-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+--------------------------+||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-static-no-ansi.svg b/docs/assets/widget-note-markdown-dark-static-no-ansi.svg
deleted file mode 100644
index df08eff3..00000000
--- a/docs/assets/widget-note-markdown-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-dark-static.svg b/docs/assets/widget-note-markdown-dark-static.svg
deleted file mode 100644
index 4a0ec6b2..00000000
--- a/docs/assets/widget-note-markdown-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-animated-ascii-no-ansi.svg b/docs/assets/widget-note-markdown-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index e6acc883..00000000
--- a/docs/assets/widget-note-markdown-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Markdownnote>Note||+-----------------------------------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-animated-ascii.svg b/docs/assets/widget-note-markdown-light-animated-ascii.svg
deleted file mode 100644
index 9c7cfed2..00000000
--- a/docs/assets/widget-note-markdown-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Markdownnote>Note||+--------------------------+|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||Markdownnote||>Note>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-animated-no-ansi.svg b/docs/assets/widget-note-markdown-light-animated-no-ansi.svg
deleted file mode 100644
index ded2ba37..00000000
--- a/docs/assets/widget-note-markdown-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-animated.svg b/docs/assets/widget-note-markdown-light-animated.svg
deleted file mode 100644
index 592cee1d..00000000
--- a/docs/assets/widget-note-markdown-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││Markdownnote││❯Note›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-static-ascii-no-ansi.svg b/docs/assets/widget-note-markdown-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 7ccdd251..00000000
--- a/docs/assets/widget-note-markdown-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-----------------------------------------------------+||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide(https://example.com/guide).|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-static-ascii.svg b/docs/assets/widget-note-markdown-light-static-ascii.svg
deleted file mode 100644
index 12476324..00000000
--- a/docs/assets/widget-note-markdown-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+--------------------------+||Markdownnote>Note|||Freshproduceorder||||Pickwhatisripetoday:||||-crispapples||||-sweetpears||||Seetheseasonalguide.|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-static-no-ansi.svg b/docs/assets/widget-note-markdown-light-static-no-ansi.svg
deleted file mode 100644
index 595d4745..00000000
--- a/docs/assets/widget-note-markdown-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭─────────────────────────────────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide(https://example.com/guide).│││╰─────────────────────────────────────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-note-markdown-light-static.svg b/docs/assets/widget-note-markdown-light-static.svg
deleted file mode 100644
index 35d873b9..00000000
--- a/docs/assets/widget-note-markdown-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Markdownnote›Note││╭──────────────────────────╮│││Freshproduceorder││││Pickwhatisripetoday:││││•crispapples││││•sweetpears││││Seetheseasonalguide.│││╰──────────────────────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-number-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index c85586a4..00000000
--- a/docs/assets/widget-number-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Numberwidget>Number||^/vadjust*<accept*esccancel||>Basketweight(g)4200|||>Number>||1200||v||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-animated-ascii.svg b/docs/assets/widget-number-dark-animated-ascii.svg
deleted file mode 100644
index a6881f7c..00000000
--- a/docs/assets/widget-number-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Numberwidget>Number||^/vadjust*<accept*esccancel||>Basketweight(g)4200|||>Number>||1200||v||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-animated-no-ansi.svg b/docs/assets/widget-number-dark-animated-no-ansi.svg
deleted file mode 100644
index 7629ee07..00000000
--- a/docs/assets/widget-number-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││↑/↓adjust·↵accept·esccancel││❯Basketweight(g)4200█││❯Number›││1200││▼││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-animated.svg b/docs/assets/widget-number-dark-animated.svg
deleted file mode 100644
index ff5a3574..00000000
--- a/docs/assets/widget-number-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││↑/↓adjust·↵accept·esccancel││❯Basketweight(g)4200█││❯Number›││1200││▼││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-static-ascii-no-ansi.svg b/docs/assets/widget-number-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 1c431605..00000000
--- a/docs/assets/widget-number-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Numberwidget>Number||>Basketweight(g)1200|||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-static-ascii.svg b/docs/assets/widget-number-dark-static-ascii.svg
deleted file mode 100644
index d3a28c97..00000000
--- a/docs/assets/widget-number-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Numberwidget>Number||>Basketweight(g)1200|||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-static-no-ansi.svg b/docs/assets/widget-number-dark-static-no-ansi.svg
deleted file mode 100644
index 300435e8..00000000
--- a/docs/assets/widget-number-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││❯Basketweight(g)1200█││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-number-dark-static.svg b/docs/assets/widget-number-dark-static.svg
deleted file mode 100644
index c49ad3e1..00000000
--- a/docs/assets/widget-number-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││❯Basketweight(g)1200█││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-animated-ascii-no-ansi.svg b/docs/assets/widget-number-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 6b575708..00000000
--- a/docs/assets/widget-number-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Numberwidget>Number||^/vadjust*<accept*esccancel||>Basketweight(g)4200|||>Number>||1200||v||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-animated-ascii.svg b/docs/assets/widget-number-light-animated-ascii.svg
deleted file mode 100644
index 14916ec0..00000000
--- a/docs/assets/widget-number-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Numberwidget>Number||^/vadjust*<accept*esccancel||>Basketweight(g)4200|||>Number>||1200||v||>Basketweight(g)1200||>Basketweight(g)1200|||>Basketweight(g)120|||>Basketweight(g)12|||>Basketweight(g)1|||>Basketweight(g)|||>Basketweight(g)4|||>Basketweight(g)42|||>Basketweight(g)420||
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-animated-no-ansi.svg b/docs/assets/widget-number-light-animated-no-ansi.svg
deleted file mode 100644
index d5504da0..00000000
--- a/docs/assets/widget-number-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││↑/↓adjust·↵accept·esccancel││❯Basketweight(g)4200█││❯Number›││1200││▼││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-animated.svg b/docs/assets/widget-number-light-animated.svg
deleted file mode 100644
index 4159b88a..00000000
--- a/docs/assets/widget-number-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││↑/↓adjust·↵accept·esccancel││❯Basketweight(g)4200█││❯Number›││1200││▼││❯Basketweight(g)1200││❯Basketweight(g)1200█││❯Basketweight(g)120█││❯Basketweight(g)12█││❯Basketweight(g)1█││❯Basketweight(g)█││❯Basketweight(g)4█││❯Basketweight(g)42█││❯Basketweight(g)420█│
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-static-ascii-no-ansi.svg b/docs/assets/widget-number-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 4f7032b8..00000000
--- a/docs/assets/widget-number-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Numberwidget>Number||>Basketweight(g)1200|||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-static-ascii.svg b/docs/assets/widget-number-light-static-ascii.svg
deleted file mode 100644
index dcf3a279..00000000
--- a/docs/assets/widget-number-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Numberwidget>Number||>Basketweight(g)1200|||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-static-no-ansi.svg b/docs/assets/widget-number-light-static-no-ansi.svg
deleted file mode 100644
index b25dff5a..00000000
--- a/docs/assets/widget-number-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││❯Basketweight(g)1200█││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-number-light-static.svg b/docs/assets/widget-number-light-static.svg
deleted file mode 100644
index e813d458..00000000
--- a/docs/assets/widget-number-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Numberwidget›Number││❯Basketweight(g)1200█││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-password-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 332c43a4..00000000
--- a/docs/assets/widget-password-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Passwordwidget>Password||>Ordercode******|||<accept*esccancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||>Password>||********||v||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-animated-ascii.svg b/docs/assets/widget-password-dark-animated-ascii.svg
deleted file mode 100644
index c2081aa8..00000000
--- a/docs/assets/widget-password-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Passwordwidget>Password||>Ordercode******|||<accept*esccancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||>Password>||********||v||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-animated-no-ansi.svg b/docs/assets/widget-password-dark-animated-no-ansi.svg
deleted file mode 100644
index 0e7839ef..00000000
--- a/docs/assets/widget-password-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││❯Password›││••••••••││▼││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-animated.svg b/docs/assets/widget-password-dark-animated.svg
deleted file mode 100644
index 76b1c3ae..00000000
--- a/docs/assets/widget-password-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││❯Password›││••••••••││▼││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-static-ascii-no-ansi.svg b/docs/assets/widget-password-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 0e3f3b5c..00000000
--- a/docs/assets/widget-password-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Passwordwidget>Password||>Ordercode******|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-static-ascii.svg b/docs/assets/widget-password-dark-static-ascii.svg
deleted file mode 100644
index 3b910f87..00000000
--- a/docs/assets/widget-password-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Passwordwidget>Password||>Ordercode******|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-static-no-ansi.svg b/docs/assets/widget-password-dark-static-no-ansi.svg
deleted file mode 100644
index 980ea75d..00000000
--- a/docs/assets/widget-password-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-dark-static.svg b/docs/assets/widget-password-dark-static.svg
deleted file mode 100644
index bf2e1384..00000000
--- a/docs/assets/widget-password-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-animated-ascii-no-ansi.svg b/docs/assets/widget-password-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 725e9ca7..00000000
--- a/docs/assets/widget-password-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Passwordwidget>Password||>Ordercode******|||<accept*esccancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||>Password>||********||v||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-animated-ascii.svg b/docs/assets/widget-password-light-animated-ascii.svg
deleted file mode 100644
index 53e82ccf..00000000
--- a/docs/assets/widget-password-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Passwordwidget>Password||>Ordercode******|||<accept*esccancel||>Ordercode*****|||>Ordercode****|||>Ordercode***|||>Ordercode**|||>Ordercode*|||>Password>||********||v||>Ordercode********||>Ordercode||
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-animated-no-ansi.svg b/docs/assets/widget-password-light-animated-no-ansi.svg
deleted file mode 100644
index 562774a1..00000000
--- a/docs/assets/widget-password-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││❯Password›││••••••••││▼││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-animated.svg b/docs/assets/widget-password-light-animated.svg
deleted file mode 100644
index fbe07dfd..00000000
--- a/docs/assets/widget-password-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel││❯Ordercode•••••█││❯Ordercode••••█││❯Ordercode•••█││❯Ordercode••█││❯Ordercode•█││❯Password›││••••••••││▼││❯Ordercode••••••••││❯Ordercode█│
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-static-ascii-no-ansi.svg b/docs/assets/widget-password-light-static-ascii-no-ansi.svg
deleted file mode 100644
index a31ef753..00000000
--- a/docs/assets/widget-password-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Passwordwidget>Password||>Ordercode******|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-static-ascii.svg b/docs/assets/widget-password-light-static-ascii.svg
deleted file mode 100644
index c15ed533..00000000
--- a/docs/assets/widget-password-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Passwordwidget>Password||>Ordercode******|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-static-no-ansi.svg b/docs/assets/widget-password-light-static-no-ansi.svg
deleted file mode 100644
index 2c6321d7..00000000
--- a/docs/assets/widget-password-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-light-static.svg b/docs/assets/widget-password-light-static.svg
deleted file mode 100644
index 26f4c6eb..00000000
--- a/docs/assets/widget-password-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercode••••••█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-reveal-dark-static.svg b/docs/assets/widget-password-reveal-dark-static.svg
deleted file mode 100644
index f0dd47f0..00000000
--- a/docs/assets/widget-password-reveal-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercodemelon7█││tabreveal·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-password-reveal-light-static.svg b/docs/assets/widget-password-reveal-light-static.svg
deleted file mode 100644
index 6027e406..00000000
--- a/docs/assets/widget-password-reveal-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Passwordwidget›Password││❯Ordercodemelon7█││tabreveal·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-pause-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 906be436..00000000
--- a/docs/assets/widget-pause-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Pausewidget>Pause||>Reviewyourbasketyes||>Pause>||yes||v|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-animated-ascii.svg b/docs/assets/widget-pause-dark-animated-ascii.svg
deleted file mode 100644
index 1f2727bf..00000000
--- a/docs/assets/widget-pause-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Pausewidget>Pause||>Reviewyourbasketyes||>Pause>||yes||v|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-animated-no-ansi.svg b/docs/assets/widget-pause-dark-animated-no-ansi.svg
deleted file mode 100644
index ccc5d74f..00000000
--- a/docs/assets/widget-pause-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││❯Pause›││yes││▼│
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-animated.svg b/docs/assets/widget-pause-dark-animated.svg
deleted file mode 100644
index 6dd134cf..00000000
--- a/docs/assets/widget-pause-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││❯Pause›││yes││▼│
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-static-ascii-no-ansi.svg b/docs/assets/widget-pause-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index da9f0fb5..00000000
--- a/docs/assets/widget-pause-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Pausewidget>Pause||>Reviewyourbasketyes||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-static-ascii.svg b/docs/assets/widget-pause-dark-static-ascii.svg
deleted file mode 100644
index 60f62073..00000000
--- a/docs/assets/widget-pause-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Pausewidget>Pause||>Reviewyourbasketyes||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-static-no-ansi.svg b/docs/assets/widget-pause-dark-static-no-ansi.svg
deleted file mode 100644
index 34e79193..00000000
--- a/docs/assets/widget-pause-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-pause-dark-static.svg b/docs/assets/widget-pause-dark-static.svg
deleted file mode 100644
index 4fa08315..00000000
--- a/docs/assets/widget-pause-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-animated-ascii-no-ansi.svg b/docs/assets/widget-pause-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 5d02b72e..00000000
--- a/docs/assets/widget-pause-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Pausewidget>Pause||>Reviewyourbasketyes||>Pause>||yes||v|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-animated-ascii.svg b/docs/assets/widget-pause-light-animated-ascii.svg
deleted file mode 100644
index 014b7e1d..00000000
--- a/docs/assets/widget-pause-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Pausewidget>Pause||>Reviewyourbasketyes||>Pause>||yes||v|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-animated-no-ansi.svg b/docs/assets/widget-pause-light-animated-no-ansi.svg
deleted file mode 100644
index d35e78db..00000000
--- a/docs/assets/widget-pause-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││❯Pause›││yes││▼│
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-animated.svg b/docs/assets/widget-pause-light-animated.svg
deleted file mode 100644
index a9c7bf2b..00000000
--- a/docs/assets/widget-pause-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││❯Pause›││yes││▼│
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-static-ascii-no-ansi.svg b/docs/assets/widget-pause-light-static-ascii-no-ansi.svg
deleted file mode 100644
index bc997488..00000000
--- a/docs/assets/widget-pause-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Pausewidget>Pause||>Reviewyourbasketyes||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-static-ascii.svg b/docs/assets/widget-pause-light-static-ascii.svg
deleted file mode 100644
index 1e494d58..00000000
--- a/docs/assets/widget-pause-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Pausewidget>Pause||>Reviewyourbasketyes||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-static-no-ansi.svg b/docs/assets/widget-pause-light-static-no-ansi.svg
deleted file mode 100644
index 2b4e2fa5..00000000
--- a/docs/assets/widget-pause-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-pause-light-static.svg b/docs/assets/widget-pause-light-static.svg
deleted file mode 100644
index 5cadd596..00000000
--- a/docs/assets/widget-pause-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Pausewidget›Pause││❯Reviewyourbasketyes││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-progress-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 8f2a0427..00000000
--- a/docs/assets/widget-progress-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Progresswidget>Progress||>Packingthebox[------------------------]0/6||>Packingthebox[########################]6/6||Progresswidget||>Progress>||[Submit][Cancel]||>Packingthebox[####--------------------]1/6||>Packingthebox[########----------------]2/6||>Packingthebox[############------------]3/6||>Packingthebox[################--------]4/6||>Packingthebox[####################----]5/6|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-animated-ascii.svg b/docs/assets/widget-progress-dark-animated-ascii.svg
deleted file mode 100644
index 57bf9415..00000000
--- a/docs/assets/widget-progress-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Progresswidget>Progress||>Packingthebox[------------------------]0/6||>Packingthebox[########################]6/6||Progresswidget||>Progress>||[Submit][Cancel]||>Packingthebox[####--------------------]1/6||>Packingthebox[########----------------]2/6||>Packingthebox[############------------]3/6||>Packingthebox[################--------]4/6||>Packingthebox[####################----]5/6|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-animated-no-ansi.svg b/docs/assets/widget-progress-dark-animated-no-ansi.svg
deleted file mode 100644
index 39d32281..00000000
--- a/docs/assets/widget-progress-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6││❯Packingthebox[████████████████████████]6/6││Progresswidget││❯Progress›││[Submit][Cancel]││❯Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6││❯Packingthebox[████████░░░░░░░░░░░░░░░░]2/6││❯Packingthebox[████████████░░░░░░░░░░░░]3/6││❯Packingthebox[████████████████░░░░░░░░]4/6││❯Packingthebox[████████████████████░░░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-animated.svg b/docs/assets/widget-progress-dark-animated.svg
deleted file mode 100644
index c1e0e5f9..00000000
--- a/docs/assets/widget-progress-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6││❯Packingthebox[████████████████████████]6/6││Progresswidget││❯Progress›││[Submit][Cancel]││❯Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6││❯Packingthebox[████████░░░░░░░░░░░░░░░░]2/6││❯Packingthebox[████████████░░░░░░░░░░░░]3/6││❯Packingthebox[████████████████░░░░░░░░]4/6││❯Packingthebox[████████████████████░░░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-static-ascii-no-ansi.svg b/docs/assets/widget-progress-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 2a411a9f..00000000
--- a/docs/assets/widget-progress-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Progresswidget>Progress||>Packingthebox[########################]6/6||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-static-ascii.svg b/docs/assets/widget-progress-dark-static-ascii.svg
deleted file mode 100644
index d11851cd..00000000
--- a/docs/assets/widget-progress-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Progresswidget>Progress||>Packingthebox[########################]6/6||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-static-no-ansi.svg b/docs/assets/widget-progress-dark-static-no-ansi.svg
deleted file mode 100644
index cf457c04..00000000
--- a/docs/assets/widget-progress-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[████████████████████████]6/6││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-progress-dark-static.svg b/docs/assets/widget-progress-dark-static.svg
deleted file mode 100644
index fff9c6a8..00000000
--- a/docs/assets/widget-progress-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[████████████████████████]6/6││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-animated-ascii-no-ansi.svg b/docs/assets/widget-progress-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 94714595..00000000
--- a/docs/assets/widget-progress-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Progresswidget>Progress||>Packingthebox[------------------------]0/6||>Packingthebox[########################]6/6||Progresswidget||>Progress>||[Submit][Cancel]||>Packingthebox[####--------------------]1/6||>Packingthebox[########----------------]2/6||>Packingthebox[############------------]3/6||>Packingthebox[################--------]4/6||>Packingthebox[####################----]5/6|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-animated-ascii.svg b/docs/assets/widget-progress-light-animated-ascii.svg
deleted file mode 100644
index d6ddef39..00000000
--- a/docs/assets/widget-progress-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Progresswidget>Progress||>Packingthebox[------------------------]0/6||>Packingthebox[########################]6/6||Progresswidget||>Progress>||[Submit][Cancel]||>Packingthebox[####--------------------]1/6||>Packingthebox[########----------------]2/6||>Packingthebox[############------------]3/6||>Packingthebox[################--------]4/6||>Packingthebox[####################----]5/6|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-animated-no-ansi.svg b/docs/assets/widget-progress-light-animated-no-ansi.svg
deleted file mode 100644
index 785c7dc7..00000000
--- a/docs/assets/widget-progress-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6││❯Packingthebox[████████████████████████]6/6││Progresswidget││❯Progress›││[Submit][Cancel]││❯Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6││❯Packingthebox[████████░░░░░░░░░░░░░░░░]2/6││❯Packingthebox[████████████░░░░░░░░░░░░]3/6││❯Packingthebox[████████████████░░░░░░░░]4/6││❯Packingthebox[████████████████████░░░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-animated.svg b/docs/assets/widget-progress-light-animated.svg
deleted file mode 100644
index fdd56f0f..00000000
--- a/docs/assets/widget-progress-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6││❯Packingthebox[████████████████████████]6/6││Progresswidget││❯Progress›││[Submit][Cancel]││❯Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6││❯Packingthebox[████████░░░░░░░░░░░░░░░░]2/6││❯Packingthebox[████████████░░░░░░░░░░░░]3/6││❯Packingthebox[████████████████░░░░░░░░]4/6││❯Packingthebox[████████████████████░░░░]5/6│
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-static-ascii-no-ansi.svg b/docs/assets/widget-progress-light-static-ascii-no-ansi.svg
deleted file mode 100644
index fb882de9..00000000
--- a/docs/assets/widget-progress-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Progresswidget>Progress||>Packingthebox[########################]6/6||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-static-ascii.svg b/docs/assets/widget-progress-light-static-ascii.svg
deleted file mode 100644
index 967375b4..00000000
--- a/docs/assets/widget-progress-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Progresswidget>Progress||>Packingthebox[########################]6/6||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-static-no-ansi.svg b/docs/assets/widget-progress-light-static-no-ansi.svg
deleted file mode 100644
index f140613a..00000000
--- a/docs/assets/widget-progress-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[████████████████████████]6/6││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-progress-light-static.svg b/docs/assets/widget-progress-light-static.svg
deleted file mode 100644
index f2235305..00000000
--- a/docs/assets/widget-progress-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Progresswidget›Progress││❯Packingthebox[████████████████████████]6/6││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-rating-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 54f4694d..00000000
--- a/docs/assets/widget-rating-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ratingwidget>Rating||>Freshness****-4/5||^/vadjust*<accept*esccancel||>Freshness***--3/5Fair||>Rating>||****-4/5||v||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-animated-ascii.svg b/docs/assets/widget-rating-dark-animated-ascii.svg
deleted file mode 100644
index 17dae2f5..00000000
--- a/docs/assets/widget-rating-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ratingwidget>Rating||>Freshness****-4/5||^/vadjust*<accept*esccancel||>Freshness***--3/5Fair||>Rating>||****-4/5||v||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-animated-no-ansi.svg b/docs/assets/widget-rating-dark-animated-no-ansi.svg
deleted file mode 100644
index 92a360dc..00000000
--- a/docs/assets/widget-rating-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●●○4/5││↑/↓adjust·↵accept·esccancel││❯Freshness●●●○○3/5Fair││❯Rating›││●●●●○4/5││▼││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-animated.svg b/docs/assets/widget-rating-dark-animated.svg
deleted file mode 100644
index d7918966..00000000
--- a/docs/assets/widget-rating-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●●○4/5││↑/↓adjust·↵accept·esccancel││❯Freshness●●●○○3/5Fair││❯Rating›││●●●●○4/5││▼││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-static-ascii-no-ansi.svg b/docs/assets/widget-rating-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 2bfc4205..00000000
--- a/docs/assets/widget-rating-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ratingwidget>Rating||>Freshness***--3/5Fair||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-static-ascii.svg b/docs/assets/widget-rating-dark-static-ascii.svg
deleted file mode 100644
index 1197a627..00000000
--- a/docs/assets/widget-rating-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ratingwidget>Rating||>Freshness***--3/5Fair||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-static-no-ansi.svg b/docs/assets/widget-rating-dark-static-no-ansi.svg
deleted file mode 100644
index ca603e12..00000000
--- a/docs/assets/widget-rating-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●○○3/5Fair││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-rating-dark-static.svg b/docs/assets/widget-rating-dark-static.svg
deleted file mode 100644
index ae006056..00000000
--- a/docs/assets/widget-rating-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●○○3/5Fair││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-animated-ascii-no-ansi.svg b/docs/assets/widget-rating-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 73191b42..00000000
--- a/docs/assets/widget-rating-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ratingwidget>Rating||>Freshness****-4/5||^/vadjust*<accept*esccancel||>Freshness***--3/5Fair||>Rating>||****-4/5||v||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-animated-ascii.svg b/docs/assets/widget-rating-light-animated-ascii.svg
deleted file mode 100644
index 1b16923b..00000000
--- a/docs/assets/widget-rating-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ratingwidget>Rating||>Freshness****-4/5||^/vadjust*<accept*esccancel||>Freshness***--3/5Fair||>Rating>||****-4/5||v||>Freshness**---2/5|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-animated-no-ansi.svg b/docs/assets/widget-rating-light-animated-no-ansi.svg
deleted file mode 100644
index a9841dee..00000000
--- a/docs/assets/widget-rating-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●●○4/5││↑/↓adjust·↵accept·esccancel││❯Freshness●●●○○3/5Fair││❯Rating›││●●●●○4/5││▼││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-animated.svg b/docs/assets/widget-rating-light-animated.svg
deleted file mode 100644
index e1e9e3c3..00000000
--- a/docs/assets/widget-rating-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●●○4/5││↑/↓adjust·↵accept·esccancel││❯Freshness●●●○○3/5Fair││❯Rating›││●●●●○4/5││▼││❯Freshness●●○○○2/5│
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-static-ascii-no-ansi.svg b/docs/assets/widget-rating-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 896fcdbf..00000000
--- a/docs/assets/widget-rating-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ratingwidget>Rating||>Freshness***--3/5Fair||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-static-ascii.svg b/docs/assets/widget-rating-light-static-ascii.svg
deleted file mode 100644
index 5a43fe1e..00000000
--- a/docs/assets/widget-rating-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ratingwidget>Rating||>Freshness***--3/5Fair||^/vadjust*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-static-no-ansi.svg b/docs/assets/widget-rating-light-static-no-ansi.svg
deleted file mode 100644
index 88259105..00000000
--- a/docs/assets/widget-rating-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●○○3/5Fair││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-rating-light-static.svg b/docs/assets/widget-rating-light-static.svg
deleted file mode 100644
index 92959460..00000000
--- a/docs/assets/widget-rating-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ratingwidget›Rating││❯Freshness●●●○○3/5Fair││↑/↓adjust·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-reorder-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 67db8d3d..00000000
--- a/docs/assets/widget-reorder-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Reorderwidget>Reorder||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>BasketCarrot||>Apple||Reorderwidget||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-animated-ascii.svg b/docs/assets/widget-reorder-dark-animated-ascii.svg
deleted file mode 100644
index d9a3386d..00000000
--- a/docs/assets/widget-reorder-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Reorderwidget>Reorder||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>BasketCarrot||>Apple||Reorderwidget||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-animated-no-ansi.svg b/docs/assets/widget-reorder-dark-animated-no-ansi.svg
deleted file mode 100644
index 7c0c26ab..00000000
--- a/docs/assets/widget-reorder-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Reorderwidget›Reorder││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel││↑/↓reorder·spacedrop·esccancel││❯BasketCarrot││❯Apple││Reorderwidget││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-animated.svg b/docs/assets/widget-reorder-dark-animated.svg
deleted file mode 100644
index 913544df..00000000
--- a/docs/assets/widget-reorder-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Reorderwidget›Reorder││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel││↑/↓reorder·spacedrop·esccancel││❯BasketCarrot││❯Apple││Reorderwidget││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-static-ascii-no-ansi.svg b/docs/assets/widget-reorder-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index ae8b7765..00000000
--- a/docs/assets/widget-reorder-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Reorderwidget>Reorder||>Basket>Apple||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-static-ascii.svg b/docs/assets/widget-reorder-dark-static-ascii.svg
deleted file mode 100644
index 98b98373..00000000
--- a/docs/assets/widget-reorder-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Reorderwidget>Reorder||>Basket>Apple||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-static-no-ansi.svg b/docs/assets/widget-reorder-dark-static-no-ansi.svg
deleted file mode 100644
index c97ee7ca..00000000
--- a/docs/assets/widget-reorder-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Reorderwidget›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-dark-static.svg b/docs/assets/widget-reorder-dark-static.svg
deleted file mode 100644
index 75c46dc2..00000000
--- a/docs/assets/widget-reorder-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Reorderwidget›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-reorder-descriptions-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 6a419485..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Reorder||Tomato||^/vmove*spacegrab*<accept*esccancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-animated-ascii.svg b/docs/assets/widget-reorder-descriptions-dark-animated-ascii.svg
deleted file mode 100644
index 01d2ab41..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Reorder||Tomato||^/vmove*spacegrab*<accept*esccancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-animated-no-ansi.svg b/docs/assets/widget-reorder-descriptions-dark-animated-no-ansi.svg
deleted file mode 100644
index 490a54a5..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓move·spacegrab·↵accept·esccancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-animated.svg b/docs/assets/widget-reorder-descriptions-dark-animated.svg
deleted file mode 100644
index 7a6f96de..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓move·spacegrab·↵accept·esccancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/widget-reorder-descriptions-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 8556f18b..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-static-ascii.svg b/docs/assets/widget-reorder-descriptions-dark-static-ascii.svg
deleted file mode 100644
index adafe4bf..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-static-no-ansi.svg b/docs/assets/widget-reorder-descriptions-dark-static-no-ansi.svg
deleted file mode 100644
index c1defee5..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-dark-static.svg b/docs/assets/widget-reorder-descriptions-dark-static.svg
deleted file mode 100644
index 0e295bc5..00000000
--- a/docs/assets/widget-reorder-descriptions-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/widget-reorder-descriptions-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 31fa907b..00000000
--- a/docs/assets/widget-reorder-descriptions-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Reorder||Tomato||^/vmove*spacegrab*<accept*esccancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-animated-ascii.svg b/docs/assets/widget-reorder-descriptions-light-animated-ascii.svg
deleted file mode 100644
index 0f469fd7..00000000
--- a/docs/assets/widget-reorder-descriptions-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Reorder||Tomato||^/vmove*spacegrab*<accept*esccancel||>BasketApple||>Carrot||Stayscrispforweekswhenkeptcold.||Optiondescriptions||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||Carrot||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-animated-no-ansi.svg b/docs/assets/widget-reorder-descriptions-light-animated-no-ansi.svg
deleted file mode 100644
index e7862412..00000000
--- a/docs/assets/widget-reorder-descriptions-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓move·spacegrab·↵accept·esccancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-animated.svg b/docs/assets/widget-reorder-descriptions-light-animated.svg
deleted file mode 100644
index 575d456e..00000000
--- a/docs/assets/widget-reorder-descriptions-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Reorder││Tomato││↑/↓move·spacegrab·↵accept·esccancel││❯BasketApple││❯Carrot││Stayscrispforweekswhenkeptcold.││Optiondescriptions││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││Carrot││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/widget-reorder-descriptions-light-static-ascii-no-ansi.svg
deleted file mode 100644
index c420516f..00000000
--- a/docs/assets/widget-reorder-descriptions-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-static-ascii.svg b/docs/assets/widget-reorder-descriptions-light-static-ascii.svg
deleted file mode 100644
index 925f481f..00000000
--- a/docs/assets/widget-reorder-descriptions-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Reorder||>Basket>Apple||Carrot||Tomato||Crispandsweet,theeverydaychoice.||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-static-no-ansi.svg b/docs/assets/widget-reorder-descriptions-light-static-no-ansi.svg
deleted file mode 100644
index d05289a9..00000000
--- a/docs/assets/widget-reorder-descriptions-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-descriptions-light-static.svg b/docs/assets/widget-reorder-descriptions-light-static.svg
deleted file mode 100644
index 85e58c2c..00000000
--- a/docs/assets/widget-reorder-descriptions-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Reorder││❯Basket❯Apple││Carrot││Tomato││Crispandsweet,theeverydaychoice.││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-animated-ascii-no-ansi.svg b/docs/assets/widget-reorder-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index b4cc0b7b..00000000
--- a/docs/assets/widget-reorder-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Reorderwidget>Reorder||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>BasketCarrot||>Apple||Reorderwidget||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-animated-ascii.svg b/docs/assets/widget-reorder-light-animated-ascii.svg
deleted file mode 100644
index b2034153..00000000
--- a/docs/assets/widget-reorder-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Reorderwidget>Reorder||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>BasketCarrot||>Apple||Reorderwidget||>Reorder>||apple,carrot,tomato||[Submit][Cancel]||>Basketapple,carrot,tomato||>Basket>Apple||>Basket^vApple||^vApple|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-animated-no-ansi.svg b/docs/assets/widget-reorder-light-animated-no-ansi.svg
deleted file mode 100644
index 736cb860..00000000
--- a/docs/assets/widget-reorder-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Reorderwidget›Reorder││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel││↑/↓reorder·spacedrop·esccancel││❯BasketCarrot││❯Apple││Reorderwidget││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-animated.svg b/docs/assets/widget-reorder-light-animated.svg
deleted file mode 100644
index aee854cc..00000000
--- a/docs/assets/widget-reorder-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Reorderwidget›Reorder││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel││↑/↓reorder·spacedrop·esccancel││❯BasketCarrot││❯Apple││Reorderwidget││❯Reorder›││apple,carrot,tomato││[Submit][Cancel]││❯Basketapple,carrot,tomato││❯Basket❯Apple││❯Basket↑↓Apple││↑↓Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-static-ascii-no-ansi.svg b/docs/assets/widget-reorder-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 0b722cc8..00000000
--- a/docs/assets/widget-reorder-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Reorderwidget>Reorder||>Basket>Apple||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-static-ascii.svg b/docs/assets/widget-reorder-light-static-ascii.svg
deleted file mode 100644
index 54c6879c..00000000
--- a/docs/assets/widget-reorder-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Reorderwidget>Reorder||>Basket>Apple||Carrot||Tomato||^/vmove*spacegrab*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-static-no-ansi.svg b/docs/assets/widget-reorder-light-static-no-ansi.svg
deleted file mode 100644
index 33cae10d..00000000
--- a/docs/assets/widget-reorder-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Reorderwidget›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-reorder-light-static.svg b/docs/assets/widget-reorder-light-static.svg
deleted file mode 100644
index 650c5399..00000000
--- a/docs/assets/widget-reorder-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Reorderwidget›Reorder││❯Basket❯Apple││Carrot││Tomato││↑/↓move·spacegrab·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-search-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 963c4b72..00000000
--- a/docs/assets/widget-search-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Searchwidget>Search||()Potato||^/vmove*<accept*esccancel||(*)Onion||>Vegetableon|||Searchwidget||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Onion||v||>Vegetableo|||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-animated-ascii.svg b/docs/assets/widget-search-dark-animated-ascii.svg
deleted file mode 100644
index fcfc7f55..00000000
--- a/docs/assets/widget-search-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Searchwidget>Search||^/vmove*<accept*esccancel||>Vegetableon|||(*)Onion||Searchwidget||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Potato||()Onion||v||>Vegetableo|||(*)Onion||()Potato||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-animated-no-ansi.svg b/docs/assets/widget-search-dark-animated-no-ansi.svg
deleted file mode 100644
index 91d4d38c..00000000
--- a/docs/assets/widget-search-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Searchwidget›Search││○Potato││↑/↓move·↵accept·esccancel││●Onion││❯Vegetableon█││Searchwidget││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Onion││▼││❯Vegetableo█││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-animated.svg b/docs/assets/widget-search-dark-animated.svg
deleted file mode 100644
index fb96ebc2..00000000
--- a/docs/assets/widget-search-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Searchwidget›Search││↑/↓move·↵accept·esccancel││❯Vegetableon█││●Onion││Searchwidget││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Potato││○Onion││▼││❯Vegetableo█││●Onion││○Potato││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-static-ascii-no-ansi.svg b/docs/assets/widget-search-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 3802a71b..00000000
--- a/docs/assets/widget-search-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Searchwidget>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-static-ascii.svg b/docs/assets/widget-search-dark-static-ascii.svg
deleted file mode 100644
index 12325fcd..00000000
--- a/docs/assets/widget-search-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Searchwidget>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-static-no-ansi.svg b/docs/assets/widget-search-dark-static-no-ansi.svg
deleted file mode 100644
index 15eeca83..00000000
--- a/docs/assets/widget-search-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Searchwidget›Search││❯Vegetable█││●Carrot││○Potato││○Onion││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-dark-static.svg b/docs/assets/widget-search-dark-static.svg
deleted file mode 100644
index c7f1bfbf..00000000
--- a/docs/assets/widget-search-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Searchwidget›Search││❯Vegetable█││●Carrot││○Potato││○Onion││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-search-descriptions-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 4854fd41..00000000
--- a/docs/assets/widget-search-descriptions-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/vmove*<accept*esccancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-animated-ascii.svg b/docs/assets/widget-search-descriptions-dark-animated-ascii.svg
deleted file mode 100644
index 4e9e32df..00000000
--- a/docs/assets/widget-search-descriptions-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/vmove*<accept*esccancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-animated-no-ansi.svg b/docs/assets/widget-search-descriptions-dark-animated-no-ansi.svg
deleted file mode 100644
index 6d9d1769..00000000
--- a/docs/assets/widget-search-descriptions-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓move·↵accept·esccancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-animated.svg b/docs/assets/widget-search-descriptions-dark-animated.svg
deleted file mode 100644
index e3079b93..00000000
--- a/docs/assets/widget-search-descriptions-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓move·↵accept·esccancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/widget-search-descriptions-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index c857d12b..00000000
--- a/docs/assets/widget-search-descriptions-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-static-ascii.svg b/docs/assets/widget-search-descriptions-dark-static-ascii.svg
deleted file mode 100644
index 881d4c1b..00000000
--- a/docs/assets/widget-search-descriptions-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-static-no-ansi.svg b/docs/assets/widget-search-descriptions-dark-static-no-ansi.svg
deleted file mode 100644
index d73f247a..00000000
--- a/docs/assets/widget-search-descriptions-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-dark-static.svg b/docs/assets/widget-search-descriptions-dark-static.svg
deleted file mode 100644
index a62d0cff..00000000
--- a/docs/assets/widget-search-descriptions-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/widget-search-descriptions-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index a05ff82c..00000000
--- a/docs/assets/widget-search-descriptions-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/vmove*<accept*esccancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-animated-ascii.svg b/docs/assets/widget-search-descriptions-light-animated-ascii.svg
deleted file mode 100644
index b132c217..00000000
--- a/docs/assets/widget-search-descriptions-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Search||>Vegetable|||()Onion||()Pepper||^/vmove*<accept*esccancel||()Carrot||(*)Potato||Storesbestsomewherecoolanddark.||Optiondescriptions||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||(*)Carrot||()Potato||Stayscrispforweekswhenkeptcold.|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-animated-no-ansi.svg b/docs/assets/widget-search-descriptions-light-animated-no-ansi.svg
deleted file mode 100644
index ae5c61be..00000000
--- a/docs/assets/widget-search-descriptions-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓move·↵accept·esccancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-animated.svg b/docs/assets/widget-search-descriptions-light-animated.svg
deleted file mode 100644
index 322d8fb4..00000000
--- a/docs/assets/widget-search-descriptions-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Search││❯Vegetable█││○Onion││○Pepper││↑/↓move·↵accept·esccancel││○Carrot││●Potato││Storesbestsomewherecoolanddark.││Optiondescriptions││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││●Carrot││○Potato││Stayscrispforweekswhenkeptcold.│
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/widget-search-descriptions-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 6e6cc656..00000000
--- a/docs/assets/widget-search-descriptions-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-static-ascii.svg b/docs/assets/widget-search-descriptions-light-static-ascii.svg
deleted file mode 100644
index b863e233..00000000
--- a/docs/assets/widget-search-descriptions-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||()Pepper||Stayscrispforweekswhenkeptcold.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-static-no-ansi.svg b/docs/assets/widget-search-descriptions-light-static-no-ansi.svg
deleted file mode 100644
index 0e307fae..00000000
--- a/docs/assets/widget-search-descriptions-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-descriptions-light-static.svg b/docs/assets/widget-search-descriptions-light-static.svg
deleted file mode 100644
index bffea3ee..00000000
--- a/docs/assets/widget-search-descriptions-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Search││❯Vegetable█││●Carrot││○Potato││○Onion││○Pepper││Stayscrispforweekswhenkeptcold.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-animated-ascii-no-ansi.svg b/docs/assets/widget-search-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 8f816e9a..00000000
--- a/docs/assets/widget-search-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Searchwidget>Search||()Potato||^/vmove*<accept*esccancel||(*)Onion||>Vegetableon|||Searchwidget||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Onion||v||>Vegetableo|||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-animated-ascii.svg b/docs/assets/widget-search-light-animated-ascii.svg
deleted file mode 100644
index bda06f6b..00000000
--- a/docs/assets/widget-search-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Searchwidget>Search||^/vmove*<accept*esccancel||>Vegetableon|||(*)Onion||Searchwidget||>Search>||carrot||[Submit][Cancel]||>Vegetablecarrot||>Vegetable|||(*)Carrot||()Potato||()Onion||v||>Vegetableo|||(*)Onion||()Potato||()Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-animated-no-ansi.svg b/docs/assets/widget-search-light-animated-no-ansi.svg
deleted file mode 100644
index f64d8d6a..00000000
--- a/docs/assets/widget-search-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Searchwidget›Search││○Potato││↑/↓move·↵accept·esccancel││●Onion││❯Vegetableon█││Searchwidget││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Onion││▼││❯Vegetableo█││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-animated.svg b/docs/assets/widget-search-light-animated.svg
deleted file mode 100644
index f35e7188..00000000
--- a/docs/assets/widget-search-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Searchwidget›Search││↑/↓move·↵accept·esccancel││❯Vegetableon█││●Onion││Searchwidget││❯Search›││carrot││[Submit][Cancel]││❯Vegetablecarrot││❯Vegetable█││●Carrot││○Potato││○Onion││▼││❯Vegetableo█││●Onion││○Potato││○Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-static-ascii-no-ansi.svg b/docs/assets/widget-search-light-static-ascii-no-ansi.svg
deleted file mode 100644
index b46079de..00000000
--- a/docs/assets/widget-search-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Searchwidget>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-static-ascii.svg b/docs/assets/widget-search-light-static-ascii.svg
deleted file mode 100644
index c390c7f5..00000000
--- a/docs/assets/widget-search-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Searchwidget>Search||>Vegetable|||(*)Carrot||()Potato||()Onion||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-static-no-ansi.svg b/docs/assets/widget-search-light-static-no-ansi.svg
deleted file mode 100644
index da5c908c..00000000
--- a/docs/assets/widget-search-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Searchwidget›Search││❯Vegetable█││●Carrot││○Potato││○Onion││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-light-static.svg b/docs/assets/widget-search-light-static.svg
deleted file mode 100644
index 5b80b5a7..00000000
--- a/docs/assets/widget-search-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Searchwidget›Search││❯Vegetable█││●Carrot││○Potato││○Onion││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 63b671f9..00000000
--- a/docs/assets/widget-search-multiple-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSearchwidget>MultiSearch||[]Carrot||^/vmove*<accept*esccancel||>[]Tomato||>Basketto|||>[x]Tomato||MultiSearchwidget||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||v||>Baskett||
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-animated-ascii.svg b/docs/assets/widget-search-multiple-dark-animated-ascii.svg
deleted file mode 100644
index e01a379c..00000000
--- a/docs/assets/widget-search-multiple-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSearchwidget>MultiSearch||^/vmove*<accept*esccancel||>Basketto|||>[x]Tomato||MultiSearchwidget||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||>Baskett|||>[]Tomato||[]Carrot||>[]Tomato|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-animated-no-ansi.svg b/docs/assets/widget-search-multiple-dark-animated-no-ansi.svg
deleted file mode 100644
index e63a49a6..00000000
--- a/docs/assets/widget-search-multiple-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchwidget›MultiSearch││◻Carrot││↑/↓move·↵accept·esccancel││❯◻Tomato││❯Basketto█││❯◼Tomato││MultiSearchwidget││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││▼││❯Baskett█│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-animated.svg b/docs/assets/widget-search-multiple-dark-animated.svg
deleted file mode 100644
index 7b570bb4..00000000
--- a/docs/assets/widget-search-multiple-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchwidget›MultiSearch││↑/↓move·↵accept·esccancel││❯Basketto█││❯◼Tomato││MultiSearchwidget││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││❯Baskett█││❯◻Tomato││◻Carrot││❯◻Tomato│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index d698e548..00000000
--- a/docs/assets/widget-search-multiple-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSearchwidget>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-static-ascii.svg b/docs/assets/widget-search-multiple-dark-static-ascii.svg
deleted file mode 100644
index db25376b..00000000
--- a/docs/assets/widget-search-multiple-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSearchwidget>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-static-no-ansi.svg b/docs/assets/widget-search-multiple-dark-static-no-ansi.svg
deleted file mode 100644
index 2b9eeed0..00000000
--- a/docs/assets/widget-search-multiple-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchwidget›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-dark-static.svg b/docs/assets/widget-search-multiple-dark-static.svg
deleted file mode 100644
index 166376bf..00000000
--- a/docs/assets/widget-search-multiple-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchwidget›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 42c5f9b2..00000000
--- a/docs/assets/widget-search-multiple-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSearchwidget>MultiSearch||[]Carrot||^/vmove*<accept*esccancel||>[]Tomato||>Basketto|||>[x]Tomato||MultiSearchwidget||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||v||>Baskett||
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-animated-ascii.svg b/docs/assets/widget-search-multiple-light-animated-ascii.svg
deleted file mode 100644
index 9fd50bef..00000000
--- a/docs/assets/widget-search-multiple-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSearchwidget>MultiSearch||^/vmove*<accept*esccancel||>Basketto|||>[x]Tomato||MultiSearchwidget||>MultiSearch>||apple||[Submit][Cancel]||>Basketapple||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||>Baskett|||>[]Tomato||[]Carrot||>[]Tomato|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-animated-no-ansi.svg b/docs/assets/widget-search-multiple-light-animated-no-ansi.svg
deleted file mode 100644
index c7b16fa1..00000000
--- a/docs/assets/widget-search-multiple-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchwidget›MultiSearch││◻Carrot││↑/↓move·↵accept·esccancel││❯◻Tomato││❯Basketto█││❯◼Tomato││MultiSearchwidget││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││▼││❯Baskett█│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-animated.svg b/docs/assets/widget-search-multiple-light-animated.svg
deleted file mode 100644
index 39f75887..00000000
--- a/docs/assets/widget-search-multiple-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSearchwidget›MultiSearch││↑/↓move·↵accept·esccancel││❯Basketto█││❯◼Tomato││MultiSearchwidget││❯MultiSearch›││apple││[Submit][Cancel]││❯Basketapple││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││❯Baskett█││❯◻Tomato││◻Carrot││❯◻Tomato│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-static-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 5fc531ad..00000000
--- a/docs/assets/widget-search-multiple-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSearchwidget>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-static-ascii.svg b/docs/assets/widget-search-multiple-light-static-ascii.svg
deleted file mode 100644
index a8d1d9e9..00000000
--- a/docs/assets/widget-search-multiple-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSearchwidget>MultiSearch||>Basket|||>[x]Apple||[]Banana||[]Carrot||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-static-no-ansi.svg b/docs/assets/widget-search-multiple-light-static-no-ansi.svg
deleted file mode 100644
index 4dc05a22..00000000
--- a/docs/assets/widget-search-multiple-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchwidget›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-light-static.svg b/docs/assets/widget-search-multiple-light-static.svg
deleted file mode 100644
index ce0c5aa7..00000000
--- a/docs/assets/widget-search-multiple-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSearchwidget›MultiSearch││❯Basket█││❯◼Apple││◻Banana││◻Carrot││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-limited-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 48812f60..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-animated-ascii.svg b/docs/assets/widget-search-multiple-limited-dark-animated-ascii.svg
deleted file mode 100644
index 021510ae..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/widget-search-multiple-limited-dark-animated-no-ansi.svg
deleted file mode 100644
index c1fb0462..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-animated.svg b/docs/assets/widget-search-multiple-limited-dark-animated.svg
deleted file mode 100644
index b9a09a36..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-limited-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 7d893467..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-static-ascii.svg b/docs/assets/widget-search-multiple-limited-dark-static-ascii.svg
deleted file mode 100644
index bc58035c..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-static-no-ansi.svg b/docs/assets/widget-search-multiple-limited-dark-static-no-ansi.svg
deleted file mode 100644
index 9e661968..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-dark-static.svg b/docs/assets/widget-search-multiple-limited-dark-static.svg
deleted file mode 100644
index 576432c5..00000000
--- a/docs/assets/widget-search-multiple-limited-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-limited-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index bcfadc4f..00000000
--- a/docs/assets/widget-search-multiple-limited-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-animated-ascii.svg b/docs/assets/widget-search-multiple-limited-light-animated-ascii.svg
deleted file mode 100644
index 13563cc8..00000000
--- a/docs/assets/widget-search-multiple-limited-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSearch>MultiSearch||>Basket|||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||[x]Apple||>[x]Banana||BoundedMultiSearch||>MultiSearch>||[Submit][Cancel]||>Basket||>[]Apple||>[x]Apple||>[]Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-animated-no-ansi.svg b/docs/assets/widget-search-multiple-limited-light-animated-no-ansi.svg
deleted file mode 100644
index df51a144..00000000
--- a/docs/assets/widget-search-multiple-limited-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-animated.svg b/docs/assets/widget-search-multiple-limited-light-animated.svg
deleted file mode 100644
index 7cfce064..00000000
--- a/docs/assets/widget-search-multiple-limited-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSearch›MultiSearch││❯Basket█││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││◼Apple││❯◼Banana││BoundedMultiSearch││❯MultiSearch›││[Submit][Cancel]││❯Basket││❯◻Apple││❯◼Apple││❯◻Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/widget-search-multiple-limited-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 8b51cc5e..00000000
--- a/docs/assets/widget-search-multiple-limited-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-static-ascii.svg b/docs/assets/widget-search-multiple-limited-light-static-ascii.svg
deleted file mode 100644
index 324a06b1..00000000
--- a/docs/assets/widget-search-multiple-limited-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSearch>MultiSearch||>Basket|||>[]Apple||[]Banana||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-static-no-ansi.svg b/docs/assets/widget-search-multiple-limited-light-static-no-ansi.svg
deleted file mode 100644
index 96c8f526..00000000
--- a/docs/assets/widget-search-multiple-limited-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-search-multiple-limited-light-static.svg b/docs/assets/widget-search-multiple-limited-light-static.svg
deleted file mode 100644
index 5cf493bc..00000000
--- a/docs/assets/widget-search-multiple-limited-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSearch›MultiSearch││❯Basket█││❯◻Apple││◻Banana││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-select-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 7b8348d6..00000000
--- a/docs/assets/widget-select-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Selectwidget>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Selectwidget||>Select>||apple||v||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-animated-ascii.svg b/docs/assets/widget-select-dark-animated-ascii.svg
deleted file mode 100644
index 7f6d5457..00000000
--- a/docs/assets/widget-select-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Selectwidget>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Selectwidget||>Select>||apple||v||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-animated-no-ansi.svg b/docs/assets/widget-select-dark-animated-no-ansi.svg
deleted file mode 100644
index 5d194682..00000000
--- a/docs/assets/widget-select-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Selectwidget›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Selectwidget││❯Select›││apple││▼││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-animated.svg b/docs/assets/widget-select-dark-animated.svg
deleted file mode 100644
index 377f1cdf..00000000
--- a/docs/assets/widget-select-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Selectwidget›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Selectwidget││❯Select›││apple││▼││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index a1f5326d..00000000
--- a/docs/assets/widget-select-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Selectwidget>Select||>Fruit(*)Apple||()Banana||()Cherry||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-static-ascii.svg b/docs/assets/widget-select-dark-static-ascii.svg
deleted file mode 100644
index 6f6428ee..00000000
--- a/docs/assets/widget-select-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Selectwidget>Select||>Fruit(*)Apple||()Banana||()Cherry||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-static-no-ansi.svg b/docs/assets/widget-select-dark-static-no-ansi.svg
deleted file mode 100644
index 7b63d887..00000000
--- a/docs/assets/widget-select-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Selectwidget›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-dark-static.svg b/docs/assets/widget-select-dark-static.svg
deleted file mode 100644
index 4978e43e..00000000
--- a/docs/assets/widget-select-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Selectwidget›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-select-descriptions-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index a42d061e..00000000
--- a/docs/assets/widget-select-descriptions-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-animated-ascii.svg b/docs/assets/widget-select-descriptions-dark-animated-ascii.svg
deleted file mode 100644
index e938b827..00000000
--- a/docs/assets/widget-select-descriptions-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-animated-no-ansi.svg b/docs/assets/widget-select-descriptions-dark-animated-no-ansi.svg
deleted file mode 100644
index baae28ab..00000000
--- a/docs/assets/widget-select-descriptions-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-animated.svg b/docs/assets/widget-select-descriptions-dark-animated.svg
deleted file mode 100644
index 79f2ab3c..00000000
--- a/docs/assets/widget-select-descriptions-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-descriptions-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 897cf147..00000000
--- a/docs/assets/widget-select-descriptions-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-static-ascii.svg b/docs/assets/widget-select-descriptions-dark-static-ascii.svg
deleted file mode 100644
index 508eecc8..00000000
--- a/docs/assets/widget-select-descriptions-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-static-no-ansi.svg b/docs/assets/widget-select-descriptions-dark-static-no-ansi.svg
deleted file mode 100644
index 33189d8c..00000000
--- a/docs/assets/widget-select-descriptions-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-dark-static.svg b/docs/assets/widget-select-descriptions-dark-static.svg
deleted file mode 100644
index 88121251..00000000
--- a/docs/assets/widget-select-descriptions-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/widget-select-descriptions-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 66cf030c..00000000
--- a/docs/assets/widget-select-descriptions-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-animated-ascii.svg b/docs/assets/widget-select-descriptions-light-animated-ascii.svg
deleted file mode 100644
index f8b8308c..00000000
--- a/docs/assets/widget-select-descriptions-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Richinpotassium;ripensoffthetree.||Optiondescriptions||>Select>||apple||[Submit][Cancel]||>Fruitapple||>Fruit(*)Apple||()Banana||Crispandsweet,theeverydaychoice.|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-animated-no-ansi.svg b/docs/assets/widget-select-descriptions-light-animated-no-ansi.svg
deleted file mode 100644
index 7ba26ff1..00000000
--- a/docs/assets/widget-select-descriptions-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-animated.svg b/docs/assets/widget-select-descriptions-light-animated.svg
deleted file mode 100644
index 6e74a402..00000000
--- a/docs/assets/widget-select-descriptions-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Richinpotassium;ripensoffthetree.││Optiondescriptions││❯Select›││apple││[Submit][Cancel]││❯Fruitapple││❯Fruit●Apple││○Banana││Crispandsweet,theeverydaychoice.│
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-descriptions-light-static-ascii-no-ansi.svg
deleted file mode 100644
index bdfd5d25..00000000
--- a/docs/assets/widget-select-descriptions-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-static-ascii.svg b/docs/assets/widget-select-descriptions-light-static-ascii.svg
deleted file mode 100644
index 8c47acaf..00000000
--- a/docs/assets/widget-select-descriptions-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Select||>Fruit(*)Apple||()Banana||()Cherry||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-static-no-ansi.svg b/docs/assets/widget-select-descriptions-light-static-no-ansi.svg
deleted file mode 100644
index 22fe6d58..00000000
--- a/docs/assets/widget-select-descriptions-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-descriptions-light-static.svg b/docs/assets/widget-select-descriptions-light-static.svg
deleted file mode 100644
index c4033f0b..00000000
--- a/docs/assets/widget-select-descriptions-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Select││❯Fruit●Apple││○Banana││○Cherry││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-groups-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index b85d8489..00000000
--- a/docs/assets/widget-select-groups-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||-------------------------------||()Cherry(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-dark-static-ascii.svg b/docs/assets/widget-select-groups-dark-static-ascii.svg
deleted file mode 100644
index 4a83e980..00000000
--- a/docs/assets/widget-select-groups-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||-------------------------------||()Cherry(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-dark-static-no-ansi.svg b/docs/assets/widget-select-groups-dark-static-no-ansi.svg
deleted file mode 100644
index 9d7aeae7..00000000
--- a/docs/assets/widget-select-groups-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────││○Cherry(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-dark-static.svg b/docs/assets/widget-select-groups-dark-static.svg
deleted file mode 100644
index 70311547..00000000
--- a/docs/assets/widget-select-groups-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────││○Cherry(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-groups-light-static-ascii-no-ansi.svg
deleted file mode 100644
index d34b59a6..00000000
--- a/docs/assets/widget-select-groups-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||-------------------------------||()Cherry(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-light-static-ascii.svg b/docs/assets/widget-select-groups-light-static-ascii.svg
deleted file mode 100644
index 3c70b31b..00000000
--- a/docs/assets/widget-select-groups-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||Selectwithgroups>Select||>FruitFruit||(*)Apple||()Banana||-------------------------------||()Cherry(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-light-static-no-ansi.svg b/docs/assets/widget-select-groups-light-static-no-ansi.svg
deleted file mode 100644
index e1c83eab..00000000
--- a/docs/assets/widget-select-groups-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────││○Cherry(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-groups-light-static.svg b/docs/assets/widget-select-groups-light-static.svg
deleted file mode 100644
index 62554965..00000000
--- a/docs/assets/widget-select-groups-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│Selectwithgroups›Select││❯FruitFruit││●Apple││○Banana││───────────────────────────────││○Cherry(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-animated-ascii-no-ansi.svg b/docs/assets/widget-select-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 2ef3c4d1..00000000
--- a/docs/assets/widget-select-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Selectwidget>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Selectwidget||>Select>||apple||v||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-animated-ascii.svg b/docs/assets/widget-select-light-animated-ascii.svg
deleted file mode 100644
index 76061522..00000000
--- a/docs/assets/widget-select-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Selectwidget>Select||()Cherry||^/vmove*<accept*esccancel||>Fruit()Apple||(*)Banana||Selectwidget||>Select>||apple||v||>Fruitapple||>Fruit(*)Apple||()Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-animated-no-ansi.svg b/docs/assets/widget-select-light-animated-no-ansi.svg
deleted file mode 100644
index 7c85b910..00000000
--- a/docs/assets/widget-select-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Selectwidget›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Selectwidget││❯Select›││apple││▼││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-animated.svg b/docs/assets/widget-select-light-animated.svg
deleted file mode 100644
index b13f0871..00000000
--- a/docs/assets/widget-select-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Selectwidget›Select││○Cherry││↑/↓move·↵accept·esccancel││❯Fruit○Apple││●Banana││Selectwidget││❯Select›││apple││▼││❯Fruitapple││❯Fruit●Apple││○Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 3f89bc6a..00000000
--- a/docs/assets/widget-select-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Selectwidget>Select||>Fruit(*)Apple||()Banana||()Cherry||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-static-ascii.svg b/docs/assets/widget-select-light-static-ascii.svg
deleted file mode 100644
index e2dd6352..00000000
--- a/docs/assets/widget-select-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Selectwidget>Select||>Fruit(*)Apple||()Banana||()Cherry||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-static-no-ansi.svg b/docs/assets/widget-select-light-static-no-ansi.svg
deleted file mode 100644
index d7836c8b..00000000
--- a/docs/assets/widget-select-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Selectwidget›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-light-static.svg b/docs/assets/widget-select-light-static.svg
deleted file mode 100644
index 38eb4509..00000000
--- a/docs/assets/widget-select-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Selectwidget›Select││❯Fruit●Apple││○Banana││○Cherry││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index d61cea7b..00000000
--- a/docs/assets/widget-select-multiple-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSelectwidget>MultiSelect||[]Tomato||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||MultiSelectwidget||>MultiSelect>||apple||v||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-animated-ascii.svg b/docs/assets/widget-select-multiple-dark-animated-ascii.svg
deleted file mode 100644
index 6a6728f8..00000000
--- a/docs/assets/widget-select-multiple-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSelectwidget>MultiSelect||[]Tomato||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||MultiSelectwidget||>MultiSelect>||apple||v||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-animated-no-ansi.svg b/docs/assets/widget-select-multiple-dark-animated-no-ansi.svg
deleted file mode 100644
index dc9e6d8d..00000000
--- a/docs/assets/widget-select-multiple-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectwidget›MultiSelect││◻Tomato││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││MultiSelectwidget││❯MultiSelect›││apple││▼││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-animated.svg b/docs/assets/widget-select-multiple-dark-animated.svg
deleted file mode 100644
index 549825e5..00000000
--- a/docs/assets/widget-select-multiple-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectwidget›MultiSelect││◻Tomato││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││MultiSelectwidget││❯MultiSelect›││apple││▼││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 9e4aacca..00000000
--- a/docs/assets/widget-select-multiple-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSelectwidget>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-static-ascii.svg b/docs/assets/widget-select-multiple-dark-static-ascii.svg
deleted file mode 100644
index 98118078..00000000
--- a/docs/assets/widget-select-multiple-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSelectwidget>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-static-no-ansi.svg b/docs/assets/widget-select-multiple-dark-static-no-ansi.svg
deleted file mode 100644
index 8932d22e..00000000
--- a/docs/assets/widget-select-multiple-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwidget›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-dark-static.svg b/docs/assets/widget-select-multiple-dark-static.svg
deleted file mode 100644
index 19af50a1..00000000
--- a/docs/assets/widget-select-multiple-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwidget›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-groups-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index a127e32c..00000000
--- a/docs/assets/widget-select-multiple-groups-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-dark-static-ascii.svg b/docs/assets/widget-select-multiple-groups-dark-static-ascii.svg
deleted file mode 100644
index 603aab4f..00000000
--- a/docs/assets/widget-select-multiple-groups-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-dark-static-no-ansi.svg b/docs/assets/widget-select-multiple-groups-dark-static-no-ansi.svg
deleted file mode 100644
index 1f6978af..00000000
--- a/docs/assets/widget-select-multiple-groups-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-dark-static.svg b/docs/assets/widget-select-multiple-groups-dark-static.svg
deleted file mode 100644
index f74d87da..00000000
--- a/docs/assets/widget-select-multiple-groups-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-groups-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 9a5411de..00000000
--- a/docs/assets/widget-select-multiple-groups-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-light-static-ascii.svg b/docs/assets/widget-select-multiple-groups-light-static-ascii.svg
deleted file mode 100644
index efaf393f..00000000
--- a/docs/assets/widget-select-multiple-groups-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+------------------------------------------+|||MultiSelectwithgroups>MultiSelect||>BasketFruit||>[x]Apple||[]Banana||------------------------------||Vegetables||[]Carrot||[]Tomato||[]Leek(outofseason)||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-light-static-no-ansi.svg b/docs/assets/widget-select-multiple-groups-light-static-no-ansi.svg
deleted file mode 100644
index fc716c11..00000000
--- a/docs/assets/widget-select-multiple-groups-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-groups-light-static.svg b/docs/assets/widget-select-multiple-groups-light-static.svg
deleted file mode 100644
index ed506e6a..00000000
--- a/docs/assets/widget-select-multiple-groups-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────┤││╭──────────────────────────────────────────╮│MultiSelectwithgroups›MultiSelect││❯BasketFruit││❯◼Apple││◻Banana││──────────────────────────────││Vegetables││◻Carrot││◻Tomato││◻Leek(outofseason)││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-animated-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 2533b3d4..00000000
--- a/docs/assets/widget-select-multiple-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSelectwidget>MultiSelect||[]Tomato||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||MultiSelectwidget||>MultiSelect>||apple||v||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-animated-ascii.svg b/docs/assets/widget-select-multiple-light-animated-ascii.svg
deleted file mode 100644
index f6d7aa2c..00000000
--- a/docs/assets/widget-select-multiple-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||MultiSelectwidget>MultiSelect||[]Tomato||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||MultiSelectwidget||>MultiSelect>||apple||v||>Basketapple||>Basket>[x]Apple||[]Carrot||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-animated-no-ansi.svg b/docs/assets/widget-select-multiple-light-animated-no-ansi.svg
deleted file mode 100644
index 40f5802b..00000000
--- a/docs/assets/widget-select-multiple-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectwidget›MultiSelect││◻Tomato││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││MultiSelectwidget││❯MultiSelect›││apple││▼││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-animated.svg b/docs/assets/widget-select-multiple-light-animated.svg
deleted file mode 100644
index 16e2f6f4..00000000
--- a/docs/assets/widget-select-multiple-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│MultiSelectwidget›MultiSelect││◻Tomato││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││MultiSelectwidget││❯MultiSelect›││apple││▼││❯Basketapple││❯Basket❯◼Apple││◻Carrot││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 6737512f..00000000
--- a/docs/assets/widget-select-multiple-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSelectwidget>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-static-ascii.svg b/docs/assets/widget-select-multiple-light-static-ascii.svg
deleted file mode 100644
index bcbaf201..00000000
--- a/docs/assets/widget-select-multiple-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||MultiSelectwidget>MultiSelect||>Basket>[x]Apple||[]Carrot||[]Tomato||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-static-no-ansi.svg b/docs/assets/widget-select-multiple-light-static-no-ansi.svg
deleted file mode 100644
index 4ef0ab34..00000000
--- a/docs/assets/widget-select-multiple-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwidget›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-light-static.svg b/docs/assets/widget-select-multiple-light-static.svg
deleted file mode 100644
index 04e321c1..00000000
--- a/docs/assets/widget-select-multiple-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│MultiSelectwidget›MultiSelect││❯Basket❯◼Apple││◻Carrot││◻Tomato││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-limited-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index e1f45883..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-animated-ascii.svg b/docs/assets/widget-select-multiple-limited-dark-animated-ascii.svg
deleted file mode 100644
index 237b289e..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-animated-no-ansi.svg b/docs/assets/widget-select-multiple-limited-dark-animated-no-ansi.svg
deleted file mode 100644
index e2627457..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-animated.svg b/docs/assets/widget-select-multiple-limited-dark-animated.svg
deleted file mode 100644
index 87d31419..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-limited-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index bf59ea91..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-static-ascii.svg b/docs/assets/widget-select-multiple-limited-dark-static-ascii.svg
deleted file mode 100644
index 3bb8f495..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-static-no-ansi.svg b/docs/assets/widget-select-multiple-limited-dark-static-no-ansi.svg
deleted file mode 100644
index 2708f64b..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-dark-static.svg b/docs/assets/widget-select-multiple-limited-dark-static.svg
deleted file mode 100644
index 7a5ba7a4..00000000
--- a/docs/assets/widget-select-multiple-limited-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-animated-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-limited-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 746e41bc..00000000
--- a/docs/assets/widget-select-multiple-limited-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-animated-ascii.svg b/docs/assets/widget-select-multiple-limited-light-animated-ascii.svg
deleted file mode 100644
index 1cf4360e..00000000
--- a/docs/assets/widget-select-multiple-limited-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||BoundedMultiSelect>MultiSelect||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel||>Basket[x]Apple||>[x]Carrot||BoundedMultiSelect||>MultiSelect>||[Submit][Cancel]||>Basket||>Basket>[]Apple||>Basket>[x]Apple||>[]Carrot|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-animated-no-ansi.svg b/docs/assets/widget-select-multiple-limited-light-animated-no-ansi.svg
deleted file mode 100644
index 6dc053b9..00000000
--- a/docs/assets/widget-select-multiple-limited-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-animated.svg b/docs/assets/widget-select-multiple-limited-light-animated.svg
deleted file mode 100644
index 94fbe5b4..00000000
--- a/docs/assets/widget-select-multiple-limited-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│BoundedMultiSelect›MultiSelect││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel││❯Basket◼Apple││❯◼Carrot││BoundedMultiSelect││❯MultiSelect›││[Submit][Cancel]││❯Basket││❯Basket❯◻Apple││❯Basket❯◼Apple││❯◻Carrot│
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-static-ascii-no-ansi.svg b/docs/assets/widget-select-multiple-limited-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 14117ed5..00000000
--- a/docs/assets/widget-select-multiple-limited-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-static-ascii.svg b/docs/assets/widget-select-multiple-limited-light-static-ascii.svg
deleted file mode 100644
index 35332514..00000000
--- a/docs/assets/widget-select-multiple-limited-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||BoundedMultiSelect>MultiSelect||>Basket>[]Apple||[]Carrot||[]Tomato||Selectbetween2and3items.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-static-no-ansi.svg b/docs/assets/widget-select-multiple-limited-light-static-no-ansi.svg
deleted file mode 100644
index 59f42ff7..00000000
--- a/docs/assets/widget-select-multiple-limited-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-select-multiple-limited-light-static.svg b/docs/assets/widget-select-multiple-limited-light-static.svg
deleted file mode 100644
index 5e7b7775..00000000
--- a/docs/assets/widget-select-multiple-limited-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│BoundedMultiSelect›MultiSelect││❯Basket❯◻Apple││◻Carrot││◻Tomato││Selectbetween2and3items.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 96336d60..00000000
--- a/docs/assets/widget-suggest-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Suggestwidget>Suggest||Apricot||^/vmove*<accept*esccancel||Cherry||>FruitCh|||>Cherry||Suggestwidget||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Banana||v||>FruitC||
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-animated-ascii.svg b/docs/assets/widget-suggest-dark-animated-ascii.svg
deleted file mode 100644
index 9786d680..00000000
--- a/docs/assets/widget-suggest-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Suggestwidget>Suggest||^/vmove*<accept*esccancel||>FruitCh|||>Cherry||Suggestwidget||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||v||>FruitC|||Cherry||Apricot||Cherry|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-animated-no-ansi.svg b/docs/assets/widget-suggest-dark-animated-no-ansi.svg
deleted file mode 100644
index a0ddc559..00000000
--- a/docs/assets/widget-suggest-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Suggestwidget›Suggest││Apricot││↑/↓move·↵accept·esccancel││Cherry││❯FruitCh█││❯Cherry││Suggestwidget││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Banana││▼││❯FruitC█│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-animated.svg b/docs/assets/widget-suggest-dark-animated.svg
deleted file mode 100644
index fa90c6cc..00000000
--- a/docs/assets/widget-suggest-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Suggestwidget›Suggest││↑/↓move·↵accept·esccancel││❯FruitCh█││❯Cherry││Suggestwidget││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││▼││❯FruitC█││Cherry││Apricot││Cherry│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 24f0282b..00000000
--- a/docs/assets/widget-suggest-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Suggestwidget>Suggest||>Fruit|||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-static-ascii.svg b/docs/assets/widget-suggest-dark-static-ascii.svg
deleted file mode 100644
index 5775e912..00000000
--- a/docs/assets/widget-suggest-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Suggestwidget>Suggest||>Fruit|||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-static-no-ansi.svg b/docs/assets/widget-suggest-dark-static-no-ansi.svg
deleted file mode 100644
index 2c7291ab..00000000
--- a/docs/assets/widget-suggest-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Suggestwidget›Suggest││❯Fruit█││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-dark-static.svg b/docs/assets/widget-suggest-dark-static.svg
deleted file mode 100644
index b37f45f3..00000000
--- a/docs/assets/widget-suggest-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Suggestwidget›Suggest││❯Fruit█││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-descriptions-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 387ea064..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/vmove*<accept*esccancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-animated-ascii.svg b/docs/assets/widget-suggest-descriptions-dark-animated-ascii.svg
deleted file mode 100644
index 13b73b39..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/vmove*<accept*esccancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-animated-no-ansi.svg b/docs/assets/widget-suggest-descriptions-dark-animated-no-ansi.svg
deleted file mode 100644
index 0a9dd2f3..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓move·↵accept·esccancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-animated.svg b/docs/assets/widget-suggest-descriptions-dark-animated.svg
deleted file mode 100644
index 3ad7fe72..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓move·↵accept·esccancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-descriptions-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 4469ca77..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-static-ascii.svg b/docs/assets/widget-suggest-descriptions-dark-static-ascii.svg
deleted file mode 100644
index 45a4b4ec..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-static-no-ansi.svg b/docs/assets/widget-suggest-descriptions-dark-static-no-ansi.svg
deleted file mode 100644
index c3f866ad..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-dark-static.svg b/docs/assets/widget-suggest-descriptions-dark-static.svg
deleted file mode 100644
index ae5f6fbd..00000000
--- a/docs/assets/widget-suggest-descriptions-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-descriptions-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index fe69d21f..00000000
--- a/docs/assets/widget-suggest-descriptions-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/vmove*<accept*esccancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-animated-ascii.svg b/docs/assets/widget-suggest-descriptions-light-animated-ascii.svg
deleted file mode 100644
index 708e39e7..00000000
--- a/docs/assets/widget-suggest-descriptions-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Optiondescriptions>Suggest||>Fruit|||Apricot||Banana||Cherry||Mango||^/vmove*<accept*esccancel||>Apple||Crispandsweet,theeverydaychoice.||Optiondescriptions||>Suggest>||[Submit][Cancel]||>Fruit||Apple|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-animated-no-ansi.svg b/docs/assets/widget-suggest-descriptions-light-animated-no-ansi.svg
deleted file mode 100644
index 38c24ce2..00000000
--- a/docs/assets/widget-suggest-descriptions-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓move·↵accept·esccancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-animated.svg b/docs/assets/widget-suggest-descriptions-light-animated.svg
deleted file mode 100644
index e517ef2a..00000000
--- a/docs/assets/widget-suggest-descriptions-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Optiondescriptions›Suggest││❯Fruit█││Apricot││Banana││Cherry││Mango││↑/↓move·↵accept·esccancel││❯Apple││Crispandsweet,theeverydaychoice.││Optiondescriptions││❯Suggest›││[Submit][Cancel]││❯Fruit││Apple│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-descriptions-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 9c4e8128..00000000
--- a/docs/assets/widget-suggest-descriptions-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-static-ascii.svg b/docs/assets/widget-suggest-descriptions-light-static-ascii.svg
deleted file mode 100644
index 4b5477c5..00000000
--- a/docs/assets/widget-suggest-descriptions-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Optiondescriptions>Suggest||>Fruit|||>Apple||Apricot||Banana||Cherry||Mango||Crispandsweet,theeverydaychoice.||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-static-no-ansi.svg b/docs/assets/widget-suggest-descriptions-light-static-no-ansi.svg
deleted file mode 100644
index d5fbdfc3..00000000
--- a/docs/assets/widget-suggest-descriptions-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-descriptions-light-static.svg b/docs/assets/widget-suggest-descriptions-light-static.svg
deleted file mode 100644
index 02adf770..00000000
--- a/docs/assets/widget-suggest-descriptions-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Optiondescriptions›Suggest││❯Fruit█││❯Apple││Apricot││Banana││Cherry││Mango││Crispandsweet,theeverydaychoice.││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-ghost-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 28350fda..00000000
--- a/docs/assets/widget-suggest-ghost-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ghosttext>Suggest||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel||>FruitAp|||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||>FruitA||
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-animated-ascii.svg b/docs/assets/widget-suggest-ghost-dark-animated-ascii.svg
deleted file mode 100644
index 29d771e0..00000000
--- a/docs/assets/widget-suggest-ghost-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ghosttext>Suggest||v||^/vmove*<accept*esccancel||>FruitAp|ple||Apple||Apricot||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||>FruitA|pple||Apple||Apricot||Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-animated-no-ansi.svg b/docs/assets/widget-suggest-ghost-dark-animated-no-ansi.svg
deleted file mode 100644
index 142661ea..00000000
--- a/docs/assets/widget-suggest-ghost-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel││❯FruitAp█││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││❯FruitA█│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-animated.svg b/docs/assets/widget-suggest-ghost-dark-animated.svg
deleted file mode 100644
index 73abc0d9..00000000
--- a/docs/assets/widget-suggest-ghost-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││▼││↑/↓move·↵accept·esccancel││❯FruitAp█ple││Apple││Apricot││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││❯FruitA█pple││Apple││Apricot││Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-ghost-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 32fa5375..00000000
--- a/docs/assets/widget-suggest-ghost-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|||Apple||Apricot||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-static-ascii.svg b/docs/assets/widget-suggest-ghost-dark-static-ascii.svg
deleted file mode 100644
index 9eb66102..00000000
--- a/docs/assets/widget-suggest-ghost-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|ple||Apple||Apricot||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-static-no-ansi.svg b/docs/assets/widget-suggest-ghost-dark-static-no-ansi.svg
deleted file mode 100644
index 93dc2407..00000000
--- a/docs/assets/widget-suggest-ghost-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█││Apple││Apricot││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-dark-static.svg b/docs/assets/widget-suggest-ghost-dark-static.svg
deleted file mode 100644
index 76e593ae..00000000
--- a/docs/assets/widget-suggest-ghost-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█ple││Apple││Apricot││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-ghost-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 8361a092..00000000
--- a/docs/assets/widget-suggest-ghost-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ghosttext>Suggest||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel||>FruitAp|||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||>FruitA||
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-animated-ascii.svg b/docs/assets/widget-suggest-ghost-light-animated-ascii.svg
deleted file mode 100644
index 1f755b74..00000000
--- a/docs/assets/widget-suggest-ghost-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Ghosttext>Suggest||v||^/vmove*<accept*esccancel||>FruitAp|ple||Apple||Apricot||Ghosttext||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||>FruitA|pple||Apple||Apricot||Banana|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-animated-no-ansi.svg b/docs/assets/widget-suggest-ghost-light-animated-no-ansi.svg
deleted file mode 100644
index c8e8fc08..00000000
--- a/docs/assets/widget-suggest-ghost-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel││❯FruitAp█││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││❯FruitA█│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-animated.svg b/docs/assets/widget-suggest-ghost-light-animated.svg
deleted file mode 100644
index 3f85ec77..00000000
--- a/docs/assets/widget-suggest-ghost-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Ghosttext›Suggest││▼││↑/↓move·↵accept·esccancel││❯FruitAp█ple││Apple││Apricot││Ghosttext││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││❯FruitA█pple││Apple││Apricot││Banana│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-ghost-light-static-ascii-no-ansi.svg
deleted file mode 100644
index faf2cadb..00000000
--- a/docs/assets/widget-suggest-ghost-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|||Apple||Apricot||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-static-ascii.svg b/docs/assets/widget-suggest-ghost-light-static-ascii.svg
deleted file mode 100644
index 5ad6b03e..00000000
--- a/docs/assets/widget-suggest-ghost-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Ghosttext>Suggest||>FruitAp|ple||Apple||Apricot||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-static-no-ansi.svg b/docs/assets/widget-suggest-ghost-light-static-no-ansi.svg
deleted file mode 100644
index 14ead9fc..00000000
--- a/docs/assets/widget-suggest-ghost-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█││Apple││Apricot││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-ghost-light-static.svg b/docs/assets/widget-suggest-ghost-light-static.svg
deleted file mode 100644
index 4c1e27a1..00000000
--- a/docs/assets/widget-suggest-ghost-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Ghosttext›Suggest││❯FruitAp█ple││Apple││Apricot││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-animated-ascii-no-ansi.svg b/docs/assets/widget-suggest-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index a6e322f1..00000000
--- a/docs/assets/widget-suggest-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Suggestwidget>Suggest||Apricot||^/vmove*<accept*esccancel||Cherry||>FruitCh|||>Cherry||Suggestwidget||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Banana||v||>FruitC||
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-animated-ascii.svg b/docs/assets/widget-suggest-light-animated-ascii.svg
deleted file mode 100644
index a6f5ddd1..00000000
--- a/docs/assets/widget-suggest-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Suggestwidget>Suggest||^/vmove*<accept*esccancel||>FruitCh|||>Cherry||Suggestwidget||>Suggest>||[Submit][Cancel]||>Fruit||>Fruit|||Apple||Apricot||Banana||v||>FruitC|||Cherry||Apricot||Cherry|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-animated-no-ansi.svg b/docs/assets/widget-suggest-light-animated-no-ansi.svg
deleted file mode 100644
index 33463ca5..00000000
--- a/docs/assets/widget-suggest-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Suggestwidget›Suggest││Apricot││↑/↓move·↵accept·esccancel││Cherry││❯FruitCh█││❯Cherry││Suggestwidget││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Banana││▼││❯FruitC█│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-animated.svg b/docs/assets/widget-suggest-light-animated.svg
deleted file mode 100644
index 0a197ff5..00000000
--- a/docs/assets/widget-suggest-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Suggestwidget›Suggest││↑/↓move·↵accept·esccancel││❯FruitCh█││❯Cherry││Suggestwidget││❯Suggest›││[Submit][Cancel]││❯Fruit││❯Fruit█││Apple││Apricot││Banana││▼││❯FruitC█││Cherry││Apricot││Cherry│
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-static-ascii-no-ansi.svg b/docs/assets/widget-suggest-light-static-ascii-no-ansi.svg
deleted file mode 100644
index dfd24709..00000000
--- a/docs/assets/widget-suggest-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Suggestwidget>Suggest||>Fruit|||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-static-ascii.svg b/docs/assets/widget-suggest-light-static-ascii.svg
deleted file mode 100644
index e5cc6ffc..00000000
--- a/docs/assets/widget-suggest-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Suggestwidget>Suggest||>Fruit|||Apple||Apricot||Banana||v||^/vmove*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-static-no-ansi.svg b/docs/assets/widget-suggest-light-static-no-ansi.svg
deleted file mode 100644
index ccbb9676..00000000
--- a/docs/assets/widget-suggest-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Suggestwidget›Suggest││❯Fruit█││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-suggest-light-static.svg b/docs/assets/widget-suggest-light-static.svg
deleted file mode 100644
index ccf39c55..00000000
--- a/docs/assets/widget-suggest-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Suggestwidget›Suggest││❯Fruit█││Apple││Apricot││Banana││▼││↑/↓move·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-table-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index b7ca3947..00000000
--- a/docs/assets/widget-table-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablewidget||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-animated-ascii.svg b/docs/assets/widget-table-dark-animated-ascii.svg
deleted file mode 100644
index b8973bd4..00000000
--- a/docs/assets/widget-table-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablewidget||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-animated-no-ansi.svg b/docs/assets/widget-table-dark-animated-no-ansi.svg
deleted file mode 100644
index 72114cff..00000000
--- a/docs/assets/widget-table-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablewidget││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-animated.svg b/docs/assets/widget-table-dark-animated.svg
deleted file mode 100644
index 0134c70b..00000000
--- a/docs/assets/widget-table-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablewidget││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-static-ascii-no-ansi.svg b/docs/assets/widget-table-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index fdda2cf3..00000000
--- a/docs/assets/widget-table-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-static-ascii.svg b/docs/assets/widget-table-dark-static-ascii.svg
deleted file mode 100644
index 9735b91c..00000000
--- a/docs/assets/widget-table-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-static-no-ansi.svg b/docs/assets/widget-table-dark-static-no-ansi.svg
deleted file mode 100644
index e4454614..00000000
--- a/docs/assets/widget-table-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-table-dark-static.svg b/docs/assets/widget-table-dark-static.svg
deleted file mode 100644
index d78480df..00000000
--- a/docs/assets/widget-table-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-animated-ascii-no-ansi.svg b/docs/assets/widget-table-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 005e8b97..00000000
--- a/docs/assets/widget-table-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablewidget||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-animated-ascii.svg b/docs/assets/widget-table-light-animated-ascii.svg
deleted file mode 100644
index 96056dde..00000000
--- a/docs/assets/widget-table-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:||+-------+--------+----------+|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||Tablewidget||>Stock>||[Submit][Cancel]|
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-animated-no-ansi.svg b/docs/assets/widget-table-light-animated-no-ansi.svg
deleted file mode 100644
index 42d3776f..00000000
--- a/docs/assets/widget-table-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablewidget││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-animated.svg b/docs/assets/widget-table-light-animated.svg
deleted file mode 100644
index e23a844d..00000000
--- a/docs/assets/widget-table-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││Tablewidget││❯Stock›││[Submit][Cancel]│
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-static-ascii-no-ansi.svg b/docs/assets/widget-table-light-static-ascii-no-ansi.svg
deleted file mode 100644
index ccf37f76..00000000
--- a/docs/assets/widget-table-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-static-ascii.svg b/docs/assets/widget-table-light-static-ascii.svg
deleted file mode 100644
index a6776726..00000000
--- a/docs/assets/widget-table-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||+-------+--------+----------+||Tablewidget>Stock||Basketcontents||Everythingpickedsofar:|||Fruit|Colour|Instock||||Apple|Red|12||||Pear|Green|5||||Plum|Purple|120|||^/vmove*<select*escback*qquit*?help|
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-static-no-ansi.svg b/docs/assets/widget-table-light-static-no-ansi.svg
deleted file mode 100644
index 7585ec9b..00000000
--- a/docs/assets/widget-table-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-table-light-static.svg b/docs/assets/widget-table-light-static.svg
deleted file mode 100644
index 7dd07c9c..00000000
--- a/docs/assets/widget-table-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Tablewidget›Stock││Basketcontents││Everythingpickedsofar:││╭───────┬────────┬──────────╮│││Fruit│Colour│Instock│││├───────┼────────┼──────────┤│││Apple│Red│12││││Pear│Green│5││││Plum│Purple│120│││╰───────┴────────┴──────────╯││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-template-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 01cbe8a7..00000000
--- a/docs/assets/widget-template-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Templatewidget>Template||fillinginOrchard||v/^next/previous*<accept*esccancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatewidget||>Template>||valley-pear-a||v||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-animated-ascii.svg b/docs/assets/widget-template-dark-animated-ascii.svg
deleted file mode 100644
index ea022cd6..00000000
--- a/docs/assets/widget-template-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Templatewidget>Template||fillinginOrchard||v/^next/previous*<accept*esccancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatewidget||>Template>||valley-pear-a||v||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-animated-no-ansi.svg b/docs/assets/widget-template-dark-animated-no-ansi.svg
deleted file mode 100644
index 7b7fb140..00000000
--- a/docs/assets/widget-template-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││fillinginOrchard││↓/↑next/previous·↵accept·esccancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatewidget││❯Template›││valley-pear-a││▼││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-animated.svg b/docs/assets/widget-template-dark-animated.svg
deleted file mode 100644
index d92248a7..00000000
--- a/docs/assets/widget-template-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││fillinginOrchard││↓/↑next/previous·↵accept·esccancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatewidget││❯Template›││valley-pear-a││▼││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-static-ascii-no-ansi.svg b/docs/assets/widget-template-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 2cb7ee81..00000000
--- a/docs/assets/widget-template-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Templatewidget>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||v/^next/previous*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-static-ascii.svg b/docs/assets/widget-template-dark-static-ascii.svg
deleted file mode 100644
index 4338782c..00000000
--- a/docs/assets/widget-template-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Templatewidget>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||v/^next/previous*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-static-no-ansi.svg b/docs/assets/widget-template-dark-static-no-ansi.svg
deleted file mode 100644
index 00f5cc7c..00000000
--- a/docs/assets/widget-template-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑next/previous·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-template-dark-static.svg b/docs/assets/widget-template-dark-static.svg
deleted file mode 100644
index 0a4267af..00000000
--- a/docs/assets/widget-template-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑next/previous·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-animated-ascii-no-ansi.svg b/docs/assets/widget-template-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index a5b69f56..00000000
--- a/docs/assets/widget-template-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Templatewidget>Template||fillinginOrchard||v/^next/previous*<accept*esccancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatewidget||>Template>||valley-pear-a||v||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-animated-ascii.svg b/docs/assets/widget-template-light-animated-ascii.svg
deleted file mode 100644
index f9508203..00000000
--- a/docs/assets/widget-template-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Templatewidget>Template||fillinginOrchard||v/^next/previous*<accept*esccancel||fillinginGrade||>Cratelabelridge-pear-b|||Templatewidget||>Template>||valley-pear-a||v||>Cratelabelvalley-pear-a||>Cratelabelvalley|-pear-a||>Cratelabelvalle|-pear-a||>Cratelabelvall|-pear-a||>Cratelabelval|-pear-a||>Cratelabelva|-pear-a||>Cratelabelv|-pear-a||>Cratelabel|-pear-a||>Cratelabelr|-pear-a||>Cratelabelri|-pear-a||>Cratelabelrid|-pear-a||>Cratelabelridg|-pear-a||>Cratelabelridge|-pear-a||>Cratelabelridge-pear|-a||fillinginFruit||>Cratelabelridge-pear-a|||>Cratelabelridge-pear-||
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-animated-no-ansi.svg b/docs/assets/widget-template-light-animated-no-ansi.svg
deleted file mode 100644
index 5bda0906..00000000
--- a/docs/assets/widget-template-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││fillinginOrchard││↓/↑next/previous·↵accept·esccancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatewidget││❯Template›││valley-pear-a││▼││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-animated.svg b/docs/assets/widget-template-light-animated.svg
deleted file mode 100644
index e60dfe34..00000000
--- a/docs/assets/widget-template-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││fillinginOrchard││↓/↑next/previous·↵accept·esccancel││fillinginGrade││❯Cratelabelridge-pear-b█││Templatewidget││❯Template›││valley-pear-a││▼││❯Cratelabelvalley-pear-a││❯Cratelabelvalley█-pear-a││❯Cratelabelvalle█-pear-a││❯Cratelabelvall█-pear-a││❯Cratelabelval█-pear-a││❯Cratelabelva█-pear-a││❯Cratelabelv█-pear-a││❯Cratelabel█-pear-a││❯Cratelabelr█-pear-a││❯Cratelabelri█-pear-a││❯Cratelabelrid█-pear-a││❯Cratelabelridg█-pear-a││❯Cratelabelridge█-pear-a││❯Cratelabelridge-pear█-a││fillinginFruit││❯Cratelabelridge-pear-a█││❯Cratelabelridge-pear-█│
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-static-ascii-no-ansi.svg b/docs/assets/widget-template-light-static-ascii-no-ansi.svg
deleted file mode 100644
index ddbe3489..00000000
--- a/docs/assets/widget-template-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Templatewidget>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||v/^next/previous*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-static-ascii.svg b/docs/assets/widget-template-light-static-ascii.svg
deleted file mode 100644
index b86c76b2..00000000
--- a/docs/assets/widget-template-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Templatewidget>Template||>Cratelabelvalley|-pear-a||fillinginOrchard||v/^next/previous*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-static-no-ansi.svg b/docs/assets/widget-template-light-static-no-ansi.svg
deleted file mode 100644
index 60baf3d2..00000000
--- a/docs/assets/widget-template-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑next/previous·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-template-light-static.svg b/docs/assets/widget-template-light-static.svg
deleted file mode 100644
index edf119f2..00000000
--- a/docs/assets/widget-template-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Templatewidget›Template││❯Cratelabelvalley█-pear-a││fillinginOrchard││↓/↑next/previous·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-text-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 0af88747..00000000
--- a/docs/assets/widget-text-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textwidget>Text||<accept*esccancel||>ItemApple|||>Text>||Pear||v||>ItemPear||>ItemPear|||>ItemPea|||>ItemPe|||>ItemP|||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-animated-ascii.svg b/docs/assets/widget-text-dark-animated-ascii.svg
deleted file mode 100644
index d38e6530..00000000
--- a/docs/assets/widget-text-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textwidget>Text||<accept*esccancel||>ItemApple|||>Text>||Pear||v||>ItemPear||>ItemPear|||>ItemPea|r||>ItemPe|ar||>ItemP|ear||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-animated-no-ansi.svg b/docs/assets/widget-text-dark-animated-no-ansi.svg
deleted file mode 100644
index 939bb20a..00000000
--- a/docs/assets/widget-text-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││↵accept·esccancel││❯ItemApple█││❯Text›││Pear││▼││❯ItemPear││❯ItemPear█││❯ItemPea█││❯ItemPe█││❯ItemP█││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-animated.svg b/docs/assets/widget-text-dark-animated.svg
deleted file mode 100644
index 2a4c11af..00000000
--- a/docs/assets/widget-text-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││↵accept·esccancel││❯ItemApple█││❯Text›││Pear││▼││❯ItemPear││❯ItemPear█││❯ItemPea█r││❯ItemPe█ar││❯ItemP█ear││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-static-ascii-no-ansi.svg b/docs/assets/widget-text-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index df92dd6d..00000000
--- a/docs/assets/widget-text-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textwidget>Text||>ItemPear|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-static-ascii.svg b/docs/assets/widget-text-dark-static-ascii.svg
deleted file mode 100644
index dd603eef..00000000
--- a/docs/assets/widget-text-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textwidget>Text||>ItemPear|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-static-no-ansi.svg b/docs/assets/widget-text-dark-static-no-ansi.svg
deleted file mode 100644
index cd07e670..00000000
--- a/docs/assets/widget-text-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││❯ItemPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-text-dark-static.svg b/docs/assets/widget-text-dark-static.svg
deleted file mode 100644
index 1f489935..00000000
--- a/docs/assets/widget-text-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││❯ItemPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-animated-ascii-no-ansi.svg b/docs/assets/widget-text-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 4f7e1e5e..00000000
--- a/docs/assets/widget-text-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textwidget>Text||<accept*esccancel||>ItemApple|||>Text>||Pear||v||>ItemPear||>ItemPear|||>ItemPea|||>ItemPe|||>ItemP|||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-animated-ascii.svg b/docs/assets/widget-text-light-animated-ascii.svg
deleted file mode 100644
index d3ade1f4..00000000
--- a/docs/assets/widget-text-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textwidget>Text||<accept*esccancel||>ItemApple|||>Text>||Pear||v||>ItemPear||>ItemPear|||>ItemPea|r||>ItemPe|ar||>ItemP|ear||>Item|||>ItemA|||>ItemAp|||>ItemApp|||>ItemAppl||
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-animated-no-ansi.svg b/docs/assets/widget-text-light-animated-no-ansi.svg
deleted file mode 100644
index 5895e5c4..00000000
--- a/docs/assets/widget-text-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││↵accept·esccancel││❯ItemApple█││❯Text›││Pear││▼││❯ItemPear││❯ItemPear█││❯ItemPea█││❯ItemPe█││❯ItemP█││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-animated.svg b/docs/assets/widget-text-light-animated.svg
deleted file mode 100644
index f5cc70c6..00000000
--- a/docs/assets/widget-text-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││↵accept·esccancel││❯ItemApple█││❯Text›││Pear││▼││❯ItemPear││❯ItemPear█││❯ItemPea█r││❯ItemPe█ar││❯ItemP█ear││❯Item█││❯ItemA█││❯ItemAp█││❯ItemApp█││❯ItemAppl█│
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-static-ascii-no-ansi.svg b/docs/assets/widget-text-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 8fcfa89c..00000000
--- a/docs/assets/widget-text-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textwidget>Text||>ItemPear|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-static-ascii.svg b/docs/assets/widget-text-light-static-ascii.svg
deleted file mode 100644
index 015a52c4..00000000
--- a/docs/assets/widget-text-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textwidget>Text||>ItemPear|||<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-static-no-ansi.svg b/docs/assets/widget-text-light-static-no-ansi.svg
deleted file mode 100644
index ec0cbbf2..00000000
--- a/docs/assets/widget-text-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││❯ItemPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-text-light-static.svg b/docs/assets/widget-text-light-static.svg
deleted file mode 100644
index c803f898..00000000
--- a/docs/assets/widget-text-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textwidget›Text││❯ItemPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-textarea-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index 0a3fe6ba..00000000
--- a/docs/assets/widget-textarea-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus||<newline*tabaccept*esccancel||Slightlytart|||Textareawidget||>Textarea>||CrispandsweetHintofcitrus||v||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-animated-ascii.svg b/docs/assets/widget-textarea-dark-animated-ascii.svg
deleted file mode 100644
index d3fbf457..00000000
--- a/docs/assets/widget-textarea-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textareawidget>Textarea||>TastingnotesCrispandsweet||<newline*tabaccept*esccancel||Hintofcitrus||Slightlytart|||Textareawidget||>Textarea>||CrispandsweetHintofcitrus||v||>TastingnotesCrispandsweet||Hintofcitrus||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-animated-no-ansi.svg b/docs/assets/widget-textarea-dark-animated-no-ansi.svg
deleted file mode 100644
index f24c2190..00000000
--- a/docs/assets/widget-textarea-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus││↵newline·tabaccept·esccancel││Slightlytart█││Textareawidget││❯Textarea›││CrispandsweetHintofcitrus││▼││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-animated.svg b/docs/assets/widget-textarea-dark-animated.svg
deleted file mode 100644
index b95269a9..00000000
--- a/docs/assets/widget-textarea-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Textareawidget›Textarea││❯TastingnotesCrispandsweet││↵newline·tabaccept·esccancel││Hintofcitrus││Slightlytart█││Textareawidget││❯Textarea›││CrispandsweetHintofcitrus││▼││❯TastingnotesCrispandsweet││Hintofcitrus││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-static-ascii-no-ansi.svg b/docs/assets/widget-textarea-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 58ce1d40..00000000
--- a/docs/assets/widget-textarea-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-static-ascii.svg b/docs/assets/widget-textarea-dark-static-ascii.svg
deleted file mode 100644
index 23d3f44e..00000000
--- a/docs/assets/widget-textarea-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-static-no-ansi.svg b/docs/assets/widget-textarea-dark-static-no-ansi.svg
deleted file mode 100644
index f3646026..00000000
--- a/docs/assets/widget-textarea-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-dark-static.svg b/docs/assets/widget-textarea-dark-static.svg
deleted file mode 100644
index e6b14f27..00000000
--- a/docs/assets/widget-textarea-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-animated-ascii-no-ansi.svg b/docs/assets/widget-textarea-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index eaf46a21..00000000
--- a/docs/assets/widget-textarea-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus||<newline*tabaccept*esccancel||Slightlytart|||Textareawidget||>Textarea>||CrispandsweetHintofcitrus||v||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-animated-ascii.svg b/docs/assets/widget-textarea-light-animated-ascii.svg
deleted file mode 100644
index 2a2b9bd7..00000000
--- a/docs/assets/widget-textarea-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Textareawidget>Textarea||>TastingnotesCrispandsweet||<newline*tabaccept*esccancel||Hintofcitrus||Slightlytart|||Textareawidget||>Textarea>||CrispandsweetHintofcitrus||v||>TastingnotesCrispandsweet||Hintofcitrus||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar||
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-animated-no-ansi.svg b/docs/assets/widget-textarea-light-animated-no-ansi.svg
deleted file mode 100644
index 6f0c859d..00000000
--- a/docs/assets/widget-textarea-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus││↵newline·tabaccept·esccancel││Slightlytart█││Textareawidget││❯Textarea›││CrispandsweetHintofcitrus││▼││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-animated.svg b/docs/assets/widget-textarea-light-animated.svg
deleted file mode 100644
index 326c2681..00000000
--- a/docs/assets/widget-textarea-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Textareawidget›Textarea││❯TastingnotesCrispandsweet││↵newline·tabaccept·esccancel││Hintofcitrus││Slightlytart█││Textareawidget││❯Textarea›││CrispandsweetHintofcitrus││▼││❯TastingnotesCrispandsweet││Hintofcitrus││Hintofcitrus█││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█│
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-static-ascii-no-ansi.svg b/docs/assets/widget-textarea-light-static-ascii-no-ansi.svg
deleted file mode 100644
index 941cb479..00000000
--- a/docs/assets/widget-textarea-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-static-ascii.svg b/docs/assets/widget-textarea-light-static-ascii.svg
deleted file mode 100644
index 0e9da2ce..00000000
--- a/docs/assets/widget-textarea-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Textareawidget>Textarea||>TastingnotesCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-static-no-ansi.svg b/docs/assets/widget-textarea-light-static-no-ansi.svg
deleted file mode 100644
index c0431277..00000000
--- a/docs/assets/widget-textarea-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-textarea-light-static.svg b/docs/assets/widget-textarea-light-static.svg
deleted file mode 100644
index 5322a1d0..00000000
--- a/docs/assets/widget-textarea-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Textareawidget›Textarea││❯TastingnotesCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-animated-ascii-no-ansi.svg b/docs/assets/widget-toggle-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index f740fedd..00000000
--- a/docs/assets/widget-toggle-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Togglewidget>Toggle||^toggle*<accept*esccancel||>Ripeness()Ripe(*)Unripe||>Toggle>||ripe||v||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-animated-ascii.svg b/docs/assets/widget-toggle-dark-animated-ascii.svg
deleted file mode 100644
index 6d0d9b31..00000000
--- a/docs/assets/widget-toggle-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Togglewidget>Toggle||^toggle*<accept*esccancel||>Ripeness()Ripe(*)Unripe||>Toggle>||ripe||v||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-animated-no-ansi.svg b/docs/assets/widget-toggle-dark-animated-no-ansi.svg
deleted file mode 100644
index 3f8455c0..00000000
--- a/docs/assets/widget-toggle-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││↑toggle·↵accept·esccancel││❯Ripeness○Ripe●Unripe││❯Toggle›││ripe││▼││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-animated.svg b/docs/assets/widget-toggle-dark-animated.svg
deleted file mode 100644
index 3ba66a85..00000000
--- a/docs/assets/widget-toggle-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││↑toggle·↵accept·esccancel││❯Ripeness○Ripe●Unripe││❯Toggle›││ripe││▼││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-static-ascii-no-ansi.svg b/docs/assets/widget-toggle-dark-static-ascii-no-ansi.svg
deleted file mode 100644
index 2a5d7e90..00000000
--- a/docs/assets/widget-toggle-dark-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Togglewidget>Toggle||>Ripeness(*)Ripe()Unripe||^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-static-ascii.svg b/docs/assets/widget-toggle-dark-static-ascii.svg
deleted file mode 100644
index 1a7e345f..00000000
--- a/docs/assets/widget-toggle-dark-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Togglewidget>Toggle||>Ripeness(*)Ripe()Unripe||^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-static-no-ansi.svg b/docs/assets/widget-toggle-dark-static-no-ansi.svg
deleted file mode 100644
index fae71df9..00000000
--- a/docs/assets/widget-toggle-dark-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││❯Ripeness●Ripe○Unripe││↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-dark-static.svg b/docs/assets/widget-toggle-dark-static.svg
deleted file mode 100644
index a7c65951..00000000
--- a/docs/assets/widget-toggle-dark-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││❯Ripeness●Ripe○Unripe││↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-animated-ascii-no-ansi.svg b/docs/assets/widget-toggle-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 08470617..00000000
--- a/docs/assets/widget-toggle-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Togglewidget>Toggle||^toggle*<accept*esccancel||>Ripeness()Ripe(*)Unripe||>Toggle>||ripe||v||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-animated-ascii.svg b/docs/assets/widget-toggle-light-animated-ascii.svg
deleted file mode 100644
index 0d80ed0a..00000000
--- a/docs/assets/widget-toggle-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Togglewidget>Toggle||^toggle*<accept*esccancel||>Ripeness()Ripe(*)Unripe||>Toggle>||ripe||v||>Ripenessripe||>Ripeness(*)Ripe()Unripe|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-animated-no-ansi.svg b/docs/assets/widget-toggle-light-animated-no-ansi.svg
deleted file mode 100644
index be8d7a22..00000000
--- a/docs/assets/widget-toggle-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││↑toggle·↵accept·esccancel││❯Ripeness○Ripe●Unripe││❯Toggle›││ripe││▼││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-animated.svg b/docs/assets/widget-toggle-light-animated.svg
deleted file mode 100644
index dd98551d..00000000
--- a/docs/assets/widget-toggle-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││↑toggle·↵accept·esccancel││❯Ripeness○Ripe●Unripe││❯Toggle›││ripe││▼││❯Ripenessripe││❯Ripeness●Ripe○Unripe│
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-static-ascii-no-ansi.svg b/docs/assets/widget-toggle-light-static-ascii-no-ansi.svg
deleted file mode 100644
index d11656ac..00000000
--- a/docs/assets/widget-toggle-light-static-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Togglewidget>Toggle||>Ripeness(*)Ripe()Unripe||^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-static-ascii.svg b/docs/assets/widget-toggle-light-static-ascii.svg
deleted file mode 100644
index 0088dcd7..00000000
--- a/docs/assets/widget-toggle-light-static-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||Togglewidget>Toggle||>Ripeness(*)Ripe()Unripe||^toggle*<accept*esccancel|
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-static-no-ansi.svg b/docs/assets/widget-toggle-light-static-no-ansi.svg
deleted file mode 100644
index 19b6340d..00000000
--- a/docs/assets/widget-toggle-light-static-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││❯Ripeness●Ripe○Unripe││↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-light-static.svg b/docs/assets/widget-toggle-light-static.svg
deleted file mode 100644
index ace02c78..00000000
--- a/docs/assets/widget-toggle-light-static.svg
+++ /dev/null
@@ -1 +0,0 @@
-├──────────────────────────────────────────────────────────────────────────┤││╭──────────────────────────────────────────────────────────────────────────╮│Togglewidget›Toggle││❯Ripeness●Ripe○Unripe││↑toggle·↵accept·esccancel│╰──────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/docs/assets/widgets-dark-animated-ascii-no-ansi.svg b/docs/assets/widgets-dark-animated-ascii-no-ansi.svg
deleted file mode 100644
index c5181680..00000000
--- a/docs/assets/widgets-dark-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Widgets>Widgets||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200|||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||v||>TextPear|||<accept*esccancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextAppleedited||TextAppleedited||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Number1200|v/^next/previous*<accept*esccancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-bedited||Templateridge-pear-bedited||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200edited||Number4200edited||>|Calendar||July2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031|+--------------------------------------------------------------------|<|</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendar2026-07-22edited||>TextareaCrispandsweet||<newline*tabaccept*esccancel||Slightlytart||^||TextareaCrispandsweetedited||Password********edited||^/vmove*<accept*esccancel||Hintofcitrus|Selectbananaedited||>MultiSelect[x]Apple||MultiSelectapple,carrotedited||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>ReorderCarrot||Reordercarrot,apple,tomatoedited||>SuggestCh|||SuggestCherryedited||Searchonionedited||>MultiSearchto|||MultiSearchapple,tomatoedited||y/nyes/no*^toggle*<accept*esccancel||Confirmnoedited||^toggle*<accept*esccancel||Toggleunripeedited||>Pauseyes||>PausePress<tocontinue||<continue*esccancel||Widgets||>Widgets>||Pear*valley-pear-a*1200*2026-07-15||[Submit][Cancel]||>Calendar2026-07-15||>Calendar2026-07-22edited||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweetedited||>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password********edited||>Selectapple||>Select(*)Apple||>Select()Apple||>Selectbananaedited||>MultiSelectapple||>MultiSelect>[x]Apple||>MultiSelectapple,carrotedited||>Reorderapple,carrot,tomato||>Reorder>Apple||>Reorder^vApple||>Reordercarrot,apple,tomatoedited||>Suggest||>Suggest|||>SuggestC|||>SuggestCherryedited||>Searchcarrot||>Search|||>Searcho|||>Searchon|||>Searchonionedited||>MultiSearchapple||>MultiSearch|||>MultiSearcht|||>MultiSearchapple,tomatoedited||>Confirmyes||>Confirm(*)Yes()No||>Confirm()Yes(*)No||>Confirmnoedited||>Toggleripe||>Toggle(*)Ripe()Unripe||>Toggle()Ripe(*)Unripe||>Toggleunripeedited|
\ No newline at end of file
diff --git a/docs/assets/widgets-dark-animated-ascii.svg b/docs/assets/widgets-dark-animated-ascii.svg
deleted file mode 100644
index 77276a10..00000000
--- a/docs/assets/widgets-dark-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|Widgets||||>Widgets>||Pear*valley-pear-a*1200*2026-07-15||[Submit][Cancel]|||^/vmove*<select*escback*qquit*?help||Widgets>Widgets||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||v||>TextPear|||<accept*esccancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextApple edited ||TextApple edited ||>Templatevalley-pear-a||>Templatevalley|-pear-a|>Templatevalley|-pear-a||fillinginorchard||v|v/^next/previous*<accept*esccancel||>Templatevalle|-pear-a|>Templatevalle|-pear-a||>Templatevall|-pear-a|>Templatevall|-pear-a||>Templateval|-pear-a|>Templateval|-pear-a||>Templateva|-pear-a|>Templateva|-pear-a||>Templatev|-pear-a|>Templatev|-pear-a||>Template|-pear-a|>Template|-pear-a||>Templater|-pear-a|>Templater|-pear-a||>Templateri|-pear-a|>Templateri|-pear-a||>Templaterid|-pear-a|>Templaterid|-pear-a||>Templateridg|-pear-a|>Templateridg|-pear-a||>Templateridge|-pear-a|>Templateridge|-pear-a||>Templateridge-pear|-a|>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-||>Templateridge-pear-|||>Templateridge-pear-b||>Templateridge-pear-b|||>Templateridge-pear-b edited ||Templateridge-pear-b edited ||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200 edited ||Number4200 edited ||>Calendar2026-07-15||Calendar||July2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||272829|2728293031|2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||>Calendar2026-07-22 edited ||Calendar2026-07-22 edited ||>TextareaCrispandsweet||Hintofcitrus||>TextareaCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel||Hintofcitrus|||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweet edited ||Slightlytart||^||TextareaCrispandsweet edited ||Slightlytart||>Password********|>Password******||>Password*******||>Password********||>Password*********||>Password**********||>Password***********||>Password************||Password******** edited ||>Selectapple|>Select(*)Apple|^/vmove*<accept*esccancel||>Select()Apple|>Selectbanana|Selectbanana edited ||>MultiSelectapple|>MultiSelect>[x]Apple|>MultiSelect[x]Apple|>MultiSelect[x]Apple||>MultiSelectapple,carrot|MultiSelectapple,carrot edited ||>Reorderapple,carrot,tomato|>Reorder>Apple|^/vmove*spacegrab*<accept*esccancel||>Reorder^vApple|^/vreorder*spacedrop*esccancel||>ReorderCarrot|>ReorderCarrot||>Reordercarrot,apple,tomato|Reordercarrot,apple,tomato edited ||>Suggest|>Suggest||>SuggestC||>SuggestCh||>SuggestCh|||>SuggestCherry|SuggestCherry edited ||>Searchcarrot|>Search||>Searcho||>Searchon||>Searchonion|Searchonion edited ||>MultiSearchapple|>MultiSearch||>MultiSearcht||>MultiSearchto||>MultiSearchto|||>MultiSearchapple,tomato|MultiSearchapple,tomato edited ||>Confirmyes|>Confirm(*)Yes()No|y/nyes/no*^toggle*<accept*esccancel||>Confirm()Yes(*)No|>Confirmno|Confirmno edited ||>Toggleripe|>Toggle(*)Ripe()Unripe|^toggle*<accept*esccancel||>Toggle()Ripe(*)Unripe|>Toggleunripe|Toggleunripe edited ||>Pauseyes|>Pauseyes||>PausePress<tocontinue|>PausePress<tocontinue||<continue*esccancel||Templatevalley-pear-a|>Templatevalley-pear-a|>Templateridge-pear-b edit|>Templateridge-pear-b edited |>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password******** edited ||>Selectapple||>Select(*)Apple||>Select()Apple||>Selectbanana edited ||>MultiSelectapple||>MultiSelect>[x]Apple||>MultiSelectapple,carrot edited ||>Reorderapple,carrot,tomato||>Reorder>Apple||>Reorder^vApple||>Reordercarrot,apple,tomato edited ||>Suggest||>Suggest|||>SuggestC|||>SuggestCherry edited ||>Searchcarrot||>Search|||>Searcho|||>Searchon|||>Searchonion edited ||>MultiSearchapple||>MultiSearch|||>MultiSearcht|||>MultiSearchapple,tomato edited ||>Confirmyes||>Confirm(*)Yes()No||>Confirm()Yes(*)No||>Confirmno edited ||>Toggleripe||>Toggle(*)Ripe()Unripe||>Toggle()Ripe(*)Unripe||>Toggleunripe edited |
\ No newline at end of file
diff --git a/docs/assets/widgets-dark-animated-no-ansi.svg b/docs/assets/widgets-dark-animated-no-ansi.svg
deleted file mode 100644
index a3eaaff0..00000000
--- a/docs/assets/widgets-dark-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Widgets│├──────────────────────────────────────────────────────────────────────────┤│││❯Widgets›││Pear·valley-pear-a·1200·2026-07-15││[Submit][Cancel]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Widgets›Widgets││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││▼││❯TextPear█││↵accept·esccancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextAppleedited││TextAppleedited││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard│├────│↓/↑next/previous·↵accept·esccancel││❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-bedited││Templateridge-pear-bedited││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200edited││Number4200edited││❯Calendar2026-07-15││Calendar││July2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819│2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819│❯Calendar2026-07-22edited││Calendar2026-07-22edited││❯TextareaCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweetedited││Slightlytart│├│▲││TextareaCrispandsweetedited││❯Password••••••••││▼│❯Password••••••█││❯Password•••••••█││❯Password••••••••█││❯Password•••••••••█││Calendar2026-07-22│❯Password••••••••••█││↵accept·esccancel│❯Password•••••••••••█││❯Password••••••••••••█││❯Password••••••••edited││Password••••••••edited││❯Selectapple││❯Select●Apple││↑/↓move·↵accept·esccancel││❯Select○Apple││❯Selectbananaedited││Selectbananaedited││❯MultiSelectapple││❯MultiSelect❯◼Apple││❯MultiSelect◼Apple││❯MultiSelectapple,carrotedited││MultiSelectapple,carrotedited││❯Reorderapple,carrot,tomato││❯Reorder❯Apple││↑/↓move·spacegrab·↵accept·esccancel││❯Reorder↑↓Apple││↑/↓reorder·spacedrop·esccancel││❯ReorderCarrot││❯Reordercarrot,apple,tomatoedited││Hintofcitrus│Reordercarrot,apple,tomatoedited││❯Suggest││❯Suggest█││❯SuggestC█││❯SuggestCh█││❯SuggestCherryedited││SuggestCherryedited││❯Searchcarrot││❯Search█││❯Searcho█││❯Searchon█││❯Searchonionedited││Searchonionedited││❯MultiSearchapple││❯MultiSearch█││❯MultiSearcht█││❯MultiSearchto█││❯MultiSearchapple,tomatoedited││MultiSearchapple,tomatoedited││❯Confirmyes││❯Confirm●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel││❯Confirm○Yes●No││❯Confirmnoedited││Confirmnoedited││❯Toggleripe││❯Toggle●Ripe○Unripe││↑toggle·↵accept·esccancel││❯Toggle○Ripe●Unripe││❯Toggleunripeedited││Toggleunripeedited││❯Pauseyes││❯PausePress↵tocontinue││↵continue·esccancel││1314[15]16171819││20212223242526││13141516171819││2021[22]23242526│├─────────────────────├───────────────────────
\ No newline at end of file
diff --git a/docs/assets/widgets-dark-animated.svg b/docs/assets/widgets-dark-animated.svg
deleted file mode 100644
index b9902969..00000000
--- a/docs/assets/widgets-dark-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Widgets│├──────────────────────────────────────────────────────────────────────────┤│││❯Widgets›││Pear·valley-pear-a·1200·●●●●○4/5│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Widgets›Widgets││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││▼│╰──────────────────────────────────────────────────────╰────────────────────────────────────────────────────────│❯TextPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────│❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextApple edited │╰───────────────────────────────────────────────────╰─────────────────────────────────────────────────────│TextApple edited ││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard││↓/↑next/previous·↵accept·esccancel│╰─────────────────────────────────────────────╰────────────────────────────────────────────────│❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-b edited │╰──────────────────────────────────────────────────│Templateridge-pear-b edited ││❯Number1200│╰─────────────────────────────────────────────────│❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200 edited ││Number4200 edited ││❯Rating●●●●○4/5│╰──────────────────────────────────────────────│↑/↓adjust·↵accept·esccancel││❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fair edited │╰───────────────────────────────────────────│Rating●●●○○3/5Fair edited ││❯Calendar2026-07-15││Calendar││July2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22 edited │╰────────────────────────────────────────╰──────────────────────────────────────────│Calendar2026-07-22 edited ││❯TextareaCrispandsweet││Hintofcitrus││❯TextareaCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel││Hintofcitrus│╰────────────────────────────────────────────────────│❯TextareaCrispandsweet edited ││▲││TextareaCrispandsweet edited ││Slightlytart││❯Password••••••••││❯Password••••••█│╰─│❯Password•••••••█│╰│❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password•••••••• edited ││↑/↓move·↵select·escback·qquit·?help│Password•••••••• edited ││❯Selectapple││❯Select●Apple││↑/↓move·↵accept·esccancel│↑/↓move·↵accept·esccancel││❯Select○Apple│╰──│❯Selectbanana edited ││Password•••••••• edited │Selectbanana edited ││❯MultiSelectapple││❯MultiSelect❯◼Apple││❯MultiSelect◼Apple││❯MultiSelectapple,carrot edited ││Selectbanana edited │MultiSelectapple,carrot edited ││❯Reorderapple,carrot,tomato││❯Reorder❯Apple││↑/↓move·spacegrab·↵accept·esccancel│↑/↓move·spacegrab·↵accept·esccancel││❯Reorder↑↓Apple││↑/↓reorder·spacedrop·esccancel││❯ReorderCarrot│╰───│❯Reordercarrot,apple,tomato edited ││Reordercarrot,apple,tomato edited ││❯Suggest│╰─────│❯Suggest█││❯SuggestC█││❯SuggestCh█││❯SuggestCherry edited ││Hintofcitrus│SuggestCherry edited ││❯Searchcarrot││❯Search█││❯Searcho█││❯Searchon█││❯Searchonion edited ││Searchonion edited ││❯MultiSearchapple│╰────────│❯MultiSearch█││❯MultiSearcht█││❯MultiSearchto█││❯MultiSearchapple,tomato edited ││MultiSearchapple,tomato edited ││❯Confirmyes││❯Confirm●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel││❯Confirm○Yes●No││❯Confirmno edited ││Confirmno edited ││❯Toggleripe││❯Toggle●Ripe○Unripe││↑toggle·↵accept·esccancel│╰────╰───────│❯Toggle○Ripe●Unripe││❯Toggleunripe edited ││Toggleunripe edited ││❯Pauseyes││❯PausePress↵tocontinue││↵continue·esccancel││[Submit][Cancel]││❯Calendar2026-07-22 edited ╰───────────────────────────────────────────────╰─────────────────────────────────────╰───────────────────────────────────────│↵accept·esccancel╰───────────
\ No newline at end of file
diff --git a/docs/assets/widgets-light-animated-ascii-no-ansi.svg b/docs/assets/widgets-light-animated-ascii-no-ansi.svg
deleted file mode 100644
index 94c21daf..00000000
--- a/docs/assets/widgets-light-animated-ascii-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|||^/vmove*<select*escback*qquit*?help||Widgets>Widgets||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200|||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||v||>TextPear|||<accept*esccancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextAppleedited||TextAppleedited||>Templatevalley-pear-a||>Templatevalley|-pear-a||fillinginorchard||Number1200|v/^next/previous*<accept*esccancel||>Templatevalle|-pear-a||>Templatevall|-pear-a||>Templateval|-pear-a||>Templateva|-pear-a||>Templatev|-pear-a||>Template|-pear-a||>Templater|-pear-a||>Templateri|-pear-a||>Templaterid|-pear-a||>Templateridg|-pear-a||>Templateridge|-pear-a||>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-|||>Templateridge-pear-b|||>Templateridge-pear-bedited||Templateridge-pear-bedited||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200edited||Number4200edited||>|Calendar||July2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||2728293031|+--------------------------------------------------------------------|<|</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||Calendar2026-07-22edited||>TextareaCrispandsweet||<newline*tabaccept*esccancel||Slightlytart||^||TextareaCrispandsweetedited||Password********edited||^/vmove*<accept*esccancel||Hintofcitrus|Selectbananaedited||>MultiSelect[x]Apple||MultiSelectapple,carrotedited||^/vmove*spacegrab*<accept*esccancel||^/vreorder*spacedrop*esccancel||>ReorderCarrot||Reordercarrot,apple,tomatoedited||>SuggestCh|||SuggestCherryedited||Searchonionedited||>MultiSearchto|||MultiSearchapple,tomatoedited||y/nyes/no*^toggle*<accept*esccancel||Confirmnoedited||^toggle*<accept*esccancel||Toggleunripeedited||>Pauseyes||>PausePress<tocontinue||<continue*esccancel||Widgets||>Widgets>||Pear*valley-pear-a*1200*2026-07-15||[Submit][Cancel]||>Calendar2026-07-15||>Calendar2026-07-22edited||Hintofcitrus||||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweetedited||>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password********edited||>Selectapple||>Select(*)Apple||>Select()Apple||>Selectbananaedited||>MultiSelectapple||>MultiSelect>[x]Apple||>MultiSelectapple,carrotedited||>Reorderapple,carrot,tomato||>Reorder>Apple||>Reorder^vApple||>Reordercarrot,apple,tomatoedited||>Suggest||>Suggest|||>SuggestC|||>SuggestCherryedited||>Searchcarrot||>Search|||>Searcho|||>Searchon|||>Searchonionedited||>MultiSearchapple||>MultiSearch|||>MultiSearcht|||>MultiSearchapple,tomatoedited||>Confirmyes||>Confirm(*)Yes()No||>Confirm()Yes(*)No||>Confirmnoedited||>Toggleripe||>Toggle(*)Ripe()Unripe||>Toggle()Ripe(*)Unripe||>Toggleunripeedited|
\ No newline at end of file
diff --git a/docs/assets/widgets-light-animated-ascii.svg b/docs/assets/widgets-light-animated-ascii.svg
deleted file mode 100644
index 448b0ee0..00000000
--- a/docs/assets/widgets-light-animated-ascii.svg
+++ /dev/null
@@ -1 +0,0 @@
-+--------------------------------------------------------------------------+|Widgets||||>Widgets>||Pear*valley-pear-a*1200*2026-07-15||[Submit][Cancel]|||^/vmove*<select*escback*qquit*?help||Widgets>Widgets||Note||Aread-onlycard-thecursorskipsitanditcollectsnothing.||>TextPear||Templatevalley-pear-a||Number1200||Calendar2026-07-15||TextareaCrispandsweet||Hintofcitrus||Password********||v||>TextPear|||<accept*esccancel||>TextPea|||>TextPe|||>TextP|||>Text|||>TextA|||>TextAp|||>TextApp|||>TextAppl|||>TextApple|||>TextApple edited ||TextApple edited ||>Templatevalley-pear-a||>Templatevalley|-pear-a|>Templatevalley|-pear-a||fillinginorchard||v|v/^next/previous*<accept*esccancel||>Templatevalle|-pear-a|>Templatevalle|-pear-a||>Templatevall|-pear-a|>Templatevall|-pear-a||>Templateval|-pear-a|>Templateval|-pear-a||>Templateva|-pear-a|>Templateva|-pear-a||>Templatev|-pear-a|>Templatev|-pear-a||>Template|-pear-a|>Template|-pear-a||>Templater|-pear-a|>Templater|-pear-a||>Templateri|-pear-a|>Templateri|-pear-a||>Templaterid|-pear-a|>Templaterid|-pear-a||>Templateridg|-pear-a|>Templateridg|-pear-a||>Templateridge|-pear-a|>Templateridge|-pear-a||>Templateridge-pear|-a|>Templateridge-pear|-a||fillinginfruit||>Templateridge-pear-a||>Templateridge-pear-a|||fillingingrade||>Templateridge-pear-||>Templateridge-pear-|||>Templateridge-pear-b||>Templateridge-pear-b|||>Templateridge-pear-b edited ||Templateridge-pear-b edited ||>Number1200||>Number1200|||>Number120|||>Number12|||>Number1|||>Number|||>Number4|||>Number42|||>Number420|||>Number4200|||>Number4200 edited ||Number4200 edited ||>Calendar2026-07-15||Calendar||July2026||MoTuWeThFrSaSu||12345||6789101112||1314[15]16171819||20212223242526||272829|2728293031|2728293031||</>day*^/vweek*<accept*esccancel||13141516171819||2021[22]23242526||>Calendar2026-07-22 edited ||Calendar2026-07-22 edited ||>TextareaCrispandsweet||Hintofcitrus||>TextareaCrispandsweet||Hintofcitrus|||<newline*tabaccept*esccancel||Hintofcitrus|||||S|||Sl|||Sli|||Slig|||Sligh|||Slight|||Slightl|||Slightly|||Slightly|||Slightlyt|||Slightlyta|||Slightlytar|||Slightlytart|||>TextareaCrispandsweet edited ||Slightlytart||^||TextareaCrispandsweet edited ||Slightlytart||>Password********|>Password******||>Password*******||>Password********||>Password*********||>Password**********||>Password***********||>Password************||Password******** edited ||>Selectapple|>Select(*)Apple|^/vmove*<accept*esccancel||>Select()Apple|>Selectbanana|Selectbanana edited ||>MultiSelectapple|>MultiSelect>[x]Apple|>MultiSelect[x]Apple|>MultiSelect[x]Apple||>MultiSelectapple,carrot|MultiSelectapple,carrot edited ||>Reorderapple,carrot,tomato|>Reorder>Apple|^/vmove*spacegrab*<accept*esccancel||>Reorder^vApple|^/vreorder*spacedrop*esccancel||>ReorderCarrot|>ReorderCarrot||>Reordercarrot,apple,tomato|Reordercarrot,apple,tomato edited ||>Suggest|>Suggest||>SuggestC||>SuggestCh||>SuggestCh|||>SuggestCherry|SuggestCherry edited ||>Searchcarrot|>Search||>Searcho||>Searchon||>Searchonion|Searchonion edited ||>MultiSearchapple|>MultiSearch||>MultiSearcht||>MultiSearchto||>MultiSearchto|||>MultiSearchapple,tomato|MultiSearchapple,tomato edited ||>Confirmyes|>Confirm(*)Yes()No|y/nyes/no*^toggle*<accept*esccancel||>Confirm()Yes(*)No|>Confirmno|Confirmno edited ||>Toggleripe|>Toggle(*)Ripe()Unripe|^toggle*<accept*esccancel||>Toggle()Ripe(*)Unripe|>Toggleunripe|Toggleunripe edited ||>Pauseyes|>Pauseyes||>PausePress<tocontinue|>PausePress<tocontinue||<continue*esccancel||Templatevalley-pear-a|>Templatevalley-pear-a|>Templateridge-pear-b edit|>Templateridge-pear-b edited |>Password********||>Password******|||>Password*******|||>Password********|||>Password*********|||>Password**********|||>Password***********|||>Password************|||>Password******** edited ||>Selectapple||>Select(*)Apple||>Select()Apple||>Selectbanana edited ||>MultiSelectapple||>MultiSelect>[x]Apple||>MultiSelectapple,carrot edited ||>Reorderapple,carrot,tomato||>Reorder>Apple||>Reorder^vApple||>Reordercarrot,apple,tomato edited ||>Suggest||>Suggest|||>SuggestC|||>SuggestCherry edited ||>Searchcarrot||>Search|||>Searcho|||>Searchon|||>Searchonion edited ||>MultiSearchapple||>MultiSearch|||>MultiSearcht|||>MultiSearchapple,tomato edited ||>Confirmyes||>Confirm(*)Yes()No||>Confirm()Yes(*)No||>Confirmno edited ||>Toggleripe||>Toggle(*)Ripe()Unripe||>Toggle()Ripe(*)Unripe||>Toggleunripe edited |
\ No newline at end of file
diff --git a/docs/assets/widgets-light-animated-no-ansi.svg b/docs/assets/widgets-light-animated-no-ansi.svg
deleted file mode 100644
index 1fb8b60d..00000000
--- a/docs/assets/widgets-light-animated-no-ansi.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Widgets│├──────────────────────────────────────────────────────────────────────────┤│││❯Widgets›││Pear·valley-pear-a·1200·2026-07-15││[Submit][Cancel]│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Widgets›Widgets││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││Password••••••••││▼││❯TextPear█││↵accept·esccancel││❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextAppleedited││TextAppleedited││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard│├────│↓/↑next/previous·↵accept·esccancel││❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-bedited││Templateridge-pear-bedited││❯Number1200││❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200edited││Number4200edited││❯Calendar2026-07-15││Calendar││July2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819│2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819│❯Calendar2026-07-22edited││Calendar2026-07-22edited││❯TextareaCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel││█││S█││Sl█││Sli█││Slig█││Sligh█││Slight█││Slightl█││Slightly█││Slightly█││Slightlyt█││Slightlyta█││Slightlytar█││Slightlytart█││❯TextareaCrispandsweetedited││Slightlytart│├│▲││TextareaCrispandsweetedited││❯Password••••••••││▼│❯Password••••••█││❯Password•••••••█││❯Password••••••••█││❯Password•••••••••█││Calendar2026-07-22│❯Password••••••••••█││↵accept·esccancel│❯Password•••••••••••█││❯Password••••••••••••█││❯Password••••••••edited││Password••••••••edited││❯Selectapple││❯Select●Apple││↑/↓move·↵accept·esccancel││❯Select○Apple││❯Selectbananaedited││Selectbananaedited││❯MultiSelectapple││❯MultiSelect❯◼Apple││❯MultiSelect◼Apple││❯MultiSelectapple,carrotedited││MultiSelectapple,carrotedited││❯Reorderapple,carrot,tomato││❯Reorder❯Apple││↑/↓move·spacegrab·↵accept·esccancel││❯Reorder↑↓Apple││↑/↓reorder·spacedrop·esccancel││❯ReorderCarrot││❯Reordercarrot,apple,tomatoedited││Hintofcitrus│Reordercarrot,apple,tomatoedited││❯Suggest││❯Suggest█││❯SuggestC█││❯SuggestCh█││❯SuggestCherryedited││SuggestCherryedited││❯Searchcarrot││❯Search█││❯Searcho█││❯Searchon█││❯Searchonionedited││Searchonionedited││❯MultiSearchapple││❯MultiSearch█││❯MultiSearcht█││❯MultiSearchto█││❯MultiSearchapple,tomatoedited││MultiSearchapple,tomatoedited││❯Confirmyes││❯Confirm●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel││❯Confirm○Yes●No││❯Confirmnoedited││Confirmnoedited││❯Toggleripe││❯Toggle●Ripe○Unripe││↑toggle·↵accept·esccancel││❯Toggle○Ripe●Unripe││❯Toggleunripeedited││Toggleunripeedited││❯Pauseyes││❯PausePress↵tocontinue││↵continue·esccancel││1314[15]16171819││20212223242526││13141516171819││2021[22]23242526│├─────────────────────├───────────────────────
\ No newline at end of file
diff --git a/docs/assets/widgets-light-animated.svg b/docs/assets/widgets-light-animated.svg
deleted file mode 100644
index fe57acfc..00000000
--- a/docs/assets/widgets-light-animated.svg
+++ /dev/null
@@ -1 +0,0 @@
-╭──────────────────────────────────────────────────────────────────────────╮│Widgets│├──────────────────────────────────────────────────────────────────────────┤│││❯Widgets›││Pear·valley-pear-a·1200·●●●●○4/5│││↑/↓move·↵select·escback·qquit·?help│╰──────────────────────────────────────────────────────────────────────────╯│Widgets›Widgets││Note││Aread-onlycard-thecursorskipsitanditcollectsnothing.││❯TextPear││Templatevalley-pear-a││Number1200││Rating●●●●○4/5││Calendar2026-07-15││TextareaCrispandsweet││Hintofcitrus││▼│╰──────────────────────────────────────────────────────╰────────────────────────────────────────────────────────│❯TextPear█││↵accept·esccancel│╰──────────────────────────────────────────────────────────│❯TextPea█││❯TextPe█││❯TextP█││❯Text█││❯TextA█││❯TextAp█││❯TextApp█││❯TextAppl█││❯TextApple█││❯TextApple edited │╰───────────────────────────────────────────────────╰─────────────────────────────────────────────────────│TextApple edited ││❯Templatevalley-pear-a││❯Templatevalley█-pear-a││fillinginorchard││↓/↑next/previous·↵accept·esccancel│╰─────────────────────────────────────────────╰────────────────────────────────────────────────│❯Templatevalle█-pear-a││❯Templatevall█-pear-a││❯Templateval█-pear-a││❯Templateva█-pear-a││❯Templatev█-pear-a││❯Template█-pear-a││❯Templater█-pear-a││❯Templateri█-pear-a││❯Templaterid█-pear-a││❯Templateridg█-pear-a││❯Templateridge█-pear-a││❯Templateridge-pear█-a││fillinginfruit││❯Templateridge-pear-a█││fillingingrade││❯Templateridge-pear-█││❯Templateridge-pear-b█││❯Templateridge-pear-b edited │╰──────────────────────────────────────────────────│Templateridge-pear-b edited ││❯Number1200│╰─────────────────────────────────────────────────│❯Number1200█││❯Number120█││❯Number12█││❯Number1█││❯Number█││❯Number4█││❯Number42█││❯Number420█││❯Number4200█││❯Number4200 edited ││Number4200 edited ││❯Rating●●●●○4/5│╰──────────────────────────────────────────────│↑/↓adjust·↵accept·esccancel││❯Rating●●●○○3/5Fair││❯Rating●●●○○3/5Fair edited │╰───────────────────────────────────────────│Rating●●●○○3/5Fair edited ││❯Calendar2026-07-15││Calendar││July2026││MoTuWeThFrSaSu││12345││6789101112││1314[15]16171819││20212223242526││2728293031││←/→day·↑/↓week·↵accept·esccancel││13141516171819││2021[22]23242526││❯Calendar2026-07-22 edited │╰────────────────────────────────────────╰──────────────────────────────────────────│Calendar2026-07-22 edited ││❯TextareaCrispandsweet││Hintofcitrus││❯TextareaCrispandsweet││Hintofcitrus█││↵newline·tabaccept·esccancel││Hintofcitrus│╰────────────────────────────────────────────────────│❯TextareaCrispandsweet edited ││▲││TextareaCrispandsweet edited ││Slightlytart││❯Password••••••••││❯Password••••••█│╰─│❯Password•••••••█│╰│❯Password••••••••█││❯Password•••••••••█││❯Password••••••••••█││❯Password•••••••••••█││❯Password••••••••••••█││❯Password•••••••• edited ││↑/↓move·↵select·escback·qquit·?help│Password•••••••• edited ││❯Selectapple││❯Select●Apple││↑/↓move·↵accept·esccancel│↑/↓move·↵accept·esccancel││❯Select○Apple│╰──│❯Selectbanana edited ││Password•••••••• edited │Selectbanana edited ││❯MultiSelectapple││❯MultiSelect❯◼Apple││❯MultiSelect◼Apple││❯MultiSelectapple,carrot edited ││Selectbanana edited │MultiSelectapple,carrot edited ││❯Reorderapple,carrot,tomato││❯Reorder❯Apple││↑/↓move·spacegrab·↵accept·esccancel│↑/↓move·spacegrab·↵accept·esccancel││❯Reorder↑↓Apple││↑/↓reorder·spacedrop·esccancel││❯ReorderCarrot│╰───│❯Reordercarrot,apple,tomato edited ││Reordercarrot,apple,tomato edited ││❯Suggest│╰─────│❯Suggest█││❯SuggestC█││❯SuggestCh█││❯SuggestCherry edited ││Hintofcitrus│SuggestCherry edited ││❯Searchcarrot││❯Search█││❯Searcho█││❯Searchon█││❯Searchonion edited ││Searchonion edited ││❯MultiSearchapple│╰────────│❯MultiSearch█││❯MultiSearcht█││❯MultiSearchto█││❯MultiSearchapple,tomato edited ││MultiSearchapple,tomato edited ││❯Confirmyes││❯Confirm●Yes○No││y/nyes/no·↑toggle·↵accept·esccancel││❯Confirm○Yes●No││❯Confirmno edited ││Confirmno edited ││❯Toggleripe││❯Toggle●Ripe○Unripe││↑toggle·↵accept·esccancel│╰────╰───────│❯Toggle○Ripe●Unripe││❯Toggleunripe edited ││Toggleunripe edited ││❯Pauseyes││❯PausePress↵tocontinue││↵continue·esccancel││[Submit][Cancel]││❯Calendar2026-07-22 edited ╰───────────────────────────────────────────────╰─────────────────────────────────────╰───────────────────────────────────────│↵accept·esccancel╰───────────
\ No newline at end of file
diff --git a/docs/content/ai-agents.mdx b/docs/content/ai-agents.mdx
index f15c9531..3426fd06 100644
--- a/docs/content/ai-agents.mdx
+++ b/docs/content/ai-agents.mdx
@@ -6,7 +6,7 @@ keywords: ['ai agents', 'json schema', 'automation', 'agent help', 'validation']
# AI agents
-A form built with this engine is self-describing: it hands an AI agent (or any automation) the questions, their allowed values and the precedence rules, so answers can arrive unattended - without the agent ever reading your form's source. The facade exposes three calls for this - `agentHelp()`, `schema()` and `validate()` - and you surface them in your own tool, so an agent can discover the form the moment it meets it.
+A form built with this library is self-describing: it hands an AI agent (or any automation) the questions, their allowed values and the precedence rules, so answers can arrive unattended - without the agent ever reading your form's source. The facade exposes three calls for this - `agentHelp()`, `schema()` and `validate()` - and you surface them in your own tool, so an agent can discover the form the moment it meets it.
## The answer schema
@@ -67,7 +67,7 @@ A field's other two [guidance texts](/field-behaviour#guidance-texts) travel bes
"type": "string",
"title": "Crop",
"description": "The crop this basket was picked from.",
- "x-hint": "Type a few letters to filter.",
+ "x-help": "Type a few letters to filter.",
"x-placeholder": "E.g. Golden Beetroot",
"env": "TUI_CROP"
}
@@ -89,11 +89,14 @@ Dynamic defaults - the `fn (Context $c): mixed` closures from [field behavior](/
"type": "text",
"label": "Order name",
"description": "",
- "hint": "",
+ "help": "",
"placeholder": "",
"options": [],
+ "options_dynamic": false,
"default": "",
"required": true,
+ "env": "TUI_NAME",
+ "env_aliases": [],
"min": null,
"max": null,
"step": null,
@@ -113,6 +116,8 @@ Dynamic defaults - the `fn (Context $c): mixed` closures from [field behavior](/
}
```
+Every prompt carries every key, whether or not the field declares it - so a reader never has to tell a missing key from an unset one. A note and a progress row are absent: they collect no answer, so they are not prompts anything drives or validates.
+
`validate()` checks an answer set against those rules before collection, so an agent can confirm a payload without running the form. Each violation is one message; an empty list means the answers are valid:
```php
diff --git a/docs/content/architecture.mdx b/docs/content/architecture.mdx
index aab1c9a5..44618b99 100644
--- a/docs/content/architecture.mdx
+++ b/docs/content/architecture.mdx
@@ -1,7 +1,7 @@
---
title: Architecture
-description: 'How the engine works: what you assemble to build a form and what happens when it runs, with diagrams derived from the source.'
-keywords: ['architecture', 'engine', 'design', 'diagrams', 'internals']
+description: 'How the library works: what you assemble to build a form and what happens when it runs, with diagrams derived from the source.'
+keywords: ['architecture', 'design', 'diagrams', 'internals', 'block tree']
---
import ThemedImage from '@theme/ThemedImage';
@@ -9,63 +9,78 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# How the TUI works
-A walkthrough of the `drevops/tui` engine: what you assemble to build a form, and what happens when it runs. The diagrams are rendered from the PlantUML sources in [`docs/architecture/`](https://github.com/drevops/tui/tree/main/docs/architecture); everything below is derived from `src/`, so if the prose and the code ever disagree, the code wins.
+A walkthrough of `drevops/tui`: what you assemble to build a form, and what happens when it runs. The diagrams are rendered from the PlantUML sources in [`docs/architecture/`](https://github.com/drevops/tui/tree/main/docs/architecture); everything below is derived from `src/`, so if the prose and the code ever disagree, the code wins.
+
+The model underneath it - four levels, seventeen capabilities, one canonical tree - is written out on the [specification](/specification). This page is the same thing seen from the outside: which class does which part, and in what order.
## The shape of it
-At the center is the **Engine**. Everything else is either something you hand it (a configuration, a set of handlers, a theme) or something it produces (validated answers, a JSON schema). The packages below mirror the `src/` subdirectories, and the arrows are the main dependencies.
+At the center is the **block tree**. A form is declared as one, and everything else either writes into it, reads it or draws it. The packages below mirror the `src/` subdirectories, and the arrows are the main dependencies.
-Read it in three bands:
+Read it as three concerns around that tree:
-- **Left - what you provide.** A **FormDefinition** (assembled by the fluent `Form` builder into `FormDefinition` -> `Panel` -> `Field`) and, optionally, **Handlers** (classes that carry behavior). The global TUI runtime - theme, key bindings, color and language - is configured on the `Tui` facade and shared by every form. Together these declare the questions, how each one behaves, and how the TUI presents them.
-- **Middle - the Engine and its helpers.** The Engine drives collection, leaning on `InputResolver` (read a payload), `Discovery` (detect from the directory), `Deriver` + `Transform` (compute values), and `ConditionEvaluator` (decide what is shown).
-- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), a `KeyMap` (resolved by preset through `KeyMapManager`), widgets, a `Navigator` and a `Terminal`.
+- **Declaring.** `Form`, `PanelBuilder` and `FieldBuilder` write the tree; `Tui` is the facade you hold. Everything that describes the terminal rather than the questionnaire - the [theme](/themes), the [layout](/layouts), the [key bindings](/key-bindings), color, Unicode, the footer - is set on the facade, so one declaration serves every way of running it.
+- **The tree itself.** `Panel`, `Field`, `Markup`, `Breadcrumb`, `Legend`, `Actions` and `Progress` all implement `BlockInterface`. Each declares what it can do as a capability interface and what it needs drawn as an [elements interface](/fields/anatomy#elements-what-a-theme-actually-implements).
+- **Running it.** `Collector` collects the tree with no screen at all. `ScreenController` drives it through a terminal - arranging it with a `Screen`, a layout and its regions, sending each key inward with `KeyRouter`, drawing outward with `ScreenRenderer`. Both paths settle the same rules and produce the same `Answers`.
## Step 1 - describe the questions
-You declare the questions in PHP with the fluent `Form` builder: panels holding fields. A field has an `id`, a `type` (text, select, suggest, search, file picker, confirm; select, search and file picker collect a list with `->multiple()`) and optional rules - `default`, `required`, `options`, `when` (show it only when a condition holds), `derive` (compute it from other fields) and `discover` (detect it from the target directory). The builder validates the declaration - duplicate field ids are rejected - and builds the immutable `FormDefinition` model. The global TUI runtime is configured on the `Tui` facade, not the form. Nothing runs yet; this is pure description.
+You declare the questions in PHP with the fluent `Form` builder: panels holding fields. A field has an `id`, a type (`text`, `select`, `suggest`, `search`, `filepicker`, `confirm` and the rest; `select`, `search` and `filepicker` collect a list with `->multiple()`) and optional rules - `default`, `required`, `options`, `when` (show it only when a condition holds), `derive` (compute it from other fields) and `discover` (detect it from the target directory).
+
+What the builder produces is not a separate model: **it writes the block tree directly**. `$form->root()` hands back the root `Panel`, its regions hold the blocks, and a sub-panel is a block in a region like any other. There is one tree, and every call on the facade reads that one - `collect()`, `interact()`, `schema()`, `validate()`, `agentHelp()`.
+
+The declaration is checked as it is written: duplicate field ids, an unknown transform name, a layout name nothing answers to, a modal declaring sub-panels. Each throws when the form is built rather than mid-session. Nothing runs yet; this is pure description.
## Step 2 - attach behavior where you need it
-Most fields need no code at all. When one does - a dynamic default, discovery, validation or a normalization - declare it on the field itself: `->default(fn ...)`, `->validate(fn ...)`, `->transform(fn ...)`, `->discover(...)`. Reusable validators and transformers are public static methods on a class named after the field id (`red_apple` -> `RedApple`) in a registered namespace - reference them explicitly as first-class callables, or let the engine discover them as the fallback. When both exist, the field declaration wins.
+Most fields need no code at all. When one does - a dynamic default, discovery, validation or a normalization - declare it on the field itself: `->default(fn ...)`, `->validate(fn ...)`, `->transform(fn ...)`, `->discover(...)`. Reusable validators and transformers are public static methods on a class named after the field id (`red_apple` -> `RedApple`) in a registered namespace - reference them explicitly as first-class callables, or let the `HandlerRegistry` resolve them as the fallback. When both exist, the field declaration wins.
## Step 3 - collect the answers
-`Engine::collect()` turns the config plus whatever the caller supplied into a settled set of answers. This is the heart of the engine:
+`Tui::collect()` turns the tree plus whatever you supplied into a settled set of answers, with no screen anywhere:
+Four capabilities survive here and thirteen do not, and the line between them is the useful part: collecting, constraining, refusing and depending on another answer are the form's meaning; the rest is how it looks. No `Screen`, no layout and no `Region` is built, and neither is any block that only shows.
+
Walking the sequence:
-1. **Resolve each field's starting value**, in priority order: an explicit input (from `--prompts` or the environment, via `InputResolver`) beats a discovered value (in update mode), which beats a handler's dynamic `default()`, which beats the static default in the config.
-2. **Settle the derived and conditional fields.** `Deriver` recomputes `derive` values (with `Transform`) until they stop changing, `ConditionEvaluator` decides which fields are active from their `when` rules, and fix-ups reconcile dependents - repeated until the whole set is stable.
-3. **Validate and transform** every active field through its handler.
-4. **Emit `Answers`** - the values plus their provenance (default, detected, edited).
+1. **Resolve each field's starting value**, in priority order: an explicit input (from a JSON payload or the environment, through `InputResolver`) beats a discovered value (in update mode, adopted only when it passes the field's emptiness, type, bounds and rows), which beats a dynamic default, which beats the static one.
+2. **Normalize each supplied value** through its declared or resolved transform, so derivation, conditions and fix-ups all see the final value. Defaults and derived values are the form's own and skip the transformers.
+3. **Settle.** `Deriver` recomputes `derive` values until they stop changing, the `Condition` rules decide which fields are there at all, [option lists that follow the answers](/field-behaviour#options-from-the-answers) re-resolve, and fix-ups reconcile dependents - repeated until nothing moves.
+4. **Measure each supplied value** that survived: emptiness on a required field first, then type, bounds and rows. A value the form refuses raises `CollectException` naming the field and the reason, because with no screen there is nobody to retype it.
+5. **Emit `Answers`** - the values plus their provenance (default, detected, edited, derived, override).
-The same lifecycle runs whether the caller is a human at the TUI or a script passing JSON - which is exactly why the engine is testable without a terminal.
+Only supplied values are measured, and only once the set has settled, because until then there is nothing final to measure them against.
## Step 4 - let a person answer (optional)
-For interactive use, `PanelController::run()` seeds itself with the engine's resolved answers and drives a panel TUI until the user is done:
+For interactive use, `ScreenController::run()` seeds itself from the same collector and drives a terminal session until the form ends:
-
+
-The theme instance comes from `ThemeManager` - a registry keyed by name (`default`, a registered short name, or a theme class name directly). Color, Unicode and the dark/light mode are display options: anything you leave unset is detected from the terminal by the `Tui` facade, with the mode picked from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default).
+**Assembling.** `Assembler` builds a `Screen` around the panel: a `Breadcrumb` in `header`, the panel and its `Actions` in `content`, a `Legend` in `footer` - wherever the named layout keeps a place for them. A layout naming its regions something else shows no trail rather than being refused, which is what keeps every layout usable. The layout comes from `LayoutManager`: a shipped name, a name you registered, or the class itself. See [Layouts](/layouts).
+
+**Drawing runs outward.** The `Screen` gives its layout the terminal; the layout takes the fixed regions off the top and divides the remainder by the declared shares; each `Region` flows its blocks and scrolls them if it was declared to; each block's `render()` reaches the theme for elements; the theme returns styled strings. Every step hands down exactly one thing and knows nothing of the step after it, and nothing reaches back up.
+
+**Keys run inward.** `KeyParser` turns raw bytes into `Key` objects and a `KeyMap` resolves each to a semantic action rather than a fixed key - configurable per field type, shipping a vim preset beside the default, validated when `->keys()` is called. `KeyRouter` then sends the key to the innermost thing that binds it: the focused block if it binds that key, else the panel around it. That is why an open text field swallows ? as a character while a closed one lets it travel outward and open help.
+
+Three kinds of key never reach the router, and all three for the same reason - they act on something outside the screen. Pressing a button ends the form or closes the dialog it belongs to; activating a [progress row](/fields/progress) runs its work against the terminal a step at a time, repainting between steps; and leaving is about the session rather than about anything in it. A block never learns where it is drawn, so whoever owns the terminal holds these.
-Each turn, the controller asks the **Theme** to compose a frame (the theme owns colors, glyphs and layout), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A key press is parsed by `KeyParser` into a `Key`, which a **KeyMap** resolves to a semantic action (move, accept, toggle, quit, ...) rather than a fixed key - the bindings behind each action are configurable per widget type, ship a vim preset alongside the default, and are validated when the form is built.
+**Every answer re-settles the form.** An accepted edit goes back through the collector: derive rules recompute, `when` conditions show and hide rows, answer-driven option lists re-resolve and fix-ups re-apply - so the session honors exactly what a headless collection would.
-Armed with the action, the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field. The widget consults the same key map, and both render themselves through the theme, under a theme-composed underlined label header. An accept enforces the field's declared or handler-resolved `validate()`/`transform()` - the same behavior the headless path applies - showing a rejection inline and writing the accepted value back marked "edited" (or "override" when it pins a derive rule). Every accepted edit then re-settles the form logic through the **Engine**: derive rules recompute, `when` conditions show and hide fields, and fix-ups re-apply - so the session honors exactly what a headless collection would.
+**The frame.** Border, spacing, alignment and the min/max sizes are [theme options](/themes#display-options). In [fullscreen](/panels#fullscreen) the frame stretches to the terminal and the content anchors at `halign`/`valign`; below the minimum size a resize notice takes the frame's place and every key but the one that leaves is dropped. A panel declared `->modal()` is drawn as a centered dialog over the dimmed screen behind it, with its own submit/cancel pair - submit keeps the edits, cancel restores the answers the dialog opened with.
-A panel declared modal with `->modal()` opens as a centered dialog composited over the dimmed parent, with its own configurable submit/cancel buttons - submit keeps the edits, cancel or Escape restores the answers it opened with. When the user finishes, the controller returns the same active-field `Answers` a headless collection would produce: a condition-hidden field keeps its settled value internally - so a later activation change can surface it - but contributes no answer.
+**How it ends** is the whole of what a caller sees. Finishing hands back `Answers`; abandoning through the cancel button raises `CancelException`; Ctrl-C raises `InterruptException` from anywhere, including from inside an open field. Partial answers are never mistaken for a completed form.
## Step 5 - apply the answers (the consumer's job)
-Collecting produces answers; acting on them - writing files, renaming directories - is the consumer's job, never the engine's. A consumer that processes answers defines its own processor contract with a `process()` hook, resolves each processor class by field id through the `HandlerRegistry`, and sequences the work by its own rules - ordering is a processing concern the form declaration doesn't carry. One class per field can carry both its `process()` and the reusable static `validate()`/`transform()` the engine discovers. This is the pattern a consumer CLI follows with its own `ProcessorInterface` and `Processor`.
+Collecting produces answers; acting on them - writing files, renaming directories - is your job, never the library's. A consumer that processes answers defines its own processor contract with a `process()` hook, resolves each processor class by field id through the `HandlerRegistry`, and sequences the work by its own rules - ordering is a processing concern the form declaration does not carry. One class per field can carry both its `process()` and the reusable static `validate()`/`transform()` the collector resolves.
diff --git a/docs/content/configuration.mdx b/docs/content/configuration.mdx
index bcabec03..b4aef8bd 100644
--- a/docs/content/configuration.mdx
+++ b/docs/content/configuration.mdx
@@ -36,9 +36,11 @@ $form = Form::create('My form')
});
```
-Each field builder chains `->description()`, `->hint()` and `->placeholder()` (the three [guidance texts](/field-behaviour#guidance-texts)), `->default()`, `->required()` (with an optional `message:` overriding the label-derived one), `->options()` / `->option()` (with per-option descriptions and optional `disabled` state), `->heading()` / `->separator()` (non-selectable option-list structure), `->when(new Condition(...))`, `->derive(new Derive(...))`, `->discover(...)`, `->validate(...)` and `->transform(...)`.
+Each field builder chains `->description()`, `->help()` and `->placeholder()` (the three [guidance texts](/field-behaviour#guidance-texts)), `->default()`, `->required()` (with an optional `message:` overriding the label-derived one), `->options()` / `->option()` (with per-option descriptions and optional `disabled` state), `->heading()` / `->separator()` (non-selectable option-list structure), `->when(new Condition(...))`, `->derive(new Derive(...))`, `->discover(...)`, `->validate(...)` and `->transform(...)`.
-The form declares its own chrome: `->banner()` sets a start banner and `->buttons()` controls the submit/cancel buttons. Everything that describes the terminal rather than the questionnaire - the global TUI runtime - is configured on the `Tui` facade instead: `->theme()` names a theme, auto-detected from the terminal background when unset (see [Themes](/themes)); `->keys()` sets the key bindings (see [Key bindings](/key-bindings)); `->footer()` toggles the key-hint footer; `->clearOnExit()` keeps or clears the final frame; `->color()` / `->unicode()` force a [display mode](/display-modes); `->fullscreen()` expands the frame to the whole terminal (see [Fullscreen](/panels#fullscreen)); and `->translator()` presents chrome and questions in another language (see [Translations](/translations)).
+The form declares its own chrome: `->banner()` sets a start banner and `->buttons()` controls the submit/cancel buttons. Everything that describes the terminal rather than the questionnaire - the global TUI runtime - is configured on the `Tui` facade instead: `->theme()` names a theme, auto-detected from the terminal background when unset, or patches individual elements (see [Themes](/themes)); `->layout()` arranges the screen into named regions (see [Layouts](/layouts)); `->keys()` sets the key bindings (see [Key bindings](/key-bindings)); `->footer()` toggles the key-hint footer; `->clearOnExit()` keeps or clears the final frame; `->color()` / `->unicode()` force a [display mode](/display-modes); `->markdown()` renders the [markdown subset](/markdown) in descriptions and notes; `->fullscreen()` expands the frame to the whole terminal (see [Fullscreen](/panels#fullscreen)); and `->translator()` presents chrome and questions in another language (see [Translations](/translations)).
+
+A panel can be arranged too - `$p->layout('two-column')`, then `$p->in('left')` - which is the same registry the facade reads. That is the one structural choice a form does make for itself, because it is about where a panel's own blocks go rather than about the terminal.
## Derived values
@@ -69,7 +71,7 @@ A conditional field renders flush with every other field, so nothing on screen s
$tui = (new Tui($form))->theme('default', ['indent_conditional' => TRUE]);
```
-The steps follow the rules rather than the declaration order: a rule naming several fields takes the deepest of them, and a field whose rule names another conditional field sits one step further in than that one.
+The steps follow the rules rather than the declaration order: a rule naming several fields takes the deepest of them, and a field whose rule names another conditional field sits one step further in than that one. A rule that decides for itself - a closure rather than a `Condition` - names no field, so nothing can be said about what it waits on and its row sits flush like an unconditional one.
diff --git a/docs/content/field-behaviour.mdx b/docs/content/field-behaviour.mdx
index 4935578a..0e3d2b0a 100644
--- a/docs/content/field-behaviour.mdx
+++ b/docs/content/field-behaviour.mdx
@@ -1,7 +1,7 @@
---
title: Field behavior
-description: 'Guide an answer with a description, a hint and a placeholder; mark fields required, narrow one field options by another answer, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.'
-keywords: ['description', 'hint', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'dynamic options', 'discovery', 'handler']
+description: 'Guide an answer with a description, help and a placeholder; mark fields required, narrow one field options by another answer, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.'
+keywords: ['description', 'help', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'dynamic options', 'discovery', 'handler']
---
import ThemedImage from '@theme/ThemedImage';
@@ -28,7 +28,7 @@ $p->text('slug', 'Basket slug')
->schemaDefault('weekly-box');
```
-Reusable validators and transformers live as public static methods on a class in your code. Reference one explicitly with a first-class callable - `->validate(Ripeness::validate(...))` - or let the engine discover it: register a namespace (`new Tui($form, handler_namespaces: ['App\\Handler'])`) and the engine resolves the class by field id (`red_apple` -> `RedApple`), using its static `validate()`/`transform()` whenever the field declares none. When both exist, the field declaration wins.
+Reusable validators and transformers live as public static methods on a class in your code. Reference one explicitly with a first-class callable - `->validate(Ripeness::validate(...))` - or let the library find it: register a namespace (`new Tui($form, handler_namespaces: ['App\\Handler'])`) and the `HandlerRegistry` resolves the class by field id (`red_apple` -> `RedApple`), using its static `validate()`/`transform()` whenever the field declares none. When both exist, the field declaration wins.
The TUI only collects. It presents answers and never applies them - **writing files, renaming directories, acting on the answers is your job**. A consumer that processes answers defines its own processor interface, keeping the form for collection and the processors for side effects; one class per field can carry both its `process()` and the reusable static behavior. (This is exactly what a consumer CLI does.)
@@ -41,17 +41,17 @@ Three texts guide an answer, each declared on its own so a form never has to mer
```php
$p->text('crop', 'Crop')
->description('The crop this basket was picked from.') // What is being asked.
- ->hint('Type a few letters to filter.') // How to answer it.
+ ->help('Every crate is weighed at the packing bench.') // The long version, on request.
->placeholder('E.g. Golden Beetroot'); // Ghost text, empty input only.
```
`description()` says what the question is. It renders under the field row, carries the [markdown subset](/markdown), and widens the panel to fit.
-`hint()` says how to answer it. It renders beneath the description in a style of its own, so guidance never reads as part of the question - the default theme italicizes it, and a theme sets its own (the dos theme colors it instead, since CGA had no italic). It stays plain text either way, because one short instruction carries no formatting of its own.
+`help()` is the long version. It never renders in the panel - the row wears a [help marker](/fields/anatomy) instead, and ? opens the text on a page of its own. That is what lets it run to paragraphs where a description has to stay a sentence, and it is why a long help never widens the panel it was declared on.
-`placeholder()` is the ghost text an empty editor shows. It never becomes a value: it disappears at the first keystroke, and it is suppressed when color is off, where it could not be told apart from something typed. Available on the `text`, `number`, `textarea`, `password`, `suggest` and `search` types - the ones with an input buffer to ghost. Declaring one on any other type raises a `FormException` when the form is built, rather than being quietly ignored.
+`placeholder()` is the ghost text an empty editor shows. It never becomes a value: it disappears at the first keystroke, and it is suppressed when color is off, where it could not be told apart from something typed. Available on the `text`, `number`, `textarea`, `password`, `suggest` and `search` types - the ones with a [draft](/fields/anatomy) to ghost. Declaring one on any other type raises a `FormException` when the form is built, rather than being quietly ignored.
-The description and hint rows are secondary chrome, so compact [spacing](/themes) drops both; a placeholder belongs to the editor and shows whatever the spacing. All three reach machine-readable output, so an agent reads the same guidance a person does - see [AI agents](/ai-agents).
+The description row is secondary chrome, so compact [spacing](/themes) drops it; a placeholder belongs to the editor and shows whatever the spacing. All three reach machine-readable output, so an agent reads the same guidance a person does - see [AI agents](/ai-agents).
## Required fields
@@ -70,7 +70,7 @@ Empty means an empty string, an empty list or `null` - so a cleared text field,
The check runs before the field's own validator and before the type check, in both collection modes:
-- Headlessly, an empty supplied input throws an `EngineException` naming the field. A field nothing was supplied for keeps its default - reporting that gap belongs to [`Tui::validate()`](/ai-agents), which lists it as a missing question.
+- Headlessly, an empty supplied input throws a `CollectException` naming the field. With no screen there is nobody to retype it, so the whole collection fails rather than handing back answers one of which was never accepted. A field nothing was supplied for keeps its default - reporting that gap belongs to [`Tui::validate()`](/ai-agents), which lists it as a missing question.
- Interactively, committing an empty value keeps the editor open with the message shown like any other validation error, and the Submit button refuses to finish the form while any active required field is empty. Cancel is never blocked, and a field hidden by its `when` condition is not asked for.
Emptying the name shows the label-derived message in the editor; leaving the basket untouched withholds the submit with that field's declared message instead:
@@ -109,7 +109,7 @@ Options are for the types that have a list - `select`, `search`, `suggest`, `tog
A resolver runs as part of the form settling - the same pass that computes derived values, evaluates `when` conditions and applies fix-ups - so every surface sees one narrowed list:
- **Interactively**, changing the category re-resolves the item list before the next frame, so the editor offers exactly what the new category holds.
-- **Headlessly**, a supplied value is checked against the list the payload's own answers resolve to. A value outside it throws an `EngineException` naming the value and what was allowed.
+- **Headlessly**, a supplied value is checked against the list the payload's own answers resolve to. A value outside it throws a `CollectException` naming the value and what was allowed.
- **[`Tui::validate()`](/ai-agents)** checks membership against the set the answers under validation resolve to, and the [schema](/ai-agents) resolves the list against whatever context you pass it, flagging the field as `options_dynamic` so tooling can tell an empty list from one that is not fixed.
A choice the narrowed list no longer holds does not survive in the answers: it is dropped, a `reorder` ranking is completed back to a full permutation, and a `toggle` returns to its first state. Only a value supplied headlessly is left standing, so it is reported rather than disappearing without a word. A `suggest` field's options are hints rather than a closed set, so its value is never narrowed away.
diff --git a/docs/content/fields/anatomy.mdx b/docs/content/fields/anatomy.mdx
new file mode 100644
index 00000000..4da0d97e
--- /dev/null
+++ b/docs/content/fields/anatomy.mdx
@@ -0,0 +1,376 @@
+---
+title: Anatomy
+description: 'The atoms of a field, named: the window chrome, view mode, edit mode, every line each of them draws, and the theme elements behind them.'
+keywords: ['anatomy', 'nomenclature', 'atoms', 'elements', 'fields', 'tui']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Anatomy of a field
+
+A screen has two layers. The **window chrome** frames the whole form and belongs to no field in particular: the border, the trail of panel titles, the key legend. Inside it, each field draws itself in one of two modes. In **view mode** it's a single line carrying the answer. Open it and it switches to **edit mode**, where the field takes over the space right of the label.
+
+An **atom** is one named piece of that interface - the smallest thing worth naming on its own, and the unit a [theme](/themes) restyles. Every atom is drawn by an **element**, the method a theme answers with; [Elements](#elements-what-a-theme-actually-implements) lists all of them, and [Patching an element](#patching-an-element) shows how to restate one without writing a theme at all.
+
+:::note
+
+The vocabulary is settled; [open questions](#open-questions) tracks what isn't.
+
+:::
+
+## Window chrome
+
+The chrome is the same whatever the form asks. You declare it once - the form's title and its panels' titles feed the trail, the theme picks the border - and it maintains itself from there. The trail gains a segment as you descend, the legend rewrites itself as focus moves, the overflow marker appears when the rows outgrow the frame.
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | -------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
+| 1 | border | The frame around everything. One of `Border::None`, `Border::Line`, `Border::Rounded` or `Border::Double`, form-wide. | `Tui::theme(..., ['border' => Border::Rounded])` | `chromeBorder()` |
+| 2 | breadcrumb | The trail of panel titles: every panel you've descended through, plus the one you're in. | `Form::create('Orchard')`, `->panel('main', 'Delivery', ...)` | `breadcrumbLabel()` |
+| 3 | breadcrumb separator | Stands between **breadcrumb** (2) segments. | Not declared | `breadcrumbSeparator()` |
+| 4 | overflow marker | Points at content past the top or bottom edge. Drawn only when the rows outgrow the frame. | Not declared | `chromeOverflowMarker()` |
+| 5 | legend | The keys bound right now. | `Tui::footer(FALSE)` hides it; the entries come from the field | the `Legend` block itself |
+| 6 | legend key | One key in the **legend** (5). Worded keys are uppercased: ESC, TAB, SPACE. | `Tui::keys(...)` rebinds which key it shows | `legendKey()` |
+| 7 | legend description | What that key does. Reads as `KEY to action`. | Not declared; comes from the field | `legendDescription()` |
+| 8 | legend separator | Stands between **legend** (5) entries. | Not declared | `legendSeparator()` |
+
+
+
+The **legend** (5) is the one atom of the chrome that changes as you work: it lists the keys that apply where you are, so an open field advertises different keys from the panel around it. That's also why a **legend key** (6) needs no weight of its own. Case alone tells ESC from the words beside it, and the `KEY to action` wording of a **legend description** (7) makes an entry read as a sentence rather than two words abutted.
+
+## View mode
+
+A panel stacks its fields as rows, each one in view mode until you open it. A row is a single line: the field's name, its answer, and the marks that say where focus is and whether there's more to read.
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | --------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------- |
+| 1 | field selector | Which field has focus. Moves with ↑ and ↓. | Not declared; follows focus | `fieldSelector()` |
+| 2 | label | The field's name. Every row has one. | `$p->select('basket', 'Basket contents')` | `fieldLabel()` |
+| 3 | help marker | Marks a field carrying **help** (7). Sits after the **label** (2), in the label's own color and never bolded. | Not declared; appears when the field has **help** (7) | `fieldHelpMarker()` |
+| 4 | value | The settled answer. Empty until the field is answered, and never what you're mid-way through typing. Every row has one. | `->default(['apple', 'carrot'])`, then whatever is answered | `fieldValue()` |
+| 5 | value separator | Stands between the parts of a **value** (4) that has more than one, so `Basket contents` reads `apple, carrot`. | Not declared | `fieldValueSeparator()` |
+| 6 | description | The field's explanatory text, under its row. | `->description('Pick the produce for this delivery.')` | `fieldDescription()` |
+| 7 | help | The field's long-form text. Never drawn in the panel: ? opens it on a page of its own. | `->help('Every crate is weighed at the packing bench.')` | a bordered `Markup` block |
+
+
+
+Only one field holds focus at a time. The row the **field selector** (1) sits on is drawn brighter too, so the glyph and that emphasis are one signal in two forms.
+
+The **label** (2) is the one atom every row draws, and the only one that never changes while you work. That's why the **help marker** (3) hangs off it rather than off the **description** (6): a field can carry **help** (7) without carrying a **description** (6).
+
+Two pairs are easy to confuse, and both come down to length or timing. A **description** (6) has to fit under the row, so it stays a sentence; **help** (7) opens on a page and can run to paragraphs, which is why a long help never widens the panel it was declared on. A **value** (4) is what was accepted; the **draft** (edit 9) is what you're typing.
+
+## Edit mode
+
+Edit mode hands the region right of the label to the field, and what the field draws there depends on how it collects an answer. The three shapes below cover it: choosing from a list, typing, and browsing.
+
+### Choosing from a list
+
+A multiple-choice field with bounds and per-entry text draws the fullest set of atoms.
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | ----------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------- |
+| 1 | entry | One line of the list the field offers. | `->option('apple', 'Apple')`, `->options([...])` | `fieldEntry()` |
+| 2 | entry selector | Which **entry** (1) has focus. Moves with ↑ and ↓. | Not declared; follows focus | `fieldEntrySelector()` |
+| 3 | entry marker | The per-entry chosen-state glyph. | `->multiple()` picks which pair of glyphs | `fieldEntryMarker()` |
+| 4 | entry note | A qualifier on an **entry** (1), such as why it's unavailable. Drawn only on entries carrying a reason. | `->option('tomato', 'Tomato', disabled: TRUE, disabled_reason: 'out of season')` | `fieldEntryNote()` |
+| 5 | entry description | The focused **entry** (1)'s own explanatory text. Indented to start where the entry text starts. | `->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')` | `fieldEntryDescription()` |
+| 6 | constraint | What the field expects, before anything is rejected. | `->minSelections(2)->maxSelections(3)`, `->maxSize(64)`, `->min()`/`->max()` | `fieldConstraint()` |
+
+
+
+What fills the list varies by field: a fixed set, a set filtered as you type, or one fetched by a query. An **entry description** (5) is rewritten every time the **entry selector** (2) moves, and is absent for entries that declare none - which is why it's indented to the entry text rather than to the list, so it reads as belonging to the entry above it.
+
+**Selecting and marking are different things.** A selector shows where you are; a marker shows what you've marked. That's why the **entry selector** (2) and the **field selector** (view 1) are the glyphs that follow your movement, while the **entry marker** (3) is the box that records a decision. Moving a selector chooses nothing. The **entry marker** (3) only changes when you pick something, which in a multiple-choice list is Space.
+
+The pairs are deliberate. The **field selector** (view 1) shows which field you're on and the **entry selector** (2) which entry within it; the **description** (view 6) is the field's own text and the **entry description** (5) an entry's. Same idea at two levels, so a parent's name is never reused for its child.
+
+### Typing rather than choosing
+
+Some fields collect an answer by typing instead of by choosing from a list, so they have no **entry** (1) at all. The [Template](/fields/template) field fills the named slots of a fixed pattern.
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | ----- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------- |
+| 8 | caret | The insertion point within the **draft** (9), showing where the next keystroke lands. | Not declared; follows what you type | `fieldCaret()` |
+| 9 | draft | The text you're typing, before it's accepted. | `->default('valley-pear-a')` seeds it; typing changes it | `fieldDraft()` |
+| 10 | state | What the field is doing right now. | `->slot('fruit', 'Fruit')` names what it reports | `fieldState()` |
+
+
+
+The **draft** (9) and the **value** (view 4) are the same answer at two moments: what you're typing, and what was accepted. Accepting promotes one to the other; canceling discards the **draft** (9). In the [Template](/fields/template) field the **caret** (8) is also what moves between slots as you fill them.
+
+The **state** (10) is easy to confuse with the **description** (view 6), so it's worth being precise. The first tracks the field and changes while you work - here it names the slot you're filling. The second belongs to the field and never moves.
+
+### Browsing a list
+
+The [FilePicker](/fields/filepicker) field draws the same skeleton over a directory listing, and adds a **caption** (11) above it.
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | ------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | ---------------- |
+| 11 | caption | What the list below is showing. Rewritten whenever the list changes underneath it. | `->startIn($directory)` sets where it begins | `fieldCaption()` |
+
+
+
+The lines in the list are files and directories rather than options, and they're still **entries** (1): the name describes the line the field offers, not where its content came from. The **caption** (11) names a directory in this field because that's what a file browser browses, but the atom - and the theme method that draws it - stays generic.
+
+### Constraint and error: one line, two states
+
+The **constraint** (6) and the **error** (7) share a single physical line. A [FilePicker](/fields/filepicker) field limited to files of at most 64 bytes shows both states of it.
+
+Nothing has been picked yet, so the line states what the field expects:
+
+
+
+
+
+Now `harvest.csv` is picked, and at 88 bytes it breaks that limit. The same line, in the same place, turns into the **error** (7). The **constraint** (6) doesn't move down or stay above it - it's replaced:
+
+
+
+
+
+
+
+| # | Atom | What it does | Set with | Theme element |
+| --- | ----- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------- |
+| 7 | error | Why the value was rejected. Drawn only after a refused accept, and cleared the moment the value becomes acceptable. | The declared bounds, or `->validate(...)` | `fieldError()` |
+
+
+
+The two lines say different kinds of thing:
+
+| | Line | Says |
+| ------------------ | ------------------------------------ | ------------------------------------------ |
+| **constraint** (6) | `Files only. Max 64 B.` | what the field will accept, before you act |
+| **error** (7) | `Choose a file no larger than 64 B.` | why what you just did was refused |
+
+A **constraint** (6) describes the field; an **error** (7) describes your value. A **constraint** (6) is there from the moment edit mode opens, and hands the line over the instant a value is refused; the **error** (7) hands it back as soon as the value is acceptable again.
+
+That's also why they're built differently. The first is an unframed phrase - `a file no larger than 64 B` - that its caller wraps, so the same phrase becomes `Choose ...` in the picker, `Select ...` in a bounded list and `must be ...` headlessly. The second is already a whole message, and is shown as it stands.
+
+## Elements: what a theme actually implements
+
+An atom is what you see. An **element** is the method a theme answers with, and every one of them takes plain strings and scalars and returns a styled string. That is the whole contract: order, spacing and how many elements there are belong to the block; color and glyph belong to the theme.
+
+Elements are grouped by the block that declares them, in one interface per block, and each is prefixed with its owner's name so a theme can implement every interface on one class without a collision. A theme that doesn't implement a block's interface can't draw that block, and it says so by name rather than leaving a blank line.
+
+`ThemeInterface` itself carries only two methods, because only two belong to no block at all: `contentWidth()`, the one width every block lays out against, and `keyGlyph(Key $key)`, so the legend, a field naming a key in a prompt and the notice saying how to quit all spell the same key the same way.
+
+### The chrome
+
+`ChromeElementsInterface` is the one interface named for something other than a block. The frame surrounds every region at once, and the overflow mark says a region's contents outran it - neither is something a block could ask for, since a block only fills the space it is given and never learns where that space ends.
+
+| Element | Draws |
+| ------------------------ | ------------------------------------------------------------- |
+| `chromeBorder()` | the **border** (chrome 1) - the run of box-drawing characters |
+| `chromeOverflowMarker()` | the **overflow marker** (chrome 4), told whether it points up |
+
+### The trail, the keys and the buttons
+
+| Interface | Element | Draws |
+| ----------------------------- | ----------------------- | -------------------------------------------- |
+| `BreadcrumbElementsInterface` | `breadcrumbLabel()` | one segment of the **breadcrumb** (chrome 2) |
+| | `breadcrumbSeparator()` | the **breadcrumb separator** (chrome 3) |
+| `LegendElementsInterface` | `legendKey()` | a **legend key** (chrome 6) |
+| | `legendDescription()` | a **legend description** (chrome 7) |
+| | `legendSeparator()` | a **legend separator** (chrome 8) |
+| `ActionsElementsInterface` | `actionButton()` | a button that does not have focus |
+| | `actionSelected()` | the button that has focus |
+| | `actionSeparator()` | the gap standing between two buttons |
+
+The brackets around a button belong to `actionButton()`, not to the block. A theme that frames a button differently changes that one method, and the block goes on knowing only that it has labels and one of them has focus.
+
+### A nested panel's row
+
+A panel draws a row of its own only as a sub-panel - the shape you select to enter. Once you are inside it, it draws nothing itself: its blocks do.
+
+| Element | Draws |
+| ------------------------- | ------------------------------------------------- |
+| `panelSelector()` | which row has focus |
+| `panelTitle()` | the sub-panel's title |
+| `panelDescend()` | the mark saying the row leads somewhere |
+| `panelDescription()` | the sub-panel's standing text |
+| `panelSummary()` | the run of answers the sub-panel is holding |
+| `panelSummarySeparator()` | the mark standing between two answers in that run |
+
+### A field, in both of its modes
+
+One field owns both modes, so one interface names both. `FieldElementsInterface` is the largest of them for that reason.
+
+
+
+| Element | Draws |
+| ------------------------- | -------------------------------------------------------------------------------------------------- |
+| `fieldSelector()` | the **field selector** (view 1) |
+| `fieldIndent()` | the blank gutter a conditional field's rows step in behind, given the depth of its condition chain |
+| `fieldLabel()` | the **label** (view 2) |
+| `fieldHelpMarker()` | the **help marker** (view 3) |
+| `fieldValue()` | the **value** (view 4) |
+| `fieldValueSeparator()` | the **value separator** (view 5) |
+| `fieldMask()` | one character of a secret, standing in for what was typed |
+| `fieldBadge()` | the mark saying where an answer came from - `default`, `detected`, `edited`, `derived`, `override` |
+| `fieldDescription()` | the **description** (view 6) |
+| `fieldEntry()` | an **entry** (edit 1), told whether it is picked and whether the cursor rests on it |
+| `fieldEntryMatch()` | the run of an entry's label that answers what was typed |
+| `fieldEntrySelector()` | the **entry selector** (edit 2) |
+| `fieldEntryMarker()` | the **entry marker** (edit 3), told whether picking gives up every other choice |
+| `fieldEntryNote()` | an **entry note** (edit 4) |
+| `fieldEntryDescription()` | an **entry description** (edit 5) |
+| `fieldEntrySeparator()` | the mark standing between two runs of entries |
+| `fieldConstraint()` | the **constraint** (edit 6) |
+| `fieldError()` | the **error** (edit 7) |
+| `fieldCaret()` | the **caret** (edit 8) |
+| `fieldDraft()` | the **draft** (edit 9) |
+| `fieldGhost()` | the completion offered after the draft, which nobody typed |
+| `fieldInput()` | the whole typed line: draft, caret and completion in one piece |
+| `fieldScale()` | the run of points a graded answer reads as |
+| `fieldLoading()` | the mark saying the field is still fetching what it will offer |
+| `fieldState()` | the **state** (edit 10) |
+| `fieldCaption()` | the **caption** (edit 11) |
+
+
+
+Four of these answer with a whole composed line rather than one styled string - `fieldInput()`, `fieldScale()`, `fieldEntryMarker()` and `fieldEntrySelector()`. Each still takes plain scalars and nothing else, so the piece stays the theme's to arrange without the field handing over any of its state. `fieldInput()` is one piece rather than three because where the caret sits is a position _within_ the draft rather than a thing beside it, so only whatever draws the draft can put it there.
+
+### A passage of text
+
+`MarkupElementsInterface` draws prose wherever it appears - a field's **description** (view 6), a standing note, the page behind the **help** (view 7) key. A passage is not one string with one style, so each span is its own element and a theme restyling what is emphatic restyles it everywhere.
+
+| Element | Draws |
+| ------------------ | ----------------------------------------------------------- |
+| `markupTitle()` | the title above a body of markup |
+| `markupLine()` | one line of it |
+| `markupStrong()` | a span the passage states emphatically |
+| `markupEmphasis()` | a span it leans on |
+| `markupCode()` | a span it quotes verbatim |
+| `markupLink()` | a span that leads somewhere, given the label and the target |
+| `markupBullet()` | the mark leading one item of a list |
+
+### Work in progress
+
+`ProgressElementsInterface` covers the [progress](/fields/progress) row and the [progress primitive](/progress) alike.
+
+| Element | Draws |
+| ------------------- | ------------------------------------ |
+| `progressCaption()` | the caption naming the work |
+| `progressSpinner()` | the spinner glyph for a frame number |
+| `progressTrack()` | the filled and empty run of a bar |
+| `progressCount()` | the tally beside it |
+
+`progressSpinner()` takes the frame number rather than a glyph, so the theme owns both the animation's characters and how many there are - a Unicode theme can spin through ten frames where an ASCII one cycles four.
+
+### The finished pieces a primitive draws
+
+The [primitives](/output) collect nothing and never run inside a panel, so they cannot ask a block for anything. What they draw is a whole finished piece, declared in `PrimitiveElementsInterface`:
+
+| Element | Draws |
+| --------------------- | --------------------------------------------------------- |
+| `renderCard()` | a heading, a body and an optional grid, boxed or indented |
+| `renderTable()` | an aligned, bordered grid of headers and rows |
+| `renderText()` | source text as wrapped, markup-styled lines |
+| `renderRule()` | a line spanning the frame |
+| `renderBanner()` | a logo above an optional version line |
+| `renderStatus()` | one of the five status lines: its glyph and its message |
+| `renderDefinitions()` | label/value pairs as an aligned definition list |
+| `renderSpinner()` | an indeterminate spinner beside its caption |
+| `renderProgressBar()` | a determinate bar with its step count and label |
+
+`renderCard()` and `renderTable()` are each the single renderer behind both the standalone piece and its in-panel counterpart, so restyling one restyles the [note field's card](/fields/note) or [its grid](/fields/table) at the same time. Every method here takes plain strings and arrays: a renderer that reached for a field, a panel or an answer set could only ever be used from inside a form.
+
+## Patching an element
+
+Subclassing a theme is the full answer, and overkill when all you want is a different glyph. Hand `->theme()` a closure instead of a name and it is given a `ThemeBuilder`, whose groups are the blocks that declare the elements - so the prefix is implied, and `->separator()` means one thing under `->breadcrumb()` and another under `->legend()`:
+
+```php
+use DrevOps\Tui\Theme\Override\BreadcrumbOverrides;
+use DrevOps\Tui\Theme\Override\FieldOverrides;
+use DrevOps\Tui\Theme\Override\LegendOverrides;
+use DrevOps\Tui\Theme\Sgr;
+use DrevOps\Tui\Theme\ThemeBuilder;
+
+$tui->theme(fn(ThemeBuilder $t) => $t
+ ->breadcrumb(fn(BreadcrumbOverrides $b) => $b
+ ->separator('›', '>'))
+ ->legend(fn(LegendOverrides $l) => $l
+ ->separator('·', '|')
+ ->key(Sgr::Bold, Sgr::BrightCyan))
+ ->field(fn(FieldOverrides $f) => $f
+ ->selector('❯', '>')
+ ->helpMarker('ⁱ', '[?]')
+ ->valueSeparator(', ')
+ ->entrySelector('▸', '->')
+ ->entryMarker('◼', '[x]')
+ ->caret('█', '|')));
+```
+
+Nine elements can be patched, and that is the closed set:
+
+| Group | Call | Patches | Takes |
+| ---------------- | -------------------- | ----------------------- | ------------------------------ |
+| `->breadcrumb()` | `->separator()` | `breadcrumbSeparator()` | a glyph and its ASCII stand-in |
+| `->legend()` | `->separator()` | `legendSeparator()` | a glyph and its ASCII stand-in |
+| | `->key()` | `legendKey()` | `Sgr` palette parts, in order |
+| `->field()` | `->selector()` | `fieldSelector()` | a glyph and its ASCII stand-in |
+| | `->helpMarker()` | `fieldHelpMarker()` | a glyph and its ASCII stand-in |
+| | `->valueSeparator()` | `fieldValueSeparator()` | text |
+| | `->entrySelector()` | `fieldEntrySelector()` | a glyph and its ASCII stand-in |
+| | `->entryMarker()` | `fieldEntryMarker()` | a glyph and its ASCII stand-in |
+| | `->caret()` | `fieldCaret()` | a glyph and its ASCII stand-in |
+
+The argument count says what kind of thing you are restating. A **glyph** takes two - the mark and its ASCII stand-in - so a patch can't set one display mode and silently leave the other broken. **Text** takes one, because a phrase the reader parses is not something a terminal fails to draw. A **color** takes the palette parts in order.
+
+Reading `->entryMarker('◼', '[x]')`: the two arguments are the Unicode mark a picked entry carries and what stands in for it where that mark can't be drawn - not the picked and unpicked states. An entry nobody picked keeps whatever the theme draws for it, which is what keeps this a patch.
+
+Anything the patch doesn't name keeps the theme's own answer. Reach for a subclass when you're changing a palette; reach for this when you're changing a handful of glyphs. Runnable in [`playground/09-themes-elements.php`](https://github.com/drevops/tui/blob/main/playground/09-themes-elements.php).
+
+## What a theme is allowed to do
+
+A terminal may have no color, no Unicode, or a background the theme should read. A theme declares which of those it handles, and declaring one is what grants the facility that goes with it. Five capabilities exist, and that is the whole set:
+
+| Declaration | Grants | For |
+| ----------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| `ColorSchemeCapableInterface` | `hasColor()`, `isDark()` | painting at all, and picking a palette for a dark or light terminal |
+| `UnicodeCapableInterface` | `hasUnicode()` | choosing between a glyph and its ASCII stand-in |
+| `DimCapableInterface` | `dim()` | pushing back what a dialog is drawn over |
+| `MarkdownCapableInterface` | `hasMarkdown()` | drawing the [markdown subset](/markdown) rather than its markers |
+| `OccupyCapableInterface` | `isFullscreen()`, `halign()`, `valign()`, the min/max sizes, `spacing()`, `background()` | saying how much of the terminal the frame takes, and where it anchors |
+
+Color and the background are one declaration rather than two, because the two questions are never asked apart: a color is chosen against a background, and a color legible on a dark terminal is not legible on a light one.
+
+Two of the five carry a **trait** with the plumbing. `ColorSchemeCapableTrait` brings `paint()` and `emphasize()`, and `UnicodeCapableTrait` brings `glyph()`, so a palette reads as color choices rather than as escape-sequence handling. `AbstractTheme` declares none of them and implements every element interface, which is the floor: the strings it was handed, and the stand-ins that read without color or glyphs. See [Themes](/themes) for writing one.
+
+## Open questions
+
+**Can the widest legend fit the frame?** A multiple-choice list advertises five entries - `SPACE to select · ↑/↓ to move · ←/→ to select none or all · ↵ to accept · ESC to cancel` - which is 87 columns. A non-fullscreen frame is 76, or 72 inside a border. The minimum-width guard measures the panels' own rows and never the **legend** (chrome 5), so the line is drawn and then clipped mid-word by a frame sized without it. Either the frame accounts for the legend, or the legend wraps, or it sheds its lowest-priority entries as the room runs out.
+
+**Is an entry declared or is it an option?** The atom is an _entry_ and every element that draws one is named for it - `fieldEntry()`, `fieldEntrySelector()`, `fieldEntryMarker()`. The call that declares one is `->option()`. The two names sit either side of the same thing: what you supply, and what appears. Nothing is broken by it, and one of the two would have to move for the vocabulary to be whole.
diff --git a/docs/content/fields/calendar.mdx b/docs/content/fields/calendar.mdx
new file mode 100644
index 00000000..98ca5cb5
--- /dev/null
+++ b/docs/content/fields/calendar.mdx
@@ -0,0 +1,78 @@
+---
+title: Calendar
+description: 'A month-grid date picker returning a normalized ISO YYYY-MM-DD string; arrows move by day and week.'
+keywords: ['calendar', 'date picker', 'iso date', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Calendar
+
+
+
+
+
+A month-grid date picker. It collects a single **normalized ISO `YYYY-MM-DD` string**; give it no default and it opens on today.
+
+```php
+use DrevOps\Tui\Model\Weekday;
+
+$p->calendar('harvest', 'Harvest date')
+ ->default('2026-07-15') // Date the grid opens on.
+ ->minDate('2026-01-01') // Earliest selectable date, inclusive.
+ ->maxDate('2026-12-31') // Latest selectable date, inclusive.
+ ->weekStart(Weekday::Sunday); // Day the week grid starts on.
+```
+
+Runnable script: [`playground/02-fields-calendar.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-calendar.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------- | ----------------------------------------------------- | -------- | ----------------- |
+| `default()` | Date the grid opens on, as `YYYY-MM-DD`. | No | Today |
+| `minDate()` | Earliest selectable date, inclusive, as `YYYY-MM-DD`. | No | Unbounded |
+| `maxDate()` | Latest selectable date, inclusive, as `YYYY-MM-DD`. | No | Unbounded |
+| `weekStart()` | Day the week grid starts on, a `Weekday` enum case. | No | `Weekday::Monday` |
+
+Navigation is clamped to the `minDate()`/`maxDate()` range: the cursor never leaves it, days outside it render dimmed, and an opening date outside the range snaps to the nearest bound. These are the field's own options; the shared field options (`required()`, `when()`, `validate()`, ...) are covered in [Field behavior](/field-behaviour).
+
+## Keyboard
+
+| Key | Action |
+| --------------------------------------- | ------------------------------------------------ |
+| ← / → | Move one day (vim: h / l) |
+| ↑ / ↓ | Move one week (vim: k / j) |
+| PageUp / PageDown | Previous / next month |
+| Home / End | First / last day of the visible month |
+| Enter | Accept the highlighted date |
+| Esc | Cancel |
+
+The day and week moves resolve through the [key map](/key-bindings), so the arrows and the vim letters can be remapped; the month and edge jumps (PageUp/PageDown, Home/End) are fixed keys with no action behind them.
+
+## Headless behavior
+
+The bounds are enforced when the form runs [headlessly](/headless-collection) too - a value outside the range is rejected - and they surface in the JSON schema as `min_date`, `max_date` and `week_start` on the prompt.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/confirm.mdx b/docs/content/fields/confirm.mdx
new file mode 100644
index 00000000..88906ade
--- /dev/null
+++ b/docs/content/fields/confirm.mdx
@@ -0,0 +1,60 @@
+---
+title: Confirm
+description: 'A Yes/No gate collecting a bool; arrows or Space switch the choice, y and n set it directly.'
+keywords: ['confirm', 'yes no', 'boolean', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Confirm
+
+
+
+
+
+A Yes/No gate. It collects a **`bool`**.
+
+```php
+$p->confirm('organic', 'Organic only?')
+ ->default(TRUE); // Which choice starts highlighted (defaults to No).
+```
+
+Runnable script: [`playground/02-fields-confirm.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-confirm.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ----------- | ----------------------------------------------------------------- | -------- | ------------ |
+| `default()` | Which choice starts highlighted - `TRUE` for Yes, `FALSE` for No. | No | `FALSE` (No) |
+
+## Keyboard
+
+| Key | Action |
+| ---------------------------------------------------------------------------- | ------------------------- |
+| y / n | Choose Yes / No directly |
+| ← / → / Space / ↑ / ↓ | Flip the choice |
+| Enter | Accept the current choice |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/filepicker.mdx b/docs/content/fields/filepicker.mdx
new file mode 100644
index 00000000..83a9b2c0
--- /dev/null
+++ b/docs/content/fields/filepicker.mdx
@@ -0,0 +1,157 @@
+---
+title: FilePicker
+description: 'Browse the filesystem for one path - or several with ->multiple() - entering directories and returning to their parents.'
+keywords: ['file picker', 'filesystem', 'path', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# FilePicker
+
+
+
+
+
+Browse the filesystem for a single path. It collects the **chosen path** (a `string`).
+
+```php
+$p->filePicker('list', 'Price list')
+ ->startIn(getcwd()) // Directory to open in (and the floor for ←).
+ ->filesOnly() // Only files are selectable; directories stay navigable.
+ ->extensions(['csv']) // Limit selectable files to these extensions.
+ ->maxSize(5_000_000) // Reject a selected file larger than this many bytes.
+ ->showHidden(); // Show hidden (dot) entries when the browser opens.
+```
+
+Runnable scripts: [`playground/02-fields-filepicker.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-filepicker.php) and [`filepicker-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-filepicker-multiple.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------------- | --------------------------------------------------------------------------- | -------- | --------------------- |
+| `startIn()` | Directory the browser opens in, and the floor it cannot ascend above. | No | Current directory |
+| `filesOnly()` | Only files are selectable; directories stay navigable. | No | Files and directories |
+| `directoriesOnly()` | Only directories are selectable. | No | Files and directories |
+| `extensions()` | Restrict selectable files to these extensions (dot-less, case-insensitive). | No | All |
+| `maxSize()` | Reject any selected file larger than this many bytes. | No | No limit |
+| `showHidden()` | Show hidden (dot) entries when the browser opens. | No | Off |
+| `pageSize()` | Entries shown before the list pages around the cursor. | No | `10` |
+
+`filesOnly()` and `directoriesOnly()` are mutually exclusive - the last one set wins.
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | -------------------------------------------------------------------------------- |
+| ↑ / ↓ | Move the highlight |
+| → | Descend into the highlighted directory |
+| ← | Ascend to the parent (never above the start directory) |
+| printable keys | Filter the current directory |
+| Tab | Toggle hidden entries |
+| Backspace | Delete a filter character, or ascend when the filter is empty |
+| Enter | Select the highlighted entry if selectable, otherwise descend into the directory |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Type and size constraints
+
+Constrain what counts as a valid pick with `->filesOnly()` / `->directoriesOnly()`, `->extensions()` and `->maxSize()`. The active limits show as a hint below the browser, a pick that breaks one is rejected inline when you accept, and the same limits are enforced in [headless collection](/headless-collection).
+
+```php
+$p->filePicker('list', 'Price list')
+ ->filesOnly() // A directory (or a missing path) is not a valid pick.
+ ->extensions(['csv']) // Only .csv files may be chosen.
+ ->maxSize(5_000_000); // Reject a file larger than 5 MB.
+```
+
+A missing path, a directory where a file is required (or the reverse), a disallowed extension, or an oversized file each fail with a message naming the unmet limit.
+
+## Multiple selection
+
+Add `->multiple()` to accumulate **several paths** (a `list`) instead of one: Space toggles the highlighted entry, selections stick as you browse between directories, and Enter accepts them all.
+
+```php
+$p->filePicker('lists', 'Price lists')
+ ->multiple()
+ ->startIn(getcwd())
+ ->extensions(['csv']);
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Selection limits
+
+Bound how many paths a multiple file picker collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the browser, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
+
+```php
+$p->filePicker('price_lists', 'Price lists')
+ ->multiple()
+ ->minSelections(2) // Reject fewer than two paths.
+ ->maxSelections(3); // Reject more than three paths.
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+Runnable script: [`playground/02-fields-filepicker-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-filepicker-multiple-limited.php).
diff --git a/docs/content/fields/index.mdx b/docs/content/fields/index.mdx
new file mode 100644
index 00000000..f1f618b4
--- /dev/null
+++ b/docs/content/fields/index.mdx
@@ -0,0 +1,46 @@
+---
+title: Fields
+description: 'The field gallery: text entry, choices, filesystem browsing and gates, each shown in all four display modes.'
+keywords: ['fields', 'gallery', 'fields', 'input', 'tui']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Fields
+
+The fields cover text entry, choices, filesystem browsing and gates. Every field of the form opens its field in an editor, and the same fields also run standalone (see [`playground/02-fields-*`](https://github.com/drevops/tui/tree/main/playground)). Fields pull their glyphs and colors from the theme, so each one is shown in all four display modes.
+
+
+
+
+
+## Text entry
+
+- [Calendar](/fields/calendar) - a month calendar returning an ISO date.
+- [Number](/fields/number) - integer input with optional bounds and step keys.
+- [Password](/fields/password) - masked input with optional reveal and confirm.
+- [Template](/fields/template) - fill the named slots of a fixed shape.
+- [Text](/fields/text) - single-line input, with optional ghost-text autocomplete.
+- [Textarea](/fields/textarea) - multi-line input with optional external-editor handoff.
+
+## Choices
+
+- [Option groups](/fields/option-groups) - headings, separators and disabled options.
+- [Rating](/fields/rating) - a graded answer picked from a scale of points.
+- [Reorder](/fields/reorder) - rank a list by moving items into order.
+- [Search](/fields/search) - single or multiple choice with a filter line.
+- [Select](/fields/select) - single or multiple choice from a list.
+- [Suggest](/fields/suggest) - free text with autocomplete over a fixed set.
+
+## Filesystem
+
+- [FilePicker](/fields/filepicker) - browse for one path, or several with `->multiple()`.
+
+## Toggles, gates and cards
+
+- [Confirm](/fields/confirm) - a Yes/No toggle.
+- [Note](/fields/note) - a read-only informational card that collects nothing.
+- [Pause](/fields/pause) - an acknowledgment gate.
+- [Progress](/fields/progress) - a row that runs work, showing a bar or spinner.
+- [Toggle](/fields/toggle) - an inline switch between two labeled values.
diff --git a/docs/content/fields/note.mdx b/docs/content/fields/note.mdx
new file mode 100644
index 00000000..eb563bd5
--- /dev/null
+++ b/docs/content/fields/note.mdx
@@ -0,0 +1,74 @@
+---
+title: Note
+description: 'A non-interactive informational card that shows a title and body inline without collecting a value.'
+keywords: ['note', 'card', 'informational', 'read-only', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Note
+
+
+
+
+
+A non-interactive informational card. It renders a **title and body inline** in the form flow and collects **nothing** - the selection cursor skips it, it never appears in the answers, and it is absent from headless collection. Its title and body take the same `{{field}}` templating derived values use, so a note can reflect earlier answers, and it honors `->when()` like any other field.
+
+```php
+$p->note('intro', 'Fresh produce order')
+ ->description('A read-only card - the cursor skips it.');
+
+$p->text('item', 'Produce name')->default('Pear');
+
+// ->border() frames the card; the body reflects the earlier answer.
+$p->note('summary', 'Ready to pack')
+ ->description('Packing {{item}} into the basket.')
+ ->border();
+```
+
+A note body can carry a `[text](url)` link, and when the enclosing TUI is configured with [`->markdown()`](/markdown) it also renders bold, emphasis, inline code and bullet lists - all degrading to clean plain text where the terminal cannot show them.
+
+A note can also present tabular context: `->table(headers, rows)` renders an aligned, bordered grid beneath the title and body. See [Table](/fields/table) for the full reference.
+
+Runnable script: [`playground/02-fields-note.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-note.php).
+
+## Options
+
+| Method | Effect |
+| ------------------------ | -------------------------------------------------------------------------------- |
+| `->description(body)` | The card's body text, shown beneath the title. |
+| `->border()` | Frames the card in the theme's box with minimal padding. |
+| `->table(headers, rows)` | Renders an aligned, bordered grid beneath the body (see [Table](/fields/table)). |
+
+The title is the second `note()` argument and is optional - an empty title renders the body alone. The shared field options `->when()` (conditional visibility) and `{{field}}` templating in both the title and body still apply.
+
+## Keyboard
+
+A note is non-interactive: the selection cursor skips over it, so it has no keys of its own.
+
+## Headless behavior
+
+A note carries no value - a table it renders is presentational too. It is absent from headless collection, from the answers payload, and from the machine-readable schemas (`schema()` and `agentHelp()`), so an agent is never asked to provide one.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/number.mdx b/docs/content/fields/number.mdx
new file mode 100644
index 00000000..77601aa1
--- /dev/null
+++ b/docs/content/fields/number.mdx
@@ -0,0 +1,74 @@
+---
+title: Number
+description: 'Integer input with optional bounds and step keys, collected as an int.'
+keywords: ['number', 'integer', 'bounds', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Number
+
+
+
+
+
+Integer input - digits with an optional leading minus. It collects an **`int`**.
+
+```php
+$p->number('weight', 'Basket weight (g)')
+ ->min(200) // Lowest accepted value, inclusive.
+ ->max(9000) // Highest accepted value, inclusive.
+ ->step(100) // Amount the Up/Down keys adjust by.
+ ->default(1200); // Initial value.
+```
+
+Runnable script: [`playground/02-fields-number.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-number.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ----------- | -------------------------------------------------------------------------- | -------- | --------- |
+| `min()` | Lowest accepted value, inclusive. | No | Unbounded |
+| `max()` | Highest accepted value, inclusive. | No | Unbounded |
+| `step()` | Amount the Up/Down keys adjust by; must be positive. | No | `1` |
+| `default()` | Initial value. | No | `0` |
+
+With no bounds declared, the field is a plain integer entry and the arrow keys are inert. Declare `min()`, `max()` or `step()` and Up/Down adjustment turns on; the value is clamped only when a range (`min()` / `max()`) is set.
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | ---------------------------------------------------------- |
+| digits | Insert a digit |
+| - | Leading minus (once, at the start) |
+| ← / → | Move the caret |
+| ↑ / ↓ | Increment / decrement by `step` (only when bounds are set) |
+| Enter | Accept (an out-of-range value is rejected inline) |
+| Esc | Cancel |
+
+## Headless behavior
+
+The bounds are enforced when the form runs [headlessly](/headless-collection) too - a value outside the range is rejected - and they surface in the JSON schema as `min`, `max` and `step` on the prompt.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/option-groups.mdx b/docs/content/fields/option-groups.mdx
new file mode 100644
index 00000000..1696a775
--- /dev/null
+++ b/docs/content/fields/option-groups.mdx
@@ -0,0 +1,82 @@
+---
+title: Option groups
+description: 'Structure long option lists with headings, separators and disabled options in the choice fields.'
+keywords: ['option groups', 'headings', 'separators', 'disabled options', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Option groups, separators and disabled options
+
+The choice fields - [`select`](/fields/select) and [`search`](/fields/search), single-choice or with `->multiple()` - accept more than a flat list. Alongside the `->options(['value' => 'Label'])` map shorthand, you can declare options one at a time, mark them disabled, and add non-selectable structure.
+
+```php
+$p->select('item', 'Item')
+ ->heading('Fruit') // A non-selectable group heading.
+ ->option('apple', 'Apple') // value => label
+ ->option('banana', 'Banana')
+ ->separator() // A non-selectable divider row.
+ ->heading('Vegetable')
+ ->option('carrot', 'Carrot')
+ ->option('cherry', 'Cherry', disabled: TRUE, disabled_reason: 'out of season');
+```
+
+Runnable scripts: [`playground/02-fields-select-groups.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select-groups.php) and [`select-multiple-groups.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select-multiple-groups.php).
+
+## Builder methods
+
+| Name | Description |
+| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `option($value, $label = '', ...)` | Add one selectable row. The label defaults to the value; re-declaring a value replaces it in place. Pass `disabled: TRUE` (with an optional `disabled_reason`) to show it but block selection, or `description:` for a [contextual line](/fields/select#option-descriptions) shown when it is highlighted. |
+| `options([$value => $label])` | Add many selectable rows from a map - shorthand for repeated `option()`. |
+| `heading($label)` | Add a non-selectable group-heading row. |
+| `separator()` | Add a non-selectable divider row. |
+
+## Behavior
+
+Headings, separators and disabled options are **visual only**: navigation skips them, so the cursor lands only on selectable options, and they can never be highlighted or selected. A disabled option shows its reason beside the label, dimmed. Every kind is theme-driven - override `heading()`, `divider()` or `disabled()` on a theme to restyle it.
+
+Non-selectable rows never leak into the answer: a disabled value is dropped from a multiple-choice default, absent from the collected value, and excluded from the JSON schema (`Tui::schema()` lists selectable options only). Supplying a disabled - or otherwise unknown - option value [headlessly](/headless-collection) (via `--prompts` JSON or an environment override) fails collection with a clear error naming the value.
+
+## Examples
+
+A single-choice `select` with a group heading, a separator and a disabled option (its reason shown beside the dimmed label):
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+A multiple `select` where the cursor and Space skip the separator and the disabled option, which can never be checked:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/password.mdx b/docs/content/fields/password.mdx
new file mode 100644
index 00000000..ffd58c12
--- /dev/null
+++ b/docs/content/fields/password.mdx
@@ -0,0 +1,72 @@
+---
+title: Password
+description: 'Masked text input with optional reveal and confirmation; the accepted value stays plain for the consumer.'
+keywords: ['password', 'masked input', 'reveal', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Password
+
+
+
+
+
+Text input rendered as a mask - in the editor, on the panel row and in the summary. The accepted value stays plain for your code. It collects a **`string`**.
+
+```php
+$p->password('code', 'Order code')
+ ->revealable() // Add a Tab toggle to reveal the typed value.
+ ->confirmation(); // Prompt for the value twice and reject a mismatch.
+```
+
+Runnable scripts: [`playground/02-fields-password.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-password.php) and [`password-reveal.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-password-reveal.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ---------------- | ------------------------------------------------------------------------------------------ | -------- | ------- |
+| `revealable()` | Add a reveal toggle: Tab cycles the editor display hidden → masked → plaintext. | No | Off |
+| `confirmation()` | Prompt for the value a second time and reject a mismatch before accepting. | No | Off |
+
+Both are off by default, so a plain `password()` masks the input and nothing more. `revealable` only changes what's drawn - the stored value is never affected, and the panel row and summary always stay masked.
+
+With `revealable()` on, Tab cycles the editor's display through hidden, masked and plaintext, and the hint line shows the toggle:
+
+
+
+
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | -------------------------------------------------------------------------- |
+| printable keys | Insert at the caret (drawn masked) |
+| ← / → | Move the caret |
+| Backspace | Delete the character before the caret |
+| Tab | Cycle the display hidden → masked → plaintext (when `revealable()`) |
+| Enter | Accept - or, with `confirmation()`, re-prompt once, then accept on a match |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/pause.mdx b/docs/content/fields/pause.mdx
new file mode 100644
index 00000000..1eaa598f
--- /dev/null
+++ b/docs/content/fields/pause.mdx
@@ -0,0 +1,59 @@
+---
+title: Pause
+description: 'An acknowledgment gate that shows its label and waits for the reader before continuing.'
+keywords: ['pause', 'gate', 'acknowledgment', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Pause
+
+
+
+
+
+An acknowledgment gate: it shows its label and waits for the reader to continue. It collects a **`bool`** - always `TRUE` once acknowledged - and holds no other value.
+
+```php
+$p->pause('ready', 'Review your basket'); // A gate; it takes no options.
+```
+
+Runnable script: [`playground/02-fields-pause.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-pause.php).
+
+## Options
+
+Pause has no options of its own - it only renders its label as a gate. The shared field options (`when()`, `description()`) still apply.
+
+## Keyboard
+
+| Key | Action |
+| ----------------------------------- | ------------------------ |
+| Enter / Space | Acknowledge and continue |
+| Esc | Cancel |
+
+## Headless behavior
+
+An unattended run has nothing to wait for, so a pause auto-acknowledges (`TRUE`) and never blocks automation.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/progress.mdx b/docs/content/fields/progress.mdx
new file mode 100644
index 00000000..78640873
--- /dev/null
+++ b/docs/content/fields/progress.mdx
@@ -0,0 +1,71 @@
+---
+title: Progress
+description: 'A panel row that runs work when activated, filling a bar or ticking a spinner in place.'
+keywords: ['progress', 'bar', 'spinner', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Progress
+
+
+
+
+
+A place to do work inside the form: select the row, press `Enter`, and its work runs with a **determinate bar** (when it declares a step count) or an **indeterminate spinner** (when it does not), drawn in the row itself as the work advances. It collects **no value** - it sits beside the fields it depends on, not among the answers.
+
+```php
+$p->progress('pack', 'Packing the box')
+ ->steps(6) // Omit for an indeterminate spinner.
+ ->run(function (ProgressReporter $reporter) use ($items): void {
+ foreach ($items as $item) {
+ // ... one step of work ...
+ $reporter->advance(); // Fills one step of the bar (ticks the spinner).
+ }
+ });
+```
+
+Runnable script: [`playground/02-fields-progress.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-progress.php).
+
+## Options
+
+| Method | Effect |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `->steps(int)` | The step count, making the indicator a determinate bar. Omit it for an indeterminate spinner. |
+| `->run(callable)` | The work run when the row is activated. The callback receives a `ProgressReporter` and calls `advance()` once per step. |
+
+The indicator is drawn by the [active theme](/themes), in its accent and Unicode/ASCII mode - the same spinner and bar as the standalone [`progress()` primitive](/progress).
+
+## Keyboard
+
+| Key | Action |
+| ------- | ------------------ |
+| `Enter` | Run the row's work |
+| `Esc` | Leave the panel |
+
+## Headless behavior
+
+A progress row is display-only: it carries no answer, is absent from the [machine schema](/headless-collection), and an unattended run skips it.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/widgets/rating.mdx b/docs/content/fields/rating.mdx
similarity index 76%
rename from docs/content/widgets/rating.mdx
rename to docs/content/fields/rating.mdx
index 39c83b23..ce549618 100644
--- a/docs/content/widgets/rating.mdx
+++ b/docs/content/fields/rating.mdx
@@ -1,7 +1,7 @@
---
title: Rating
description: 'A graded answer picked from a scale of points, collected as an int.'
-keywords: ['rating', 'scale', 'grade', 'widget']
+keywords: ['rating', 'scale', 'grade', 'field']
---
import ThemedImage from '@theme/ThemedImage';
@@ -10,7 +10,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# Rating
-
+
A graded answer - how fresh the produce was, how ripe a fruit is, a one-to-five score. The arrows walk a row of points and the chosen one is collected as an **`int`**.
@@ -27,7 +27,7 @@ $p->rating('freshness', 'Freshness')
]);
```
-Runnable script: [`playground/02-widgets-rating.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-rating.php).
+Runnable script: [`playground/02-fields-rating.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-rating.php).
## Options
@@ -73,12 +73,12 @@ In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
Unicode
-
-
+
+
ASCII
-
-
+
+
diff --git a/docs/content/fields/reorder.mdx b/docs/content/fields/reorder.mdx
new file mode 100644
index 00000000..70bbab2a
--- /dev/null
+++ b/docs/content/fields/reorder.mdx
@@ -0,0 +1,92 @@
+---
+title: Reorder
+description: 'Rank a list by moving items into order; collects the values in their final order.'
+keywords: ['reorder', 'ranking', 'ordering', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Reorder
+
+
+
+
+
+Rank a list by moving items into the order you want. It returns a **`list`** - a full permutation of the option values, never a subset.
+
+```php
+$p->reorder('basket', 'Rank your basket')
+ ->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ])
+ ->default(['apple', 'carrot']) // Seed the starting order; omitted items are appended.
+ ->pageSize(10); // Items visible before the list pages around the cursor.
+```
+
+Runnable script: [`playground/02-fields-reorder.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-reorder.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------- |
+| `options()` | The items to rank, as a `value => label` map. | Yes | - |
+| `default()` | Seeds the starting order; any omitted options are appended in declared order, so the ranking is always complete. | No | Declared order |
+| `pageSize()` | Items shown before the list pages around the cursor. | No | `10` |
+
+A [description line](/fields/select#option-descriptions) can accompany the highlighted item, declared with `->option(..., description: ...)`.
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | --------------------------------------------------------- |
+| ↑ / ↓ | Move the highlight, or carry a held item through the list |
+| Space | Pick the highlighted item up, or drop a held one |
+| Enter | Drop a held item, or accept when nothing is held |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Option descriptions
+
+The highlighted item's [description](/fields/select#option-descriptions), in every display mode:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/search.mdx b/docs/content/fields/search.mdx
new file mode 100644
index 00000000..be585e7a
--- /dev/null
+++ b/docs/content/fields/search.mdx
@@ -0,0 +1,181 @@
+---
+title: Search
+description: 'Single or multiple choice with a fuzzy filter line that ranks the options and highlights the matched characters.'
+keywords: ['search', 'fuzzy filter', 'choice', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Search
+
+
+
+
+
+Single choice with a filter line above the options. Typing fuzzy-matches and ranks the labels - exact and prefix matches lead, looser subsequence matches follow - and highlights the matched characters. It collects the **selected option value** (a `string`).
+
+```php
+$p->search('vegetable', 'Vegetable')
+ ->options([
+ 'carrot' => 'Carrot',
+ 'potato' => 'Potato',
+ 'onion' => 'Onion',
+ 'pepper' => 'Pepper',
+ ])
+ ->default('carrot') // Which option starts highlighted.
+ ->pageSize(8); // Matches visible before the list pages around the cursor.
+```
+
+Runnable scripts: [`playground/02-fields-search.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-search.php) and [`search-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-search-multiple.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------ | -------------------------------------------------------------------------------- | -------- | ------------ |
+| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). | Yes | - |
+| `default()` | Which option starts highlighted, by value. | No | First option |
+| `pageSize()` | Matches shown before the list pages around the cursor. | No | `10` |
+
+For headings, separators and disabled options, see [Option groups](/fields/option-groups). For a per-option [description line](/fields/select#option-descriptions) shown beneath the highlighted match, declare it with `->option(..., description: ...)`.
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | ----------------------------------------- |
+| printable keys | Type to fuzzy-filter and rank the options |
+| ↑ / ↓ | Move over the matches |
+| Backspace | Delete a filter character |
+| Enter | Accept the highlighted option |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Multiple selection
+
+Add `->multiple()` to collect a **`list`** of checked values under the filter line. Typing fuzzy-matches and ranks with the matched characters highlighted, Space toggles, → / ← select or deselect all visible, and Enter accepts the checked set.
+
+```php
+$p->search('basket', 'Basket')
+ ->multiple()
+ ->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ])
+ ->default(['apple']);
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Selection limits
+
+Bound how many values a multiple field collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the list, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
+
+```php
+$p->search('basket', 'Basket')
+ ->multiple()
+ ->minSelections(2) // Reject fewer than two checked.
+ ->maxSelections(3) // Reject more than three checked.
+ ->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+Runnable script: [`playground/02-fields-search-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-search-multiple-limited.php).
+
+## Option descriptions
+
+The highlighted match's [description](/fields/select#option-descriptions), in every display mode:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Options from a query
+
+The options can come from the query itself rather than a fixed list, for a catalog too large to hold - see [options from a query](/progress#options-from-a-query):
+
+```php
+$p->search('veg', 'Vegetable')->optionsFrom(fn(string $query): array => $pantry->search($query));
+```
diff --git a/docs/content/fields/select.mdx b/docs/content/fields/select.mdx
new file mode 100644
index 00000000..534fe4d5
--- /dev/null
+++ b/docs/content/fields/select.mdx
@@ -0,0 +1,183 @@
+---
+title: Select
+description: 'Single or multiple choice from a list of options, with defaults and per-option descriptions.'
+keywords: ['select', 'choice', 'options', 'multiple', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Select
+
+
+
+
+
+Single choice from a list of options. It collects the **selected option value** (a `string`).
+
+```php
+$p->select('fruit', 'Fruit')
+ ->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'cherry' => 'Cherry',
+ ])
+ ->default('banana') // Which option starts highlighted (defaults to the first).
+ ->pageSize(10); // Options visible before the list pages around the cursor.
+```
+
+Runnable scripts: [`playground/02-fields-select.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select.php) and [`select-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select-multiple.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ |
+| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). Also takes a callback returning that map. | Yes | - |
+| `default()` | Which option starts highlighted, by value. | No | First option |
+| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` |
+
+For headings, separators and disabled options, see [Option groups](/fields/option-groups). To narrow the choices by an earlier answer, see [options from the answers](/field-behaviour#options-from-the-answers).
+
+## Option descriptions
+
+Give an option a description to explain what the choice implies. It shows as a secondary line beneath the list for the **highlighted** option and updates as the highlight moves. It is presentational only - the field still collects the selected value, never the description - it wraps to the available width, is dropped when the panel is too narrow to show it, and is absent from [headless collection](/headless-collection).
+
+
+
+
+
+```php
+$p->select('fruit', 'Fruit')
+ ->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')
+ ->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')
+ ->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.');
+```
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+Descriptions work the same on [`search`](/fields/search), [`suggest`](/fields/suggest) and [`reorder`](/fields/reorder). Runnable script: [`playground/02-fields-select-descriptions.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select-descriptions.php).
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | -------------------------------------------------------------------- |
+| ↑ / ↓ | Move the highlight (skips headings, separators and disabled options) |
+| Enter | Accept the highlighted option |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Multiple selection
+
+Add `->multiple()` to collect a **`list`** of checked values instead of one. Space toggles the highlighted option, typing narrows the list by substring, → / ← select or deselect all visible, and Enter accepts the checked set.
+
+```php
+$p->select('basket', 'Basket')
+ ->multiple()
+ ->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ])
+ ->default(['apple']); // Values pre-checked when the field opens.
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Selection limits
+
+Bound how many values a multiple field collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the list, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
+
+```php
+$p->select('basket', 'Basket')
+ ->multiple()
+ ->minSelections(2) // Reject fewer than two checked.
+ ->maxSelections(3) // Reject more than three checked.
+ ->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+```
+
+
+
+
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+Runnable script: [`playground/02-fields-select-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-select-multiple-limited.php).
diff --git a/docs/content/fields/suggest.mdx b/docs/content/fields/suggest.mdx
new file mode 100644
index 00000000..e72a9913
--- /dev/null
+++ b/docs/content/fields/suggest.mdx
@@ -0,0 +1,132 @@
+---
+title: Suggest
+description: 'Free-text input with autocomplete over a fixed set of suggestions.'
+keywords: ['suggest', 'autocomplete', 'ghost text', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Suggest
+
+
+
+
+
+Free text with autocomplete over a fixed candidate set. As you type, candidates are fuzzy-matched and ranked by relevance. It's an **open set** - it collects a **`string`** that doesn't have to be one of the candidates.
+
+```php
+$p->suggest('fruit', 'Fruit')
+ ->options([
+ 'Apple' => 'Apple',
+ 'Apricot' => 'Apricot',
+ 'Banana' => 'Banana',
+ 'Cherry' => 'Cherry',
+ 'Mango' => 'Mango',
+ ])
+ ->default('Apple') // Initial text.
+ ->pageSize(8) // Suggestions visible before the list pages.
+ ->ghost(); // Preview the leading match inline as you type.
+```
+
+Runnable script: [`playground/02-fields-suggest.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-suggest.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------ | -------------------------------------------------------------------- | -------- | ------------ |
+| `options()` | The candidate set to autocomplete against; only the values are used. | No | None |
+| `default()` | Initial text. | No | `''` (empty) |
+| `pageSize()` | Suggestions shown before the list pages around the cursor. | No | `10` |
+| `ghost()` | Preview the leading prefix match as inline ghost-text. | No | `false` |
+
+Because the set is open, Enter accepts the highlighted suggestion, or your typed text as-is when none is highlighted. A [description line](/fields/select#option-descriptions) can accompany the highlighted suggestion, keyed by value with `->option(..., description: ...)`.
+
+## Keyboard
+
+| Key | Action |
+| ----------------------------- | ------------------------------------------------------------ |
+| printable keys | Type to filter the candidates |
+| ↑ / ↓ | Highlight a suggestion |
+| Backspace | Delete the character before the caret |
+| Tab / → | Accept the ghost-text preview, when `ghost()` is on |
+| Enter | Accept the highlighted suggestion, or the typed text if none |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Ghost text
+
+With `->ghost()`, the highest-ranked candidate your input is a prefix of is previewed dimmed after the caret, and Tab or → accepts it. The completion becomes the new query rather than a selection, so the ranked list stays open and narrows around it.
+
+It complements the list rather than replacing it, and it steps aside where it would mislead: the preview is suppressed once you arrow into the list (the highlighted suggestion is the value then, not your typed text), while a [query source](#suggestions-from-a-query) is still resolving (those candidates answer the previous query), and it only ever completes a _prefix_ - a fuzzy hit like `ga` → `Green apple` has no inline suffix to draw. Like the [Text](/fields/text) field's ghost text, it is suppressed when color is off.
+
+A completion and a [`placeholder()`](/field-behaviour#guidance-texts) share that dimmed slot and never contend for it: a completion needs a typed query, a placeholder needs an empty one.
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Option descriptions
+
+The highlighted suggestion's [description](/fields/select#option-descriptions), in every display mode:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+## Suggestions from a query
+
+The suggestions can come from the query itself rather than a fixed list, for a catalog too large to hold - see [options from a query](/progress#options-from-a-query):
+
+```php
+$p->suggest('extra', 'Add another')->optionsFrom(fn(string $query): array => $pantry->search($query))->minQuery(2);
+```
diff --git a/docs/content/fields/table.mdx b/docs/content/fields/table.mdx
new file mode 100644
index 00000000..8b7baac7
--- /dev/null
+++ b/docs/content/fields/table.mdx
@@ -0,0 +1,67 @@
+---
+title: Table
+description: 'A presentational, aligned and bordered grid a note renders beneath its title and body to show tabular context.'
+keywords: ['table', 'grid', 'tabular', 'note', 'presentational', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Table
+
+
+
+
+
+A table is presentational context, not a field of its own: a [note](/fields/note) renders one with `->table(headers, rows)`, drawing an aligned, bordered grid beneath its title and body. It honors the active theme - the border style, color and Unicode switches - and its cells take the same `{{field}}` templating the note's title and body do, so the grid can reflect earlier answers. Like every note it collects **nothing**: the cursor skips it and it is absent from headless collection.
+
+```php
+$p->note('stock', 'Basket contents')
+ ->description('Everything picked so far:')
+ ->table(['Fruit', 'Color', 'In stock'], [
+ ['Apple', 'Red', '12'],
+ ['Pear', 'Green', '5'],
+ ['Plum', 'Purple', '120'],
+ ]);
+```
+
+Runnable script: [`playground/02-fields-table.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-table.php).
+
+## Arguments
+
+| Argument | Effect |
+| --------- | --------------------------------------------------------------------------------------------------- |
+| `headers` | The header cells. An empty list (`[]`) draws the grid with no header row. |
+| `rows` | The body rows, each a list of cells. A short row pads with empty cells; a long one widens the grid. |
+
+Each column sizes to its widest cell, and the whole grid is capped at the frame width - an over-wide table shrinks its widest columns and truncates the clipped cells with an ellipsis so its borders always stay whole. Cells are coerced to strings, so numbers and booleans need no pre-formatting, and any line breaks in a cell fold to a space so it stays a single row.
+
+## Keyboard
+
+A table is non-interactive: it renders inside a note the selection cursor skips over, so it has no keys of its own.
+
+## Headless behavior
+
+A table is presentational - it carries no value. The note that holds it is absent from headless collection, from the answers payload, and from the machine-readable schemas (`schema()` and `agentHelp()`), so an agent is never asked to provide one.
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/widgets/template.mdx b/docs/content/fields/template.mdx
similarity index 81%
rename from docs/content/widgets/template.mdx
rename to docs/content/fields/template.mdx
index 24ced0a0..8ad1565d 100644
--- a/docs/content/widgets/template.mdx
+++ b/docs/content/fields/template.mdx
@@ -1,7 +1,7 @@
---
title: Template
description: 'Fill the named slots of a fixed shape, tabbing between them, and collect the assembled string plus its parts.'
-keywords: ['template', 'slot', 'pattern', 'structured', 'widget']
+keywords: ['template', 'slot', 'pattern', 'structured', 'field']
---
import ThemedImage from '@theme/ThemedImage';
@@ -10,7 +10,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# Template
-
+
A fixed shape with named slots to fill in. The shape's fixed text renders as context and only the slots are typed into, so a value that has to be formatted a particular way does not put that burden on the reader. It collects a **`string`** - the assembled shape - and its parts are available alongside it.
@@ -28,7 +28,7 @@ $p->template('crate', 'Crate label')
->slot('grade', 'Grade', fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c');
```
-Runnable script: [`playground/02-widgets-template.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-template.php).
+Runnable script: [`playground/02-fields-template.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-template.php).
## Options
@@ -95,12 +95,12 @@ In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
Unicode
-
-
+
+
ASCII
-
-
+
+
diff --git a/docs/content/fields/text.mdx b/docs/content/fields/text.mdx
new file mode 100644
index 00000000..85042d02
--- /dev/null
+++ b/docs/content/fields/text.mdx
@@ -0,0 +1,76 @@
+---
+title: Text
+description: 'Single-line text input with a movable caret and optional ghost-text autocomplete.'
+keywords: ['text', 'input', 'string', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Text
+
+
+
+
+
+Single-line text input with a movable caret. It collects a **`string`**.
+
+```php
+$p->text('item', 'Item')
+ ->default('Pear'); // Initial value.
+
+// Inline ghost-text autocomplete over a static candidate list:
+$p->text('item', 'Item')
+ ->complete(['Pear', 'Peach', 'Plum']);
+
+// The candidates can be computed from the answers collected so far
+// (guard the lookup - a field may be unanswered when this runs):
+$p->text('variety', 'Variety')
+ ->complete(fn(array $answers): array => [($answers['fruit'] ?? 'Apple') . ' - Gala']);
+```
+
+Runnable script: [`playground/02-fields-text.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-text.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------ | ---------------------------------------------------------------------------------------- | -------- | ------------ |
+| `default()` | Initial value. | No | `''` (empty) |
+| `complete()` | Ghost-text completion source: a `list`, or a `fn(array $answers): list`. | No | None |
+
+As you type, the first candidate that starts with your input (case-insensitively) appears dimmed after the caret; accept it with Tab or → at the end of the line. Ghost text keeps your eye on the input line rather than a dropdown, and it's suppressed when color is off. The [Suggest](/fields/suggest) field can [show the same preview](/fields/suggest#ghost-text) above its ranked list.
+
+A completion and a [`placeholder()`](/field-behaviour#guidance-texts) share that dimmed slot and never contend for it: a completion needs a typed prefix, a placeholder needs an empty input.
+
+## Keyboard
+
+| Key | Action |
+| ------------------------------------------- | ------------------------------------- |
+| printable keys | Insert at the caret |
+| ← / → | Move the caret |
+| Backspace | Delete the character before the caret |
+| Tab / → (at line end) | Accept the ghost-text suggestion |
+| Enter | Accept |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/textarea.mdx b/docs/content/fields/textarea.mdx
new file mode 100644
index 00000000..56c86a32
--- /dev/null
+++ b/docs/content/fields/textarea.mdx
@@ -0,0 +1,68 @@
+---
+title: Textarea
+description: 'Multi-line text input with an optional external-editor handoff; collects a string that may contain newlines.'
+keywords: ['textarea', 'multi-line', 'external editor', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Textarea
+
+
+
+
+
+Multi-line text input. It collects a **`string`** that may contain newlines.
+
+```php
+$p->textarea('notes', 'Tasting notes')
+ ->default("Crisp and sweet\nHint of citrus") // Initial value (newlines allowed).
+ ->externalEditor(); // Allow a handoff to $EDITOR / $VISUAL.
+```
+
+Runnable script: [`playground/02-fields-textarea.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-textarea.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ------------------ | ------------------------------------------------------ | -------- | ------------ |
+| `default()` | Initial value; may contain newlines. | No | `''` (empty) |
+| `externalEditor()` | Allow a handoff to the reader's `$EDITOR` / `$VISUAL`. | No | Off |
+
+With `externalEditor()` on, Ctrl-E suspends the TUI, opens the editor seeded with the current value, and captures the saved text on return: saving commits it, and an aborted edit (a non-zero editor exit) keeps the inline value. With no editor available, the option is silently ignored and the field stays a plain inline textarea.
+
+## Keyboard
+
+| Key | Action |
+| --------------------------- | --------------------------------------------------------------- |
+| printable keys | Insert at the caret |
+| Enter | Insert a newline |
+| Tab | Accept (note: Enter adds a line, it does not accept) |
+| ↑ / ↓ | Move between lines, keeping the column |
+| ← / → | Move the caret |
+| Backspace | Delete the character before the caret |
+| Ctrl-E | Open the external editor (when enabled) |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/fields/toggle.mdx b/docs/content/fields/toggle.mdx
new file mode 100644
index 00000000..77ac126c
--- /dev/null
+++ b/docs/content/fields/toggle.mdx
@@ -0,0 +1,65 @@
+---
+title: Toggle
+description: 'An inline switch cycling between labeled values; always in one of its states, so it always returns a value.'
+keywords: ['toggle', 'switch', 'inline', 'field']
+---
+
+import ThemedImage from '@theme/ThemedImage';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+# Toggle
+
+
+
+
+
+An inline switch that cycles between a fixed set of labeled values. It collects the **selected option value** (a `string`). It's always in one of its states, so it always returns a value.
+
+```php
+$p->toggle('ripeness', 'Ripeness')
+ ->options([
+ 'ripe' => 'Ripe', // value => label
+ 'unripe' => 'Unripe',
+ ])
+ ->default('ripe'); // Which value starts selected (defaults to the first).
+```
+
+Runnable script: [`playground/02-fields-toggle.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-toggle.php).
+
+## Options
+
+| Name | Description | Required | Default |
+| ----------- | -------------------------------------------------------- | -------- | ------------ |
+| `options()` | The values to switch between, as a `value => label` map. | Yes | - |
+| `default()` | Which value starts selected. | No | First option |
+
+## Keyboard
+
+| Key | Action |
+| ---------------------------------------------------------------------------- | -------------------------------------------------- |
+| ← / → / Space / ↑ / ↓ | Cycle to the adjacent value |
+| a letter | Jump to the first value whose label starts with it |
+| Enter | Accept the current value |
+| Esc | Cancel |
+
+## Display modes
+
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
diff --git a/docs/content/headless-collection.mdx b/docs/content/headless-collection.mdx
index c648d086..0ccb8811 100644
--- a/docs/content/headless-collection.mdx
+++ b/docs/content/headless-collection.mdx
@@ -13,6 +13,8 @@ $answers = $tui->collect('{"name":"Weekly Box"}');
$answers = $tui->run($prompts, '1.0.0'); // Headless when prompts are supplied; otherwise the TUI on a TTY.
```
+A value the form refuses ends the whole call with a `DrevOps\Tui\CollectException` naming the field and the reason. There is nobody to retype it and no row to say so on, so the collection fails rather than handing back answers one of which was never accepted.
+
## Naming the variables
Environment overrides are named `` - the uppercased field id under a prefix. `->envPrefix('MYAPP_')` declares that namespace on the form, a `new Tui($form, env_prefix: 'MYAPP_')` constructor argument overrides it, and if you set neither, the prefix is `TUI_`:
@@ -29,7 +31,7 @@ $panel->text('crate_size', 'Crate size')
->envAliases(['LEGACY_CRATE_SIZE']);
```
-The name `->env()` declares is absolute: the form's prefix is not applied to it, which is what lets it reproduce a variable published elsewhere character for character. It replaces `` rather than adding to it, so once a field names itself the mechanical name is no longer read - list it in `->envAliases()` if you want to keep honouring it.
+The name `->env()` declares is absolute: the form's prefix is not applied to it, which is what lets it reproduce a variable published elsewhere character for character. It replaces `` rather than adding to it, so once a field names itself the mechanical name is no longer read - list it in `->envAliases()` if you want to keep honoring it.
Aliases are absolute for the same reason, and they are consulted in the order you declare them, after the canonical name. So with the field above, `ORCHARD_CRATE` wins wherever it is set, `LEGACY_CRATE_SIZE` answers only when it is not, and a deployment can move from the old name to the new one without a cut-over that breaks the scripts already in use.
diff --git a/docs/content/index.mdx b/docs/content/index.mdx
index a2627785..b9a58437 100644
--- a/docs/content/index.mdx
+++ b/docs/content/index.mdx
@@ -20,9 +20,9 @@ The engine doesn't know (or care) what application it serves. It stays generic:
## Features
-- 🧭 [**Full-screen TUI**](/panels) - a scrollable, keyboard-driven form: fields group into sections that drill in to any depth (or open as modal dialogs), with a contextual key-hint footer and a ? help overlay
+- 🧭 [**Full-screen TUI**](/panels) - a scrollable, keyboard-driven form: fields group into panels you go into to any depth (or that open as modal dialogs), with a contextual key-hint footer and ? opening a field's own long-form help
- ⚡ [**Inline editing**](/panels#inline-editing) - a field's editor opens in place on the panel row; opt a field out to full-screen with `->standalone()`
-- 🧩 [**Widgets**](/widgets) - `calendar`, `confirm`, `filepicker`, `number`, `password`, `pause`, `rating`, `reorder`, `search`, `select`, `suggest`, `template`, `text`, `textarea`, `toggle`
+- 🧩 [**Fields**](/fields) - `calendar`, `confirm`, `filepicker`, `number`, `password`, `pause`, `rating`, `reorder`, `search`, `select`, `suggest`, `template`, `text`, `textarea`, `toggle`
- 🏗️ [**Builder-driven**](/configuration) - the form is declared in PHP with a fluent builder; the common cases need no code
- 🎛️ [**Interactive or unattended**](/headless-collection) - answer the form by keyboard, or supply the answers up front as a JSON payload and environment variables so it runs without prompting
- 🤖 [**AI agents**](/ai-agents) - the form describes itself: `agentHelp()` returns a JSON Schema of the answers and `schema()` the full question metadata - fold them into your tool's help and an agent can answer the form unattended
@@ -32,14 +32,14 @@ The engine doesn't know (or care) what application it serves. It stays generic:
- ⚙️ [**Declared behavior**](/field-behaviour) - required fields, validation, transforms and dynamic defaults as closures on the field; per-field handler classes remain as a fallback
- 📦 [**Self-describing answers**](/headless-collection#self-describing-answers) - each answer carries a snapshot of its question and its provenance; summaries need no form config
- 🎨 [**Themes**](/themes) - the whole visual representation (colors, glyphs, layout) is a theme class; six ship built-in, each serving dark and light
-- ⌨️ [**Key bindings**](/key-bindings) - remap navigation, edit, accept and cancel keys per widget type; ships a vim-style preset, and a bad binding fails loudly at build time
+- ⌨️ [**Key bindings**](/key-bindings) - remap navigation, edit, accept and cancel keys per field type; ships a vim-style preset, and a bad binding fails loudly at build time
- ✨ [**Unicode and ASCII**](/display-modes) - glyphs follow the terminal locale and color honors `NO_COLOR`; both can be forced on the `Tui` facade
- 🧪 [**Test harness**](/testing) - drive the real TUI from scripted keystrokes and assert on the answers and rendered output, no TTY needed
- 🌍 [**Translations**](/translations) - present chrome and questions in another language through a consumer catalog, falling back to English
## Quick start
-Declare a form with the fluent `Form` builder - a panel of fields, each one a [widget](/widgets) - then hand it to the `Tui` facade, the one class that wires up the engine, resolver, schema tools and TUI for you:
+Declare a form with the fluent `Form` builder - a panel of fields, each one a [field](/fields) - then hand it to the `Tui` facade, the one class that wires up collection, the input resolver, the schema tools and the interactive screen for you:
```php
use DrevOps\Tui\Builder\Form;
@@ -88,6 +88,6 @@ Run it on a terminal and the panel opens on the form's fields, ready to fill:
-`run()` picks the mode for you - the interactive panel TUI on a terminal, headless otherwise. The facade also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want finer control - the internals via `form()`, `engine()` and `registry()`.
+`run()` picks the mode for you - the interactive panel TUI on a terminal, headless otherwise. The facade also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want finer control - `root()` for the declared [block tree](/specification) and `registry()` for the handler registry.
This is the runnable [`playground/01-quickstart.php`](https://github.com/drevops/tui/blob/main/playground/01-quickstart.php) example, so you don't have to type it out. See [Installation](/installation) to get set up, and the [playground](/playground) for more complete examples.
diff --git a/docs/content/installation.mdx b/docs/content/installation.mdx
index efe7cf79..da0693b3 100644
--- a/docs/content/installation.mdx
+++ b/docs/content/installation.mdx
@@ -19,7 +19,7 @@ The package is a library you consume programmatically - it has no CLI entry poin
## Usage
-Declare a form with the fluent `Form` builder, then hand it to the `Tui` facade - one class that wires up the engine, resolver, schema tools and TUI so you don't have to:
+Declare a form with the fluent `Form` builder, then hand it to the `Tui` facade - one class that wires up collection, the input resolver, the schema tools and the interactive screen so you don't have to:
```php
use DrevOps\Tui\Builder\Form;
@@ -62,7 +62,7 @@ echo $tui->collect('{"name":"Weekly Box"}')->toJson(); // headless: JSON + envir
$answers = $tui->interact(); // interactive panel TUI
```
-`run()` picks the mode for you: on a terminal it drives the interactive panel TUI, and anywhere else - or whenever prompts are supplied - it collects headlessly. The facade also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want finer control - the internals via `form()`, `engine()` and `registry()`.
+`run()` picks the mode for you: on a terminal it drives the interactive panel TUI, and anywhere else - or whenever prompts are supplied - it collects headlessly. The facade also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want finer control - `root()` for the declared [block tree](/specification) and `registry()` for the handler registry.
## Aborting with Ctrl-C or Cancel
@@ -84,9 +84,11 @@ echo $answers->toSummary();
Catch `CancelException` first when an explicit cancel should react differently from a Ctrl-C. Only the interactive session raises these: headless collection - `collect()`, or `run()` when prompts are supplied or the input is piped - never interacts, so it never aborts this way.
+Headless collection has its own ending. A value the form refuses raises `DrevOps\Tui\CollectException` naming the field and the reason: with no screen there is nobody to retype it, so the whole collection fails rather than handing back answers one of which was never accepted. It sits in the same namespace as the other two, so one import covers every way a collection ends short.
+
## Next steps
-- [Widgets](/widgets) - the field types and their options.
+- [Fields](/fields) - the field types and their options.
- [Configuration](/configuration) - form structure, derived values and conditional fields.
- [Headless collection](/headless-collection) - driving the form from JSON and environment variables.
- [Playground](/playground) - complete, runnable examples.
diff --git a/docs/content/key-bindings.mdx b/docs/content/key-bindings.mdx
index eb8506a6..ef53a96e 100644
--- a/docs/content/key-bindings.mdx
+++ b/docs/content/key-bindings.mdx
@@ -1,12 +1,12 @@
---
title: Key bindings
-description: 'Remap navigation, edit, accept and cancel keys per widget type; a default and a vim preset ship, and conflicting bindings fail at setup.'
+description: 'Remap navigation, edit, accept and cancel keys per field type; a default and a vim preset ship, and conflicting bindings fail at setup.'
keywords: ['key bindings', 'keyboard', 'vim', 'shortcuts', 'remap']
---
# Key bindings
-Navigation, edit, accept and cancel keys are all configurable. A widget never asks for a fixed key - it asks for a semantic **action** (`MoveUp`, `Accept`, `Toggle` - the full set is listed below), and a **key map** binds each action to one or more keys. Key bindings describe the terminal, not the questionnaire, so you set them on the `Tui` facade with `->keys(...)`, mirroring `->theme(...)`:
+Navigation, edit, accept and cancel keys are all configurable. A field never asks for a fixed key - it asks for a semantic **action** (`MoveUp`, `Accept`, `Toggle` - the full set is listed below), and a **key map** binds each action to one or more keys. Key bindings describe the terminal, not the questionnaire, so you set them on the `Tui` facade with `->keys(...)`, mirroring `->theme(...)`:
```php
$tui = (new Tui($form))->keys('vim'); // built-in vim navigation (h/j/k/l)
@@ -16,39 +16,39 @@ Two presets ship: `default` (every binding it declares is listed below) and `vim
## Actions
-The actions are the fixed set of intents the widgets understand - the bindings behind them are configurable, the intents are not. Every `Action` case:
-
-| Action | Meaning |
-| ------------------------------------------------ | -------------------------------------------------------------- |
-| `MoveUp` / `MoveDown` / `MoveLeft` / `MoveRight` | Move the cursor or caret |
-| `Accept` | Commit the editor's value |
-| `Cancel` | Close the editor without committing |
-| `Activate` | Open the focused field or panel (panel browser) |
-| `Back` | Go up one panel level |
-| `Quit` | Leave the TUI |
-| `ScrollUp` / `ScrollDown` | Scroll the panel without moving the cursor |
-| `Help` | Open the help overlay |
-| `DeleteBack` | Delete the character before the caret |
-| `InsertSpace` | Type a space |
-| `NewLine` | Insert a newline (textarea) |
-| `ExternalEdit` | Hand off to the external editor (textarea) |
-| `Complete` | Accept the ghost-text completion (text, suggest) |
-| `Increment` / `Decrement` | Step a bounded number |
-| `Toggle` | Check an option, or flip a two-state switch |
-| `SelectAll` / `SelectNone` | Check or clear every visible option |
-| `Grab` | Pick up or drop the held item (reorder) |
-| `Yes` / `No` | Set a confirm directly |
-| `Reveal` | Cycle the password display, or show hidden file-picker entries |
+The actions are the fixed set of intents the fields understand - the bindings behind them are configurable, the intents are not. Every `Action` case:
+
+| Action | Meaning |
+| ------------------------------------------------ | -------------------------------------------------------------------- |
+| `MoveUp` / `MoveDown` / `MoveLeft` / `MoveRight` | Move the cursor or caret |
+| `Accept` | Commit the editor's value |
+| `Cancel` | Close the editor without committing |
+| `Activate` | Open the focused field, go into the focused panel, or press a button |
+| `Back` | Come back out of the panel you are in |
+| `Quit` | Close the dialog that is open, else end the session |
+| `ScrollUp` / `ScrollDown` | Scroll a panel without moving the cursor |
+| `Help` | Show the focused field's help on a page of its own |
+| `DeleteBack` | Delete the character before the caret |
+| `InsertSpace` | Type a space |
+| `NewLine` | Insert a newline (textarea) |
+| `ExternalEdit` | Hand off to the external editor (textarea) |
+| `Complete` | Accept the ghost-text completion (text, suggest) |
+| `Increment` / `Decrement` | Step a bounded number |
+| `Toggle` | Check an option, or flip a two-state switch |
+| `SelectAll` / `SelectNone` | Check or clear every visible option |
+| `Grab` | Pick up or drop the held item (reorder) |
+| `Yes` / `No` | Set a confirm directly |
+| `Reveal` | Cycle the password display, or show hidden file-picker entries |
The named keys a binding can use - every `KeyName` case: `Up`, `Down`, `Left`, `Right`, `Enter`, `Escape`, `Interrupt` (Ctrl-C), `Space`, `Backspace`, `Delete`, `Tab`, `Home`, `End`, `PageUp`, `PageDown`, `MouseWheelUp`, `MouseWheelDown`. Anything printable is bound as the character itself (`'q'`, `'?'`).
## The default bindings
-What `default` actually declares, scope by scope. Every widget inherits the base layer and overrides only what differs:
+What `default` actually declares, scope by scope. Every field inherits the base layer and overrides only what differs:
| Scope | Keys | Action |
| ------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------ |
-| base (every widget) | ↑ / ↓ / ← / → | `MoveUp` / `MoveDown` / `MoveLeft` / `MoveRight` |
+| base (every field) | ↑ / ↓ / ← / → | `MoveUp` / `MoveDown` / `MoveLeft` / `MoveRight` |
| base | Enter | `Accept` |
| base | Esc | `Cancel` |
| base | Backspace | `DeleteBack` |
@@ -58,6 +58,7 @@ What `default` actually declares, scope by scope. Every widget inherits the base
| navigation | q | `Quit` |
| navigation | ? | `Help` |
| navigation | mouse wheel | `ScrollUp` / `ScrollDown` |
+| every field that takes no typed text | ? | `Help` |
| number | ↑ / ↓ | `Increment` / `Decrement` |
| rating | → / ↑ and ← / ↓ | `Increment` and `Decrement` |
| text, suggest | Tab | `Complete` |
@@ -77,11 +78,15 @@ What `default` actually declares, scope by scope. Every widget inherits the base
`vim` inherits all of it and adds letters where typing can't swallow them: the panel browser and the single-choice select gain k/j for up/down, and the calendar gains k/j/h/l for week and day movement - each alongside the arrow keys, never replacing them.
-Not everything on the keyboard routes through the map: a few widget keys are fixed. The calendar's month and edge jumps (PageUp/PageDown, Home/End) have no action behind them and always keep their keys.
+The last row is why a field's own help stays reachable while it is open. Where the field takes typed characters the key is one of them, so the scope is skipped rather than fought over - which is also what the validation below would insist on.
-## Per-widget-type overrides
+Not everything on the keyboard routes through the map: a few field keys are fixed. The calendar's month and edge jumps (PageUp/PageDown, Home/End) have no action behind them and always keep their keys. Ctrl-C is fixed the other way round - it is answered above the routing, so it aborts from anywhere, including from inside an open field.
-Bindings are layered by **scope**: a base layer shared by every widget, a navigation layer for the panel browser, and one layer per widget type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). To retune individual bindings, pass overrides on top of a preset - each names a scope, an action and its keys. Later bindings win, so re-declaring a scope-and-action pair replaces the preset's keys for it:
+`ScrollUp` and `ScrollDown` are declared by the preset but nothing acts on them yet: a panel longer than the frame scrolls to keep the focused row in view, so the wheel changes nothing. The bindings are listed because they are what the map holds, not because the wheel does something.
+
+## Per-field-type overrides
+
+Bindings are layered by **scope**: a base layer shared by every field, a navigation layer for the panel browser, and one layer per field type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). To retune individual bindings, pass overrides on top of a preset - each names a scope, an action and its keys. Later bindings win, so re-declaring a scope-and-action pair replaces the preset's keys for it:
```php
use DrevOps\Tui\Model\FieldType;
@@ -98,7 +103,7 @@ $tui = (new Tui($form))->keys('default', [
]);
```
-A binding's keys take three forms: a `KeyName` case for a named key, a single-character string for a printable one, or a `Key` for anything else - `Key::char("\x05")` is how the default preset binds Ctrl-E. A scope takes three forms too: `Scope::base()` (shared by every widget), `Scope::navigation()` (the panel browser), and `Scope::field(FieldType::X)` - with `Scope::field(FieldType::X, multiple: TRUE)` targeting the multiple-collecting variant of a choice or file-picker widget, which carries its own binding set (that's where Space-to-toggle lives).
+A binding's keys take three forms: a `KeyName` case for a named key, a single-character string for a printable one, or a `Key` for anything else - `Key::char("\x05")` is how the default preset binds Ctrl-E. A scope takes three forms too: `Scope::base()` (shared by every field), `Scope::navigation()` (the panel browser), and `Scope::field(FieldType::X)` - with `Scope::field(FieldType::X, multiple: TRUE)` targeting the multiple-collecting variant of a choice or file-picker field, which carries its own binding set (that's where Space-to-toggle lives).
The panel and editor hints are drawn from the live bindings, so they always reflect the active keys - remap quit to x and the footer says so.
@@ -109,7 +114,7 @@ A preset is a class listing its bindings. Subclass `DefaultKeyMap` to ship your
Bindings are validated the moment they're set, so a bad key map is caught at configuration time, not mid-session:
- a key bound to two different actions in the same scope is a conflict;
-- a printable character bound in the base scope, or in a scope whose widget consumes typed input (text, template, number, rating, password, textarea, search, suggest, toggle, file picker, and the multiple select), would be un-typeable and is rejected - control characters like Ctrl-E are exempt, since they're command keys, never typed content;
+- a printable character bound in the base scope, or in a scope whose field consumes typed input (text, template, number, rating, password, textarea, search, suggest, toggle, file picker, and the multiple select), would be un-typeable and is rejected - control characters like Ctrl-E are exempt, since they're command keys, never typed content;
- an unknown preset name, or a character binding that is not exactly one character, is rejected.
See [`playground/10-key-bindings-*`](https://github.com/drevops/tui/tree/main/playground) for the default map, the vim preset and a custom override side by side.
diff --git a/docs/content/layouts.mdx b/docs/content/layouts.mdx
new file mode 100644
index 00000000..cf5c3438
--- /dev/null
+++ b/docs/content/layouts.mdx
@@ -0,0 +1,197 @@
+---
+title: Layouts
+description: 'Arrange a screen or a panel with named regions: shipped layouts, fixed and flexible sizing, per-region scrolling and flow, and layouts of your own.'
+keywords: ['layout', 'region', 'columns', 'scrolling', 'arrangement', 'screen']
+---
+
+# Layouts
+
+A form never has to name a layout. Declare a panel of fields and you get the default arrangement: a trail across the top, the fields in the middle, the key hints along the bottom. A **layout** is what you reach for when that is not the shape you want - two columns instead of one, a taller header, a sidebar that stays put while its neighbor scrolls.
+
+A layout is an **arrangement and nothing else**. It names its regions, says how big each is and which of them scroll, and stops there. It never mentions a breadcrumb, a panel or a field - a layout carrying content opinions is a layout exactly one form can use. That is what makes it reusable: the same layout can arrange a whole screen and a single panel, and neither knows about the other.
+
+A **region** is one named slot inside it. Blocks go in by name, so nothing depends on the order anything was declared in.
+
+```
+Screen the frame
+└─ Layout names the regions, sizes them, says which scroll
+ └─ Region holds blocks, flows them, scrolls them
+ └─ Block a field, a panel, a note, the key hints
+```
+
+The [specification](/specification#layout) explains why the split falls exactly there. This page is how to use it.
+
+## The layouts that ship
+
+Three, picked by name:
+
+| Name | Axis | Regions | For |
+| ------------ | ------- | ----------------------------------------------------------- | ------------------------------------------- |
+| `default` | rows | `header` (fixed 1), `content` (scrolls), `footer` (fixed 1) | the screen, unless you say otherwise |
+| `panel` | rows | `content` (scrolls) | a panel, unless it says otherwise |
+| `two-column` | columns | `left`, `right` | anything that wants its blocks side by side |
+
+Two axes and one degenerate case cover it, because the second dimension comes from nesting rather than from a grid: a panel is a block that holds a layout, so any arrangement is rows of columns of rows, as deep as it needs to be.
+
+## Picking one
+
+Two places take a layout, and they are separate choices.
+
+**The screen** takes one on the facade, beside the theme and the key bindings - it describes the terminal rather than the questionnaire:
+
+```php
+$tui = (new Tui($form))->layout('two-column');
+```
+
+**A panel** takes one in its own declaration, before anything is placed in it:
+
+```php
+$form = Form::create('Market stall')
+ ->panel('order', 'Order', function (PanelBuilder $p): void {
+ $p->layout('two-column');
+
+ // A block says which region it belongs to; the ones after it keep that
+ // region until another is named.
+ $p->in('left');
+ $p->text('item', 'Item')->default('Pear');
+ $p->number('crates', 'Crates')->default(6)->min(1)->max(99);
+
+ $p->in('right');
+ $p->confirm('gift', 'Gift wrap?')->default(FALSE);
+ });
+```
+
+`->layout()` comes first because every block after it has to know which regions it may go in. Declaring it after placing blocks throws, naming the panel, rather than quietly dropping the rows that had nowhere to go. So does an unknown region name in `->in()`, and an unknown layout name in either `->layout()`.
+
+Both calls resolve through the same registry, which is the whole of what _reuse_ buys: one layout, named once, arranging a screen in one form and a panel in another.
+
+:::note
+
+`PanelBuilder::layout()` also takes **numbers**, and that is a different feature: `->layout(1, 2)` arranges the panel's _sub-panels_ as a [grid of side-by-side windows](/panels#panel-layouts). A name arranges the panel's own blocks; numbers deal its sub-panels into visual rows. Mixing the two in one call throws, because a panel is arranged one way or the other.
+
+:::
+
+## Sizing a region
+
+A region takes its share of the axis one of two ways, and both axes work the same:
+
+```php
+$this->region('header')->fixed(1); // exactly one row (or column)
+$this->region('content')->flex(1); // a share of whatever is left
+```
+
+**`fixed` is cells**, and rows are why it exists. A header is one line whatever the terminal height, and no proportion can say that: 4% of a 24-row terminal is one row, of a 50-row terminal is two. Columns rarely need it; rows almost always do at their edges.
+
+**`flex` is a share of the remainder.** Shares don't sum to anything in particular, so `30, 40, 30` and `3, 4, 3` mean the same thing. Declaring neither is `flex(1)`.
+
+The two mix without negotiating. Fixed regions come off the top first, what remains is divided by the flex values, and any cell left over by the rounding goes to the last region taking a share - so the sizes always add up to what was available. A region never sees that arithmetic; it is told a number and gets on with it.
+
+A terminal too small even for the fixed regions is a real state rather than an error: they are trimmed in declaration order, so the sizes still add up and the frame stays whole.
+
+## Scrolling
+
+Scrolling is declared **per region**, not per layout:
+
+```php
+$this->region('produce')->flex(3)->scrolls();
+$this->region('delivery')->flex(2);
+```
+
+That is what lets a long produce list outrun its column while its neighbor stays pinned, and what lets two rows scroll independently of each other. A region that was not declared to scroll clips what outruns it instead.
+
+The region does the scrolling too. Its layout hands it one number - the size it was given - and everything after that is the region's own: how tall its blocks add up to, where the viewport sits, whether an overflow marker is due, and how the cursor moves it. No sibling is involved, which is why sizing belongs to the layout and scrolling belongs to the region.
+
+## Flow: two blocks on one line
+
+Within a region, blocks run **down** it by default. Tell it otherwise and they run **across**:
+
+```php
+$this->region('header')->fixed(1)->flow(Axis::Columns);
+```
+
+```
+flow: Axis::Rows flow: Axis::Columns
+(the default)
+
+╭──────────────────╮ ╭──────────────────╮
+│ Breadcrumb │ │ Breadcrumb Clock │
+│ Markup │ ╰──────────────────╯
+╰──────────────────╯
+```
+
+This is what saves you from nesting a layout every time two things belong side by side. A trail and a standing note in one header is a flow, not a second arrangement.
+
+Flow is what a layout and a region share; sizing is what only a layout does. Both run their contents in one direction, but only a layout apportions space between them. A region's blocks take their natural size, in the order they were added.
+
+| You need | Use |
+| --------------------------------- | ---------------------------------------- |
+| Two blocks side by side | a flow |
+| Areas you can address by name | a layout |
+| Areas at declared sizes or shares | a layout |
+| Areas that scroll independently | a layout |
+| Somewhere you can navigate into | a [panel](/panels), which nests a layout |
+
+[`playground/20-layouts-region-flow.php`](https://github.com/drevops/tui/blob/main/playground/20-layouts-region-flow.php) draws the same header three ways - down a one-row header where the note is clipped, across it where both fit, and down a two-row header where they stack.
+
+## Writing one
+
+Every layout is a class extending `AbstractLayout`, shipped ones included. `AbstractLayout` carries every line of the sizing arithmetic, so a subclass declares an axis and its regions and inherits the rest:
+
+```php
+use DrevOps\Tui\Screen\Axis;
+use DrevOps\Tui\Screen\Layout\AbstractLayout;
+
+final class StallLayout extends AbstractLayout {
+
+ public function __construct() {
+ parent::__construct(Axis::Columns);
+
+ // A share is of whatever is left over, so 3 and 2 mean the produce column
+ // takes half again what the delivery column does, at every width.
+ $this->region('produce')->flex(3)->scrolls();
+ $this->region('delivery')->flex(2);
+ }
+
+}
+```
+
+Two axes exist - `Axis::Rows` and `Axis::Columns` - and that is the whole set. Declaring the same region name twice throws, naming the layout. So does arranging a panel with a layout that declares no region at all, since there would be nowhere for a block to go.
+
+Overriding the arithmetic is the other reason to subclass - a layout that packs its regions to fit, or gives the focused one extra room - and `arrange(int $available): array` is the one method that does it.
+
+## Registering one
+
+Three ways reach a layout, and they are the same three a [theme](/themes) offers:
+
+```php
+use DrevOps\Tui\Screen\Layout\LayoutManager;
+
+LayoutManager::create('two-column'); // shipped
+LayoutManager::register('stall', StallLayout::class);
+LayoutManager::create('stall'); // registered
+LayoutManager::create(StallLayout::class); // the class, unregistered
+```
+
+Registered or not, the name goes wherever a shipped one does - `->layout('stall')` on the facade for the screen, `$p->layout('stall')` for a panel. Naming the class directly needs no registration at all; registering buys a short, stable alias.
+
+Each call builds its own instance, because two forms picking the same layout must not share its regions. Registration checks the class up front - it has to implement `LayoutInterface` and be instantiable - so an abstract class or a typo is refused where it is written rather than at the first frame.
+
+Both routes are in [`playground/20-layouts-custom.php`](https://github.com/drevops/tui/blob/main/playground/20-layouts-custom.php), which registers one layout for a panel and another for the screen.
+
+## Where the standard furniture goes
+
+A layout names no block, so something else has to decide that a breadcrumb belongs at the top. That is the facade's job, and it places three pieces by region name:
+
+| Region | Gets |
+| --------- | ------------------------------------------------ |
+| `header` | the breadcrumb - the trail of panels you entered |
+| `content` | the panel, and the buttons that end the form |
+| `footer` | the legend - the keys that apply right now |
+
+Furniture goes only where the named layout keeps a place for it. A layout with no `header` shows no trail rather than being refused, and one with no `content` puts the panel in whichever region it declared first - which is why `two-column` works as a screen layout even though it has neither name. The trail and the keys keep tracking the session either way; they are just never drawn.
+
+So a layout meant to replace `default` keeps those three names. A layout meant to arrange a panel's own blocks can name its regions anything, because nothing is placed in it but what the form places itself.
+
+## What a layout does not touch
+
+Arranging exists only to draw, so [headless collection](/headless-collection) ignores every word of it. The same form collects the same answers under any layout, or none - which is also why `->layout()` sits on the facade beside the theme rather than in the form declaration.
diff --git a/docs/content/markdown.mdx b/docs/content/markdown.mdx
index ffe3ec78..1fb2d4a5 100644
--- a/docs/content/markdown.mdx
+++ b/docs/content/markdown.mdx
@@ -12,7 +12,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
Label, description and note text is more than a flat string: it can carry a clickable link, and - when you opt in - a small, safe subset of markdown. Both adapt to the terminal, degrading to clean plain text where a terminal (or your [color switch](/display-modes)) cannot show the real thing, so the same form reads well everywhere.
-
+
## Links
@@ -30,7 +30,7 @@ The summary resolves links too: `$answers->toSummary()` follows the terminal's c
## Markdown
-Turn on `->markdown()` and field descriptions and note bodies render a small subset of markdown, each construct mapped to one of the theme's [style atoms](/themes):
+Turn on `->markdown()` and field descriptions and note bodies render a small subset of markdown, each construct mapped to one of the theme's [markup elements](/fields/anatomy#a-passage-of-text):
```php
$answers = (new Tui($form))->markdown()->run();
@@ -53,7 +53,7 @@ $p->number('quantity', 'Quantity')->min(1)->max(99)
->description('Baskets hold up to **99**; order more in a *second* basket.');
```
-The subset is deliberately small and inline - no headings, tables or nested blocks - so a description stays a description. Because each construct maps to a theme atom, a [custom theme](/themes) restyles bold, emphasis, code, links and bullets by overriding `strong()`, `emphasis()`, `code()`, `link()` and `bullet()`.
+The subset is deliberately small and inline - no headings, tables or nested blocks - so a description stays a description. Because each construct is its own element, a [custom theme](/themes) restyles bold, emphasis, code, links and bullets by overriding `markupStrong()`, `markupEmphasis()`, `markupCode()`, `markupLink()` and `markupBullet()` - and restyles them everywhere a passage is drawn rather than in the one place that happened to compose it.
Markdown honors both the color and the Unicode switches. With color off the markers drop and the text renders plain (bold `**ripe**` becomes `ripe`, a link becomes `text (url)`); with ASCII glyphs the bullet falls back from `•` to `-`. Headless collection never renders descriptions or notes at all, so it is unaffected. Links stay recognized whether or not markdown is on - `->markdown()` only adds the rest of the subset.
@@ -67,13 +67,13 @@ The same markdown note in all four [display modes](/display-modes) - the color-o
Unicode
-
-
+
+
ASCII
-
-
+
+
diff --git a/docs/content/output.mdx b/docs/content/output.mdx
index 97b8184d..e89150bb 100644
--- a/docs/content/output.mdx
+++ b/docs/content/output.mdx
@@ -1,6 +1,6 @@
---
title: Output
-description: 'Boxes, status lines and definition lists you can draw outside a form run - theme-drawn, and dropping their colour when piped.'
+description: 'Boxes, status lines and definition lists you can draw outside a form run - theme-drawn, and dropping their color when piped.'
keywords: ['output', 'box', 'status', 'definition list', 'primitive', 'chrome']
---
@@ -45,7 +45,7 @@ Long lines wrap inside the border rather than being clipped by it, so you can ha
Runnable in [`playground/18-output-box.php`](https://github.com/drevops/tui/blob/main/playground/18-output-box.php).
-In all four [display modes](/display-modes) - Unicode or ASCII, colour on or off:
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
@@ -67,7 +67,7 @@ In all four [display modes](/display-modes) - Unicode or ASCII, colour on or off
## Table
-`table()` lays headers and rows into a bordered grid, sizing each column to its widest cell and capping the whole thing at the terminal. It is the same renderer a [note field's grid](/widgets/table) uses, so a standalone table matches the ones inside the panel.
+`table()` lays headers and rows into a bordered grid, sizing each column to its widest cell and capping the whole thing at the terminal. It is the same renderer a [note field's grid](/fields/table) uses, so a standalone table matches the ones inside the panel.
@@ -105,7 +105,7 @@ Runnable in [`playground/18-output-table.php`](https://github.com/drevops/tui/bl
## Card
-`card()` is the full form of `box()`: a title, a body and a grid, boxed together when the three belong to one another. It is the same renderer behind a [note field's card](/widgets/note), so a standalone card and a note card are the same object drawn twice.
+`card()` is the full form of `box()`: a title, a body and a grid, boxed together when the three belong to one another. It is the same renderer behind a [note field's card](/fields/note), so a standalone card and a note card are the same object drawn twice.
@@ -122,7 +122,7 @@ The grid is sized to fit inside the card's own border, so the two frames never c
## Status lines
-Five kinds, each with its own glyph and its own colour: `note()`, `info()`, `success()`, `warning()` and `error()`.
+Five kinds, each with its own glyph and its own color: `note()`, `info()`, `success()`, `warning()` and `error()`.
@@ -136,7 +136,7 @@ $out->info('Checking the morning harvest')
->note('Anything short is refunded, never substituted');
```
-The glyph carries the meaning on its own, so the five stay distinguishable with colour off and in ASCII alike. Every glyph is one column wide in any terminal, so a run of status lines always aligns.
+The glyph carries the meaning on its own, so the five stay distinguishable with color off and in ASCII alike. Every glyph is one column wide in any terminal, so a run of status lines always aligns.
To choose the kind at runtime, pass a `Status` case to `status()`:
@@ -230,7 +230,7 @@ Runnable in [`playground/18-output-text.php`](https://github.com/drevops/tui/blo
## Theme-drawn
-The colours and glyphs come from the active [theme](/themes), the same way every widget does, and the pieces reuse the atoms the panel already styles: a box takes the frame's border and heading, a success line the value colour, an info line the theme's accent. So `->theme('ember')` prints info lines in ember's orange and `->theme('frost')` in frost's blue, with no extra configuration and nothing a custom theme has to override to inherit its own palette.
+The colors and glyphs come from the active [theme](/themes), the same way every field does, and the pieces reuse the voices the panel already speaks in: a box takes the frame's border and heading, a success line the value color, an info line the theme's accent. So `->theme('ember')` prints info lines in ember's orange and `->theme('frost')` in frost's blue, with no extra configuration and nothing a custom theme has to override to inherit its own palette.
To go further and restyle one piece outright, override its `render*()` method on your theme - `renderCard()`, `renderTable()`, `renderStatus()`, `renderDefinitions()`, `renderText()` or `renderBanner()`.
@@ -238,7 +238,7 @@ To go further and restyle one piece outright, override its `render*()` method on
## Degrading off a TTY
-Output is chrome, not data, so it is written to standard error and leaves standard output for your program's own results. Piped, redirected or captured, the escape codes would land in the text rather than on a terminal, so the colour is dropped and the plain lines remain:
+Output is chrome, not data, so it is written to standard error and leaves standard output for your program's own results. Piped, redirected or captured, the escape codes would land in the text rather than on a terminal, so the color is dropped and the plain lines remain:
```bash
php playground/18-output-status.php 2>&1 | cat
@@ -247,7 +247,7 @@ php playground/18-output-status.php 2>&1 | cat
# ! Only two crates of pears left
```
-The frames, glyphs and alignment survive, because they are text. Forcing wins over the detection either way, and both switches are set on the facade before you reach for `output()`: `$tui->color(true)->output()` keeps the colour in a captured log, and `$tui->unicode(false)->output()` draws the ASCII glyphs on a capable terminal. See [display modes](/display-modes) for the full set of switches.
+The frames, glyphs and alignment survive, because they are text. Forcing wins over the detection either way, and both switches are set on the facade before you reach for `output()`: `$tui->color(true)->output()` keeps the color in a captured log, and `$tui->unicode(false)->output()` draws the ASCII glyphs on a capable terminal. See [display modes](/display-modes) for the full set of switches.
To write somewhere other than standard error - standard output, or a stream you control - pass your own terminal:
diff --git a/docs/content/panels.mdx b/docs/content/panels.mdx
index 9c24821e..a5962f21 100644
--- a/docs/content/panels.mdx
+++ b/docs/content/panels.mdx
@@ -9,17 +9,19 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
# Panels and navigation
-The interactive TUI is a full-screen panel browser. The root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field in place (or drills into a sub-panel), Esc goes back, q quits, and the mouse wheel scrolls long panels without moving the cursor. All of these keys are configurable (see [Key bindings](/key-bindings)). **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them.
+The interactive TUI is a full-screen panel browser. The root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Left/Right move across a [grid of sub-panels](#panel-layouts) and along the buttons, Enter edits a field in place (or goes into a sub-panel), Esc comes back out, and q leaves. All of these keys are configurable (see [Key bindings](/key-bindings)). A panel longer than the frame scrolls to keep the focused row in view. **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them.
-A **contextual help footer** runs along the bottom of every screen, listing exactly the keys that work right now and updating as focus moves between the hub and each editor. The hub shows move, select, back, quit and ?. Each widget adds its own bindings on top of accept/cancel: a select adds move, a multiple select adds toggle and select-all/none, a bounded number adds the step keys, a textarea adds newline and the external-editor handoff, a revealable password adds the reveal toggle. Press ? at the hub and a fuller overlay lists the hub keys and every widget type the form uses - so the form teaches its own less-common widgets. The footer follows the active theme and key map (it degrades to ASCII glyphs and always reflects remapped keys), and the facade's `->footer(FALSE)` turns it off.
+A **contextual help footer** runs along the bottom of every screen, listing exactly the keys that work right now and updating as focus moves between the hub and each open field. That is not a list somebody wrote out: it is read back off the bindings that actually apply, so a remapped key changes the line that advertises it. The hub shows move, select, back and quit. Each field adds its own bindings on top of accept/cancel: a select adds move, a multiple select adds toggle and select-all/none, a bounded number adds the step keys, a textarea adds newline and the external-editor handoff, a revealable password adds the reveal toggle. A field carrying [help](/field-behaviour#guidance-texts) adds ?, which opens that text on a page of its own; any key dismisses it. The footer follows the active theme and key map (it degrades to ASCII glyphs and always reflects remapped keys), and the facade's `->footer(FALSE)` turns it off.
A form-level `->banner()` shows a start screen (with an optional version) before the panels, and the facade's `->clearOnExit(FALSE)` keeps the final frame on screen after the TUI exits.
+Where the panels, the trail and the key hints actually sit is a [layout](/layouts) - the default arrangement is a pinned header, a scrolling middle and a pinned footer, and the facade's `->layout()` swaps it for another.
+
## Inline editing
-Editing happens **in place**. Press Enter on a field and its editor opens right where the value sits - the widget's own view, driven by its own keys - while the rest of the panel stays around it. The widget's accept key commits and collapses the row back to its summary; Esc cancels. A confirm shows its `● Yes ○ No` in the row, a select drops its option list under the label, a text field becomes a caret input. Each is the same editor the widget always uses, just drawn in the panel instead of on its own screen - so changing a value costs a single Enter and no context switch.
+Editing happens **in place**. Press Enter on a field and its editor opens right where the value sits - the field's own view, driven by its own keys - while the rest of the panel stays around it. The field's accept key commits and collapses the row back to its summary; Esc cancels. A confirm shows its `● Yes ○ No` in the row, a select drops its option list under the label, a text field becomes a caret input. Each is the same editor the field always uses, just drawn in the panel instead of on its own screen - so changing a value costs a single Enter and no context switch.
-Inline is the default for every field. A field opts out with `->standalone()`, which opens that same editor full-screen instead - the better fit for a widget that wants the whole viewport, like a month calendar, a long option list or a multi-line textarea:
+Inline is the default for every field. A field opts out with `->standalone()`, which opens that same editor full-screen instead - the better fit for a field that wants the whole viewport, like a month calendar, a long option list or a multi-line textarea:
```php
$form = Form::create('Order')
@@ -35,7 +37,7 @@ $form = Form::create('Order')
## Nested panels
-Panels nest to any depth: a sub-panel renders as a drillable row with a one-line summary of its values, and the breadcrumb header keeps track of where you are. A `->fixup()` rule reconciles dependent answers on every settle pass - here, organic sourcing is forced off outside the premium grade, whatever was answered:
+Panels nest to any depth: a sub-panel renders as a row you select to enter, carrying a one-line summary of its values, and the breadcrumb header keeps track of where you are. Going in **replaces** the screen with the sub-panel's contents and grows the trail; coming back out restores both, along with the row you were on. A `->fixup()` rule reconciles dependent answers on every settle pass - here, organic sourcing is forced off outside the premium grade, whatever was answered:
```php
$form = Form::create('Basket settings')
@@ -120,7 +122,9 @@ $bare = (new Tui($form))->theme('default', ['border' => 'none', 'spacing' => 'no
## Panel layouts
-A panel's sub-panels list vertically by default; `->layout()` arranges them as a grid of side-by-side columns instead. Each argument declares one visual row and says how many panels sit beside each other in it, filled in declaration order: `layout(2)` puts two panels side by side, `layout(2, 2)` makes four windows, `layout(1, 2)` one full-width panel above two columns - and `layout(2, 1)` the other way around. Every level of the panel tree declares its own layout, so a drilled-in panel arranges its children independently: `Form::layout()` arranges the top-level panels, `PanelBuilder::layout()` a panel's children:
+A panel's sub-panels list vertically by default; `->layout()` given **numbers** arranges them as a grid of side-by-side windows instead. Each argument declares one visual row and says how many panels sit beside each other in it, filled in declaration order: `layout(2)` puts two panels side by side, `layout(2, 2)` makes four windows, `layout(1, 2)` one full-width panel above two columns - and `layout(2, 1)` the other way around. Every level of the panel tree declares its own, so a panel you have gone into arranges its children independently: `Form::layout()` arranges the top-level panels, `PanelBuilder::layout()` a panel's children.
+
+Given a **name** instead, the same `PanelBuilder::layout()` arranges the panel's own blocks into named regions - `$p->layout('two-column')`, then `$p->in('left')`. That is a different feature and a separate page: see [Layouts](/layouts). Numbers deal sub-panels into a grid; a name says where every block goes. Mixing the two in one call throws.
```php
$form = Form::create('Market stall')
@@ -170,7 +174,7 @@ $tui = (new Tui($form))
Four sizing options bound the stretch, each a non-negative integer:
- `max_width` / `max_height` (default `0`, uncapped) stop the frame short of a very wide or tall terminal; the capped frame then floats at the `halign` / `valign` anchor, like a dialog.
-- `min_width` / `min_height` guard against a terminal too small for the layout: below either, the TUI shows a centered resize notice (only quit works there) until the terminal grows. `min_height` defaults to `10`. `min_width` defaults to `0`, which measures the form's own content - the widest label, value, badge and description row across every panel - so the guard adapts to your questionnaire without any configuration.
+- `min_width` / `min_height` guard against a terminal too small for the frame: below either, the TUI shows a centered resize notice (only the key that leaves works there) until the terminal grows. `min_height` defaults to `10`. `min_width` defaults to `0`, which measures the form's own content instead - the widest row any panel draws, plus the border - so the guard adapts to your questionnaire without any configuration. It is measured once, from the rows the form opens on: a minimum that followed the answers would trip and clear again as they grew, which is a screen nobody can work in rather than a guard.
Fullscreen affects only the interactive TUI; headless collection ignores it. The frame's layout width is resolved once at start-up, while the live terminal size is still sampled every frame for the minimum-size guard and frame positioning - so a window resized mid-session reflows on height but keeps its layout width until the next run.
diff --git a/docs/content/playground.mdx b/docs/content/playground.mdx
index c37faa46..80925962 100644
--- a/docs/content/playground.mdx
+++ b/docs/content/playground.mdx
@@ -8,22 +8,31 @@ keywords: ['playground', 'examples', 'scripts', 'demos', 'php']
Runnable examples live in [`playground/`](https://github.com/drevops/tui/tree/main/playground) - one file per example, grouped by a numbered prefix that follows this documentation's order, from the quick start to the capstone form:
-| Group | Demonstrates |
-| ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
-| `01-quickstart` | The fluent builder and `run()` - the form from [the quick start](/introduction#quick-start) |
-| `02-widgets-*` | One script per [widget](/widgets), plus the whole gallery on one form |
-| `03-panels-*` | [Nested panels](/panels), [modal dialogs](/panels#modal-panels) and the border frame |
-| `04-inline-editing` | [Inline editing](/panels#inline-editing) and `->standalone()` |
-| `05-form-logic-*` | [Derived values](/configuration#derived-values), [conditional fields](/configuration#conditional-fields), fix-ups |
-| `06-field-behaviour-*` | [Declared behavior](/field-behaviour): [required fields](/field-behaviour#required-fields), closures, handler classes |
-| `07-discovery` | [Discovery](/field-behaviour#discovery) against a bundled sample project |
-| `08-headless-*` | [Unattended collection](/headless-collection), the JSON schema, validation, [agent help](/ai-agents) |
-| `09-themes-*` | The [built-in themes](/themes), a custom theme class, theme options, field styles |
-| `10-key-bindings-*` | [Key bindings](/key-bindings): the vim preset and per-binding overrides |
-| `11-display-modes-*` | [Display modes](/display-modes): dark/light, ASCII, no color |
-| `12-translations` | [Translations](/translations) through a consumer catalog |
-| `13-testing` | The [test harness](/testing) driving the TUI from scripted keystrokes |
-| `14-produce-box` | The capstone: panels, widgets, logic and behavior in one real form |
+| Group | Demonstrates |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `01-quickstart` | The fluent builder and `run()` - the form from [the quick start](/introduction#quick-start) |
+| `02-fields-*` | One script per [field](/fields), plus the whole gallery on one form |
+| `03-panels-*` | [Nested panels](/panels), [modal dialogs](/panels#modal-panels), the border frame, sub-panel grids, the fullscreen stretch |
+| `04-inline-editing` | [Inline editing](/panels#inline-editing) and `->standalone()` |
+| `05-form-logic-*` | [Derived values](/configuration#derived-values), [conditional fields](/configuration#conditional-fields), fix-ups |
+| `06-field-behaviour-*` | [Declared behavior](/field-behaviour): [required fields](/field-behaviour#required-fields), closures, handler classes |
+| `07-discovery` | [Discovery](/field-behaviour#discovery) against a bundled sample project |
+| `08-headless-*` | [Unattended collection](/headless-collection), the JSON schema, validation, [agent help](/ai-agents) |
+| `09-themes-*` | The [built-in themes](/themes), a custom theme class, [element overrides](/themes#patching-an-element), theme options, field styles |
+| `10-key-bindings-*` | [Key bindings](/key-bindings): the vim preset and per-binding overrides |
+| `11-display-modes-*` | [Display modes](/display-modes): dark/light, ASCII, no color, [markdown](/markdown) |
+| `12-translations` | [Translations](/translations) through a consumer catalog |
+| `12-specification-screen` | The [specification](/specification) made runnable: the levels, keys traveling inward, the same tree collected headlessly |
+| `13-testing` | The [test harness](/testing) driving the TUI from scripted keystrokes |
+| `14-produce-box` | The capstone: panels, fields, logic and behavior in one real form |
+| `15-progress-*` | The [progress primitive](/progress): a spinner when the length is unknown, a bar when it is |
+| `16-loading-data` | [Loading a panel's data](/progress#inside-the-form) on demand, with a themed indicator while it runs |
+| `17-query-options` | [Options that follow the query](/progress#options-from-a-query), cached and held back by `->minQuery()` |
+| `18-output-*` | The [output primitives](/output): boxes and cards, tables, status lines, definition lists, text, rules, a banner |
+| `19-dynamic-options` | [Options that follow the answers](/field-behaviour#options-from-the-answers), narrowing one field by another |
+| `20-layouts-*` | [Layouts](/layouts): a layout class of your own registered by name, and a region flowing its blocks across |
+
+The reusable helper classes the scripts load sit beside them - `themes/`, `layouts/` and `handlers/` - with the fixtures the examples read from in `sample-project/` and `translations/`.
Every script is self-contained: it requires the Composer autoloader directly and declares its whole form inline, so you can copy any single file out as a starting point. Most take no CLI options - each demonstrates exactly one thing, variants are separate scripts, and unattended runs are driven by piping stdin and setting `TUI_` environment variables. The exception is `03-panels-fullscreen.php`, which takes `--halign`/`--valign` and `--max-width` to pick one alignment from its grid.
@@ -32,4 +41,4 @@ composer install
php playground/01-quickstart.php
```
-The SVG demos throughout this documentation are generated from the playground scripts and forms: `php docs/util/update-assets.php` records the panel walkthroughs through a scripted terminal session (it needs `asciinema`, `expect`, `node` and `npm`), while `php docs/util/render-widget-svgs.php` and `php docs/util/render-theme-svgs.php` render the widget cards and theme previews deterministically through the library's own [test harness](/testing) - no terminal involved.
+The SVG demos throughout this documentation are generated from the playground scripts and forms: `php docs/util/update-assets.php` records the panel walkthroughs through a scripted terminal session (it needs `asciinema`, `expect`, `node` and `npm`), while `php docs/util/render-field-svgs.php`, `php docs/util/render-progress-svgs.php`, `php docs/util/render-output-svgs.php` and `php docs/util/render-theme-svgs.php` render the field cards, the primitives and the theme previews deterministically through the library's own [test harness](/testing) - no terminal involved.
diff --git a/docs/content/progress.mdx b/docs/content/progress.mdx
index 30ee2eae..5b17c726 100644
--- a/docs/content/progress.mdx
+++ b/docs/content/progress.mdx
@@ -11,7 +11,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
Collection sometimes triggers slow work: a discovery scan of a target directory, a computed option set, a value resolved against external state. Left silent, the form looks frozen. The `progress()` primitive wraps that work and shows it running - a spinner when the length is unknown, a determinate bar when it is - and passes the callback's result straight back.
-`progress()` is one of the facade's **primitives**: it collects no answer and never runs inside the interactive panel. It is for slow work that happens _around_ the form - before it opens, after it closes, or wrapping a slow `->default` / `->discover`. The active theme draws it, so it matches the panel's look and honours the colour and Unicode switches. Off a TTY, or in headless collection, it degrades to a single plain caption line with no control sequences. The callback receives the primitive and drives it with `advance()`.
+`progress()` is one of the facade's **primitives**: it collects no answer and never runs inside the interactive panel. It is for slow work that happens _around_ the form - before it opens, after it closes, or wrapping a slow `->default` / `->discover`. The active theme draws it, so it matches the panel's look and honors the color and Unicode switches. Off a TTY, or in headless collection, it degrades to a single plain caption line with no control sequences. The callback receives the primitive and drives it with `advance()`.
```php
use DrevOps\Tui\Primitive\Progress;
@@ -46,7 +46,7 @@ With a `null` total the indicator is an animated spinner: an accent glyph beside
Runnable in [`playground/15-progress-spinner.php`](https://github.com/drevops/tui/blob/main/playground/15-progress-spinner.php).
-In all four [display modes](/display-modes) - Unicode or ASCII, colour on or off:
+In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
@@ -96,11 +96,11 @@ Runnable in [`playground/15-progress-bar.php`](https://github.com/drevops/tui/bl
## Theme-drawn
-The glyphs and the accent come from the active [theme](/themes), the same way every widget does - the spinner glyph and the bar fill carry the theme's accent, and the theme picks Unicode or ASCII. So `->theme('ember')` spins and fills in ember's orange, `->theme('frost')` in frost's blue, with no extra configuration.
+The glyphs and the accent come from the active [theme](/themes), the same way every field does - the spinner glyph and the bar fill carry the theme's accent, and the theme picks Unicode or ASCII. So `->theme('ember')` spins and fills in ember's orange, `->theme('frost')` in frost's blue, with no extra configuration.
## Degrading off a TTY
-Feedback is chrome, not data, so it is drawn on standard error and animates only when standard error is an interactive terminal. Piped, redirected or collected headlessly, `progress()` prints the caption once as a plain line and emits no cursor, colour or carriage-return sequences, so a captured log stays clean:
+Feedback is chrome, not data, so it is drawn on standard error and animates only when standard error is an interactive terminal. Piped, redirected or collected headlessly, `progress()` prints the caption once as a plain line and emits no cursor, color or carriage-return sequences, so a captured log stays clean:
```bash
php playground/15-progress-bar.php 2>&1 | cat
@@ -115,7 +115,7 @@ php playground/15-progress-bar.php 2>&1 | cat
- **Preloading a panel.** `->preload(closure)` on a panel runs once, before the panel's fields first draw - prep the panel needs, fetched on entry rather than up front, so one fetch can feed several fields.
- **[Options that follow the query](#options-from-a-query).** `->optionsFrom()` is called again on every query change rather than once, for candidates that live behind a search API.
- **[Options that follow the answers](/field-behaviour#options-from-the-answers).** The same `->options()` callback, but asking for the run context: it is called again whenever the answers change, so one field's choices narrow by another's answer. It resolves during the form settling rather than on entry, so it shows no indicator - keep it cheap.
-- **The [progress widget](/widgets/progress).** A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself. Unlike `progress()`, it lives among the fields and collects no value.
+- **The [progress field](/fields/progress).** A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself. Unlike `progress()`, it lives among the fields and collects no value.
```php
$form->panel('order', 'New order', function (PanelBuilder $p) use ($pack): void {
@@ -127,11 +127,11 @@ $form->panel('order', 'New order', function (PanelBuilder $p) use ($pack): void
});
```
-Runnable in [`playground/16-loading-data.php`](https://github.com/drevops/tui/blob/main/playground/16-loading-data.php) and [`playground/02-widgets-progress.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-progress.php).
+Runnable in [`playground/16-loading-data.php`](https://github.com/drevops/tui/blob/main/playground/16-loading-data.php) and [`playground/02-fields-progress.php`](https://github.com/drevops/tui/blob/main/playground/02-fields-progress.php).
## Options from a query
-A loader resolves one list, once. When the candidates are too many to hold - a catalog behind a search API, a database lookup, an index - a [search](/widgets/search) or [suggest](/widgets/suggest) field can source them from the query instead, with `->optionsFrom()`. (For a list that follows the _answers_ rather than the query, see [options from the answers](/field-behaviour#options-from-the-answers).)
+A loader resolves one list, once. When the candidates are too many to hold - a catalog behind a search API, a database lookup, an index - a [search](/fields/search) or [suggest](/fields/suggest) field can source them from the query instead, with `->optionsFrom()`. (For a list that follows the _answers_ rather than the query, see [options from the answers](/field-behaviour#options-from-the-answers).)
```php
$form->panel('order', 'New order', function (PanelBuilder $p) use ($pantry): void {
diff --git a/docs/content/specification.mdx b/docs/content/specification.mdx
new file mode 100644
index 00000000..d6062683
--- /dev/null
+++ b/docs/content/specification.mdx
@@ -0,0 +1,1058 @@
+---
+title: Specification
+description: 'What a screen is made of, what each part is allowed to do, how a block reaches the theme to draw itself, and what survives with no screen at all.'
+keywords: ['specification', 'structure', 'screen', 'layout', 'region', 'block', 'field', 'panel', 'capability', 'theme']
+toc_max_heading_level: 4
+---
+
+# Specification
+
+A screen is built from four kinds of thing - a _screen_, its _layout_, that layout's _regions_, and the _blocks_ drawn in them. Each kind owns a fixed set of capabilities, and owns them alone.
+
+That last part is what makes the model useful. When something doesn't obviously fit - a new kind of block, a feature that could live in two places - the question is never "where does this go", it's **which level owns the capability it needs**. The answer follows.
+
+## Structure
+
+What a screen is made of, and what each part of it is allowed to do.
+
+### The hierarchy
+
+Four levels, and nothing else:
+
+```
+Screen the root; occupies the terminal, or fits its contents
+└─ Layout arranges; reusable by name
+ └─ Region holds blocks and flows them; declares whether it scrolls
+ └─ Block drawn in a region
+```
+
+One kind of block - a _panel_ - contains a _layout_, which starts the chain again. That's where depth comes from, rather than from a fifth level:
+
+```
+Screen
+└─ Layout 'default'
+ └─ Region 'content'
+ └─ Block a Panel
+ └─ Layout 'two-column'
+ └─ Region 'left'
+ └─ Block a Field
+```
+
+Nothing holds a _field_ except a _region_. A field is a block, so it's placed exactly as any other block is: a panel doesn't contain fields, it contains a layout whose regions do.
+
+### Levels, kinds and instances
+
+The four levels are what the model is made of. What you actually build with are _kinds_ of each, and what ends up on screen are _instances_:
+
+| Level | Kinds | Instances, on the screen below |
+| ------ | ----------------------------------------------------------- | --------------------------------------------------- |
+| Screen | one | the screen |
+| Layout | `default`, `panel`, `two-column` | the screen's `default` |
+| Region | none - a layout names its own | `header`, `content`, `footer` |
+| Block | Panel, Field, Markup, Breadcrumb, Legend, Actions, Progress | one Breadcrumb, one Panel, three Fields, one Legend |
+
+_Layouts_ are the level with reusable named kinds - that's what _Reuse_ means in the table below. _Regions_ have no kinds at all: a region is a named slot its layout declares, so `header` exists because `default` declares it.
+
+The rows in the next table mix the two: the first three are levels, and the seven after them are kinds of block.
+
+### Capabilities
+
+Seventeen capabilities cover everything on screen, each described as what you can observe rather than how it's built.
+
+| Capability | What's possible |
+| ------------- | ---------------------------------------------------------------------------------------- |
+| **Activate** | Activating it does something, rather than revealing something. |
+| **Arrange** | It decides where the things inside it sit. |
+| **Bind** | It says which keys apply while it's in play. |
+| **Capture** | It opens in place to capture something, then closes again. |
+| **Collect** | It holds a value that ends up in the result. |
+| **Constrain** | It says what it will accept, before you act. |
+| **Depend** | It appears or disappears depending on other answers. |
+| **Descend** | You go into it: the screen becomes its contents, the trail grows, and you can come back. |
+| **Flow** | The things inside it run in one direction - down, or across. |
+| **Focus** | You can move onto it, and your keys then act on it. |
+| **Nest** | Other things appear inside it. |
+| **Occupy** | It expands to the whole terminal, instead of fitting its contents. |
+| **Overlay** | It draws over everything else. |
+| **Reject** | It can reject what you gave it, and say why. |
+| **Reuse** | One definition, used in more than one place. |
+| **Scroll** | Its contents can outrun its space, and you can move through them. |
+| **Show** | It draws something you can read. |
+
+### What claims what
+
+
+
+Nine readings of that table are worth stating outright, because they're the ones a new block will test.
+
+**Only a field collects.** Everything else on screen shows, focuses or activates, and none of it reaches the collected result.
+
+**Focus and Activate come apart.** A _progress_ block takes the cursor and runs work while collecting nothing. _Markup_ does neither. A _field_ focuses and collects but never activates.
+
+**Arrange is Flow plus sizing.** Both a _layout_ and a _region_ run what is inside them in one direction, so both claim _Flow_. Only the layout also apportions space between them, which is _Arrange_, and only the layout claims that. A _panel_ arranges nothing at all - it _nests_ a layout and the layout arranges, which is what lets one layout serve a panel and a screen without either knowing about the other.
+
+**Scroll belongs to the region.** A layout is composed of region instances, and each declares whether it scrolls - so one layout can pin a column and scroll its neighbor, and two rows can scroll independently. The layout is only where the declaration is made, since that is where regions come into being.
+
+**Occupy belongs to the screen.** Whether the frame takes the whole terminal or shrinks to its contents is a property of the root, not of any layout inside it - so a layout behaves the same either way.
+
+**A panel shows and focuses only when nested.** A sub-panel is a row you select to enter. The panel currently filling the screen draws no row of its own and takes no cursor; its blocks do.
+
+**Reject does not imply Collect.** A _field_ claims both: it holds a value and refuses one it will not take. _Actions_ claims Reject alone - submit is withheld while a required field is empty, and it says why - while holding no value of its own. Refusing and holding are separate jobs, and only one of them reaches the result.
+
+**Formatting is not a capability.** _Markup_ renders as plain lines, as a bordered card, or as a table of rows and headers, and none of that changes what it can do. A table is markup laid out as a table, so it needs no row of its own. Capabilities say what a block can do; the theme says what it looks like doing it.
+
+A note on the code below. The sections that follow build each level directly, because that's what each level _is_. Declaring a form rarely needs that: the builder wraps the same objects, and `$p` in these examples is a panel builder handed to you by `Form::panel()`. [Building one](#building-one) shows how little of this a three-field form has to name.
+
+### Screen
+
+The **screen** is the root. It _Nests_ one _layout_, and claims one capability of its own - _Occupy_:
+
+```php
+$screen = (new Screen())->layout($layout);
+```
+
+Without _Occupy_ the frame shrinks to fit its contents. With it, the frame takes the whole terminal however little there is to show:
+
+```php
+$screen->fullscreen();
+```
+
+The capability and the method that turns it on are named separately on purpose. _Occupy_ is what this page calls the behavior; `fullscreen()` is what a consumer types. A capability describes what is possible, so it stays the same however the API spells it.
+
+_Occupy_ sits here rather than on a layout because it's a fact about the terminal, not about any arrangement inside it. A layout behaves identically whether the frame stretches to the terminal or shrinks to its contents, which is what makes a layout portable between the two.
+
+### Layout
+
+A **layout** is composed of _region_ instances and decides where they sit. It claims _Arrange_, _Flow_, _Nest_ and _Reuse_.
+
+Every layout is a class extending `AbstractLayout`, which carries the sizing arithmetic. A subclass declares its **axis** - whether its regions run top to bottom or left to right - and names them:
+
+```php
+final class DefaultLayout extends AbstractLayout {
+
+ public function __construct() {
+ parent::__construct(Axis::Rows);
+
+ $this->region('header')->fixed(1);
+ $this->region('content')->scrolls();
+ $this->region('footer')->fixed(1);
+ }
+
+}
+```
+
+Two axes cover every arrangement, because the second dimension comes from nesting rather than from a grid.
+
+Regions are named, so blocks go in by name and nothing depends on declaration order:
+
+```php
+$layout = new DefaultLayout();
+$layout->in('header')->add(new Breadcrumb());
+```
+
+Sizes and scrolling are stated where the regions are declared, because that's where regions come into being. The _region_ still owns _Scroll_; the layout is only where it's written down.
+
+A layout draws nothing itself - neither it nor a _region_ claims _Show_. And a layout with no regions renders nothing at all, because there is nowhere for a block to go.
+
+The variations below show only the constructor body, since that's the part that differs.
+
+#### The default layout
+
+Three regions stacked, with the middle one scrolling. This is what a form gets without asking:
+
+```php
+parent::__construct(Axis::Rows);
+
+$this->region('header')->fixed(1);
+$this->region('content')->scrolls();
+$this->region('footer')->fixed(1);
+```
+
+```
+╭─────────────────────────╮
+│ header │ pinned
+├─────────────────────────┤
+│ content ▲ │
+│ ▼ │ scrolls
+├─────────────────────────┤
+│ footer │ pinned
+╰─────────────────────────╯
+```
+
+#### One region
+
+This is what a _panel_ takes when it names no layout, and it ships as `panel`. A single-region layout needs no special axis, because it is `Axis::Rows` that happens to declare one:
+
+```php
+parent::__construct(Axis::Rows);
+
+$this->region('content')->scrolls();
+```
+
+```
+╭─────────────────────────╮
+│ content ▲ │
+│ ▼ │ scrolls
+╰─────────────────────────╯
+```
+
+Put a _breadcrumb_ or a _legend_ block in that one region and it renders inline with everything else. There's no rule that says a breadcrumb belongs at the top - only the default layout says so.
+
+#### Two columns
+
+The other axis. Neither region scrolls here, so both are pinned:
+
+```php
+parent::__construct(Axis::Columns);
+
+$this->region('left');
+$this->region('right');
+```
+
+```
+╭────────────┬────────────╮
+│ left │ right │
+│ │ │
+╰────────────┴────────────╯
+```
+
+_Scroll_ is per region, on either axis, and any number of regions can claim it. Two columns can scroll independently of each other:
+
+```php
+parent::__construct(Axis::Columns);
+
+$this->region('left')->scrolls();
+$this->region('right')->scrolls();
+```
+
+Two rows can too, which is the same statement with the axis turned:
+
+```php
+parent::__construct(Axis::Rows);
+
+$this->region('top')->flex(1)->scrolls();
+$this->region('bottom')->flex(1)->scrolls();
+```
+
+#### Sizing a region
+
+A region takes its share of the axis one of two ways, and both axes work the same:
+
+```php
+$this->region('header')->fixed(1); // exactly one row (or column)
+$this->region('content')->flex(1); // a share of whatever is left
+```
+
+**`fixed` is cells**, and rows are why it exists. A header is one line whatever the terminal height, and no proportion can say that - 4% of a 24-row terminal is one row, of a 50-row terminal is two. Columns rarely need it; rows almost always do at their edges.
+
+**`flex` is a share of the remainder.** Shares don't sum to anything in particular, so `30`, `40`, `30` and `3`, `4`, `3` mean the same thing:
+
+```php
+parent::__construct(Axis::Rows);
+
+$this->region('top')->flex(30)->scrolls();
+$this->region('middle')->flex(40)->scrolls();
+$this->region('bottom')->flex(30)->scrolls();
+```
+
+```
+╭─────────────────────────╮
+│ top ▲ │ 30
+│ ▼ │
+├─────────────────────────┤
+│ middle ▲ │ 40
+│ ▼ │
+├─────────────────────────┤
+│ bottom ▲ │ 30
+│ ▼ │
+╰─────────────────────────╯
+```
+
+Declaring neither is `flex(1)`, which is why the default layout can leave its middle region bare:
+
+```php
+parent::__construct(Axis::Rows);
+
+$this->region('header')->fixed(1);
+$this->region('content')->scrolls();
+$this->region('footer')->fixed(1);
+```
+
+The two mix without negotiating: fixed regions are subtracted first, then what remains is divided by the flex values. A header stays one row however tall the terminal, and the flexible regions share the rest between them.
+
+#### Who calculates what
+
+Sizing and scrolling are two calculations, and each can only be done by one level.
+
+**The layout sizes.** "How many rows does `content` get" can't be answered by `content` alone - its fixed siblings have to come off the top first, and the remainder split by flex. Only the thing that sees every region can do that arithmetic, which is why _Arrange_ is the layout's capability.
+
+**The region scrolls.** Given the one number the layout hands it, everything else is its own: how tall its blocks are, where its viewport sits, whether an overflow marker is due, and how the cursor moves it. No sibling is involved, which is why _Scroll_ is the region's.
+
+```
+Layout ──▸ each Region is given a size
+Region ──▸ offset, visible rows, overflow markers
+```
+
+Character cells don't divide evenly, so the layout rounds: it takes the fixed sizes off, divides the remainder by the flex values, and hands any leftover cell to the last flexible region. A region never sees that arithmetic - it's told a number and gets on with it.
+
+#### Both axes at once
+
+A _panel_ is a _block_ that contains a layout, so nesting one inside a region gives you rows and columns together:
+
+```php
+$layout = new DefaultLayout();
+
+// The Panel carries a TwoColumnLayout of its own.
+$layout->in('content')->add($panel);
+```
+
+```
+╭─────────────────────────╮
+│ header │
+├─────────────────────────┤
+│ ╭──────────┬──────────╮ │
+│ │ left │ right │ │ a Panel in 'content',
+│ ╰──────────┴──────────╯ │ laid out in columns
+├─────────────────────────┤
+│ footer │
+╰─────────────────────────╯
+```
+
+This is why the axis needs no third value. Any arrangement is rows of columns of rows, as deep as it needs to be, from two primitives.
+
+#### Shipped layouts
+
+_Reuse_ means a layout is named and reusable, so the same one serves a _screen_ and a _panel_. Three ship, and a form picks one by name rather than describing an arrangement inline:
+
+| Name | Axis | Regions |
+| ------------ | ------- | ----------------------------------------------------------- |
+| `default` | rows | `header` (fixed 1), `content` (scrolls), `footer` (fixed 1) |
+| `panel` | rows | `content` (scrolls) |
+| `two-column` | columns | `left`, `right` |
+
+Two axes and one degenerate case is enough: `default` and `panel` run down, `two-column` runs across, and anything else is those nested, or a layout you write yourself. `panel` is what a panel is arranged by when it names none.
+
+```php
+(new Tui($form))->layout('two-column')->run();
+```
+
+The name is checked where you write it, so a typo throws at declaration rather than mid-session. The names are read from the shipped layout classes rather than listed anywhere, so the list above cannot fall out of step with what actually ships.
+
+#### A layout knows nothing about blocks
+
+A layout class declares arrangement and stops there. It never names a _breadcrumb_, a _panel_ or anything else that might be drawn in it - which is what _Reuse_ actually costs. A layout carrying content opinions is a layout exactly one form can use.
+
+The line is between the class and the instance:
+
+| | Knows about |
+| --------------------- | ---------------------------------------- |
+| the layout **class** | its regions: names, sizes, scrolling |
+| a layout **instance** | the blocks somebody put in those regions |
+
+```php
+$layout = new DefaultLayout(); // arrangement, reusable
+$layout->in('header')->add(new Breadcrumb()); // this instance, this form
+```
+
+It's tempting to let a layout furnish itself, on the grounds that only it knows a `header` exists. That confuses two things. Whatever places a block does need to know the region names, but it doesn't need to _be_ the layout - it only needs to be written against one that has them. Placing the breadcrumb inside `DefaultLayout` would buy one line and cost the class every reuse.
+
+For the same reason there's no separate object that furnishes a layout from outside. It would have to assume a `header` and a `footer`, and `TwoColumnLayout` has `left` and `right`, so it could only ever work with the layout it was written for.
+
+**The standard furniture is assembled by the facade**, which is where the form's other defaults already come from - its theme, its key bindings, its controller. Declare a screen yourself and the facade assembles nothing; declare nothing and it gives you a breadcrumb, the panel, its actions, and a legend.
+
+#### Writing one
+
+Every layout is a class, shipped ones included. `AbstractLayout` carries the sizing arithmetic; a subclass declares an axis and its regions:
+
+```php
+final class SidebarLayout extends AbstractLayout {
+
+ public function __construct() {
+ parent::__construct(Axis::Columns);
+
+ $this->region('sidebar')->fixed(24);
+ $this->region('main')->flex(1)->scrolls();
+ }
+
+}
+```
+
+Register it under a short name and it's available everywhere a shipped one is:
+
+```php
+LayoutManager::register('sidebar', SidebarLayout::class);
+
+(new Tui($form))->layout('sidebar')->run();
+```
+
+Three ways to reach one, which is the same set a [theme](/themes) offers: by shipped name, by registered name, or by passing the class itself.
+
+```php
+LayoutManager::create('two-column'); // shipped
+LayoutManager::create('sidebar'); // registered
+LayoutManager::create(SidebarLayout::class); // the class, unregistered
+```
+
+Most subclasses only declare regions, and inherit every line of the arithmetic. Overriding that arithmetic is the other reason to subclass - a layout that packs regions to fit, or gives the focused one extra room - and it is the same door `AbstractLayout` already holds open.
+
+### Region
+
+A **region** is a named container inside a _layout_. It claims _Flow_, _Nest_ and _Scroll_.
+
+It's a class, and its layout builds one by name and hands it back to be configured. Every capability it claims is a method, so nothing piles up as arguments:
+
+```php
+$this->region('sidebar')
+ ->fixed(24)
+ ->flow(Axis::Rows)
+ ->scrolls();
+```
+
+| Call | Declares |
+| ----------------------- | --------------------------------------------------------- |
+| `->fixed(24)` | 24 cells of the axis, whatever the terminal size |
+| `->flex(2)` | twice the share of the remainder that `flex(1)` gets |
+| `->flow(Axis::Columns)` | its blocks run across it rather than down |
+| `->scrolls()` | its contents may outrun it, and you can move through them |
+| `->add($block)` | a block goes in it |
+
+`fixed()` and `flex()` are the odd pair out: they _declare_ a size but don't compute one, because _Arrange_ is the layout's. The region states what it wants and the layout does the arithmetic.
+
+The name is the whole point of a region: it's how a block says where it goes, so nothing depends on the order things were declared in.
+
+```php
+$layout->in('content')->add($panel);
+$layout->in('footer')->add(new Legend());
+```
+
+_Nest_ is the region taking blocks, and it takes every kind the same way:
+
+```php
+$layout->in('content')
+ ->add(new Markup('intro', 'Pick the produce for this delivery.'))
+ ->add($panel)
+ ->add(new Actions());
+```
+
+_Scroll_ is per region instance rather than per layout, which is why a two-column layout can pin one column while its neighbor scrolls, and why two rows can scroll independently of each other. The region owns it; the layout is only where it's declared, since that's where regions come into being.
+
+The region also _does_ the scrolling. Its layout hands it one number - the size it was given - and everything after that is the region's own work: how tall its blocks add up to, where the viewport sits, whether an overflow marker is due, and how the cursor moves it. No sibling is involved in any of that, which is what makes _Scroll_ the region's capability rather than the layout's.
+
+A region knows only that it holds blocks. It never knows which kinds - which is why the three calls above are indistinguishable to it, and why a _breadcrumb_ can go wherever a _field_ can.
+
+#### How blocks stack
+
+A region **flows** its blocks: down the region by default, or across it if you say so.
+
+```php
+$this->region('header')->flow(Axis::Columns);
+```
+
+```
+flow: Axis::Rows flow: Axis::Columns
+(the default)
+
+╭──────────────────╮ ╭──────────────────╮
+│ Breadcrumb │ │ Breadcrumb Clock │
+│ Markup │ ╰──────────────────╯
+╰──────────────────╯
+```
+
+This is what saves you from nesting a layout every time two things belong side by side. A breadcrumb and a clock in one header is a flow, not a second layout.
+
+**Flow is what a layout and a region share; sizing is what only a layout does.** Both run their contents in one direction, so both claim _Flow_. Only a layout also apportions space between them - naming the areas, sizing them, letting each scroll on its own - and that's _Arrange_. A region's blocks take their natural size and sit in the order they were added.
+
+It's the difference between a grid and the text flow inside one of its cells, and you don't nest a grid to put two words on one line. So nesting a _layout_ is for when you need what a flow can't give:
+
+| You need | Use |
+| --------------------------------- | ------------------------------- |
+| Two blocks side by side | a flow |
+| Areas you can address by name | a layout |
+| Areas at declared sizes or shares | a layout |
+| Areas that scroll independently | a layout |
+| Somewhere you can navigate into | a _panel_, which nests a layout |
+
+Blocks that outrun the region are the region's problem, not the flow's - it _Scrolls_ if it was declared to, and clips if it wasn't.
+
+### Block
+
+A **block** is anything drawn in a _region_. That's the whole definition: it fills the space it's given, and the region knows nothing else about it.
+
+Every block claims _Show_, and only a _panel_ nests anything - and what it nests is a _layout_, never blocks directly. So no block ever contains a field or another block. Seven kinds exist, and each has a section of its own below:
+
+| Block | Beyond _Show_, it claims |
+| ------------------------- | ---------------------------------------------------------------------- |
+| [Panel](#panel) | _Bind_, _Descend_, _Focus_, _Nest_, _Overlay_ |
+| [Field](#field) | _Bind_, _Capture_, _Collect_, _Constrain_, _Depend_, _Focus_, _Reject_ |
+| [Markup](#markup) | _Depend_ |
+| [Breadcrumb](#breadcrumb) | nothing |
+| [Legend](#legend) | nothing |
+| [Actions](#actions) | _Activate_, _Focus_, _Reject_ |
+| [Progress](#progress) | _Activate_, _Depend_, _Focus_ |
+
+Every one of them is constructed and added the same way:
+
+```php
+$region
+ ->add(new Breadcrumb())
+ ->add(new Legend())
+ ->add(new Markup('intro', 'Weighed at the packing bench.'))
+ ->add(new Actions())
+ ->add($panel)
+ ->add($field);
+```
+
+The last four in the list are the ones easily mistaken for chrome. They aren't: a _breadcrumb_ is a block in the header region and a _legend_ is a block in the footer region, placed exactly as a _field_ is placed. Which is why either can be moved, or joined by something else, without a new concept:
+
+```php
+// A breadcrumb at the bottom, and a standing warning at the top.
+$layout = new DefaultLayout();
+
+$layout->in('header')->add(new Markup('preview', 'Read-only preview.'));
+$layout->in('content')->add($panel);
+$layout->in('footer')->add(new Legend())->add(new Breadcrumb());
+```
+
+#### Panel
+
+A **panel** is the busiest _block_:
+
+```
+Panel "Delivery"
+├─ Show as a nested row: its title and a summary of its contents
+├─ Focus as a nested row: the cursor lands on it
+├─ Descend going in replaces the screen and grows the trail; leaving restores both
+├─ Nest it holds a layout, whose regions hold its blocks
+├─ Overlay as a modal, it draws over the dimmed screen behind it
+└─ Bind its keys are the ones that apply while you are in it
+```
+
+_Nest_ is the panel taking a layout, which is what makes it the only block that can hold anything:
+
+```php
+$columns = new TwoColumnLayout();
+$columns->in('left')->add($courier);
+$columns->in('right')->add($weight);
+
+$panel = (new Panel('delivery', 'Delivery'))->layout($columns);
+```
+
+Give it a single-region layout and it reads as an ordinary list of fields, which is what a panel is most of the time:
+
+```php
+$rows = new DefaultLayout();
+$rows->in('content')->add($courier)->add($weight);
+
+(new Panel('delivery', 'Delivery'))->layout($rows);
+```
+
+_Descend_ is a panel added to another panel's region. The nested one draws as a row you select, and selecting it replaces the screen:
+
+```php
+$layout->in('content')
+ ->add($courier)
+ ->add($advanced); // a Panel: a row here, the whole screen once entered
+```
+
+**Descend is the capability nothing else has**, and it's what makes a panel more than a container. A _region_ can hold blocks and a _layout_ can arrange them, but neither is somewhere you go.
+
+One panel fills the screen at a time. A **modal** is the same block that _Overlays_ instead of replacing what's behind it - nothing else about the panel changes:
+
+```php
+(new Panel('confirm', 'Confirm delivery'))->modal();
+```
+
+#### Field
+
+A **field** is the _block_ that collects. It's the only kind that contributes to the collected result, and the only kind that captures.
+
+A field claims eight capabilities, more than any other block. Seven of them show up in one declaration:
+
+```php
+$p->select('basket', 'Basket contents') // Show, Focus
+ ->description('Pick the produce.')
+ ->option('apple', 'Apple') // Capture
+ ->option('carrot', 'Carrot')
+ ->multiple()
+ ->default(['apple']) // Collect
+ ->minSelections(2)->maxSelections(3) // Constrain
+ ->validate($ripeness) // Reject
+ ->when(new Condition('organic', eq: TRUE)); // Depend
+```
+
+The entries are what _edit_ mode opens onto, the default is what reaches the result until you change it, the bounds are stated before you act and the validator explains itself after, and the condition decides whether the field is there at all. The line that declares an entry is `->option()`, and the element that draws it is `fieldEntry()` - the declaration names what you supply, the element names what appears.
+
+The eighth is _Bind_, and the field doesn't declare it - it comes from the field's kind. A `select` binds Space because it offers a list; a `text` field binds no printable key at all, because every one of them is something you're typing.
+
+One field owns both modes. In **view** mode it's one line, drawing every part of that line itself. Open it and it switches to **edit** mode, taking over the region right of its _label_:
+
+```
+view mode ❯ Basket contents ⁱ apple, carrot
+ └─────┬─────┘
+ the settled value
+
+edit mode ❯ Basket contents ● Apple
+ ○ Carrot
+ └───┬───┘
+ the field collecting it
+```
+
+The _label_ and the _selector_ stay put across both. Only the _value_ region changes shape, which is why a field in _edit_ mode is still one row of its _panel_ rather than something new on the screen. [Anatomy](/fields/anatomy) names every piece of both.
+
+_Constrain_ and _Reject_ are two capabilities rather than one because they answer different questions. _Constrain_ states what the field will accept before you act; _Reject_ explains why what you did was refused. They share one line on screen and never appear together - which is why [Anatomy](/fields/anatomy) names that line's two states the **constraint** and the **error**.
+
+Between the field and the theme sit its **capabilities** - the shared behavior a field draws on rather than reimplements. The chain runs one way and never doubles back:
+
+```
+Field ──▸ capabilities ──▸ render() ──▸ theme elements
+```
+
+#### Markup
+
+**Markup** renders formatted content and does nothing else. It takes plain text or the [markdown subset](/markdown), and it claims _Show_ and _Depend_. How it's laid out on the page is a presentation choice rather than a capability:
+
+```php
+// Prose.
+$p->markup('weighing', 'Every crate is weighed at the packing bench.');
+
+// The same block in a bordered card.
+$p->markup('notice', 'Deliveries leave at dawn.')->bordered();
+
+// The same block again, laid out as a table under a title.
+$p->markup('yields', '', 'Yields per crate')
+ ->table(['Produce', 'Crates'], [['Apple', '12'], ['Carrot', '8']]);
+```
+
+Prose, a bordered card and a table are the same block laid out three ways. _Depend_ is what lets a warning appear only when an earlier answer calls for it:
+
+```php
+$p->markup('certified', 'Organic crates need current certification.')
+ ->when(new Condition('organic', eq: TRUE));
+```
+
+#### Breadcrumb
+
+**Breadcrumb** renders the trail of _panels_ you've entered, gaining a segment as you _Descend_ and losing one as you come back. It declares two elements:
+
+```php
+interface BreadcrumbElementsInterface {
+
+ public function breadcrumbLabel(string $text): string;
+
+ public function breadcrumbSeparator(): string;
+
+}
+```
+
+```
+Orchard › Delivery
+───┬─── ┬ ────┬───
+ │ │ └── breadcrumbLabel()
+ │ └───────── breadcrumbSeparator()
+ └─────────────── breadcrumbLabel()
+```
+
+#### Legend
+
+**Legend** renders the keys that apply right now, rewriting itself as focus moves - so an open _field_ lists different keys from the _panel_ around it. It declares three, and composes them per key:
+
+```php
+interface LegendElementsInterface {
+
+ public function legendKey(string $text): string;
+
+ public function legendDescription(string $text): string;
+
+ public function legendSeparator(): string;
+
+}
+```
+
+```
+↑/↓ to move · ↵ to accept
+─┬─ ───┬─── ┬
+ │ │ └── legendSeparator()
+ │ └──────── legendDescription()
+ └────────────── legendKey()
+```
+
+Both follow the same shape, and so does every other block: an element per distinct thing it styles, prefixed with the block's name so a theme can implement every interface on one class without collisions.
+
+#### Actions
+
+**Actions** is the set of buttons that end the form - submit, cancel, and any the form declares. It claims _Activate_ because pressing one does something rather than revealing something, and _Reject_ because it withholds the submit with a message while a required field is empty.
+
+It's the only block other than a _field_ that refuses anything, and unlike a field it holds no value while doing so.
+
+```php
+interface ActionsElementsInterface {
+
+ public function actionButton(string $label): string;
+
+ public function actionSelected(string $label): string;
+
+ public function actionSeparator(): string;
+
+}
+```
+
+```
+ [ Submit ] [ Cancel ]
+ ─────┬──── ┬ ────┬───
+ │ │ └── actionButton()
+ │ └──────── actionSeparator()
+ └─────────────── actionSelected()
+```
+
+The brackets belong to the element, not the block. A theme that framed a button differently changes `actionButton()` alone, and the block goes on knowing only that it has labels and one of them has focus.
+
+#### Progress
+
+**Progress** runs work when activated, drawing an indicator while the work runs. It's the block that separates _Focus_ from _Collect_: the cursor lands on it and activating it does something real, but nothing it does reaches the collected result.
+
+```php
+interface ProgressElementsInterface {
+
+ public function progressCaption(string $text): string;
+
+ public function progressSpinner(int $frame): string;
+
+ public function progressTrack(int $filled, int $width): string;
+
+ public function progressCount(int $current, int $total): string;
+
+}
+```
+
+It draws one of two ways, and which one is a fact about the work rather than a setting. Work that reports a total gets a bar:
+
+```
+Packing crates [██████████░░░░░░] 4/10
+───────┬────── ───────┬─────── ─┬─
+ │ │ └── progressCount()
+ │ └─────────────── progressTrack()
+ └──────────────────────────────── progressCaption()
+```
+
+Work that can't say how long it will take gets a spinner instead, and the caption is the only element the two forms share:
+
+```
+⠙ Fetching the price list
+┬ ───────────┬───────────
+│ └── progressCaption()
+└──────────────── progressSpinner()
+```
+
+`progressSpinner()` takes the frame number rather than a glyph, so the theme owns both the animation's characters and their count - a Unicode theme can spin through ten frames where an ASCII one cycles four.
+
+### On screen
+
+Here it is on a real screen, each level labeled on the row it owns - the _region_, the _block_ in it, then the _panel_'s _fields_ and the _mode_ each is drawing:
+
+```
+ ╭──────────────────────────────────────────────────────╮
+header ▸ Breadcrumb │ Orchard › Delivery │
+ ├──────────────────────────────────────────────────────┤
+content ▸ Panel │ │
+ ▸ Field edit mode │ ❯ Basket ● Apple │
+ │ ○ Carrot │
+ │ Pick the produce. │
+ │ │
+ ▸ Field view mode │ Basket weight 1200 │
+ │ │
+ ▸ Field view mode │ Harvest date 2026-07-15 │
+ │ │
+ │ ▼ │
+ │ │
+ ├──────────────────────────────────────────────────────┤
+footer ▸ Legend │ ↑/↓ to move · ↵ to accept · ESC to cancel │
+ ╰──────────────────────────────────────────────────────╯
+```
+
+The `header` and `footer` _regions_ declare no _Scroll_, so they're pinned; `content` declares it, which is why the mark under `Harvest date` belongs to that region rather than to the frame. The `Basket` _field_ is open, so it's in _edit_ mode: its two _entries_ and its _description_ all belong to that one _field_.
+
+The labels skip a level between `Panel` and `Field`, because the _panel_ has a _layout_ of its own and the fields sit in that layout's single region. It is elided here for the same reason it is invisible on screen: a one-region layout adds a level without adding anything to see.
+
+## Theme
+
+A **block** draws itself - that's _Show_, and it arrives as a `render()` method. What a block never does is choose a color or a glyph. For those it reaches into the theme for **elements**:
+
+```php
+final class Breadcrumb extends AbstractBlock {
+
+ public function render(ThemeInterface $theme): string {
+ // Narrowed to the elements this block declares, so a theme that cannot
+ // draw one says so by name instead of drawing a blank line.
+ $elements = $this->elements($theme, BreadcrumbElementsInterface::class, 'a breadcrumb');
+ $labels = array_map(static fn(string $segment): string => $elements->breadcrumbLabel(Translator::t($segment)), $this->segments);
+
+ return implode(' ' . $elements->breadcrumbSeparator() . ' ', $labels);
+ }
+
+}
+```
+
+`ThemeInterface` itself carries only the two things no block could own - the width they all lay out against, and how the theme writes a key - so the block narrows it to its own elements interface before drawing. That narrowing is the whole reason the core stays thin: a theme grows by implementing more element interfaces, not by growing `ThemeInterface`.
+
+An **element** takes a plain string and returns a styled one. It knows nothing about what surrounds it, which is what lets the same element draw a separator inside a form and inside a standalone line of output.
+
+That's the whole division of labor. **Order, spacing and how many elements there are belong to the block; color and glyph belong to the theme.** A theme can repaint a breadcrumb but can't reorder one, because reordering isn't styling.
+
+### A block declares the elements it needs
+
+A block names its elements in an interface, and a theme implements it - [Breadcrumb](#breadcrumb) and [Legend](#legend) above show both the interface and what each element draws.
+
+Eight ship, one per block plus one for the frame that belongs to no block:
+
+| Interface | Draws |
+| ----------------------------- | -------------------------------------------------------------------- |
+| `ChromeElementsInterface` | the border, and the mark saying a region's contents outran it |
+| `BreadcrumbElementsInterface` | the trail's segments and what stands between them |
+| `LegendElementsInterface` | a key, what it does, and what stands between two entries |
+| `PanelElementsInterface` | a nested panel's row: selector, title, descend mark, summary |
+| `FieldElementsInterface` | both of a field's modes, from its selector to its caret |
+| `MarkupElementsInterface` | a passage, one span at a time: strong, emphasis, code, links, bullet |
+| `ActionsElementsInterface` | the buttons, and the gap between them |
+| `ProgressElementsInterface` | the caption, the spinner frame, the bar's track and its tally |
+
+`ChromeElementsInterface` is the one named for something other than a block, and for a reason worth stating: the frame surrounds every region at once and the overflow mark says a region's contents outran it. Neither is anything a block could ask for, since a block only ever fills the space it is given and never learns where that space ends. Both belong to whatever draws the screen, so they are declared apart from the blocks.
+
+Two things follow from the shape. A theme that doesn't implement the interface can't draw that block, and the failure names the theme and the interface rather than leaving a blank line. And adding a block to the library adds one interface instead of growing a single theme class that already knows about everything.
+
+### A theme declares what it supports
+
+A terminal may have no color, no Unicode, or a background the theme should read. A theme declares which of those it handles, and declaring one is what grants the facility that goes with it:
+
+```php
+final class OrchardTheme extends AbstractTheme implements ColorSchemeCapableInterface, UnicodeCapableInterface {
+
+ use ColorSchemeCapableTrait;
+ use UnicodeCapableTrait;
+
+ public function breadcrumbLabel(string $text): string {
+ // isDark() and paint() exist because the theme declared the scheme.
+ return $this->paint($this->isDark() ? Sgr::of(Sgr::Jade) : Sgr::of(Sgr::Forest), $text);
+ }
+
+ public function breadcrumbSeparator(): string {
+ // glyph() exists because the theme declared Unicode.
+ return $this->glyph('›', '>');
+ }
+
+}
+```
+
+Five capabilities exist, and that is the whole set:
+
+| Declaration | Grants | For |
+| ----------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| `ColorSchemeCapableInterface` | `hasColor()`, `isDark()` | painting at all, and picking a palette for a dark or light terminal |
+| `UnicodeCapableInterface` | `hasUnicode()` | choosing between a glyph and its ASCII stand-in |
+| `DimCapableInterface` | `dim()` | pushing back what a dialog is drawn over |
+| `MarkdownCapableInterface` | `hasMarkdown()` | drawing the markdown subset rather than its markers |
+| `OccupyCapableInterface` | `isFullscreen()`, `halign()`, `valign()`, the min/max sizes, `spacing()`, `background()` | saying how much of the terminal the frame takes, and where it anchors |
+
+Color and the background are one declaration rather than two, because the two questions are never asked apart: a color is chosen against a background, and a color legible on a dark terminal is not legible on a light one.
+
+Two of the five carry a **trait** with the plumbing, so a theme states a flag and inherits the rest. `ColorSchemeCapableTrait` brings `paint()` and `emphasize()`, and `UnicodeCapableTrait` brings `glyph()` - which is why the palette above reads as color choices rather than as escape-sequence handling. The other three are small enough to answer directly.
+
+`AbstractTheme` is the floor. It implements every elements interface and declares no capability at all, so it may not paint and may not reach past ASCII: what is left is the strings it was handed and the stand-ins that read without them. That is why a form renders in a terminal that supports nothing, and why a theme adds what its terminal can do rather than working around what it cannot.
+
+`DefaultTheme` is that floor with all five declared, and it is the class a theme reached **by name** extends - the theme registry accepts a `DefaultTheme` subclass, so `->theme(OrchardTheme::class)` needs one. A theme extending `AbstractTheme` directly still draws a screen; it is handed to a `ScreenController` or a `ScreenTester` rather than named on the facade.
+
+### Overriding an element
+
+Subclassing a theme is the full answer, and overkill when all you want is a different glyph. The facade takes element overrides directly, grouped by the block that declares them:
+
+```php
+$tui->theme(fn(ThemeBuilder $t) => $t
+ ->breadcrumb(fn(BreadcrumbOverrides $b) => $b
+ ->separator('›', '>'))
+ ->legend(fn(LegendOverrides $l) => $l
+ ->separator('·', '|')
+ ->key(Sgr::Bold, Sgr::BrightCyan))
+ ->field(fn(FieldOverrides $f) => $f
+ ->selector('❯', '>')
+ ->helpMarker('ⁱ', '[?]')
+ ->valueSeparator(', ')
+ ->entrySelector('▸', '->')
+ ->entryMarker('◼', '[x]')
+ ->caret('█', '|')));
+```
+
+Inside a group the block's prefix is implied, so `->separator()` under `->breadcrumb()` is `breadcrumbSeparator()`. Three kinds of thing can be restated, and the argument count says which is which. A **glyph** takes the mark and its ASCII stand-in, so a patch can't set one display mode and silently break the other - `->entryMarker('◼', '[x]')` states the mark a picked entry carries and what stands in for it, not two states of the entry. **Text** takes one argument, because a phrase the reader parses is not something a terminal fails to draw. A **color** takes the palette parts in order.
+
+Nine elements can be patched this way, and that is the closed set: the breadcrumb's separator; the legend's key and separator; and the field's selector, help marker, value separator, entry selector, entry marker and caret. Naming anything else is a type error rather than a knob that quietly does nothing.
+
+Anything the override doesn't mention keeps the theme's own answer, which is what makes this a patch rather than a replacement. Reach for a subclass when you're changing a palette; reach for this when you're changing a handful of glyphs.
+
+## Behavior
+
+How a screen moves, and what happens when there is not one.
+
+### Driving a screen
+
+Everything above describes a screen at rest. What moves it is one key at a time, and the two directions are worth seeing together: **keys travel inward, drawing travels outward.**
+
+A key goes to the innermost thing that _Binds_ it, and only outward from there:
+
+```
+key ──▸ the focused block, if it binds that key
+ ──▸ else the panel the block sits in
+ ──▸ else the screen, which claims none of its own
+```
+
+`KeyRouter` is what applies that rule, and it is the whole of what a key does to the screen: move the cursor, open a field, go into a panel, come back out, show a field's help.
+
+That one rule explains a behavior that otherwise looks like a special case. An open _text_ field binds every printable key, because each is something you're typing - so ? reaches it and becomes a character. Close the field and it binds nothing printable, so the same ? travels outward to the panel and opens help. Nobody wrote an exception; the key simply stopped at a different level.
+
+_Focus_ decides which block is innermost. It moves with ↑ and ↓ between the blocks that claim it, skipping the ones that don't - so a _markup_ block sits between two _fields_ without ever being landed on. ← and → move across, which matters where sub-panels are dealt into a grid: what is beside a window is a neighbor rather than the next row, and stepping off the grid lands on the row beside it rather than under whichever window the cursor happened to be on.
+
+Three kinds of key never reach the router, and all three for the same reason - they act on something outside the screen. Pressing a button ends the form or closes the dialog it belongs to; activating work runs it against the terminal a step at a time; and leaving is about the session rather than about anything in it. `ScreenController` holds those, because a panel knows about none of them and a block never learns where it is drawn.
+
+Drawing runs the other way, outward from the root:
+
+```
+Screen ──▸ gives the Layout the terminal, or as much as it needs
+Layout ──▸ works out a size for each Region
+Region ──▸ flows its Blocks, and scrolls them if it has to
+Block ──▸ render()s, reaching the Theme for elements
+Theme ──▸ returns styled strings
+```
+
+Each step hands down exactly one thing and knows nothing of the step after it. A layout hands a region a number; a region hands a block a space; a block hands the theme a string. Nothing reaches back up.
+
+### Collecting headlessly
+
+The same form can collect with no screen at all - from a JSON payload, from environment variables, from an agent. Nothing is drawn, and the capabilities split cleanly in two:
+
+| Capability | Headless | |
+| ----------------------------------------------------------------- | -------- | --------------------------------------------------- |
+| _Collect_ | ✓ | the whole point |
+| _Constrain_ | ✓ | a bound is a fact about the answer, not the display |
+| _Reject_ | ✓ | so is a refusal |
+| _Depend_ | ✓ | a field its condition hides is never asked for |
+| _Activate_, _Bind_, _Capture_, _Descend_, _Focus_, _Show_ | | nothing draws and no key arrives |
+| _Arrange_, _Flow_, _Nest_, _Occupy_, _Overlay_, _Reuse_, _Scroll_ | | there is no screen to arrange |
+
+Four survive, thirteen don't, and the line between them is the useful part: **the four are the form's meaning, and the rest is how it looks.** A _screen_, a _layout_ and a _region_ are never built headlessly, because they exist only to arrange drawing. Neither is a _breadcrumb_, a _legend_ or _markup_ - a block that only _Shows_ has nothing to contribute when nothing is shown.
+
+_Fields_ are built, because they're the blocks that _Collect_. They're built without their modes: no _view_, no _edit_, no _Capture_, since there's no cursor to open anything. What runs is the part that was never about the screen - the value arrives, its bounds are checked, its validator is asked, and its condition decides whether it was asked for at all.
+
+That's why the same declaration serves both. A form doesn't say how to draw itself; it says what it collects, and the drawing is a separate set of capabilities layered on top.
+
+## In practice
+
+Putting the model to work, and settling anything it does not obviously cover.
+
+### Building one
+
+The hierarchy is what's there, not what you have to type. A three-field form names none of it:
+
+```php
+$form = Form::create('Orchard')
+ ->panel('main', 'Delivery', function (PanelBuilder $p): void {
+ $p->text('courier', 'Courier');
+ $p->number('weight', 'Basket weight')->min(200)->max(9000);
+ $p->confirm('organic', 'Organic only?');
+ });
+
+(new Tui($form))->run();
+```
+
+Every level has a default, and this is what those defaults are:
+
+```php
+$layout = new DefaultLayout();
+
+$layout->in('header')->add(new Breadcrumb());
+$layout->in('content')->add($panel)->add(new Actions());
+$layout->in('footer')->add(new Legend());
+
+(new Screen())->layout($layout);
+```
+
+Adding _markup_ between two _fields_ doesn't change the shape of the code, because markup and a number are both blocks in the same region. Only one of them answers:
+
+```php
+->panel('main', 'Delivery', function (PanelBuilder $p): void {
+ $p->text('courier', 'Courier');
+ $p->markup('weighing', 'Every crate is weighed at the packing bench.');
+ $p->number('weight', 'Basket weight')->min(200)->max(9000);
+})
+```
+
+Two columns is the first thing that needs a _layout_, so it's the first thing that names one:
+
+```php
+->panel('main', 'Delivery', function (PanelBuilder $p): void {
+ $p->layout('two-column');
+ $p->in('left')->text('courier', 'Courier');
+ $p->in('right')->number('weight', 'Basket weight');
+})
+```
+
+Named regions mean a block says where it goes, rather than depending on the order it was declared in.
+
+### Resolving a tension
+
+When something doesn't fit, don't argue about where it goes. Name the capability it needs, and whichever level owns that capability is the answer. Five that have already been settled this way:
+
+| Question | Capability | Owned by | Answer |
+| -------------------------------------------------- | ---------- | ------------- | ------------------------------------------------------------------ |
+| Can _markup_ sit in the footer? | _Show_ | every _block_ | Yes. A placement, not a feature. |
+| Should a _progress_ row reach the result? | _Collect_ | _field_ | No. It _Activates_, which is a different thing. |
+| Can a _panel_ scroll one column and pin the other? | _Scroll_ | _region_ | Yes. The left _region_ declares it; no _block_ changes. |
+| Who works out how tall `content` is? | _Arrange_ | _layout_ | The _layout_. Only it sees the fixed siblings that come off first. |
+| Can two _blocks_ sit side by side? | _Flow_ | _region_ | Yes, and without nesting a _layout_. |
+
+The last two turn on the same test the others do: **which level can see what the job needs?** A region can't size itself, because its siblings' fixed cells come off the top before the remainder is divided. A layout can't furnish itself and stay reusable, because it would have to know what a breadcrumb is.
+
+That's the whole point of the split. A _region_ that knew what a breadcrumb was would need to know what every block is; instead a region knows only that it holds blocks, a block knows only how to fill the space it's given, and a theme knows only how to style what it's handed.
+
+It's also what makes an element reusable. Because `breadcrumbSeparator()` receives no field, no panel and no answers, the same element draws the separator inside a form and inside a standalone line of output. An element that reached for form state could only ever be used from inside a form.
+
+## What is built
+
+Every level, every capability and every element on this page is implemented and tested. These are the classes behind them:
+
+| Level | Class |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Screen | `Screen`, and `ScreenRenderer` to draw one |
+| Layout | `LayoutInterface`, `AbstractLayout`, `DefaultLayout`, `PanelLayout`, `TwoColumnLayout`, `LayoutManager` |
+| Region | `Region` |
+| Block | `BlockInterface`, `AbstractBlock`, and `Panel`, `Field`, `Markup`, `Breadcrumb`, `Legend`, `Actions`, `Progress` |
+| Capabilities | one interface per block capability - `ActivateCapableInterface`, `BindCapableInterface`, `CaptureCapableInterface`, `CollectCapableInterface`, `ConstrainCapableInterface`, `DependCapableInterface`, `DescendCapableInterface`, `FocusCapableInterface`, `OverlayCapableInterface`, `RejectCapableInterface` - with `BindCapableTrait`, `DependCapableTrait` and `FocusCapableTrait` carrying the shared behavior |
+| Elements | one `*ElementsInterface` per block, plus `ChromeElementsInterface`, all implemented by `AbstractTheme` |
+| Theme | `ThemeInterface` (two methods), `AbstractTheme` as the floor, `DefaultTheme` above it, the five `*CapableInterface` declarations with `ColorSchemeCapableTrait` and `UnicodeCapableTrait`, and `ThemeManager` to name one |
+| Overriding | `ThemeBuilder`, the `BreadcrumbOverrides` / `LegendOverrides` / `FieldOverrides` groups, and the `ThemeElement` set they write into |
+| Behavior | `KeyRouter` for keys, `ScreenController` for the session, `Collector` for the headless path |
+| Building | `Form`, `PanelBuilder`, `FieldBuilder`, `Assembler` |
+| Testing | `ScreenTester` for a screen, `TuiTester` for a whole form, `FieldRunner` for one field |
+
+Two endings are worth naming beside them, because they are what a caller sees when a collection does not finish: `CollectException` when the answers cannot be taken as they were given, and `CancelException` when the form is abandoned through its cancel button.
+
+[`playground/12-specification-screen.php`](https://github.com/drevops/tui/blob/main/playground/12-specification-screen.php) draws a form from these, opens a field, collects the same panel headlessly, and shows a refused value naming the field and the reason. [`playground/20-layouts-custom.php`](https://github.com/drevops/tui/blob/main/playground/20-layouts-custom.php) registers two layouts of its own and arranges a panel and a screen with them, and [`playground/09-themes-elements.php`](https://github.com/drevops/tui/blob/main/playground/09-themes-elements.php) patches a handful of elements without a theme class.
+
+## Open questions
+
+**Can a whole panel depend on an answer?** _Depend_ is claimed by a _field_, by _markup_ and by a _progress_ row, so three of the seven blocks can come and go with the answers. A _panel_ is not among them, so a whole section cannot appear or disappear on an earlier answer - only the blocks inside it can, one at a time.
+
+**Can a flow align what it holds?** Blocks in a region take their natural size in the order they were added, and there is no way to say a _legend_ sits left while a version string sits right. Every side-by-side arrangement is packed from the start of the axis.
+
+**Can a region be addressed from outside its layout?** A block goes in by name, and the name is the layout's - so whatever places blocks has to be written against a layout that declares them. `default` keeps a `header`, a `content` and a `footer`, and the standard furniture goes wherever those exist and is silently skipped where they don't. That keeps every layout usable, and it means a layout naming its regions something else shows no trail and no key hints until something places them itself.
diff --git a/docs/content/testing.mdx b/docs/content/testing.mdx
index 1b69079f..3c6ae509 100644
--- a/docs/content/testing.mdx
+++ b/docs/content/testing.mdx
@@ -1,15 +1,25 @@
---
title: Testing
-description: 'Drive the real panel TUI from scripted keystrokes with TuiTester and assert on collected answers and rendered output - no terminal needed.'
+description: 'Drive the real TUI from scripted keystrokes with TuiTester or ScreenTester and assert on collected answers and rendered frames - no terminal needed.'
keywords: ['testing', 'test harness', 'scripted keystrokes', 'phpunit', 'assertions']
---
# Testing
-The `TuiTester` harness drives a form's interactive panel TUI from scripted keystrokes - it pushes them onto the terminal's input pipe and runs the real panel loop - so you can assert on the collected answers and on what was rendered, without a terminal anywhere in sight. It's the form-level companion to the widget-level `WidgetRunner`.
+Three harnesses ship, one per scope. All of them push keystrokes onto a scripted terminal's input pipe and run the **real** loop, so you assert on the answers and on what was drawn without a terminal anywhere in sight.
+
+| Harness | Drives | Reach for it when |
+| -------------- | --------------------------- | ------------------------------------------------- |
+| `TuiTester` | a whole form, through `Tui` | you are testing a form the way a consumer runs it |
+| `ScreenTester` | one screen, from a `Panel` | you are testing what was drawn, frame by frame |
+| `FieldRunner` | a single field | you are testing one field's keys in isolation |
Keystrokes are `Key` objects and/or raw byte strings (the bytes a terminal emits for a key press), so an existing keystroke helper drops straight in.
+## A whole form
+
+`TuiTester` wraps the `Tui` facade, so it runs exactly what a consumer's `run()` would.
+
```php
use DrevOps\Tui\Input\Key;
use DrevOps\Tui\Input\KeyName;
@@ -18,7 +28,7 @@ use DrevOps\Tui\Testing\TuiTester;
$tester = new TuiTester($form);
$answers = $tester->run(
- Key::named(KeyName::Enter), // drill into the first panel
+ Key::named(KeyName::Enter), // go into the first panel
Key::named(KeyName::Enter), // open the "name" editor
'Ada', // type a value
Key::named(KeyName::Enter), // accept
@@ -34,8 +44,55 @@ $this->assertStringContainsString('Ada', $tester->display());
$this->assertFalse($tester->isCancelled());
```
-`run()` returns the collected `Answers`. `display()` is the ANSI-stripped output for substring assertions, `output()` the raw frames, and `isCancelled()` reports whether the run ended on the cancel button. `theme()`, `options()`, `rows()`, `cols()`, `version()` and `directory()` tune the run.
-
-For a single widget in isolation, `WidgetRunner::run($widget, ArrayKeyStream::of(...))` stays the lighter tool.
+`run()` returns the collected `Answers`. `display()` is the ANSI-stripped output for substring assertions, `output()` the raw frames, and `isCancelled()` / `isInterrupted()` report how the run ended. `theme()`, `layout()`, `options()`, `rows()`, `cols()`, `version()`, `directory()` and `update()` tune the run before it starts.
The harness outside PHPUnit is shown in [`playground/13-testing.php`](https://github.com/drevops/tui/blob/main/playground/13-testing.php).
+
+## One screen
+
+`ScreenTester` is the screen-native companion. It takes the block tree directly - `$form->root()`, or a `Panel` you built yourself - and drives the same session loop, so it is the tool for asserting on **what was drawn** rather than on what came back.
+
+```php
+use DrevOps\Tui\Input\Key;
+use DrevOps\Tui\Input\KeyName;
+use DrevOps\Tui\Testing\ScreenTester;
+
+$tester = new ScreenTester($form->root());
+
+$answers = $tester->cols(80)->rows(24)->run(
+ Key::named(KeyName::Enter),
+ 'Ada',
+ Key::named(KeyName::Enter),
+);
+
+$this->assertSame('Ada', $answers->value('courier'));
+$this->assertStringContainsString('Ada', $tester->frame());
+```
+
+Its display defaults are **fixed rather than detected** - no color, glyphs on, a dark palette, a terminal of a stated size - so a frame reads the same on every machine that runs the test.
+
+What it adds over `TuiTester` is the frames. `frames()` hands back every frame in the order it was drawn, split on the screen clear that separates one from the next; `frame($index)` returns one of them ANSI-stripped, counting back from the last when the index is negative, so `frame()` is the frame the session ended on. `output()` and `display()` cover the whole stream as before.
+
+Everything a session is built from can be set on it, which is what makes it the harness for a layout, a theme or a block you wrote yourself:
+
+| Call | Sets |
+| ------------------------- | -------------------------------------------------------------------- |
+| `theme(ThemeInterface)` | the theme the blocks draw through - any `ThemeInterface`, not a name |
+| `options(array)` | display options merged over the deterministic defaults |
+| `keys(KeyMap)` | the bindings the screen answers to |
+| `layout(string)` | the [layout](/layouts) the screen is arranged by |
+| `border(Border)` | the frame drawn around every region at once |
+| `collector(Collector)` | what resolves the answers the form opens on |
+| `context(Context)` | the run the session belongs to |
+| `supplied(array)` | values supplied for the fields, keyed by field id |
+| `banner(string, string)` | what is shown before the form, and the version under it |
+| `footer(bool)` | whether the keys that apply right now are advertised |
+| `clearOnExit(bool)` | whether the screen is cleared as the session ends |
+| `externalEditor(...)` | what hands a passage of text to an editor of the reader's own |
+| `rows(int)` / `cols(int)` | the reported terminal size |
+
+`theme()` takes a `ThemeInterface` instance rather than a name, which is the seam for a theme built on `AbstractTheme` - the facade resolves names through the theme registry, and that wants a `DefaultTheme`. See [Themes](/themes#what-a-theme-is-allowed-to-do).
+
+## One field
+
+For a single field in isolation, `FieldRunner::run($field, ArrayKeyStream::of(...))` stays the lighter tool: no screen, no session, just the field and the keys you hand it.
diff --git a/docs/content/themes.mdx b/docs/content/themes.mdx
index a37ac0ca..bc4daf34 100644
--- a/docs/content/themes.mdx
+++ b/docs/content/themes.mdx
@@ -1,12 +1,14 @@
---
title: Themes
-description: 'Six built-in themes selectable by name, dark and light modes detected from the terminal background, and custom themes as small DefaultTheme subclasses.'
-keywords: ['themes', 'dark mode', 'light mode', 'palette', 'ansi']
+description: 'Six built-in themes selectable by name, dark and light modes detected from the terminal background, custom themes as small DefaultTheme subclasses, and per-element patches without a class at all.'
+keywords: ['themes', 'dark mode', 'light mode', 'palette', 'ansi', 'elements']
---
# Themes
-A theme owns the entire visual representation: the palette (per-role ANSI style codes), the glyphs (marker, caret, scroll indicators, separators - each a Unicode/ASCII pair) and how every row is composed. `DefaultTheme` implements all of it with a neutral base; a concrete theme extends it and overrides only what it wants to change. The `ThemeManager` turns a theme name into an instance.
+A theme owns everything about how the form looks: the palette (per-role ANSI style codes) and the glyphs (selectors, markers, carets, separators - each a Unicode/ASCII pair). It never owns what is drawn or in what order - that belongs to the block. A block asks the theme for one **element** at a time, hands it a plain string and gets a styled one back, so a theme can repaint a breadcrumb but can't reorder one.
+
+Three classes carry the arrangement. `AbstractTheme` is the floor: it implements every element and declares no capability, so it hands back the strings it was given. `DefaultTheme` sits on that floor with color, Unicode, a dark/light scheme, markdown, dimming and occupancy all declared - it is the class a custom theme extends. `ThemeManager` turns a theme name into an instance.
## Built-in themes
@@ -28,7 +30,7 @@ $tui = (new Tui(Form::create('My form')))->theme('midnight');
| `mono` | Hue-free - bold weight, gray levels and reverse video, for maximum compatibility. |
| `dos` | Retro MS-DOS - the bright white/cyan/yellow CGA palette in a double-line window, painted on its own blue screen. |
-The colorful themes use 256-color palettes, `mono` the grayscale ramp and `dos` the classic 16-color CGA set. Every one renders across all widgets and degrades to plain text when color is off. An unknown theme name fails loudly, so a typo never silently lands you back on the default.
+The colorful themes use 256-color palettes, `mono` the grayscale ramp and `dos` the classic 16-color CGA set. Every one renders across all fields and degrades to plain text when color is off. An unknown theme name fails loudly, so a typo never silently lands you back on the default.
Each adaptive theme below is shown in four looks: the dark and light palettes, each rendered once inside the default rounded [border](/panels#bordered-panels) and once with the frame explicitly stripped (`['border' => 'none']`). Every adaptive theme has a runnable script in [`playground/09-themes-*`](https://github.com/drevops/tui/tree/main/playground).
@@ -130,37 +132,135 @@ Dark and light aren't separate themes - they're a `mode` display option that eve
(new Tui($form))->theme('frost'); // auto-detect
```
+## Display options
+
+Every theme takes the same options array, validated when the theme is built - an unknown key or a value outside the allowed set throws there, naming what it would accept. These are all of them:
+
+| Option | Values | Does |
+| -------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `mode` | `Mode::Dark`, `Mode::Light` | which palette suits the terminal background; detected when unset |
+| `color` | `TRUE`, `FALSE` | whether anything paints at all |
+| `unicode` | `TRUE`, `FALSE` | whether glyphs may reach past ASCII |
+| `markdown` | `TRUE`, `FALSE` | whether the [markdown subset](/markdown) is drawn rather than its markers |
+| `indent_conditional` | `TRUE`, `FALSE` | whether a [conditional field](/configuration#showing-the-dependency) steps in from the answer that reveals it |
+| `spacing` | `Spacing::Compact`, `Spacing::Normal`, `Spacing::Padded` | what shows between the rows a region holds |
+| `border` | `Border::None`, `Border::Line`, `Border::Rounded`, `Border::Double` | the frame drawn around everything |
+| `field` | `FieldStyle::Flat`, `FieldStyle::Boxed`, `FieldStyle::Underline` | how a field's typed value is drawn in the editor |
+| `fullscreen` | `TRUE`, `FALSE` | whether the frame takes the whole terminal |
+| `halign` | `HAlign::Left`, `HAlign::Center`, `HAlign::Right` | where a frame narrower than the terminal sits across it |
+| `valign` | `VAlign::Top`, `VAlign::Middle`, `VAlign::Bottom` | where a frame shorter than the terminal sits down it |
+| `min_width` | any non-negative integer | the narrowest terminal the frame can be read in; `0` measures the content |
+| `min_height` | any non-negative integer | the shortest terminal it can be read in |
+| `max_width` | any non-negative integer | the widest the frame will grow; `0` is uncapped |
+| `max_height` | any non-negative integer | the tallest it will grow; `0` is uncapped |
+
+Each enum case is interchangeable with its string value, so `['border' => Border::Rounded]` and `['border' => 'rounded']` mean the same thing. A theme can declare options of its own by merging over `optionSchema()`, and the [playground's accent theme](https://github.com/drevops/tui/blob/main/playground/themes/AccentTheme.php) is that recipe in fifteen lines.
+
## Writing a theme
-A custom theme subclasses `DefaultTheme` and declares its palette by overriding the appearance atoms it wants to change - `title()`, `value()`, `marker()`, `border()` and so on. Each atom returns its text wrapped in an ANSI style code with `paint()`; every role you don't mention keeps the default, including the dark/light mode.
+A custom theme subclasses `DefaultTheme` and repaints. Most of what a palette wants is written once, in a small set of protected voices the elements draw from - so a theme repaints a whole family in a line rather than element by element:
+
+| Voice | Says |
+| --------------- | ------------------------------------------------------------ |
+| `accent()` | "here", "now" or "picked" - the hue a theme is recognized by |
+| `value()` | what something holds |
+| `label()` | what something is called |
+| `title()` | a name for what follows it |
+| `heading()` | a name over a run of rows |
+| `description()` | what explains something |
+| `guidance()` | what the form expects of you |
+| `footer()` | an aside, never the point of the line |
+| `border()` | box-drawing characters |
+| `indicator()` | something that wants attention without having failed |
+| `error()` | something that failed |
```php
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Theme\Sgr;
class AquaTheme extends DefaultTheme {
- public function title(string $text): string {
- return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $text);
+
+ #[\Override]
+ protected function accent(): string {
+ return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue);
}
- public function value(string $text, bool $selected = FALSE): string {
- return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Sky) : Sgr::of(Sgr::Cobalt), $selected), $text);
+ #[\Override]
+ protected function value(string $text, bool $emphatic = FALSE): string {
+ return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Sky) : Sgr::of(Sgr::Cobalt), $emphatic), $text);
}
+
}
```
-Colors come from the `Sgr` palette map - named cases like `Sgr::Cyan` or `Sgr::Sand`, composed with `Sgr::of(...)` - so a palette reads as colors rather than raw ANSI numbers.
+Colors come from the `Sgr` palette map - named cases like `Sgr::Cyan` or `Sgr::Sand`, composed with `Sgr::of(...)` - so a palette reads as colors rather than raw ANSI numbers. `paint()` wraps text in a style and honors the color switch; `emphasize()` adds weight to whatever the cursor is on. The five adaptive built-ins are each four or five overrides of exactly this shape.
+
+`guidance()` carries a rule worth knowing before you repaint it. It is the voice that says what the field expects - a bounded list's constraint sits directly under an entry's own explanatory text - so it has to stay apart from `description()` **by color**. Weight and italic won't do it: an SVG render drops italic entirely, and so do plenty of terminals. With color off, `fieldConstraint()` opens the line with a leading mark instead, which is the one cue nothing can strip.
+
+To restyle one piece outright rather than recolor a family, override its **element** - the public method the block asks for. [Anatomy](/fields/anatomy#elements-what-a-theme-actually-implements) lists every one of them, grouped by the block that declares it:
+
+```php
+class AquaTheme extends DefaultTheme {
-Override as many atoms as the palette needs. For reference, the built-in themes each redeclare the accent-colored atoms (`title()`, `highlight()`, `marker()`, `radio()`, `caret()`) plus `value()`, `indicator()`, `highlightMatch()` and `border()`. To change how an element is laid out rather than colored, override a `render*()` method instead.
+ #[\Override]
+ public function breadcrumbSeparator(): string {
+ return $this->glyph('~', '-');
+ }
-The lowest-friction route: name the class directly on the facade, no registration needed:
+}
+```
+
+The lowest-friction route to using it: name the class directly on the facade, no registration needed:
```php
$tui = (new \DrevOps\Tui\Tui($form))->theme(AquaTheme::class);
```
-Or register a short alias with `ThemeManager::register('aqua', AquaTheme::class)`, then `->theme('aqua')`. The [playground's ocean theme](https://github.com/drevops/tui/blob/main/playground/09-themes-custom.php) goes further, overriding many atoms and `render*()` methods for a distinct look with a start banner:
+Or register a short alias with `ThemeManager::register('aqua', AquaTheme::class)`, then `->theme('aqua')`. Either way the class must extend `DefaultTheme` - registration says so up front rather than failing at the first frame. The [playground's ocean theme](https://github.com/drevops/tui/blob/main/playground/09-themes-custom.php) goes further, repainting many voices and elements for a distinct look with a start banner:
+
+## What a theme is allowed to do
+
+A terminal may have no color, no Unicode, or a background the theme should read. A theme declares which of those it handles, and declaring one is what grants the facility that goes with it. Five capabilities exist, and that is the whole set:
+
+| Declaration | Grants | For |
+| ----------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| `ColorSchemeCapableInterface` | `hasColor()`, `isDark()` | painting at all, and picking a palette for a dark or light terminal |
+| `UnicodeCapableInterface` | `hasUnicode()` | choosing between a glyph and its ASCII stand-in |
+| `DimCapableInterface` | `dim()` | pushing back what a [modal](/panels#modal-panels) is drawn over |
+| `MarkdownCapableInterface` | `hasMarkdown()` | drawing the [markdown subset](/markdown) rather than its markers |
+| `OccupyCapableInterface` | `isFullscreen()`, `halign()`, `valign()`, the min/max sizes, `spacing()`, `background()` | saying how much of the terminal the frame takes, and where it anchors |
+
+Color and the background are one declaration rather than two, because the two questions are never asked apart: a color is chosen against a background, and a color legible on a dark terminal is not legible on a light one.
+
+Two of the five carry a **trait** with the plumbing, so a theme states a flag and inherits the rest. `ColorSchemeCapableTrait` brings `paint()` and `emphasize()`; `UnicodeCapableTrait` brings `glyph()`, which is what lets an element write `$this->glyph('›', '>')` without remembering which display mode it is drawing for.
+
+`DefaultTheme` declares all five, so a subclass of it inherits every facility and never has to think about this. `AbstractTheme` declares none: it hands back the strings it was given and the ASCII stand-ins that read without them. That is the floor, and it is why a form renders in a terminal that supports nothing. A theme built on the floor is handed straight to a `ScreenController` or a [`ScreenTester`](/testing), since the facade resolves names through `ThemeManager` and that wants a `DefaultTheme`.
+
+## Patching an element
+
+Restyling a handful of glyphs doesn't need a class at all. Hand `->theme()` a closure instead of a name and it is given a `ThemeBuilder`, whose groups are the blocks that declare the elements - so the prefix is implied, and `->separator()` means one thing under `->breadcrumb()` and another under `->legend()`:
+
+```php
+use DrevOps\Tui\Theme\Override\BreadcrumbOverrides;
+use DrevOps\Tui\Theme\Override\FieldOverrides;
+use DrevOps\Tui\Theme\Override\LegendOverrides;
+use DrevOps\Tui\Theme\Sgr;
+use DrevOps\Tui\Theme\ThemeBuilder;
+
+$tui = (new Tui($form))
+ ->theme('midnight')
+ ->theme(fn(ThemeBuilder $t) => $t
+ ->breadcrumb(fn(BreadcrumbOverrides $b) => $b->separator('»', '->'))
+ ->legend(fn(LegendOverrides $l) => $l->separator('•', '|')->key(Sgr::Bold, Sgr::BrightCyan))
+ ->field(fn(FieldOverrides $f) => $f->selector('▶', '=>')->entryMarker('▣', '[x]')->caret('▎', '|')));
+```
+
+The name picks the theme; the closure states what that theme draws differently. The two calls are separate on purpose - one chooses, the other patches - and the patch survives whichever theme is chosen.
+
+A glyph takes two arguments, the mark and its ASCII stand-in, so a patch can't set one display mode and silently leave the other broken. Text takes one, and a color takes `Sgr` parts in order. [Anatomy](/fields/anatomy#patching-an-element) lists the nine elements this reaches, which is the closed set - naming anything else is a type error rather than a knob that quietly does nothing.
+
+Anything the patch doesn't name keeps the theme's own answer, which is what makes it a patch rather than a replacement. Reach for a subclass when you're changing a palette; reach for this when you're changing a handful of glyphs. Runnable in [`playground/09-themes-elements.php`](https://github.com/drevops/tui/blob/main/playground/09-themes-elements.php).
diff --git a/docs/content/translations.mdx b/docs/content/translations.mdx
index 87a9298c..8a83e910 100644
--- a/docs/content/translations.mdx
+++ b/docs/content/translations.mdx
@@ -6,7 +6,7 @@ keywords: ['translations', 'localization', 'i18n', 'catalogs', 'language']
# Translations
-Every string the TUI shows can be presented in another language - both the framework's own **chrome** (key hints, the help overlay, buttons, validation and error messages) and the **questions** you declare (field and panel labels, descriptions and option labels). A missing translation always falls back to the English source, so a partial catalog is safe to ship.
+Every string the TUI shows can be presented in another language - both the library's own **chrome** (key hints, buttons, validation and error messages) and the **questions** you declare (field and panel labels, descriptions, option labels and help text). A missing translation always falls back to the English source, so a partial catalog is safe to ship.
## Setting a translator
diff --git a/docs/content/widgets/calendar.mdx b/docs/content/widgets/calendar.mdx
deleted file mode 100644
index a64b9cb6..00000000
--- a/docs/content/widgets/calendar.mdx
+++ /dev/null
@@ -1,78 +0,0 @@
----
-title: Calendar
-description: 'A month-grid date picker returning a normalized ISO YYYY-MM-DD string; arrows move by day and week.'
-keywords: ['calendar', 'date picker', 'iso date', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Calendar
-
-
-
-
-
-A month-grid date picker. It collects a single **normalized ISO `YYYY-MM-DD` string**; give it no default and it opens on today.
-
-```php
-use DrevOps\Tui\Model\Weekday;
-
-$p->calendar('harvest', 'Harvest date')
- ->default('2026-07-15') // Date the grid opens on.
- ->minDate('2026-01-01') // Earliest selectable date, inclusive.
- ->maxDate('2026-12-31') // Latest selectable date, inclusive.
- ->weekStart(Weekday::Sunday); // Day the week grid starts on.
-```
-
-Runnable script: [`playground/02-widgets-calendar.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-calendar.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------- | ----------------------------------------------------- | -------- | ----------------- |
-| `default()` | Date the grid opens on, as `YYYY-MM-DD`. | No | Today |
-| `minDate()` | Earliest selectable date, inclusive, as `YYYY-MM-DD`. | No | Unbounded |
-| `maxDate()` | Latest selectable date, inclusive, as `YYYY-MM-DD`. | No | Unbounded |
-| `weekStart()` | Day the week grid starts on, a `Weekday` enum case. | No | `Weekday::Monday` |
-
-Navigation is clamped to the `minDate()`/`maxDate()` range: the cursor never leaves it, days outside it render dimmed, and an opening date outside the range snaps to the nearest bound. These are the widget's own options; the shared field options (`required()`, `when()`, `validate()`, ...) are covered in [Field behavior](/field-behaviour).
-
-## Keyboard
-
-| Key | Action |
-| --------------------------------------- | ------------------------------------------------ |
-| ← / → | Move one day (vim: h / l) |
-| ↑ / ↓ | Move one week (vim: k / j) |
-| PageUp / PageDown | Previous / next month |
-| Home / End | First / last day of the visible month |
-| Enter | Accept the highlighted date |
-| Esc | Cancel |
-
-The day and week moves resolve through the [key map](/key-bindings), so the arrows and the vim letters can be remapped; the month and edge jumps (PageUp/PageDown, Home/End) are fixed keys with no action behind them.
-
-## Headless behavior
-
-The bounds are enforced when the form runs [headlessly](/headless-collection) too - a value outside the range is rejected - and they surface in the JSON schema as `min_date`, `max_date` and `week_start` on the prompt.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/confirm.mdx b/docs/content/widgets/confirm.mdx
deleted file mode 100644
index 9f99e23f..00000000
--- a/docs/content/widgets/confirm.mdx
+++ /dev/null
@@ -1,60 +0,0 @@
----
-title: Confirm
-description: 'A Yes/No gate collecting a bool; arrows or Space switch the choice, y and n set it directly.'
-keywords: ['confirm', 'yes no', 'boolean', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Confirm
-
-
-
-
-
-A Yes/No gate. It collects a **`bool`**.
-
-```php
-$p->confirm('organic', 'Organic only?')
- ->default(TRUE); // Which choice starts highlighted (defaults to No).
-```
-
-Runnable script: [`playground/02-widgets-confirm.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-confirm.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ----------- | ----------------------------------------------------------------- | -------- | ------------ |
-| `default()` | Which choice starts highlighted - `TRUE` for Yes, `FALSE` for No. | No | `FALSE` (No) |
-
-## Keyboard
-
-| Key | Action |
-| ---------------------------------------------------------------------------- | ------------------------- |
-| y / n | Choose Yes / No directly |
-| ← / → / Space / ↑ / ↓ | Flip the choice |
-| Enter | Accept the current choice |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/filepicker.mdx b/docs/content/widgets/filepicker.mdx
deleted file mode 100644
index fc6e5498..00000000
--- a/docs/content/widgets/filepicker.mdx
+++ /dev/null
@@ -1,157 +0,0 @@
----
-title: FilePicker
-description: 'Browse the filesystem for one path - or several with ->multiple() - entering directories and returning to their parents.'
-keywords: ['file picker', 'filesystem', 'path', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# FilePicker
-
-
-
-
-
-Browse the filesystem for a single path. It collects the **chosen path** (a `string`).
-
-```php
-$p->filePicker('list', 'Price list')
- ->startIn(getcwd()) // Directory to open in (and the floor for ←).
- ->filesOnly() // Only files are selectable; directories stay navigable.
- ->extensions(['csv']) // Limit selectable files to these extensions.
- ->maxSize(5_000_000) // Reject a selected file larger than this many bytes.
- ->showHidden(); // Show hidden (dot) entries when the browser opens.
-```
-
-Runnable scripts: [`playground/02-widgets-filepicker.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-filepicker.php) and [`filepicker-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-filepicker-multiple.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------------- | --------------------------------------------------------------------------- | -------- | --------------------- |
-| `startIn()` | Directory the browser opens in, and the floor it cannot ascend above. | No | Current directory |
-| `filesOnly()` | Only files are selectable; directories stay navigable. | No | Files and directories |
-| `directoriesOnly()` | Only directories are selectable. | No | Files and directories |
-| `extensions()` | Restrict selectable files to these extensions (dot-less, case-insensitive). | No | All |
-| `maxSize()` | Reject any selected file larger than this many bytes. | No | No limit |
-| `showHidden()` | Show hidden (dot) entries when the browser opens. | No | Off |
-| `pageSize()` | Entries shown before the list pages around the cursor. | No | `10` |
-
-`filesOnly()` and `directoriesOnly()` are mutually exclusive - the last one set wins.
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | -------------------------------------------------------------------------------- |
-| ↑ / ↓ | Move the highlight |
-| → | Descend into the highlighted directory |
-| ← | Ascend to the parent (never above the start directory) |
-| printable keys | Filter the current directory |
-| Tab | Toggle hidden entries |
-| Backspace | Delete a filter character, or ascend when the filter is empty |
-| Enter | Select the highlighted entry if selectable, otherwise descend into the directory |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Type and size constraints
-
-Constrain what counts as a valid pick with `->filesOnly()` / `->directoriesOnly()`, `->extensions()` and `->maxSize()`. The active limits show as a hint below the browser, a pick that breaks one is rejected inline when you accept, and the same limits are enforced in [headless collection](/headless-collection).
-
-```php
-$p->filePicker('list', 'Price list')
- ->filesOnly() // A directory (or a missing path) is not a valid pick.
- ->extensions(['csv']) // Only .csv files may be chosen.
- ->maxSize(5_000_000); // Reject a file larger than 5 MB.
-```
-
-A missing path, a directory where a file is required (or the reverse), a disallowed extension, or an oversized file each fail with a message naming the unmet limit.
-
-## Multiple selection
-
-Add `->multiple()` to accumulate **several paths** (a `list`) instead of one: Space toggles the highlighted entry, selections stick as you browse between directories, and Enter accepts them all.
-
-```php
-$p->filePicker('lists', 'Price lists')
- ->multiple()
- ->startIn(getcwd())
- ->extensions(['csv']);
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Selection limits
-
-Bound how many paths a multiple file picker collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the browser, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
-
-```php
-$p->filePicker('price_lists', 'Price lists')
- ->multiple()
- ->minSelections(2) // Reject fewer than two paths.
- ->maxSelections(3); // Reject more than three paths.
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-Runnable script: [`playground/02-widgets-filepicker-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-filepicker-multiple-limited.php).
diff --git a/docs/content/widgets/index.mdx b/docs/content/widgets/index.mdx
deleted file mode 100644
index bd4b8ee4..00000000
--- a/docs/content/widgets/index.mdx
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Widgets
-description: 'The widget gallery: text entry, choices, filesystem browsing and gates, each shown in all four display modes.'
-keywords: ['widgets', 'gallery', 'fields', 'input', 'tui']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Widgets
-
-The widgets cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor, and the same widgets also run standalone (see [`playground/02-widgets-*`](https://github.com/drevops/tui/tree/main/playground)). Widgets pull their glyphs and colors from the theme, so each one is shown in all four display modes.
-
-
-
-
-
-## Text entry
-
-- [Calendar](/widgets/calendar) - a month calendar returning an ISO date.
-- [Number](/widgets/number) - integer input with optional bounds and step keys.
-- [Password](/widgets/password) - masked input with optional reveal and confirm.
-- [Template](/widgets/template) - fill the named slots of a fixed shape.
-- [Text](/widgets/text) - single-line input, with optional ghost-text autocomplete.
-- [Textarea](/widgets/textarea) - multi-line input with optional external-editor handoff.
-
-## Choices
-
-- [Option groups](/widgets/option-groups) - headings, separators and disabled options.
-- [Rating](/widgets/rating) - a graded answer picked from a scale of points.
-- [Reorder](/widgets/reorder) - rank a list by moving items into order.
-- [Search](/widgets/search) - single or multiple choice with a filter line.
-- [Select](/widgets/select) - single or multiple choice from a list.
-- [Suggest](/widgets/suggest) - free text with autocomplete over a fixed set.
-
-## Filesystem
-
-- [FilePicker](/widgets/filepicker) - browse for one path, or several with `->multiple()`.
-
-## Toggles, gates and cards
-
-- [Confirm](/widgets/confirm) - a Yes/No toggle.
-- [Note](/widgets/note) - a read-only informational card that collects nothing.
-- [Pause](/widgets/pause) - an acknowledgment gate.
-- [Progress](/widgets/progress) - a row that runs work, showing a bar or spinner.
-- [Toggle](/widgets/toggle) - an inline switch between two labeled values.
diff --git a/docs/content/widgets/note.mdx b/docs/content/widgets/note.mdx
deleted file mode 100644
index e68cde0d..00000000
--- a/docs/content/widgets/note.mdx
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: Note
-description: 'A non-interactive informational card that shows a title and body inline without collecting a value.'
-keywords: ['note', 'card', 'informational', 'read-only', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Note
-
-
-
-
-
-A non-interactive informational card. It renders a **title and body inline** in the form flow and collects **nothing** - the selection cursor skips it, it never appears in the answers, and it is absent from headless collection. Its title and body take the same `{{field}}` templating derived values use, so a note can reflect earlier answers, and it honours `->when()` like any other field.
-
-```php
-$p->note('intro', 'Fresh produce order')
- ->description('A read-only card - the cursor skips it.');
-
-$p->text('item', 'Produce name')->default('Pear');
-
-// ->border() frames the card; the body reflects the earlier answer.
-$p->note('summary', 'Ready to pack')
- ->description('Packing {{item}} into the basket.')
- ->border();
-```
-
-A note body can carry a `[text](url)` link, and when the enclosing TUI is configured with [`->markdown()`](/markdown) it also renders bold, emphasis, inline code and bullet lists - all degrading to clean plain text where the terminal cannot show them.
-
-A note can also present tabular context: `->table(headers, rows)` renders an aligned, bordered grid beneath the title and body. See [Table](/widgets/table) for the full reference.
-
-Runnable script: [`playground/02-widgets-note.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-note.php).
-
-## Options
-
-| Method | Effect |
-| ------------------------ | --------------------------------------------------------------------------------- |
-| `->description(body)` | The card's body text, shown beneath the title. |
-| `->border()` | Frames the card in the theme's box with minimal padding. |
-| `->table(headers, rows)` | Renders an aligned, bordered grid beneath the body (see [Table](/widgets/table)). |
-
-The title is the second `note()` argument and is optional - an empty title renders the body alone. The shared field options `->when()` (conditional visibility) and `{{field}}` templating in both the title and body still apply.
-
-## Keyboard
-
-A note is non-interactive: the selection cursor skips over it, so it has no keys of its own.
-
-## Headless behavior
-
-A note carries no value - a table it renders is presentational too. It is absent from headless collection, from the answers payload, and from the machine-readable schemas (`schema()` and `agentHelp()`), so an agent is never asked to provide one.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/number.mdx b/docs/content/widgets/number.mdx
deleted file mode 100644
index d98cbeb4..00000000
--- a/docs/content/widgets/number.mdx
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: Number
-description: 'Integer input with optional bounds and step keys, collected as an int.'
-keywords: ['number', 'integer', 'bounds', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Number
-
-
-
-
-
-Integer input - digits with an optional leading minus. It collects an **`int`**.
-
-```php
-$p->number('weight', 'Basket weight (g)')
- ->min(200) // Lowest accepted value, inclusive.
- ->max(9000) // Highest accepted value, inclusive.
- ->step(100) // Amount the Up/Down keys adjust by.
- ->default(1200); // Initial value.
-```
-
-Runnable script: [`playground/02-widgets-number.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-number.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ----------- | -------------------------------------------------------------------------- | -------- | --------- |
-| `min()` | Lowest accepted value, inclusive. | No | Unbounded |
-| `max()` | Highest accepted value, inclusive. | No | Unbounded |
-| `step()` | Amount the Up/Down keys adjust by; must be positive. | No | `1` |
-| `default()` | Initial value. | No | `0` |
-
-With no bounds declared, the field is a plain integer entry and the arrow keys are inert. Declare `min()`, `max()` or `step()` and Up/Down adjustment turns on; the value is clamped only when a range (`min()` / `max()`) is set.
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | ---------------------------------------------------------- |
-| digits | Insert a digit |
-| - | Leading minus (once, at the start) |
-| ← / → | Move the caret |
-| ↑ / ↓ | Increment / decrement by `step` (only when bounds are set) |
-| Enter | Accept (an out-of-range value is rejected inline) |
-| Esc | Cancel |
-
-## Headless behavior
-
-The bounds are enforced when the form runs [headlessly](/headless-collection) too - a value outside the range is rejected - and they surface in the JSON schema as `min`, `max` and `step` on the prompt.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/option-groups.mdx b/docs/content/widgets/option-groups.mdx
deleted file mode 100644
index feec605e..00000000
--- a/docs/content/widgets/option-groups.mdx
+++ /dev/null
@@ -1,82 +0,0 @@
----
-title: Option groups
-description: 'Structure long option lists with headings, separators and disabled options in the choice widgets.'
-keywords: ['option groups', 'headings', 'separators', 'disabled options', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Option groups, separators and disabled options
-
-The choice widgets - [`select`](/widgets/select) and [`search`](/widgets/search), single-choice or with `->multiple()` - accept more than a flat list. Alongside the `->options(['value' => 'Label'])` map shorthand, you can declare options one at a time, mark them disabled, and add non-selectable structure.
-
-```php
-$p->select('item', 'Item')
- ->heading('Fruit') // A non-selectable group heading.
- ->option('apple', 'Apple') // value => label
- ->option('banana', 'Banana')
- ->separator() // A non-selectable divider row.
- ->heading('Vegetable')
- ->option('carrot', 'Carrot')
- ->option('cherry', 'Cherry', disabled: TRUE, disabled_reason: 'out of season');
-```
-
-Runnable scripts: [`playground/02-widgets-select-groups.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select-groups.php) and [`select-multiple-groups.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select-multiple-groups.php).
-
-## Builder methods
-
-| Name | Description |
-| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `option($value, $label = '', ...)` | Add one selectable row. The label defaults to the value; re-declaring a value replaces it in place. Pass `disabled: TRUE` (with an optional `disabled_reason`) to show it but block selection, or `description:` for a [contextual line](/widgets/select#option-descriptions) shown when it is highlighted. |
-| `options([$value => $label])` | Add many selectable rows from a map - shorthand for repeated `option()`. |
-| `heading($label)` | Add a non-selectable group-heading row. |
-| `separator()` | Add a non-selectable divider row. |
-
-## Behavior
-
-Headings, separators and disabled options are **visual only**: navigation skips them, so the cursor lands only on selectable options, and they can never be highlighted or selected. A disabled option shows its reason beside the label, dimmed. Every kind is theme-driven - override `heading()`, `divider()` or `disabled()` on a theme to restyle it.
-
-Non-selectable rows never leak into the answer: a disabled value is dropped from a multiple-choice default, absent from the collected value, and excluded from the JSON schema (`Tui::schema()` lists selectable options only). Supplying a disabled - or otherwise unknown - option value [headlessly](/headless-collection) (via `--prompts` JSON or an environment override) fails collection with a clear error naming the value.
-
-## Examples
-
-A single-choice `select` with a group heading, a separator and a disabled option (its reason shown beside the dimmed label):
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-A multiple `select` where the cursor and Space skip the separator and the disabled option, which can never be checked:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/password.mdx b/docs/content/widgets/password.mdx
deleted file mode 100644
index fc62dd25..00000000
--- a/docs/content/widgets/password.mdx
+++ /dev/null
@@ -1,72 +0,0 @@
----
-title: Password
-description: 'Masked text input with optional reveal and confirmation; the accepted value stays plain for the consumer.'
-keywords: ['password', 'masked input', 'reveal', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Password
-
-
-
-
-
-Text input rendered as a mask - in the editor, on the panel row and in the summary. The accepted value stays plain for your code. It collects a **`string`**.
-
-```php
-$p->password('code', 'Order code')
- ->revealable() // Add a Tab toggle to reveal the typed value.
- ->confirmation(); // Prompt for the value twice and reject a mismatch.
-```
-
-Runnable scripts: [`playground/02-widgets-password.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-password.php) and [`password-reveal.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-password-reveal.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ---------------- | ------------------------------------------------------------------------------------------ | -------- | ------- |
-| `revealable()` | Add a reveal toggle: Tab cycles the editor display hidden → masked → plaintext. | No | Off |
-| `confirmation()` | Prompt for the value a second time and reject a mismatch before accepting. | No | Off |
-
-Both are off by default, so a plain `password()` masks the input and nothing more. `revealable` only changes what's drawn - the stored value is never affected, and the panel row and summary always stay masked.
-
-With `revealable()` on, Tab cycles the editor's display through hidden, masked and plaintext, and the hint line shows the toggle:
-
-
-
-
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | -------------------------------------------------------------------------- |
-| printable keys | Insert at the caret (drawn masked) |
-| ← / → | Move the caret |
-| Backspace | Delete the character before the caret |
-| Tab | Cycle the display hidden → masked → plaintext (when `revealable()`) |
-| Enter | Accept - or, with `confirmation()`, re-prompt once, then accept on a match |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/pause.mdx b/docs/content/widgets/pause.mdx
deleted file mode 100644
index 478b372c..00000000
--- a/docs/content/widgets/pause.mdx
+++ /dev/null
@@ -1,59 +0,0 @@
----
-title: Pause
-description: 'An acknowledgment gate that shows its label and waits for the reader before continuing.'
-keywords: ['pause', 'gate', 'acknowledgment', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Pause
-
-
-
-
-
-An acknowledgment gate: it shows its label and waits for the reader to continue. It collects a **`bool`** - always `TRUE` once acknowledged - and holds no other value.
-
-```php
-$p->pause('ready', 'Review your basket'); // A gate; it takes no options.
-```
-
-Runnable script: [`playground/02-widgets-pause.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-pause.php).
-
-## Options
-
-Pause has no options of its own - it only renders its label as a gate. The shared field options (`when()`, `description()`) still apply.
-
-## Keyboard
-
-| Key | Action |
-| ----------------------------------- | ------------------------ |
-| Enter / Space | Acknowledge and continue |
-| Esc | Cancel |
-
-## Headless behavior
-
-An unattended run has nothing to wait for, so a pause auto-acknowledges (`TRUE`) and never blocks automation.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/progress.mdx b/docs/content/widgets/progress.mdx
deleted file mode 100644
index d9d82cc7..00000000
--- a/docs/content/widgets/progress.mdx
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Progress
-description: 'A panel row that runs work when activated, filling a bar or ticking a spinner in place.'
-keywords: ['progress', 'bar', 'spinner', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Progress
-
-
-
-
-
-A place to do work inside the form: select the row, press `Enter`, and its work runs with a **determinate bar** (when it declares a step count) or an **indeterminate spinner** (when it does not), drawn in the row itself as the work advances. It collects **no value** - it sits beside the fields it depends on, not among the answers.
-
-```php
-$p->progress('pack', 'Packing the box')
- ->steps(6) // Omit for an indeterminate spinner.
- ->run(function (ProgressReporter $reporter) use ($items): void {
- foreach ($items as $item) {
- // ... one step of work ...
- $reporter->advance(); // Fills one step of the bar (ticks the spinner).
- }
- });
-```
-
-Runnable script: [`playground/02-widgets-progress.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-progress.php).
-
-## Options
-
-| Method | Effect |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| `->steps(int)` | The step count, making the indicator a determinate bar. Omit it for an indeterminate spinner. |
-| `->run(callable)` | The work run when the row is activated. The callback receives a `ProgressReporter` and calls `advance()` once per step. |
-
-The indicator is drawn by the [active theme](/themes), in its accent and Unicode/ASCII mode - the same spinner and bar as the standalone [`progress()` primitive](/progress).
-
-## Keyboard
-
-| Key | Action |
-| ------- | ------------------ |
-| `Enter` | Run the row's work |
-| `Esc` | Leave the panel |
-
-## Headless behaviour
-
-A progress row is display-only: it carries no answer, is absent from the [machine schema](/headless-collection), and an unattended run skips it.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, colour on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/reorder.mdx b/docs/content/widgets/reorder.mdx
deleted file mode 100644
index 927c6707..00000000
--- a/docs/content/widgets/reorder.mdx
+++ /dev/null
@@ -1,92 +0,0 @@
----
-title: Reorder
-description: 'Rank a list by moving items into order; collects the values in their final order.'
-keywords: ['reorder', 'ranking', 'ordering', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Reorder
-
-
-
-
-
-Rank a list by moving items into the order you want. It returns a **`list`** - a full permutation of the option values, never a subset.
-
-```php
-$p->reorder('basket', 'Rank your basket')
- ->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ])
- ->default(['apple', 'carrot']) // Seed the starting order; omitted items are appended.
- ->pageSize(10); // Items visible before the list pages around the cursor.
-```
-
-Runnable script: [`playground/02-widgets-reorder.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-reorder.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------- |
-| `options()` | The items to rank, as a `value => label` map. | Yes | - |
-| `default()` | Seeds the starting order; any omitted options are appended in declared order, so the ranking is always complete. | No | Declared order |
-| `pageSize()` | Items shown before the list pages around the cursor. | No | `10` |
-
-A [description line](/widgets/select#option-descriptions) can accompany the highlighted item, declared with `->option(..., description: ...)`.
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | --------------------------------------------------------- |
-| ↑ / ↓ | Move the highlight, or carry a held item through the list |
-| Space | Pick the highlighted item up, or drop a held one |
-| Enter | Drop a held item, or accept when nothing is held |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Option descriptions
-
-The highlighted item's [description](/widgets/select#option-descriptions), in every display mode:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/search.mdx b/docs/content/widgets/search.mdx
deleted file mode 100644
index 4cbadc7c..00000000
--- a/docs/content/widgets/search.mdx
+++ /dev/null
@@ -1,181 +0,0 @@
----
-title: Search
-description: 'Single or multiple choice with a fuzzy filter line that ranks the options and highlights the matched characters.'
-keywords: ['search', 'fuzzy filter', 'choice', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Search
-
-
-
-
-
-Single choice with a filter line above the options. Typing fuzzy-matches and ranks the labels - exact and prefix matches lead, looser subsequence matches follow - and highlights the matched characters. It collects the **selected option value** (a `string`).
-
-```php
-$p->search('vegetable', 'Vegetable')
- ->options([
- 'carrot' => 'Carrot',
- 'potato' => 'Potato',
- 'onion' => 'Onion',
- 'pepper' => 'Pepper',
- ])
- ->default('carrot') // Which option starts highlighted.
- ->pageSize(8); // Matches visible before the list pages around the cursor.
-```
-
-Runnable scripts: [`playground/02-widgets-search.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-search.php) and [`search-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-search-multiple.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------ | -------------------------------------------------------------------------------- | -------- | ------------ |
-| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). | Yes | - |
-| `default()` | Which option starts highlighted, by value. | No | First option |
-| `pageSize()` | Matches shown before the list pages around the cursor. | No | `10` |
-
-For headings, separators and disabled options, see [Option groups](/widgets/option-groups). For a per-option [description line](/widgets/select#option-descriptions) shown beneath the highlighted match, declare it with `->option(..., description: ...)`.
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | ----------------------------------------- |
-| printable keys | Type to fuzzy-filter and rank the options |
-| ↑ / ↓ | Move over the matches |
-| Backspace | Delete a filter character |
-| Enter | Accept the highlighted option |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Multiple selection
-
-Add `->multiple()` to collect a **`list`** of checked values under the filter line. Typing fuzzy-matches and ranks with the matched characters highlighted, Space toggles, → / ← select or deselect all visible, and Enter accepts the checked set.
-
-```php
-$p->search('basket', 'Basket')
- ->multiple()
- ->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ])
- ->default(['apple']);
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Selection limits
-
-Bound how many values a multiple field collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the list, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
-
-```php
-$p->search('basket', 'Basket')
- ->multiple()
- ->minSelections(2) // Reject fewer than two checked.
- ->maxSelections(3) // Reject more than three checked.
- ->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-Runnable script: [`playground/02-widgets-search-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-search-multiple-limited.php).
-
-## Option descriptions
-
-The highlighted match's [description](/widgets/select#option-descriptions), in every display mode:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Options from a query
-
-The options can come from the query itself rather than a fixed list, for a catalog too large to hold - see [options from a query](/progress#options-from-a-query):
-
-```php
-$p->search('veg', 'Vegetable')->optionsFrom(fn(string $query): array => $pantry->search($query));
-```
diff --git a/docs/content/widgets/select.mdx b/docs/content/widgets/select.mdx
deleted file mode 100644
index 9b1e72f2..00000000
--- a/docs/content/widgets/select.mdx
+++ /dev/null
@@ -1,183 +0,0 @@
----
-title: Select
-description: 'Single or multiple choice from a list of options, with defaults and per-option descriptions.'
-keywords: ['select', 'choice', 'options', 'multiple', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Select
-
-
-
-
-
-Single choice from a list of options. It collects the **selected option value** (a `string`).
-
-```php
-$p->select('fruit', 'Fruit')
- ->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'cherry' => 'Cherry',
- ])
- ->default('banana') // Which option starts highlighted (defaults to the first).
- ->pageSize(10); // Options visible before the list pages around the cursor.
-```
-
-Runnable scripts: [`playground/02-widgets-select.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select.php) and [`select-multiple.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select-multiple.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ |
-| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). Also takes a callback returning that map. | Yes | - |
-| `default()` | Which option starts highlighted, by value. | No | First option |
-| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` |
-
-For headings, separators and disabled options, see [Option groups](/widgets/option-groups). To narrow the choices by an earlier answer, see [options from the answers](/field-behaviour#options-from-the-answers).
-
-## Option descriptions
-
-Give an option a description to explain what the choice implies. It shows as a secondary line beneath the list for the **highlighted** option and updates as the highlight moves. It is presentational only - the field still collects the selected value, never the description - it wraps to the available width, is dropped when the panel is too narrow to show it, and is absent from [headless collection](/headless-collection).
-
-
-
-
-
-```php
-$p->select('fruit', 'Fruit')
- ->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')
- ->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')
- ->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.');
-```
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-Descriptions work the same on [`search`](/widgets/search), [`suggest`](/widgets/suggest) and [`reorder`](/widgets/reorder). Runnable script: [`playground/02-widgets-select-descriptions.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select-descriptions.php).
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | -------------------------------------------------------------------- |
-| ↑ / ↓ | Move the highlight (skips headings, separators and disabled options) |
-| Enter | Accept the highlighted option |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Multiple selection
-
-Add `->multiple()` to collect a **`list`** of checked values instead of one. Space toggles the highlighted option, typing narrows the list by substring, → / ← select or deselect all visible, and Enter accepts the checked set.
-
-```php
-$p->select('basket', 'Basket')
- ->multiple()
- ->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ])
- ->default(['apple']); // Values pre-checked when the field opens.
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Selection limits
-
-Bound how many values a multiple field collects with `->minSelections()` and `->maxSelections()`. The active limit shows as a hint below the list, an out-of-range selection is rejected inline when you accept, and the same bounds are enforced in [headless collection](/headless-collection).
-
-```php
-$p->select('basket', 'Basket')
- ->multiple()
- ->minSelections(2) // Reject fewer than two checked.
- ->maxSelections(3) // Reject more than three checked.
- ->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
-```
-
-
-
-
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-Runnable script: [`playground/02-widgets-select-multiple-limited.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-select-multiple-limited.php).
diff --git a/docs/content/widgets/suggest.mdx b/docs/content/widgets/suggest.mdx
deleted file mode 100644
index bfb9f22d..00000000
--- a/docs/content/widgets/suggest.mdx
+++ /dev/null
@@ -1,132 +0,0 @@
----
-title: Suggest
-description: 'Free-text input with autocomplete over a fixed set of suggestions.'
-keywords: ['suggest', 'autocomplete', 'ghost text', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Suggest
-
-
-
-
-
-Free text with autocomplete over a fixed candidate set. As you type, candidates are fuzzy-matched and ranked by relevance. It's an **open set** - it collects a **`string`** that doesn't have to be one of the candidates.
-
-```php
-$p->suggest('fruit', 'Fruit')
- ->options([
- 'Apple' => 'Apple',
- 'Apricot' => 'Apricot',
- 'Banana' => 'Banana',
- 'Cherry' => 'Cherry',
- 'Mango' => 'Mango',
- ])
- ->default('Apple') // Initial text.
- ->pageSize(8) // Suggestions visible before the list pages.
- ->ghost(); // Preview the leading match inline as you type.
-```
-
-Runnable script: [`playground/02-widgets-suggest.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-suggest.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------ | -------------------------------------------------------------------- | -------- | ------------ |
-| `options()` | The candidate set to autocomplete against; only the values are used. | No | None |
-| `default()` | Initial text. | No | `''` (empty) |
-| `pageSize()` | Suggestions shown before the list pages around the cursor. | No | `10` |
-| `ghost()` | Preview the leading prefix match as inline ghost-text. | No | `false` |
-
-Because the set is open, Enter accepts the highlighted suggestion, or your typed text as-is when none is highlighted. A [description line](/widgets/select#option-descriptions) can accompany the highlighted suggestion, keyed by value with `->option(..., description: ...)`.
-
-## Keyboard
-
-| Key | Action |
-| ----------------------------- | ------------------------------------------------------------ |
-| printable keys | Type to filter the candidates |
-| ↑ / ↓ | Highlight a suggestion |
-| Backspace | Delete the character before the caret |
-| Tab / → | Accept the ghost-text preview, when `ghost()` is on |
-| Enter | Accept the highlighted suggestion, or the typed text if none |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Ghost text
-
-With `->ghost()`, the highest-ranked candidate your input is a prefix of is previewed dimmed after the caret, and Tab or → accepts it. The completion becomes the new query rather than a selection, so the ranked list stays open and narrows around it.
-
-It complements the list rather than replacing it, and it steps aside where it would mislead: the preview is suppressed once you arrow into the list (the highlighted suggestion is the value then, not your typed text), while a [query source](#suggestions-from-a-query) is still resolving (those candidates answer the previous query), and it only ever completes a _prefix_ - a fuzzy hit like `ga` → `Green apple` has no inline suffix to draw. Like the [Text](/widgets/text) widget's ghost text, it is suppressed when color is off.
-
-A completion and a [`placeholder()`](/field-behaviour#guidance-texts) share that dimmed slot and never contend for it: a completion needs a typed query, a placeholder needs an empty one.
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Option descriptions
-
-The highlighted suggestion's [description](/widgets/select#option-descriptions), in every display mode:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
-
-## Suggestions from a query
-
-The suggestions can come from the query itself rather than a fixed list, for a catalog too large to hold - see [options from a query](/progress#options-from-a-query):
-
-```php
-$p->suggest('extra', 'Add another')->optionsFrom(fn(string $query): array => $pantry->search($query))->minQuery(2);
-```
diff --git a/docs/content/widgets/table.mdx b/docs/content/widgets/table.mdx
deleted file mode 100644
index 9d9c2634..00000000
--- a/docs/content/widgets/table.mdx
+++ /dev/null
@@ -1,67 +0,0 @@
----
-title: Table
-description: 'A presentational, aligned and bordered grid a note renders beneath its title and body to show tabular context.'
-keywords: ['table', 'grid', 'tabular', 'note', 'presentational', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Table
-
-
-
-
-
-A table is presentational context, not a field of its own: a [note](/widgets/note) renders one with `->table(headers, rows)`, drawing an aligned, bordered grid beneath its title and body. It honours the active theme - the border style, colour and Unicode switches - and its cells take the same `{{field}}` templating the note's title and body do, so the grid can reflect earlier answers. Like every note it collects **nothing**: the cursor skips it and it is absent from headless collection.
-
-```php
-$p->note('stock', 'Basket contents')
- ->description('Everything picked so far:')
- ->table(['Fruit', 'Colour', 'In stock'], [
- ['Apple', 'Red', '12'],
- ['Pear', 'Green', '5'],
- ['Plum', 'Purple', '120'],
- ]);
-```
-
-Runnable script: [`playground/02-widgets-table.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-table.php).
-
-## Arguments
-
-| Argument | Effect |
-| --------- | --------------------------------------------------------------------------------------------------- |
-| `headers` | The header cells. An empty list (`[]`) draws the grid with no header row. |
-| `rows` | The body rows, each a list of cells. A short row pads with empty cells; a long one widens the grid. |
-
-Each column sizes to its widest cell, and the whole grid is capped at the frame width - an over-wide table shrinks its widest columns and truncates the clipped cells with an ellipsis so its borders always stay whole. Cells are coerced to strings, so numbers and booleans need no pre-formatting, and any line breaks in a cell fold to a space so it stays a single row.
-
-## Keyboard
-
-A table is non-interactive: it renders inside a note the selection cursor skips over, so it has no keys of its own.
-
-## Headless behavior
-
-A table is presentational - it carries no value. The note that holds it is absent from headless collection, from the answers payload, and from the machine-readable schemas (`schema()` and `agentHelp()`), so an agent is never asked to provide one.
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/text.mdx b/docs/content/widgets/text.mdx
deleted file mode 100644
index 114c184b..00000000
--- a/docs/content/widgets/text.mdx
+++ /dev/null
@@ -1,76 +0,0 @@
----
-title: Text
-description: 'Single-line text input with a movable caret and optional ghost-text autocomplete.'
-keywords: ['text', 'input', 'string', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Text
-
-
-
-
-
-Single-line text input with a movable caret. It collects a **`string`**.
-
-```php
-$p->text('item', 'Item')
- ->default('Pear'); // Initial value.
-
-// Inline ghost-text autocomplete over a static candidate list:
-$p->text('item', 'Item')
- ->complete(['Pear', 'Peach', 'Plum']);
-
-// The candidates can be computed from the answers collected so far
-// (guard the lookup - a field may be unanswered when this runs):
-$p->text('variety', 'Variety')
- ->complete(fn(array $answers): array => [($answers['fruit'] ?? 'Apple') . ' - Gala']);
-```
-
-Runnable script: [`playground/02-widgets-text.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-text.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------ | ---------------------------------------------------------------------------------------- | -------- | ------------ |
-| `default()` | Initial value. | No | `''` (empty) |
-| `complete()` | Ghost-text completion source: a `list`, or a `fn(array $answers): list`. | No | None |
-
-As you type, the first candidate that starts with your input (case-insensitively) appears dimmed after the caret; accept it with Tab or → at the end of the line. Ghost text keeps your eye on the input line rather than a dropdown, and it's suppressed when color is off. The [Suggest](/widgets/suggest) widget can [show the same preview](/widgets/suggest#ghost-text) above its ranked list.
-
-A completion and a [`placeholder()`](/field-behaviour#guidance-texts) share that dimmed slot and never contend for it: a completion needs a typed prefix, a placeholder needs an empty input.
-
-## Keyboard
-
-| Key | Action |
-| ------------------------------------------- | ------------------------------------- |
-| printable keys | Insert at the caret |
-| ← / → | Move the caret |
-| Backspace | Delete the character before the caret |
-| Tab / → (at line end) | Accept the ghost-text suggestion |
-| Enter | Accept |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/textarea.mdx b/docs/content/widgets/textarea.mdx
deleted file mode 100644
index 429ea5d2..00000000
--- a/docs/content/widgets/textarea.mdx
+++ /dev/null
@@ -1,68 +0,0 @@
----
-title: Textarea
-description: 'Multi-line text input with an optional external-editor handoff; collects a string that may contain newlines.'
-keywords: ['textarea', 'multi-line', 'external editor', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Textarea
-
-
-
-
-
-Multi-line text input. It collects a **`string`** that may contain newlines.
-
-```php
-$p->textarea('notes', 'Tasting notes')
- ->default("Crisp and sweet\nHint of citrus") // Initial value (newlines allowed).
- ->externalEditor(); // Allow a handoff to $EDITOR / $VISUAL.
-```
-
-Runnable script: [`playground/02-widgets-textarea.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-textarea.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ------------------ | ------------------------------------------------------ | -------- | ------------ |
-| `default()` | Initial value; may contain newlines. | No | `''` (empty) |
-| `externalEditor()` | Allow a handoff to the reader's `$EDITOR` / `$VISUAL`. | No | Off |
-
-With `externalEditor()` on, Ctrl-E suspends the TUI, opens the editor seeded with the current value, and captures the saved buffer on return: saving commits it, and an aborted edit (a non-zero editor exit) keeps the inline value. With no editor available, the option is silently ignored and the field stays a plain inline textarea.
-
-## Keyboard
-
-| Key | Action |
-| --------------------------- | --------------------------------------------------------------- |
-| printable keys | Insert at the caret |
-| Enter | Insert a newline |
-| Tab | Accept (note: Enter adds a line, it does not accept) |
-| ↑ / ↓ | Move between lines, keeping the column |
-| ← / → | Move the caret |
-| Backspace | Delete the character before the caret |
-| Ctrl-E | Open the external editor (when enabled) |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/content/widgets/toggle.mdx b/docs/content/widgets/toggle.mdx
deleted file mode 100644
index e759bf5b..00000000
--- a/docs/content/widgets/toggle.mdx
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Toggle
-description: 'An inline switch cycling between labeled values; always in one of its states, so it always returns a value.'
-keywords: ['toggle', 'switch', 'inline', 'widget']
----
-
-import ThemedImage from '@theme/ThemedImage';
-import useBaseUrl from '@docusaurus/useBaseUrl';
-
-# Toggle
-
-
-
-
-
-An inline switch that cycles between a fixed set of labeled values. It collects the **selected option value** (a `string`). It's always in one of its states, so it always returns a value.
-
-```php
-$p->toggle('ripeness', 'Ripeness')
- ->options([
- 'ripe' => 'Ripe', // value => label
- 'unripe' => 'Unripe',
- ])
- ->default('ripe'); // Which value starts selected (defaults to the first).
-```
-
-Runnable script: [`playground/02-widgets-toggle.php`](https://github.com/drevops/tui/blob/main/playground/02-widgets-toggle.php).
-
-## Options
-
-| Name | Description | Required | Default |
-| ----------- | -------------------------------------------------------- | -------- | ------------ |
-| `options()` | The values to switch between, as a `value => label` map. | Yes | - |
-| `default()` | Which value starts selected. | No | First option |
-
-## Keyboard
-
-| Key | Action |
-| ---------------------------------------------------------------------------- | -------------------------------------------------- |
-| ← / → / Space / ↑ / ↓ | Cycle to the adjacent value |
-| a letter | Jump to the first value whose label starts with it |
-| Enter | Accept the current value |
-| Esc | Cancel |
-
-## Display modes
-
-In all four [display modes](/display-modes) - Unicode or ASCII, color on or off:
-
-
-
-
-
ANSI
-
No ANSI
-
-
-
Unicode
-
-
-
-
-
ASCII
-
-
-
-
diff --git a/docs/cspell.json b/docs/cspell.json
index ac7dbcca..98186e8a 100644
--- a/docs/cspell.json
+++ b/docs/cspell.json
@@ -12,6 +12,7 @@
"asciinema",
"autoloader",
"behaviour",
+ "browsable",
"calver",
"CGA",
"cobertura",
@@ -55,6 +56,7 @@
"SIGINT",
"Skrypnyk",
"str2name",
+ "subclassing",
"subpanel",
"textarea",
"themeable",
diff --git a/docs/sidebars.js b/docs/sidebars.js
index fb7caec5..748710a6 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -14,39 +14,40 @@ const sidebars = {
type: 'category',
label: 'Getting started',
collapsible: false,
- items: ['index', 'installation'],
+ items: ['index', 'installation', 'specification'],
},
{
type: 'category',
label: 'Forms',
collapsible: false,
- items: ['panels', 'configuration', 'field-behaviour', 'progress', 'output', 'testing'],
+ items: ['panels', 'layouts', 'configuration', 'field-behaviour', 'progress', 'output', 'testing'],
},
{
type: 'category',
- label: 'Widgets',
+ label: 'Fields',
collapsible: false,
items: [
- {type: 'doc', id: 'widgets/index', label: 'Overview'},
- 'widgets/calendar',
- 'widgets/confirm',
- 'widgets/filepicker',
- 'widgets/note',
- 'widgets/number',
- 'widgets/option-groups',
- 'widgets/password',
- 'widgets/pause',
- 'widgets/progress',
- 'widgets/rating',
- 'widgets/reorder',
- 'widgets/search',
- 'widgets/select',
- 'widgets/suggest',
- 'widgets/table',
- 'widgets/template',
- 'widgets/text',
- 'widgets/textarea',
- 'widgets/toggle',
+ {type: 'doc', id: 'fields/index', label: 'Overview'},
+ 'fields/anatomy',
+ 'fields/calendar',
+ 'fields/confirm',
+ 'fields/filepicker',
+ 'fields/note',
+ 'fields/number',
+ 'fields/option-groups',
+ 'fields/password',
+ 'fields/pause',
+ 'fields/progress',
+ 'fields/rating',
+ 'fields/reorder',
+ 'fields/search',
+ 'fields/select',
+ 'fields/suggest',
+ 'fields/table',
+ 'fields/template',
+ 'fields/text',
+ 'fields/textarea',
+ 'fields/toggle',
],
},
{
diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css
index 6c3342b5..325f037a 100644
--- a/docs/src/css/custom.css
+++ b/docs/src/css/custom.css
@@ -29,3 +29,48 @@
.theme-doc-sidebar-item-category-level-1:not(:first-child) {
margin-top: 1.5rem;
}
+
+/**
+ * A table with more columns than the content column fits.
+ *
+ * Markdown tables are not wrapped, so a wide one is clipped at the column edge
+ * and its rightmost columns cannot be reached at all. Wrapping it in this class
+ * gives it a scroller of its own without the page itself scrolling sideways.
+ */
+.table-scroll {
+ overflow-x: auto;
+}
+
+/**
+ * A scroller only answers the arrow keys once it can hold focus, so every
+ * container carries tabIndex={0} and this gives that focus somewhere to show.
+ */
+.table-scroll:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+}
+
+.table-scroll table {
+ display: table;
+ width: max-content;
+ min-width: 100%;
+}
+
+/**
+ * The row label stays put while the columns scroll under it.
+ *
+ * Without this a reader who scrolls to the far columns sees ticks with nothing
+ * to attribute them to. The stripe is restated because a sticky cell needs an
+ * opaque background of its own, and would otherwise show the row beneath it.
+ */
+.table-scroll th:first-child,
+.table-scroll td:first-child {
+ position: sticky;
+ left: 0;
+ z-index: 1;
+ background: var(--ifm-background-color);
+}
+
+.table-scroll tr:nth-child(2n) td:first-child {
+ background: var(--ifm-table-stripe-background);
+}
diff --git a/docs/tests/unit/sidebars.test.js b/docs/tests/unit/sidebars.test.js
index df8ae91b..68b3bf5d 100644
--- a/docs/tests/unit/sidebars.test.js
+++ b/docs/tests/unit/sidebars.test.js
@@ -60,7 +60,7 @@ describe('sidebars', () => {
});
test('renders every top-level category as an always-visible section', () => {
- expect(sidebars.tutorialSidebar.map((item) => item.label)).toEqual(['Getting started', 'Forms', 'Widgets', 'Automation', 'Customization', 'About']);
+ expect(sidebars.tutorialSidebar.map((item) => item.label)).toEqual(['Getting started', 'Forms', 'Fields', 'Automation', 'Customization', 'About']);
for (const item of sidebars.tutorialSidebar) {
expect(item.type).toBe('category');
diff --git a/docs/util/audit-svgs.php b/docs/util/audit-svgs.php
index ae33df23..4f747b50 100644
--- a/docs/util/audit-svgs.php
+++ b/docs/util/audit-svgs.php
@@ -51,7 +51,7 @@
*
* A needle missing from a committed asset means the recording no longer
* shows the moment its demo exists to show. Kept to the recorded demos;
- * the deterministic widget and theme renders verify themselves at
+ * the deterministic field and theme renders verify themselves at
* generation time.
*
* @return array>
@@ -59,7 +59,7 @@
*/
function contentNeedles(): array {
return [
- 'widgets-dark-animated.svg' => ['Pause'],
+ 'fields-dark-animated.svg' => ['Pause'],
'produce-box-dark-animated.svg' => ['Contents'],
'derived-values-dark-animated.svg' => ['red_plum'],
'conditional-fields-dark-animated.svg' => ['Herb bundle'],
@@ -78,7 +78,7 @@ function contentNeedles(): array {
'panel-layout-dark-animated.svg' => ['Vegetables'],
'theme-ocean-dark-animated.svg' => ['Seaside stall'],
'discovery-dark-static.svg' => ['Box name'],
- 'widget-password-reveal-dark-static.svg' => ['melon7'],
+ 'field-password-reveal-dark-static.svg' => ['melon7'],
];
}
diff --git a/docs/util/render-anatomy-svgs.php b/docs/util/render-anatomy-svgs.php
new file mode 100644
index 00000000..41c041cf
--- /dev/null
+++ b/docs/util/render-anatomy-svgs.php
@@ -0,0 +1,852 @@
+]
+ * @endcode
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\Input\Key;
+use DrevOps\Tui\Input\KeyName;
+use DrevOps\Tui\Render\Ansi;
+use DrevOps\Tui\Testing\TuiTester;
+use DrevOps\Tui\Theme\Mode;
+
+require dirname(__DIR__, 2) . '/vendor/autoload.php';
+require __DIR__ . '/svg-light-twin.php';
+
+/**
+ * The parts the window draws around the fields, in reading order.
+ *
+ * A part's number is its position here. Each group is numbered separately,
+ * because a reader looking at one is not looking at the others, and a single run
+ * of numbers across all three would make every reference in the smaller lists
+ * start at some arbitrary offset.
+ */
+const CHROME_PARTS = [
+ 'border',
+ 'breadcrumb',
+ 'breadcrumb separator',
+ 'overflow marker',
+ 'legend',
+ 'legend key',
+ 'legend description',
+ 'legend separator',
+];
+
+/**
+ * The parts a field draws in view mode, in reading order.
+ */
+const VIEW_PARTS = [
+ 'field selector',
+ 'label',
+ 'help marker',
+ 'value',
+ 'value separator',
+ 'description',
+ 'help',
+];
+
+/**
+ * The parts a field draws in edit mode, in reading order.
+ */
+const EDIT_PARTS = [
+ 'entry',
+ 'entry selector',
+ 'entry marker',
+ 'entry note',
+ 'entry description',
+ 'constraint',
+ 'error',
+ 'caret',
+ 'draft',
+ 'state',
+ 'caption',
+];
+
+/**
+ * A group of parts, numbered on its own.
+ */
+enum AnatomyGroup: string {
+
+ case Chrome = 'chrome';
+ case View = 'view';
+ case Edit = 'edit';
+
+}
+
+/**
+ * The side of a cell a callout reaches it from.
+ */
+enum Side: string {
+
+ case Up = 'up';
+ case Down = 'down';
+ case Left = 'left';
+ case Right = 'right';
+
+ /**
+ * Whether the label sits beside the frame rather than above or below it.
+ *
+ * The two axes are laid out differently - one takes a margin and stacks,
+ * the other takes a band - so most of the placement branches on this alone.
+ */
+ public function isHorizontal(): bool {
+ return $this === self::Left || $this === self::Right;
+ }
+
+}
+
+/**
+ * The number a part carries within its group.
+ *
+ * @param string $label
+ * The part name.
+ * @param \AnatomyGroup $group
+ * The group the diagram belongs to.
+ *
+ * @return int
+ * The part's number.
+ */
+function numberOf(string $label, AnatomyGroup $group): int {
+ $parts = match ($group) {
+ AnatomyGroup::Chrome => CHROME_PARTS,
+ AnatomyGroup::View => VIEW_PARTS,
+ AnatomyGroup::Edit => EDIT_PARTS,
+ };
+ $index = array_search($label, $parts, TRUE);
+
+ if ($index === FALSE) {
+ throw new \RuntimeException(sprintf('"%s" is not a named part of the %s group.', $label, $group));
+ }
+
+ return $index + 1;
+}
+
+/**
+ * The rows reserved above and below the frame for the callout labels.
+ */
+const MARGIN_ROWS = 2.7;
+
+/**
+ * The terminal width the frames are drawn at.
+ *
+ * The key legend is the longest line a diagram carries, and a legend cut off
+ * mid-word is the one thing these frames must not show, so the width is the
+ * widest the theme draws at. Every column past that is one the reader would
+ * get back as legible type once the whole canvas is scaled to the
+ * documentation column, so the frames take no more than they need.
+ */
+const FRAME_COLUMNS = 78;
+
+/**
+ * The label type size, in SVG user units.
+ *
+ * Generous against the frame: the whole canvas is scaled down to the width of
+ * the documentation column, so type sized to look right here lands near body
+ * size on the page.
+ */
+const FONT_SIZE = 15.0;
+
+/**
+ * The line height the frames are drawn at.
+ *
+ * Matches the other terminal assets, and cannot be loosened to give the
+ * leaders more room: the box-drawing borders are glyphs like any other, so
+ * spreading the rows apart leaves the frame drawn in disconnected segments.
+ */
+const LINE_HEIGHT = 1.1;
+
+/**
+ * The diagrams to render, keyed by name.
+ *
+ * A callout names the cell it points at. The cell is chosen so that one side
+ * of it is clear of text, since that is the side the leader arrives from.
+ *
+ * @param string $tree
+ * The sample project directory the file picker browses.
+ *
+ * @return array>
+ * Each spec carries the form, the keystrokes, the row budget and the
+ * callouts.
+ */
+function anatomySpecs(string $tree): array {
+ $enter = Key::named(KeyName::Enter);
+ $down = Key::named(KeyName::Down);
+ $space = Key::named(KeyName::Space);
+ $tab = Key::named(KeyName::Tab);
+ $open = [$enter, $enter];
+
+ // The window and the rows inside it are photographed from the same panel, so
+ // a reader moving between the two diagrams is looking at one screen twice
+ // rather than at two screens that happen to resemble each other.
+ $delivery = static fn(): Form => Form::create('Orchard')->panel('main', 'Delivery', function (PanelBuilder $p): void {
+ $p->select('basket', 'Basket contents')->description('Pick the produce for this delivery.')->help('Every crate is weighed and labelled at the packing bench before it leaves the orchard.')->multiple()->default(['apple', 'carrot'])->options(['apple' => 'Apple', 'carrot' => 'Carrot']);
+ $p->number('weight', 'Basket weight')->description('Weighed at the packing bench.')->default(1200)->min(200)->max(9000);
+ $p->calendar('harvest', 'Harvest date')->default('2026-07-15');
+ $p->text('courier', 'Courier')->default('Valley Runs');
+ $p->confirm('organic', 'Organic only?')->default(TRUE);
+ $p->text('notes', 'Notes')->default('Leave at the gate');
+ });
+
+ return [
+ 'chrome' => [
+ 'form' => $delivery(),
+ 'group' => AnatomyGroup::Chrome,
+ 'keys' => [$enter],
+ // The overflow marker is drawn only while the rows outgrow the frame, so
+ // the screen is one row short of holding the panel whole.
+ 'rows' => 16,
+ 'callouts' => [
+ ['col' => 0, 'row' => 0, 'label' => 'border', 'side' => Side::Left],
+ ['col' => 2, 'row' => 1, 'label' => 'breadcrumb', 'side' => Side::Left],
+ ['col' => 10, 'row' => 1, 'label' => 'breadcrumb separator'],
+ ['col' => 73, 'row' => 13, 'label' => 'overflow marker'],
+ ['col' => 2, 'row' => 14, 'label' => 'legend', 'side' => Side::Left],
+ ['col' => 2, 'row' => 14, 'label' => 'legend key', 'side' => Side::Down],
+ // Each points at the far end of its part rather than the near one, so a
+ // riser clears the label of the band above instead of crossing it.
+ ['col' => 12, 'row' => 14, 'label' => 'legend description', 'side' => Side::Down],
+ ['col' => 28, 'row' => 14, 'label' => 'legend separator', 'side' => Side::Down],
+ ],
+ ],
+ 'row' => [
+ 'form' => $delivery(),
+ 'group' => AnatomyGroup::View,
+ 'keys' => [$enter],
+ 'rows' => 21,
+ 'callouts' => [
+ ['col' => 2, 'row' => 2, 'label' => 'field selector'],
+ // A description hangs under its row at the value column, so it is
+ // named from the left where the label column is empty.
+ ['col' => 23, 'row' => 3, 'label' => 'description'],
+ ['label' => 'label', 'side' => Side::Left, 'cells' => [[5, 4], [8, 4], [10, 4]]],
+ ['col' => 20, 'row' => 2, 'label' => 'help marker'],
+ ['col' => 28, 'row' => 2, 'label' => 'value separator'],
+ ['label' => 'value', 'side' => Side::Right, 'cells' => [[2, 35], [5, 22], [8, 27], [10, 23]]],
+ ],
+ ],
+ 'editor' => [
+ 'form' => Form::create('Orchard')->panel('main', 'Basket', function (PanelBuilder $p): void {
+ $p->select('basket', 'Basket')->description('Pick the produce for this delivery.')->multiple()->minSelections(2)->maxSelections(3)
+ ->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')
+ ->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')
+ ->option('tomato', 'Tomato', disabled: TRUE, disabled_reason: 'out of season');
+ }),
+ 'group' => AnatomyGroup::Edit,
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 16,
+ 'callouts' => [
+ ['col' => 20, 'row' => 2, 'label' => 'entry'],
+ ['col' => 12, 'row' => 3, 'label' => 'entry selector'],
+ ['col' => 14, 'row' => 4, 'label' => 'entry marker'],
+ ['col' => 37, 'row' => 4, 'label' => 'entry note'],
+ ['col' => 16, 'row' => 5, 'label' => 'entry description'],
+ ['col' => 12, 'row' => 6, 'label' => 'constraint'],
+ ],
+ ],
+ 'filepicker' => [
+ 'form' => Form::create('Orchard')->panel('main', 'Price list', function (PanelBuilder $p) use ($tree): void {
+ $p->filePicker('price_list', 'Price list')->description('The CSV the orchard sends each week.')->startIn($tree)->filesOnly()->extensions(['csv'])->maxSize(2097152);
+ }),
+ 'group' => AnatomyGroup::Edit,
+ 'keys' => [...$open, $down],
+ 'rows' => 16,
+ 'callouts' => [
+ ['col' => 29, 'row' => 2, 'label' => 'caption'],
+ ['col' => 18, 'row' => 3, 'label' => 'entry'],
+ ['col' => 16, 'row' => 4, 'label' => 'entry selector'],
+ ['col' => 16, 'row' => 6, 'label' => 'constraint'],
+ ],
+ ],
+ 'text' => [
+ 'form' => Form::create('Orchard')->panel('main', 'Crate', function (PanelBuilder $p): void {
+ $p->template('crate', 'Crate label')->description('Identifies the crate on the loading dock.')->pattern('{{orchard}}-{{fruit}}-{{grade}}')->default('valley-pear-a')->slot('orchard', 'Orchard')->slot('fruit', 'Fruit')->slot('grade', 'Grade');
+ }),
+ 'group' => AnatomyGroup::Edit,
+ 'keys' => [...$open, $tab],
+ 'rows' => 10,
+ 'callouts' => [
+ ['col' => 28, 'row' => 2, 'label' => 'caret'],
+ ['col' => 30, 'row' => 2, 'label' => 'draft'],
+ ['col' => 32, 'row' => 3, 'label' => 'state', 'side' => Side::Right],
+ ],
+ ],
+ 'constraint' => [
+ 'form' => Form::create('Orchard')->panel('main', 'Price list', function (PanelBuilder $p) use ($tree): void {
+ $p->filePicker('price_list', 'Price list')->startIn($tree)->filesOnly()->maxSize(64);
+ }),
+ 'group' => AnatomyGroup::Edit,
+ 'keys' => [...$open],
+ 'rows' => 20,
+ 'callouts' => [
+ ['col' => 16, 'row' => 9, 'label' => 'constraint'],
+ ],
+ ],
+ // The same picker, after a pick that breaks the size limit the line above
+ // announced: one line, the other of its two states.
+ 'error' => [
+ 'form' => Form::create('Orchard')->panel('main', 'Price list', function (PanelBuilder $p) use ($tree): void {
+ $p->filePicker('price_list', 'Price list')->startIn($tree)->filesOnly()->maxSize(64);
+ }),
+ 'group' => AnatomyGroup::Edit,
+ 'keys' => [...$open, $down, $down, $down, $enter],
+ 'rows' => 20,
+ 'callouts' => [
+ ['col' => 16, 'row' => 9, 'label' => 'error'],
+ ],
+ ],
+ ];
+}
+
+/**
+ * Render one diagram's dark and light SVGs.
+ *
+ * @param string $name
+ * The diagram name.
+ * @param array $spec
+ * The diagram spec.
+ * @param string $assets_dir
+ * The directory the assets are written to.
+ * @param string $util_dir
+ * The directory holding the node renderer.
+ * @param string $tmp_dir
+ * The scratch directory for the intermediate cast.
+ */
+function renderAnatomy(string $name, array $spec, string $assets_dir, string $util_dir, string $tmp_dir): void {
+ $tester = (new TuiTester($spec['form']))->options(['color' => TRUE, 'unicode' => TRUE, 'mode' => Mode::Dark])->rows($spec['rows'])->cols($spec['cols'] ?? FRAME_COLUMNS);
+ $tester->run(...$spec['keys']);
+
+ $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
+ $parts = explode($clear, $tester->output());
+ $frames = array_values(array_filter($parts, static fn(string $s): bool => trim(Ansi::strip($s)) !== ''));
+
+ if ($frames === []) {
+ throw new \RuntimeException(sprintf('Diagram "%s" produced no frame.', $name));
+ }
+
+ $frame = str_replace("\n", "\r\n", str_replace("\r", '', $frames[count($frames) - 1]));
+ $lines = explode("\r\n", $frame);
+
+ // The row budget sizes the terminal the frame was drawn in, not the frame:
+ // a shorter frame leaves blank rows and a taller one would be cropped from
+ // the top, so the cast is sized from the lines actually captured.
+ while ($lines !== [] && trim(end($lines)) === '') {
+ array_pop($lines);
+ }
+
+ $frame = implode("\r\n", $lines);
+ $width = 0;
+
+ foreach ($lines as $line) {
+ $width = max($width, Ansi::width($line));
+ }
+
+ $cast = json_encode(['version' => 2, 'width' => $width, 'height' => count($lines)]) . "\n" . json_encode([0.0, 'o', $clear . $frame]) . "\n";
+ $cast_file = $tmp_dir . '/anatomy-' . $name . '.cast';
+ file_put_contents($cast_file, $cast);
+
+ $dark_file = $assets_dir . '/anatomy-' . $name . '-dark-static.svg';
+ renderCast($cast_file, $dark_file, $util_dir);
+ $light_file = deriveLightTwin($dark_file);
+
+ $plain = array_map(Ansi::strip(...), $lines);
+
+ // A callout must read as annotation rather than as output, so it takes a hue
+ // the palettes do not spend on the frame, on values or on errors - and one
+ // that stays legible against its own surface, which is why the two modes do
+ // not share it.
+ annotate($dark_file, $spec['callouts'], $plain, $width, $spec['group'], 'rgb(163,230,53)');
+ annotate($light_file, $spec['callouts'], $plain, $width, $spec['group'], 'rgb(124,45,190)');
+}
+
+/**
+ * Whether a stretch of a row holds nothing a leader may not cross.
+ *
+ * Box-drawing characters are crossable: a leader meeting the frame reads as a
+ * callout, while one meeting a letter reads as a strikethrough.
+ *
+ * @param string $line
+ * The row, without escape sequences.
+ * @param int $from
+ * The first column to test.
+ * @param int $to
+ * The last column to test.
+ *
+ * @return bool
+ * TRUE when every column in the range is blank or part of the frame.
+ */
+function crossable(string $line, int $from, int $to): bool {
+ $chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+
+ for ($column = max(0, $from); $column <= $to; $column++) {
+ $char = $chars[$column] ?? ' ';
+
+ if ($char !== ' ' && !str_contains('─│├┤╭╮╰╯┬┴┼', $char)) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/**
+ * Whether a column is clear of text across a range of rows.
+ *
+ * @param array $lines
+ * The frame's rows, without escape sequences.
+ * @param int $column
+ * The column to test.
+ * @param int $from
+ * The first row to test.
+ * @param int $to
+ * The last row to test.
+ *
+ * @return bool
+ * TRUE when every row in the range is blank or frame at that column.
+ */
+function columnClear(array $lines, int $column, int $from, int $to): bool {
+ for ($row = max(0, $from); $row <= $to; $row++) {
+ if (!crossable($lines[$row] ?? '', $column, $column)) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/**
+ * The cells a callout points at.
+ *
+ * @param array $callout
+ * The callout.
+ *
+ * @return array>
+ * The cells, each a row and a column.
+ */
+function cellsOf(array $callout): array {
+ return $callout['cells'] ?? [[(int) $callout['row'], (int) $callout['col']]];
+}
+
+/**
+ * The column a multi-cell callout gathers its arrows on.
+ *
+ * The bracket has to stand clear of every row it spans, not just the rows it
+ * points at, and each arrow has to reach its cell without crossing anything.
+ * The nearest column satisfying both wins, so the bracket hugs the cells.
+ *
+ * @param array $lines
+ * The frame's rows, without escape sequences.
+ * @param array> $cells
+ * The cells, each a row and a column.
+ * @param \Side $side
+ * The side the bracket stands on.
+ * @param int $columns
+ * The number of columns the frame holds.
+ *
+ * @return int|null
+ * The column, or NULL when no column on that side is clear.
+ */
+function bracketColumn(array $lines, array $cells, Side $side, int $columns): ?int {
+ $rows = array_column($cells, 0);
+ $cols = array_column($cells, 1);
+ $first = min($rows);
+ $last = max($rows);
+ // The frame's own border occupies the outermost column, and a bracket laid
+ // against it reads as part of the frame rather than as an annotation of it.
+ $range = $side === Side::Left ? range(min($cols) - 1, 2) : range(max($cols) + 1, $columns - 3);
+ $clear = [];
+
+ foreach ($range as $offset => $column) {
+ if (!columnClear($lines, $column, $first, $last)) {
+ continue;
+ }
+
+ foreach ($cells as [$row, $cell]) {
+ $from = $side === Side::Left ? $column + 1 : $cell + 1;
+ $to = $side === Side::Left ? $cell - 1 : $column - 1;
+
+ if (!crossable($lines[$row] ?? '', $from, $to)) {
+ continue 2;
+ }
+ }
+
+ // Standing the bracket off by a few columns where the room exists keeps
+ // the arrowheads clear of the glyphs they point at; hard against the text
+ // they read as part of it.
+ $clear[] = $column;
+
+ if ($offset >= 3) {
+ break;
+ }
+ }
+
+ return $clear === [] ? NULL : end($clear);
+}
+
+/**
+ * The row a bracket's label reaches it on.
+ *
+ * @param array $lines
+ * The frame's rows, without escape sequences.
+ * @param int $bracket
+ * The bracket's column.
+ * @param \Side $side
+ * The side the bracket stands on.
+ * @param int $first
+ * The first row the bracket spans.
+ * @param int $last
+ * The last row the bracket spans.
+ * @param int $columns
+ * The number of columns the frame holds.
+ *
+ * @return int
+ * The row, closest to the bracket's middle that the label can reach along.
+ */
+function connectorRow(array $lines, int $bracket, Side $side, int $first, int $last, int $columns): int {
+ $middle = (int) round(($first + $last) / 2);
+ $rows = range($first, $last);
+
+ usort($rows, static fn(int $a, int $b): int => abs($a - $middle) <=> abs($b - $middle));
+
+ foreach ($rows as $row) {
+ $from = $side === Side::Left ? 0 : $bracket + 1;
+ $to = $side === Side::Left ? $bracket - 1 : $columns - 1;
+
+ if (crossable($lines[$row] ?? '', $from, $to)) {
+ return $row;
+ }
+ }
+
+ return $middle;
+}
+
+/**
+ * The side a callout's leader arrives from.
+ *
+ * Vertical exits are preferred near the top and bottom of the frame so the
+ * labels spread around it rather than piling into the two margins.
+ *
+ * @param array $lines
+ * The frame's rows, without escape sequences.
+ * @param int $row
+ * The callout's row.
+ * @param int $column
+ * The callout's column.
+ * @param int $columns
+ * The number of columns the frame holds.
+ *
+ * @return \Side
+ * The side the leader reaches the cell from.
+ */
+function leaderSide(array $lines, int $row, int $column, int $columns): Side {
+ $rows = count($lines);
+ $up = columnClear($lines, $column, 0, $row - 1);
+ $down = columnClear($lines, $column, $row + 1, $rows - 1);
+ $left = crossable($lines[$row] ?? '', 0, $column - 1);
+ $right = crossable($lines[$row] ?? '', $column + 1, $columns - 1);
+
+ if ($up && $row <= 2) {
+ return Side::Up;
+ }
+
+ if ($down && $row >= $rows - 3) {
+ return Side::Down;
+ }
+
+ return match (TRUE) {
+ $left => Side::Left,
+ $right => Side::Right,
+ $up => Side::Up,
+ $down => Side::Down,
+ default => Side::Right,
+ };
+}
+
+/**
+ * Draw the callouts around a rendered frame.
+ *
+ * The canvas gains a margin on all four sides and each leader arrives from
+ * whichever side of its cell is clear, so the labels sit around the frame and
+ * no leader is ever drawn over a letter.
+ *
+ * @param string $file
+ * The SVG to annotate, rewritten in place.
+ * @param array> $callouts
+ * The callouts, each naming a cell by column and row.
+ * @param array $lines
+ * The frame's rows, without escape sequences.
+ * @param int $columns
+ * The number of columns the frame holds.
+ * @param \AnatomyGroup $group
+ * The group the diagram belongs to, which its numbering runs within.
+ * @param string $color
+ * The colour for the leaders and labels.
+ */
+function annotate(string $file, array $callouts, array $lines, int $columns, AnatomyGroup $group, string $color): void {
+ $svg = file_get_contents($file);
+
+ if ($svg === FALSE || !preg_match('/]*width="([\d.]+)"[^>]*height="([\d.]+)"/', $svg, $m)) {
+ throw new \RuntimeException('Could not read the canvas size of ' . $file);
+ }
+
+ $frame_width = (float) $m[1];
+ $frame_height = (float) $m[2];
+
+ // Derived from the render rather than assumed, so a change of font size or
+ // line height moves the callouts with the frame instead of stranding them.
+ $cell_width = $frame_width / $columns;
+ $cell_height = $frame_height / count($lines);
+
+ usort($callouts, static fn(array $a, array $b): int => cellsOf($a)[0] <=> cellsOf($b)[0]);
+
+ // Each side is settled first, then the margins are sized from the labels
+ // that will actually land in them: a fixed margin either clips the longest
+ // part name or pads every diagram out to fit it.
+ $plans = [];
+ $levels = [];
+ $needs = [Side::Left->value => 0.0, Side::Right->value => 0.0];
+
+ foreach ($callouts as $index => $callout) {
+ $text = numberOf($callout['label'], $group) . ' ' . $callout['label'];
+ $width = mb_strlen($text) * FONT_SIZE * 0.6;
+ $cells = cellsOf($callout);
+
+ // A part that occurs several times gets one label and one arrow per
+ // occurrence, gathered on a bracket - three numbers for one part would
+ // read as three parts.
+ if (count($cells) > 1) {
+ $side = $callout['side'] ?? Side::Right;
+ $bracket = bracketColumn($lines, $cells, $side, $columns);
+
+ if ($bracket === NULL) {
+ throw new \RuntimeException(sprintf('No clear %s bracket for "%s".', $side->value, $callout['label']));
+ }
+
+ $rows = array_column($cells, 0);
+ $plans[$index] = ['bracket', $side, $text, $cells, $bracket, connectorRow($lines, $bracket, $side, min($rows), max($rows), $columns)];
+ $needs[$side->value] = max($needs[$side->value], $width + $cell_width * 2.4);
+
+ continue;
+ }
+
+ [$row, $column] = $cells[0];
+ // A declared side wins: the clear sides of a cell are often several, and
+ // which of them reads best is a judgement the frame cannot make.
+ $side = $callout['side'] ?? leaderSide($lines, $row, $column, $columns);
+ $plans[$index] = ['single', $side, $text, $cells];
+
+ if ($side->isHorizontal()) {
+ $needs[$side->value] = max($needs[$side->value], $width + $cell_width * 2.4);
+
+ continue;
+ }
+
+ // A label above or below is centred on its cell, so one near either end of
+ // the frame hangs over that edge and the margin has to take the overhang.
+ // Two that overlap horizontally stack into bands, and the band count is
+ // what the room above has to be measured from - a fixed allowance clips the
+ // outermost one the moment a second band is needed.
+ $centre = ((float) $column + 0.5) * $cell_width;
+ $needs[Side::Left->value] = max($needs[Side::Left->value], $width / 2 - $centre);
+ $needs[Side::Right->value] = max($needs[Side::Right->value], $width / 2 - ($frame_width - $centre));
+
+ $level = 0;
+
+ while (isset($levels[$side->value][$level]) && overlaps($levels[$side->value][$level], $centre - $width / 2, $centre + $width / 2)) {
+ $level++;
+ }
+
+ $levels[$side->value][$level][] = [$centre - $width / 2, $centre + $width / 2];
+ $plans[$index][4] = $level;
+ }
+
+ $offset_x = $needs[Side::Left->value];
+
+ // The bands above are only worth their room when something lands in them; a
+ // diagram labelled entirely from the sides would otherwise open with a strip
+ // of empty canvas.
+ $offset_y = isset($levels[Side::Up->value])
+ ? FONT_SIZE * (1.35 * count($levels[Side::Up->value]) + 0.4)
+ : $cell_height * 0.4;
+ $left_edge = $offset_x;
+ $right_edge = $offset_x + $frame_width;
+ $bottom_edge = $offset_y + $frame_height;
+
+ $marks = '';
+ $stack = [Side::Left->value => -$cell_height, Side::Right->value => -$cell_height];
+ $lowest = $bottom_edge;
+
+ foreach ($plans as $plan) {
+ [$kind, $side, $text, $cells] = $plan;
+
+ if ($kind === 'bracket') {
+ [, , , , $bracket, $connector] = $plan;
+ $bracket_x = $offset_x + ((float) $bracket + 0.5) * $cell_width;
+ $connector_y = $offset_y + ((float) $connector + 0.5) * $cell_height;
+ $rows = array_column($cells, 0);
+ $top = $offset_y + ((float) min($rows) + 0.5) * $cell_height;
+ $foot = $offset_y + ((float) max($rows) + 0.5) * $cell_height;
+ $label_x = $side === Side::Left ? $left_edge - $cell_width * 1.4 : $right_edge + $cell_width * 1.4;
+
+ $marks .= sprintf('', $bracket_x, $top, $bracket_x, $foot, $color);
+ $marks .= sprintf('', $side === Side::Left ? $label_x + FONT_SIZE * 0.4 : $label_x - FONT_SIZE * 0.4, $connector_y, $bracket_x, $connector_y, $color);
+
+ foreach ($cells as [$cell_row, $cell_col]) {
+ $to_x = $offset_x + ((float) $cell_col + 0.5) * $cell_width;
+ $tip = $side === Side::Left ? $to_x - $cell_width * 0.7 : $to_x + $cell_width * 0.7;
+ $marks .= sprintf('', $bracket_x, $offset_y + ((float) $cell_row + 0.5) * $cell_height, $tip, $offset_y + ((float) $cell_row + 0.5) * $cell_height, $color);
+ }
+
+ $marks .= sprintf('%s', $label_x, $connector_y + FONT_SIZE * 0.35, $side === Side::Left ? 'end' : 'start', $color, FONT_SIZE, label($text));
+ $stack[$side->value] = max($stack[$side->value], $foot);
+
+ continue;
+ }
+
+ [$row, $column] = $cells[0];
+ $cell_x = $offset_x + ((float) $column + 0.5) * $cell_width;
+ $cell_y = $offset_y + ((float) $row + 0.5) * $cell_height;
+
+ if (!$side->isHorizontal()) {
+ // The band was settled while the margins were being measured, so the room
+ // above the frame already accounts for however many bands are in use.
+ $step = (FONT_SIZE * 1.35) * $plan[4];
+ $label_y = $side === Side::Up ? $offset_y - FONT_SIZE * 0.5 - $step : $bottom_edge + FONT_SIZE * 1.1 + $step;
+ $tip_y = $side === Side::Up ? $cell_y - $cell_height * 0.55 : $cell_y + $cell_height * 0.55;
+ $from_y = $side === Side::Up ? $label_y + FONT_SIZE * 0.35 : $label_y - FONT_SIZE * 0.95;
+
+ $marks .= sprintf('', $cell_x, $from_y, $cell_x, $tip_y, $color);
+ $marks .= sprintf('%s', $cell_x, $label_y, $color, FONT_SIZE, label($text));
+ $lowest = max($lowest, $label_y);
+
+ continue;
+ }
+
+ // One row of separation is enough, and it is what keeps a leader straight:
+ // a label that fits its own row sits level with the cell it names, so the
+ // leader is a horizontal line and can cross nothing on the way.
+ $label_y = max($cell_y, $stack[$side->value] + max($cell_height, FONT_SIZE * 1.15));
+ $stack[$side->value] = $label_y;
+ $tip_x = $side === Side::Left ? $cell_x - $cell_width * 0.7 : $cell_x + $cell_width * 0.7;
+ $bend_x = $side === Side::Left ? $left_edge - $cell_width * 0.6 : $right_edge + $cell_width * 0.6;
+ $label_x = $side === Side::Left ? $left_edge - $cell_width * 1.4 : $right_edge + $cell_width * 1.4;
+ $anchor = $side === Side::Left ? 'end' : 'start';
+ $from_x = $side === Side::Left ? $label_x + FONT_SIZE * 0.4 : $label_x - FONT_SIZE * 0.4;
+
+ $marks .= sprintf('', $from_x, $label_y, $bend_x, $cell_y, $tip_x, $cell_y, $color);
+ $marks .= sprintf('%s', $label_x, $label_y + FONT_SIZE * 0.35, $anchor, $color, FONT_SIZE, label($text));
+ }
+
+ $canvas_width = $needs[Side::Left->value] + $frame_width + $needs[Side::Right->value];
+ $canvas_height = max($lowest, $stack[Side::Left->value], $stack[Side::Right->value]) + FONT_SIZE;
+ $arrow = sprintf('', $color);
+
+ // The frame renders inside a nested svg of its own, so wrapping rather than
+ // editing it keeps every generated coordinate inside untouched.
+ $wrapped = sprintf('%s%s%s', $canvas_width, $canvas_height, $arrow, $offset_x, $offset_y, $svg, $marks);
+
+ file_put_contents($file, $wrapped);
+}
+
+/**
+ * Whether a span overlaps any span already placed in a band.
+ *
+ * @param array> $placed
+ * The spans already in the band, each a start and an end.
+ * @param float $from
+ * The span's start.
+ * @param float $to
+ * The span's end.
+ *
+ * @return bool
+ * TRUE when the span would collide.
+ */
+function overlaps(array $placed, float $from, float $to): bool {
+ foreach ($placed as $span) {
+ if ($from < $span[1] + 6.0 && $to + 6.0 > $span[0]) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * A label with its number set in bold.
+ *
+ * @param string $text
+ * The label, its number first.
+ *
+ * @return string
+ * The label's markup.
+ */
+function label(string $text): string {
+ [$number, $name] = explode(' ', $text, 2);
+
+ return sprintf('%s %s', $number, htmlspecialchars($name, ENT_XML1));
+}
+
+/**
+ * Render a cast file to an SVG.
+ *
+ * @param string $cast_file
+ * The cast file.
+ * @param string $svg_file
+ * The SVG to write.
+ * @param string $util_dir
+ * The directory holding the node renderer.
+ */
+function renderCast(string $cast_file, string $svg_file, string $util_dir): void {
+ if (is_file($svg_file)) {
+ unlink($svg_file);
+ }
+
+ $cmd = sprintf('node %s %s %s --line-height %s --at 0 2>&1', escapeshellarg($util_dir . '/svg-term-render.js'), escapeshellarg($cast_file), escapeshellarg($svg_file), LINE_HEIGHT);
+ $output = shell_exec($cmd);
+
+ if (!file_exists($svg_file) || filesize($svg_file) === 0) {
+ throw new \RuntimeException('Failed to render SVG: ' . $svg_file . "\n" . ($output ?? ''));
+ }
+}
+
+// Entrypoint.
+ini_set('display_errors', '1');
+
+if (PHP_SAPI !== 'cli') {
+ die('This script can be only ran from the command line.');
+}
+
+$util_dir = __DIR__;
+$assets_dir = dirname(__DIR__) . '/assets';
+$tmp_dir = dirname(__DIR__, 2) . '/.artifacts/tmp/anatomy-svgs';
+$tree = dirname(__DIR__, 2) . '/playground/sample-project';
+
+if (!is_dir($tmp_dir)) {
+ mkdir($tmp_dir, 0755, TRUE);
+}
+
+$specs = anatomySpecs($tree);
+$only = $argv[1] ?? '';
+
+foreach ($specs as $name => $spec) {
+ if ($only !== '' && $only !== $name) {
+ continue;
+ }
+
+ renderAnatomy($name, $spec, $assets_dir, $util_dir, $tmp_dir);
+ print 'Rendered anatomy-' . $name . PHP_EOL;
+}
diff --git a/docs/util/render-field-svgs.php b/docs/util/render-field-svgs.php
new file mode 100644
index 00000000..966e0eed
--- /dev/null
+++ b/docs/util/render-field-svgs.php
@@ -0,0 +1,526 @@
+#!/usr/bin/env php
+ ['color' => TRUE, 'unicode' => TRUE],
+ '-ascii' => ['color' => TRUE, 'unicode' => FALSE],
+ '-no-ansi' => ['color' => FALSE, 'unicode' => TRUE],
+ '-ascii-no-ansi' => ['color' => FALSE, 'unicode' => FALSE],
+];
+
+/**
+ * The per-field forms and the keystrokes that drive them.
+ *
+ * Each single-field form mirrors its playground/02-fields-* script - same ids,
+ * labels, defaults and options - so the rendered cards match the code a
+ * reader runs. The keystrokes follow the panel model: Enter drills the hub
+ * into the panel, a second Enter opens the field editor, then the
+ * field-specific keys exercise it and Enter (or Tab) accepts - the same path
+ * a person walks.
+ *
+ * A spec hands back a factory rather than a form: a run that ends mid-edit
+ * leaves its answers on the form it drove, so a form shared between the
+ * display modes would open the next one on the last one's value.
+ *
+ * @param string $tree
+ * The fixture directory the file-picker fields browse.
+ *
+ * @return array, rows: int, static_keys?: list}>
+ * The field specs keyed by asset name. A spec may add "static_keys" when the
+ * opened editor needs a keystroke before its static frame is worth capturing.
+ */
+function fieldSpecs(string $tree): array {
+ $enter = Key::named(KeyName::Enter);
+ $down = Key::named(KeyName::Down);
+ $space = Key::named(KeyName::Space);
+ $bs = Key::named(KeyName::Backspace);
+ $tab = Key::named(KeyName::Tab);
+ $left = Key::named(KeyName::Left);
+ $right = Key::named(KeyName::Right);
+ // Two Enters walk the hub into the panel and open the field editor; the
+ // animation then ends inside the editor on the changed value, so the frame
+ // stays as narrow as the field itself rather than the full-width panel row.
+ $open = [$enter, $enter];
+ // A short packing job whose every advance() repaints the row, so the capture
+ // holds one frame per filled step as the bar grows.
+ $pack = static function (ProgressReporter $reporter): void {
+ for ($step = 0; $step < 6; $step++) {
+ $reporter->advance();
+ }
+ };
+
+ return [
+ 'text' => [
+ 'form' => static fn(): Form => Form::create('Text field')->panel('main', 'Text', function (PanelBuilder $p): void { $p->text('item', 'Item')->default('Pear')->complete(['Pear', 'Peach', 'Plum']); }),
+ 'keys' => [...$open, $bs, $bs, $bs, $bs, 'A', 'p', 'p', 'l', 'e'],
+ 'rows' => 6,
+ ],
+ 'template' => [
+ 'form' => static fn(): Form => Form::create('Template field')->panel('main', 'Template', function (PanelBuilder $p): void { $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{fruit}}-{{grade}}')->default('valley-pear-a')->slot('orchard', 'Orchard')->slot('fruit', 'Fruit')->slot('grade', 'Grade'); }),
+ 'keys' => [...$open, $bs, $bs, $bs, $bs, $bs, $bs, 'r', 'i', 'd', 'g', 'e', $tab, $tab, $bs, 'b'],
+ 'rows' => 7,
+ ],
+ 'number' => [
+ 'form' => static fn(): Form => Form::create('Number field')->panel('main', 'Number', function (PanelBuilder $p): void { $p->number('weight', 'Basket weight (g)')->default(1200)->min(200)->max(9000)->step(100); }),
+ 'keys' => [...$open, $bs, $bs, $bs, $bs, '4', '2', '0', '0'],
+ 'rows' => 6,
+ ],
+ 'rating' => [
+ 'form' => static fn(): Form => Form::create('Rating field')->panel('main', 'Rating', function (PanelBuilder $p): void { $p->rating('freshness', 'Freshness')->default(4)->captions([1 => 'Poor', 3 => 'Fair', 5 => 'Excellent']); }),
+ // Walk down the scale and back, so the animation passes through a
+ // captioned point; the static frame rests on that point, since a caption
+ // is half of what the field shows.
+ 'keys' => [...$open, $left, $left, $right, $right],
+ 'static_keys' => [...$open, $left],
+ 'rows' => 6,
+ ],
+ 'calendar' => [
+ 'form' => static fn(): Form => Form::create('Calendar field')->panel('main', 'Calendar', function (PanelBuilder $p): void { $p->calendar('harvest', 'Harvest date')->default('2026-07-15'); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 14,
+ ],
+ 'textarea' => [
+ 'form' => static fn(): Form => Form::create('Textarea field')->panel('main', 'Textarea', function (PanelBuilder $p): void { $p->textarea('notes', 'Tasting notes')->default('Crisp and sweet' . chr(10) . 'Hint of citrus'); }),
+ 'keys' => [...$open, $enter, 'S', 'l', 'i', 'g', 'h', 't', 'l', 'y', ' ', 't', 'a', 'r', 't'],
+ 'rows' => 8,
+ ],
+ 'password' => [
+ 'form' => static fn(): Form => Form::create('Password field')->panel('main', 'Password', function (PanelBuilder $p): void { $p->password('code', 'Order code')->default('melon7'); }),
+ 'keys' => [...$open, $bs, $bs, $bs, $bs, $bs, $bs, 'g', 'r', 'a', 'p', 'e', '5'],
+ 'rows' => 6,
+ ],
+ 'select' => [
+ 'form' => static fn(): Form => Form::create('Select field')->panel('main', 'Select', function (PanelBuilder $p): void { $p->select('fruit', 'Fruit')->default('apple')->options(['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry']); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 8,
+ ],
+ 'select-descriptions' => [
+ 'form' => static fn(): Form => Form::create('Option descriptions')->panel('main', 'Select', function (PanelBuilder $p): void { $p->select('fruit', 'Fruit')->default('apple')->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.'); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 12,
+ ],
+ 'select-multiple' => [
+ 'form' => static fn(): Form => Form::create('MultiSelect field')->panel('main', 'MultiSelect', function (PanelBuilder $p): void { $p->select('basket', 'Basket')->multiple()->default(['apple'])->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
+ 'keys' => [...$open, $down, $space],
+ 'rows' => 8,
+ ],
+ 'select-multiple-limited' => [
+ 'form' => static fn(): Form => Form::create('Bounded MultiSelect')->panel('main', 'MultiSelect', function (PanelBuilder $p): void { $p->select('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 11,
+ ],
+ 'reorder' => [
+ 'form' => static fn(): Form => Form::create('Reorder field')->panel('main', 'Reorder', function (PanelBuilder $p): void { $p->reorder('basket', 'Basket')->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 12,
+ ],
+ 'reorder-descriptions' => [
+ 'form' => static fn(): Form => Form::create('Option descriptions')->panel('main', 'Reorder', function (PanelBuilder $p): void { $p->reorder('basket', 'Basket')->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')->option('tomato', 'Tomato', description: 'Best ripened on the vine, never chilled.'); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 12,
+ ],
+ 'suggest' => [
+ 'form' => static fn(): Form => Form::create('Suggest field')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->options(['Apple' => 'Apple', 'Apricot' => 'Apricot', 'Banana' => 'Banana', 'Cherry' => 'Cherry', 'Mango' => 'Mango']); }),
+ 'keys' => [...$open, 'C', 'h', $down],
+ 'rows' => 10,
+ ],
+ 'suggest-ghost' => [
+ 'form' => static fn(): Form => Form::create('Ghost text')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->options(['Apple' => 'Apple', 'Apricot' => 'Apricot', 'Banana' => 'Banana', 'Cherry' => 'Cherry', 'Mango' => 'Mango'])->ghost(); }),
+ // The preview only exists while the query is a prefix of a candidate and
+ // nothing is highlighted, so the frames settle on typed text alone.
+ 'keys' => [...$open, 'A', 'p'],
+ 'static_keys' => [...$open, 'A', 'p'],
+ 'rows' => 10,
+ ],
+ 'suggest-descriptions' => [
+ 'form' => static fn(): Form => Form::create('Option descriptions')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->option('Apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('Apricot', 'Apricot', description: 'Small and tart; best when soft.')->option('Banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')->option('Cherry', 'Cherry', description: 'Short season; best eaten fresh.')->option('Mango', 'Mango', description: 'Fragrant and juicy when it yields to a squeeze.'); }),
+ 'keys' => [...$open, $down],
+ 'static_keys' => [...$open, $down],
+ 'rows' => 13,
+ ],
+ 'search' => [
+ 'form' => static fn(): Form => Form::create('Search field')->panel('main', 'Search', function (PanelBuilder $p): void { $p->search('vegetable', 'Vegetable')->default('carrot')->options(['carrot' => 'Carrot', 'potato' => 'Potato', 'onion' => 'Onion', 'pepper' => 'Pepper']); }),
+ 'keys' => [...$open, 'o', 'n'],
+ 'rows' => 10,
+ ],
+ 'search-descriptions' => [
+ 'form' => static fn(): Form => Form::create('Option descriptions')->panel('main', 'Search', function (PanelBuilder $p): void { $p->search('vegetable', 'Vegetable')->default('carrot')->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')->option('potato', 'Potato', description: 'Stores best somewhere cool and dark.')->option('onion', 'Onion', description: 'Sharp raw, sweet once cooked.')->option('pepper', 'Pepper', description: 'Crunchy and bright; sweetest when red.'); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 12,
+ ],
+ 'search-multiple' => [
+ 'form' => static fn(): Form => Form::create('MultiSearch field')->panel('main', 'MultiSearch', function (PanelBuilder $p): void { $p->search('basket', 'Basket')->multiple()->default(['apple'])->options(['apple' => 'Apple', 'banana' => 'Banana', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
+ 'keys' => [...$open, 't', 'o', $space],
+ 'rows' => 10,
+ ],
+ 'search-multiple-limited' => [
+ 'form' => static fn(): Form => Form::create('Bounded MultiSearch')->panel('main', 'MultiSearch', function (PanelBuilder $p): void { $p->search('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options(['apple' => 'Apple', 'banana' => 'Banana', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 13,
+ ],
+ 'confirm' => [
+ 'form' => static fn(): Form => Form::create('Confirm field')->panel('main', 'Confirm', function (PanelBuilder $p): void { $p->confirm('organic', 'Organic only?')->default(TRUE); }),
+ 'keys' => [...$open, 'n'],
+ 'rows' => 6,
+ ],
+ 'toggle' => [
+ 'form' => static fn(): Form => Form::create('Toggle field')->panel('main', 'Toggle', function (PanelBuilder $p): void { $p->toggle('ripeness', 'Ripeness')->default('ripe')->options(['ripe' => 'Ripe', 'unripe' => 'Unripe']); }),
+ 'keys' => [...$open, 'u'],
+ 'rows' => 6,
+ ],
+ 'pause' => [
+ 'form' => static fn(): Form => Form::create('Pause field')->panel('main', 'Pause', function (PanelBuilder $p): void { $p->pause('review', 'Review your basket'); }),
+ 'keys' => [$enter],
+ 'rows' => 6,
+ ],
+ 'note' => [
+ 'form' => static fn(): Form => Form::create('Note field')->panel('main', 'Note', function (PanelBuilder $p): void {
+ $p->note('intro', 'Fresh produce order')->description('A read-only card - the cursor skips it.');
+ $p->note('packing', 'Ready to pack')->description('Framed with a border.')->border();
+ }),
+ 'keys' => [$enter],
+ 'rows' => 14,
+ ],
+ 'note-markdown' => [
+ 'form' => static fn(): Form => Form::create('Markdown note')->panel('main', 'Note', function (PanelBuilder $p): void {
+ $p->note('order', 'Fresh produce order')->description('Pick what is **ripe** today:' . chr(10) . '- crisp `apples`' . chr(10) . '- sweet *pears*' . chr(10) . 'See the [seasonal guide](https://example.com/guide).')->border();
+ }),
+ 'keys' => [$enter],
+ 'rows' => 14,
+ 'options' => ['markdown' => TRUE],
+ ],
+ 'table' => [
+ 'form' => static fn(): Form => Form::create('Table field')->panel('main', 'Stock', function (PanelBuilder $p): void {
+ $p->note('stock', 'Basket contents')->description('Everything picked so far:')->table(['Fruit', 'Color', 'In stock'], [
+ ['Apple', 'Red', '12'],
+ ['Pear', 'Green', '5'],
+ ['Plum', 'Purple', '120'],
+ ]);
+ }),
+ 'keys' => [$enter],
+ 'rows' => 16,
+ ],
+ 'progress' => [
+ 'form' => static fn(): Form => Form::create('Progress field')->panel('main', 'Progress', function (PanelBuilder $p) use ($pack): void { $p->progress('pack', 'Packing the box')->steps(6)->run($pack); }),
+ 'keys' => [$enter, $enter],
+ 'rows' => 6,
+ ],
+ 'filepicker' => [
+ 'form' => static fn(): Form => Form::create('File picker field')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_list', 'Price list')->startIn($tree)->filesOnly()->extensions(['csv'])->maxSize(2097152); }),
+ 'keys' => [...$open, $down],
+ 'rows' => 12,
+ ],
+ 'filepicker-multiple' => [
+ 'form' => static fn(): Form => Form::create('File picker field')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_lists', 'Price lists')->multiple()->startIn($tree); }),
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 10,
+ ],
+ 'filepicker-multiple-limited' => [
+ 'form' => static fn(): Form => Form::create('File picker field')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_lists', 'Price lists')->multiple()->minSelections(2)->maxSelections(3)->startIn($tree); }),
+ 'keys' => [...$open, $space, $down, $space],
+ 'rows' => 14,
+ ],
+ ];
+}
+
+/**
+ * Drive one field and write its animated SVG.
+ *
+ * @param string $name
+ * The asset name.
+ * @param array{form: callable(): \DrevOps\Tui\Builder\Form, keys: list, rows: int} $spec
+ * The field spec.
+ * @param string $assets_dir
+ * The output directory.
+ * @param string $util_dir
+ * The tooling directory holding the svg-term renderer.
+ * @param string $tmp_dir
+ * A scratch directory for the intermediate cast.
+ */
+function renderField(string $name, array $spec, string $assets_dir, string $util_dir, string $tmp_dir): void {
+ foreach (DISPLAY_MODES as $suffix => $mode) {
+ // The animated hero cards render at the default look - the padded
+ // rounded border every panel demo shares. The border adds a row above
+ // and below the content and the padding another one each side.
+ $tester = (new TuiTester(($spec['form'])()))
+ ->options(['color' => $mode['color'], 'unicode' => $mode['unicode'], 'mode' => Mode::Dark] + ($spec['options'] ?? []))
+ ->rows($spec['rows'] + 4);
+ $tester->run(...$spec['keys']);
+
+ $frames = splitFrames($tester->output());
+
+ if (count($frames) < 2) {
+ throw new \RuntimeException(sprintf('Field "%s" (%s) produced %d frame(s); an animation needs at least two.', $name, $suffix === '' ? 'default' : $suffix, count($frames)));
+ }
+
+ $cast_file = $tmp_dir . '/field-' . $name . '-animated' . $suffix . '.cast';
+ file_put_contents($cast_file, buildCast($frames, $spec['rows'] + 4));
+
+ // The unmarked mode is the unicode, colour hero README.md embeds; the
+ // light twin derives in the same pass.
+ $svg_file = $assets_dir . '/field-' . $name . '-dark-animated' . $suffix . '.svg';
+ renderCast($cast_file, $svg_file, $util_dir);
+ file_put_contents($svg_file, slowAnimation((string) file_get_contents($svg_file), ANIMATION_SLOWDOWN));
+ deriveLightTwin($svg_file);
+ }
+
+ printf(" field-%s-dark-animated*.svg (4 display modes)\n", $name);
+}
+
+/**
+ * Render a field's static display-mode screenshots.
+ *
+ * The documentation page shows each field's editor, opened on its default, in
+ * all four glyph and colour combinations. Every frame comes from the same
+ * scripted open, so the grid stays consistent with itself and with the animated
+ * hero above it.
+ *
+ * @param string $name
+ * The asset name.
+ * @param array{form: callable(): \DrevOps\Tui\Builder\Form, keys: list, rows: int} $spec
+ * The field spec.
+ * @param string $assets_dir
+ * The output directory.
+ * @param string $util_dir
+ * The tooling directory holding the svg-term renderer.
+ * @param string $tmp_dir
+ * A scratch directory for the intermediate cast.
+ */
+function renderStaticVariants(string $name, array $spec, string $assets_dir, string $util_dir, string $tmp_dir): void {
+ // A gate settles one Enter in and a note is non-interactive, so both open
+ // with a single drill into the panel; every other field opens its editor
+ // with the hub-into-panel-into-field drill.
+ $enter = Key::named(KeyName::Enter);
+ $open = in_array($name, ['pause', 'note', 'note-markdown', 'table'], TRUE) ? [$enter] : [$enter, $enter];
+ // A field whose opened editor shows nothing worth a screenshot until a key
+ // is pressed (suggest highlights no row until you arrow into the list)
+ // declares the keystrokes its static frame settles on.
+ $keys = $spec['static_keys'] ?? $open;
+ $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
+
+ foreach (DISPLAY_MODES as $suffix => $mode) {
+ // The static screenshots share the default padded rounded border, so
+ // the four extra chrome rows join the content budget here too.
+ $tester = (new TuiTester(($spec['form'])()))
+ ->options(['color' => $mode['color'], 'unicode' => $mode['unicode'], 'mode' => Mode::Dark] + ($spec['options'] ?? []))
+ ->rows($spec['rows'] + 4);
+ $tester->run(...$keys);
+
+ $frames = splitFrames($tester->output());
+ if ($frames === []) {
+ throw new \RuntimeException(sprintf('Field "%s" static "%s" produced no frame.', $name, $suffix === '' ? 'default' : $suffix));
+ }
+
+ $frame = $frames[count($frames) - 1];
+ $cast = json_encode(['version' => 2, 'width' => castWidth([$frame]), 'height' => $spec['rows'] + 4]) . "\n"
+ . json_encode([0.0, 'o', $clear . $frame]) . "\n";
+ $cast_file = $tmp_dir . '/field-' . $name . '-static' . $suffix . '.cast';
+ file_put_contents($cast_file, $cast);
+
+ $svg_file = $assets_dir . '/field-' . $name . '-dark-static' . $suffix . '.svg';
+ renderCast($cast_file, $svg_file, $util_dir, 0);
+ deriveLightTwin($svg_file);
+ }
+}
+
+/**
+ * Split captured output into whole rendered frames.
+ *
+ * Every frame the panel loop draws is prefixed by the clear-screen sequence, so
+ * splitting on it yields one entry per repaint; the leading setup chunk and any
+ * blank tail are dropped.
+ *
+ * @param string $output
+ * The captured terminal output.
+ *
+ * @return list
+ * The frame byte strings, in order.
+ */
+function splitFrames(string $output): array {
+ $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
+ $parts = explode($clear, $output);
+ $frames = array_values(array_filter($parts, static fn(string $s): bool => trim(Ansi::strip($s)) !== ''));
+
+ // The in-memory capture stream applies no ONLCR translation, so the frames
+ // carry bare line feeds; the emulator needs a carriage return to return to
+ // column 0, or every row starts where the last one ended.
+ return array_map(static fn(string $frame): string => str_replace("\n", "\r\n", str_replace("\r", '', $frame)), $frames);
+}
+
+/**
+ * The width in columns the frames need, from the widest visible line.
+ *
+ * @param list $frames
+ * The frames.
+ *
+ * @return int
+ * The column count.
+ */
+function castWidth(array $frames): int {
+ $width = 0;
+
+ foreach ($frames as $frame) {
+ foreach (explode("\n", str_replace("\r", '', $frame)) as $line) {
+ $width = max($width, Ansi::width($line));
+ }
+ }
+
+ return $width;
+}
+
+/**
+ * Assemble an asciicast v2 that plays the frames as an animation.
+ *
+ * @param list $frames
+ * The captured frames.
+ * @param int $rows
+ * The terminal height.
+ *
+ * @return string
+ * The cast file contents.
+ */
+function buildCast(array $frames, int $rows): string {
+ $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
+ $lines = [json_encode(['version' => 2, 'width' => castWidth($frames), 'height' => $rows])];
+
+ $last = count($frames) - 1;
+ $time = 0.0;
+ foreach ($frames as $index => $frame) {
+ $lines[] = json_encode([round($time, 3), 'o', $clear . $frame]);
+ // The final frame's hold is HOLD_LAST alone, so it does not advance here.
+ if ($index < $last) {
+ $time += $index === 0 ? HOLD_FIRST : HOLD_STEP;
+ }
+ }
+ // Hold the final frame before the animation loops back to the first.
+ $lines[] = json_encode([round($time + HOLD_LAST, 3), 'o', ' ']);
+
+ return implode("\n", $lines) . "\n";
+}
+
+/**
+ * Render a cast to an SVG with the shared svg-term renderer.
+ *
+ * @param string $cast_file
+ * The input cast path.
+ * @param string $svg_file
+ * The output SVG path.
+ * @param string $util_dir
+ * The directory holding svg-term-render.js.
+ * @param int|null $at
+ * A timestamp in milliseconds to capture a single static frame, or NULL to
+ * render the whole cast as an animation.
+ */
+function renderCast(string $cast_file, string $svg_file, string $util_dir, ?int $at = NULL): void {
+ // Clear any prior output first, so a failed render leaves no stale file for
+ // the caller to mistake for (and re-slow) a fresh one.
+ if (is_file($svg_file)) {
+ unlink($svg_file);
+ }
+
+ $cmd = sprintf(
+ 'node %s %s %s --line-height 1.1%s 2>&1',
+ escapeshellarg($util_dir . '/svg-term-render.js'),
+ escapeshellarg($cast_file),
+ escapeshellarg($svg_file),
+ $at !== NULL ? sprintf(' --at %d', $at) : ''
+ );
+ $output = shell_exec($cmd);
+
+ if (!file_exists($svg_file) || filesize($svg_file) === 0) {
+ throw new \RuntimeException('Failed to render SVG: ' . $svg_file . "\n" . ($output ?? ''));
+ }
+}
+
+/**
+ * Print an informational message unless quietened.
+ *
+ * @param string $message
+ * The message.
+ */
+function info(string $message): void {
+ if (getenv('SCRIPT_QUIET') !== '1') {
+ print $message . PHP_EOL;
+ }
+}
+
+// Entrypoint.
+ini_set('display_errors', '1');
+
+if (PHP_SAPI !== 'cli') {
+ die('This script can be only ran from the command line.');
+}
+
+$util_dir = __DIR__;
+$assets_dir = dirname(__DIR__) . '/assets';
+$tmp_dir = dirname(__DIR__, 2) . '/.artifacts/tmp/field-svgs';
+$tree = dirname(__DIR__, 2) . '/playground/sample-project';
+
+if (!is_dir($tmp_dir)) {
+ mkdir($tmp_dir, 0755, TRUE);
+}
+
+$specs = fieldSpecs($tree);
+$only = array_slice($argv, 1);
+$names = $only === [] ? array_keys($specs) : $only;
+
+info('Rendering ' . count($names) . ' field animation(s)...');
+
+foreach ($names as $name) {
+ if (!isset($specs[$name])) {
+ throw new \RuntimeException('Unknown field: ' . $name);
+ }
+
+ renderField($name, $specs[$name], $assets_dir, $util_dir, $tmp_dir);
+ renderStaticVariants($name, $specs[$name], $assets_dir, $util_dir, $tmp_dir);
+}
+
+info('Done.');
diff --git a/docs/util/render-progress-svgs.php b/docs/util/render-progress-svgs.php
index bcb8343b..3f777de4 100644
--- a/docs/util/render-progress-svgs.php
+++ b/docs/util/render-progress-svgs.php
@@ -5,7 +5,7 @@
* @file
* Render the progress primitive's animated and static SVGs deterministically.
*
- * The progress primitive is not a keystroke-driven widget: it is a single line
+ * The progress primitive is not a keystroke-driven field: it is a single line
* the theme redraws in place with a carriage return while a callback runs. So
* this drives the real {@see \DrevOps\Tui\Primitive\Progress} against an
* in-memory terminal, splits the captured output into frames on the carriage
@@ -37,7 +37,7 @@
require_once __DIR__ . '/svg-slowdown.php';
require_once __DIR__ . '/svg-light-twin.php';
-// Seconds each captured frame is held, mirroring the widget renderer's cadence.
+// Seconds each captured frame is held, mirroring the field renderer's cadence.
const HOLD_FIRST = 1.1;
const HOLD_STEP = 0.65;
const HOLD_LAST = 2.2;
diff --git a/docs/util/render-widget-svgs.php b/docs/util/render-widget-svgs.php
deleted file mode 100644
index cff53633..00000000
--- a/docs/util/render-widget-svgs.php
+++ /dev/null
@@ -1,522 +0,0 @@
-#!/usr/bin/env php
- ['color' => TRUE, 'unicode' => TRUE],
- '-ascii' => ['color' => TRUE, 'unicode' => FALSE],
- '-no-ansi' => ['color' => FALSE, 'unicode' => TRUE],
- '-ascii-no-ansi' => ['color' => FALSE, 'unicode' => FALSE],
-];
-
-/**
- * The per-widget forms and the keystrokes that drive them.
- *
- * Each single-field form mirrors its playground/02-widgets-* script - same ids,
- * labels, defaults and options - so the rendered cards match the code a
- * reader runs. The keystrokes follow the panel model: Enter drills the hub
- * into the panel, a second Enter opens the field editor, then the
- * widget-specific keys exercise it and Enter (or Tab) accepts - the same path
- * a person walks.
- *
- * @param string $tree
- * The fixture directory the file-picker widgets browse.
- *
- * @return array, rows: int, static_keys?: list}>
- * The widget specs keyed by asset name. A spec may add "static_keys" when the
- * opened editor needs a keystroke before its static frame is worth capturing.
- */
-function widgetSpecs(string $tree): array {
- $enter = Key::named(KeyName::Enter);
- $down = Key::named(KeyName::Down);
- $space = Key::named(KeyName::Space);
- $bs = Key::named(KeyName::Backspace);
- $tab = Key::named(KeyName::Tab);
- $left = Key::named(KeyName::Left);
- $right = Key::named(KeyName::Right);
- // Two Enters walk the hub into the panel and open the field editor; the
- // animation then ends inside the editor on the changed value, so the frame
- // stays as narrow as the widget itself rather than the full-width panel row.
- $open = [$enter, $enter];
- // A short packing job whose every advance() repaints the row, so the capture
- // holds one frame per filled step as the bar grows.
- $pack = static function (ProgressReporter $reporter): void {
- for ($step = 0; $step < 6; $step++) {
- $reporter->advance();
- }
- };
-
- return [
- 'text' => [
- 'form' => Form::create('Text widget')->panel('main', 'Text', function (PanelBuilder $p): void { $p->text('item', 'Item')->default('Pear')->complete(['Pear', 'Peach', 'Plum']); }),
- 'keys' => [...$open, $bs, $bs, $bs, $bs, 'A', 'p', 'p', 'l', 'e'],
- 'rows' => 6,
- ],
- 'template' => [
- 'form' => Form::create('Template widget')->panel('main', 'Template', function (PanelBuilder $p): void { $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{fruit}}-{{grade}}')->default('valley-pear-a')->slot('orchard', 'Orchard')->slot('fruit', 'Fruit')->slot('grade', 'Grade'); }),
- 'keys' => [...$open, $bs, $bs, $bs, $bs, $bs, $bs, 'r', 'i', 'd', 'g', 'e', $tab, $tab, $bs, 'b'],
- 'rows' => 7,
- ],
- 'number' => [
- 'form' => Form::create('Number widget')->panel('main', 'Number', function (PanelBuilder $p): void { $p->number('weight', 'Basket weight (g)')->default(1200)->min(200)->max(9000)->step(100); }),
- 'keys' => [...$open, $bs, $bs, $bs, $bs, '4', '2', '0', '0'],
- 'rows' => 6,
- ],
- 'rating' => [
- 'form' => Form::create('Rating widget')->panel('main', 'Rating', function (PanelBuilder $p): void { $p->rating('freshness', 'Freshness')->default(4)->captions([1 => 'Poor', 3 => 'Fair', 5 => 'Excellent']); }),
- // Walk down the scale and back, so the animation passes through a
- // captioned point; the static frame rests on that point, since a caption
- // is half of what the widget shows.
- 'keys' => [...$open, $left, $left, $right, $right],
- 'static_keys' => [...$open, $left],
- 'rows' => 6,
- ],
- 'calendar' => [
- 'form' => Form::create('Calendar widget')->panel('main', 'Calendar', function (PanelBuilder $p): void { $p->calendar('harvest', 'Harvest date')->default('2026-07-15'); }),
- 'keys' => [...$open, $down],
- 'rows' => 14,
- ],
- 'textarea' => [
- 'form' => Form::create('Textarea widget')->panel('main', 'Textarea', function (PanelBuilder $p): void { $p->textarea('notes', 'Tasting notes')->default('Crisp and sweet' . chr(10) . 'Hint of citrus'); }),
- 'keys' => [...$open, $enter, 'S', 'l', 'i', 'g', 'h', 't', 'l', 'y', ' ', 't', 'a', 'r', 't'],
- 'rows' => 8,
- ],
- 'password' => [
- 'form' => Form::create('Password widget')->panel('main', 'Password', function (PanelBuilder $p): void { $p->password('code', 'Order code')->default('melon7'); }),
- 'keys' => [...$open, $bs, $bs, $bs, $bs, $bs, $bs, 'g', 'r', 'a', 'p', 'e', '5'],
- 'rows' => 6,
- ],
- 'select' => [
- 'form' => Form::create('Select widget')->panel('main', 'Select', function (PanelBuilder $p): void { $p->select('fruit', 'Fruit')->default('apple')->options(['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry']); }),
- 'keys' => [...$open, $down],
- 'rows' => 8,
- ],
- 'select-descriptions' => [
- 'form' => Form::create('Option descriptions')->panel('main', 'Select', function (PanelBuilder $p): void { $p->select('fruit', 'Fruit')->default('apple')->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.'); }),
- 'keys' => [...$open, $down],
- 'rows' => 12,
- ],
- 'select-multiple' => [
- 'form' => Form::create('MultiSelect widget')->panel('main', 'MultiSelect', function (PanelBuilder $p): void { $p->select('basket', 'Basket')->multiple()->default(['apple'])->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
- 'keys' => [...$open, $down, $space],
- 'rows' => 8,
- ],
- 'select-multiple-limited' => [
- 'form' => Form::create('Bounded MultiSelect')->panel('main', 'MultiSelect', function (PanelBuilder $p): void { $p->select('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
- 'keys' => [...$open, $space, $down, $space],
- 'rows' => 11,
- ],
- 'reorder' => [
- 'form' => Form::create('Reorder widget')->panel('main', 'Reorder', function (PanelBuilder $p): void { $p->reorder('basket', 'Basket')->options(['apple' => 'Apple', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
- 'keys' => [...$open, $space, $down, $space],
- 'rows' => 12,
- ],
- 'reorder-descriptions' => [
- 'form' => Form::create('Option descriptions')->panel('main', 'Reorder', function (PanelBuilder $p): void { $p->reorder('basket', 'Basket')->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')->option('tomato', 'Tomato', description: 'Best ripened on the vine, never chilled.'); }),
- 'keys' => [...$open, $down],
- 'rows' => 12,
- ],
- 'suggest' => [
- 'form' => Form::create('Suggest widget')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->options(['Apple' => 'Apple', 'Apricot' => 'Apricot', 'Banana' => 'Banana', 'Cherry' => 'Cherry', 'Mango' => 'Mango']); }),
- 'keys' => [...$open, 'C', 'h', $down],
- 'rows' => 10,
- ],
- 'suggest-ghost' => [
- 'form' => Form::create('Ghost text')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->options(['Apple' => 'Apple', 'Apricot' => 'Apricot', 'Banana' => 'Banana', 'Cherry' => 'Cherry', 'Mango' => 'Mango'])->ghost(); }),
- // The preview only exists while the query is a prefix of a candidate and
- // nothing is highlighted, so the frames settle on typed text alone.
- 'keys' => [...$open, 'A', 'p'],
- 'static_keys' => [...$open, 'A', 'p'],
- 'rows' => 10,
- ],
- 'suggest-descriptions' => [
- 'form' => Form::create('Option descriptions')->panel('main', 'Suggest', function (PanelBuilder $p): void { $p->suggest('fruit', 'Fruit')->option('Apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')->option('Apricot', 'Apricot', description: 'Small and tart; best when soft.')->option('Banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')->option('Cherry', 'Cherry', description: 'Short season; best eaten fresh.')->option('Mango', 'Mango', description: 'Fragrant and juicy when it yields to a squeeze.'); }),
- 'keys' => [...$open, $down],
- 'static_keys' => [...$open, $down],
- 'rows' => 13,
- ],
- 'search' => [
- 'form' => Form::create('Search widget')->panel('main', 'Search', function (PanelBuilder $p): void { $p->search('vegetable', 'Vegetable')->default('carrot')->options(['carrot' => 'Carrot', 'potato' => 'Potato', 'onion' => 'Onion', 'pepper' => 'Pepper']); }),
- 'keys' => [...$open, 'o', 'n'],
- 'rows' => 10,
- ],
- 'search-descriptions' => [
- 'form' => Form::create('Option descriptions')->panel('main', 'Search', function (PanelBuilder $p): void { $p->search('vegetable', 'Vegetable')->default('carrot')->option('carrot', 'Carrot', description: 'Stays crisp for weeks when kept cold.')->option('potato', 'Potato', description: 'Stores best somewhere cool and dark.')->option('onion', 'Onion', description: 'Sharp raw, sweet once cooked.')->option('pepper', 'Pepper', description: 'Crunchy and bright; sweetest when red.'); }),
- 'keys' => [...$open, $down],
- 'rows' => 12,
- ],
- 'search-multiple' => [
- 'form' => Form::create('MultiSearch widget')->panel('main', 'MultiSearch', function (PanelBuilder $p): void { $p->search('basket', 'Basket')->multiple()->default(['apple'])->options(['apple' => 'Apple', 'banana' => 'Banana', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
- 'keys' => [...$open, 't', 'o', $space],
- 'rows' => 10,
- ],
- 'search-multiple-limited' => [
- 'form' => Form::create('Bounded MultiSearch')->panel('main', 'MultiSearch', function (PanelBuilder $p): void { $p->search('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options(['apple' => 'Apple', 'banana' => 'Banana', 'carrot' => 'Carrot', 'tomato' => 'Tomato']); }),
- 'keys' => [...$open, $space, $down, $space],
- 'rows' => 13,
- ],
- 'confirm' => [
- 'form' => Form::create('Confirm widget')->panel('main', 'Confirm', function (PanelBuilder $p): void { $p->confirm('organic', 'Organic only?')->default(TRUE); }),
- 'keys' => [...$open, 'n'],
- 'rows' => 6,
- ],
- 'toggle' => [
- 'form' => Form::create('Toggle widget')->panel('main', 'Toggle', function (PanelBuilder $p): void { $p->toggle('ripeness', 'Ripeness')->default('ripe')->options(['ripe' => 'Ripe', 'unripe' => 'Unripe']); }),
- 'keys' => [...$open, 'u'],
- 'rows' => 6,
- ],
- 'pause' => [
- 'form' => Form::create('Pause widget')->panel('main', 'Pause', function (PanelBuilder $p): void { $p->pause('review', 'Review your basket'); }),
- 'keys' => [$enter],
- 'rows' => 6,
- ],
- 'note' => [
- 'form' => Form::create('Note widget')->panel('main', 'Note', function (PanelBuilder $p): void {
- $p->note('intro', 'Fresh produce order')->description('A read-only card - the cursor skips it.');
- $p->note('packing', 'Ready to pack')->description('Framed with a border.')->border();
- }),
- 'keys' => [$enter],
- 'rows' => 14,
- ],
- 'note-markdown' => [
- 'form' => Form::create('Markdown note')->panel('main', 'Note', function (PanelBuilder $p): void {
- $p->note('order', 'Fresh produce order')->description('Pick what is **ripe** today:' . chr(10) . '- crisp `apples`' . chr(10) . '- sweet *pears*' . chr(10) . 'See the [seasonal guide](https://example.com/guide).')->border();
- }),
- 'keys' => [$enter],
- 'rows' => 14,
- 'options' => ['markdown' => TRUE],
- ],
- 'table' => [
- 'form' => Form::create('Table widget')->panel('main', 'Stock', function (PanelBuilder $p): void {
- $p->note('stock', 'Basket contents')->description('Everything picked so far:')->table(['Fruit', 'Colour', 'In stock'], [
- ['Apple', 'Red', '12'],
- ['Pear', 'Green', '5'],
- ['Plum', 'Purple', '120'],
- ]);
- }),
- 'keys' => [$enter],
- 'rows' => 16,
- ],
- 'progress' => [
- 'form' => Form::create('Progress widget')->panel('main', 'Progress', function (PanelBuilder $p) use ($pack): void { $p->progress('pack', 'Packing the box')->steps(6)->run($pack); }),
- 'keys' => [$enter, $enter],
- 'rows' => 6,
- ],
- 'filepicker' => [
- 'form' => Form::create('File picker widget')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_list', 'Price list')->startIn($tree)->filesOnly()->extensions(['csv'])->maxSize(2097152); }),
- 'keys' => [...$open, $down],
- 'rows' => 12,
- ],
- 'filepicker-multiple' => [
- 'form' => Form::create('File picker widget')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_lists', 'Price lists')->multiple()->startIn($tree); }),
- 'keys' => [...$open, $space, $down, $space],
- 'rows' => 10,
- ],
- 'filepicker-multiple-limited' => [
- 'form' => Form::create('File picker widget')->panel('main', 'File picker', function (PanelBuilder $p) use ($tree): void { $p->filePicker('price_lists', 'Price lists')->multiple()->minSelections(2)->maxSelections(3)->startIn($tree); }),
- 'keys' => [...$open, $space, $down, $space],
- 'rows' => 14,
- ],
- ];
-}
-
-/**
- * Drive one widget and write its animated SVG.
- *
- * @param string $name
- * The asset name.
- * @param array{form: \DrevOps\Tui\Builder\Form, keys: list, rows: int} $spec
- * The widget spec.
- * @param string $assets_dir
- * The output directory.
- * @param string $util_dir
- * The tooling directory holding the svg-term renderer.
- * @param string $tmp_dir
- * A scratch directory for the intermediate cast.
- */
-function renderWidget(string $name, array $spec, string $assets_dir, string $util_dir, string $tmp_dir): void {
- foreach (DISPLAY_MODES as $suffix => $mode) {
- // The animated hero cards render at the default look - the padded
- // rounded border every panel demo shares. The border adds a row above
- // and below the content and the padding another one each side.
- $tester = (new TuiTester($spec['form']))
- ->options(['color' => $mode['color'], 'unicode' => $mode['unicode'], 'mode' => Mode::Dark] + ($spec['options'] ?? []))
- ->rows($spec['rows'] + 4);
- $tester->run(...$spec['keys']);
-
- $frames = splitFrames($tester->output());
-
- if (count($frames) < 2) {
- throw new \RuntimeException(sprintf('Widget "%s" (%s) produced %d frame(s); an animation needs at least two.', $name, $suffix === '' ? 'default' : $suffix, count($frames)));
- }
-
- $cast_file = $tmp_dir . '/widget-' . $name . '-animated' . $suffix . '.cast';
- file_put_contents($cast_file, buildCast($frames, $spec['rows'] + 4));
-
- // The unmarked mode is the unicode, colour hero README.md embeds; the
- // light twin derives in the same pass.
- $svg_file = $assets_dir . '/widget-' . $name . '-dark-animated' . $suffix . '.svg';
- renderCast($cast_file, $svg_file, $util_dir);
- file_put_contents($svg_file, slowAnimation((string) file_get_contents($svg_file), ANIMATION_SLOWDOWN));
- deriveLightTwin($svg_file);
- }
-
- printf(" widget-%s-dark-animated*.svg (4 display modes)\n", $name);
-}
-
-/**
- * Render a widget's static display-mode screenshots.
- *
- * The documentation page shows each widget's editor, opened on its default, in
- * all four glyph and colour combinations. Every frame comes from the same
- * scripted open, so the grid stays consistent with itself and with the animated
- * hero above it.
- *
- * @param string $name
- * The asset name.
- * @param array{form: \DrevOps\Tui\Builder\Form, keys: list, rows: int} $spec
- * The widget spec.
- * @param string $assets_dir
- * The output directory.
- * @param string $util_dir
- * The tooling directory holding the svg-term renderer.
- * @param string $tmp_dir
- * A scratch directory for the intermediate cast.
- */
-function renderStaticVariants(string $name, array $spec, string $assets_dir, string $util_dir, string $tmp_dir): void {
- // A gate settles one Enter in and a note is non-interactive, so both open
- // with a single drill into the panel; every other widget opens its editor
- // with the hub-into-panel-into-field drill.
- $enter = Key::named(KeyName::Enter);
- $open = in_array($name, ['pause', 'note', 'note-markdown', 'table'], TRUE) ? [$enter] : [$enter, $enter];
- // A widget whose opened editor shows nothing worth a screenshot until a key
- // is pressed (suggest highlights no row until you arrow into the list)
- // declares the keystrokes its static frame settles on.
- $keys = $spec['static_keys'] ?? $open;
- $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
-
- foreach (DISPLAY_MODES as $suffix => $mode) {
- // The static screenshots share the default padded rounded border, so
- // the four extra chrome rows join the content budget here too.
- $tester = (new TuiTester($spec['form']))
- ->options(['color' => $mode['color'], 'unicode' => $mode['unicode'], 'mode' => Mode::Dark] + ($spec['options'] ?? []))
- ->rows($spec['rows'] + 4);
- $tester->run(...$keys);
-
- $frames = splitFrames($tester->output());
- if ($frames === []) {
- throw new \RuntimeException(sprintf('Widget "%s" static "%s" produced no frame.', $name, $suffix === '' ? 'default' : $suffix));
- }
-
- $frame = $frames[count($frames) - 1];
- $cast = json_encode(['version' => 2, 'width' => castWidth([$frame]), 'height' => $spec['rows'] + 4]) . "\n"
- . json_encode([0.0, 'o', $clear . $frame]) . "\n";
- $cast_file = $tmp_dir . '/widget-' . $name . '-static' . $suffix . '.cast';
- file_put_contents($cast_file, $cast);
-
- $svg_file = $assets_dir . '/widget-' . $name . '-dark-static' . $suffix . '.svg';
- renderCast($cast_file, $svg_file, $util_dir, 0);
- deriveLightTwin($svg_file);
- }
-}
-
-/**
- * Split captured output into whole rendered frames.
- *
- * Every frame the panel loop draws is prefixed by the clear-screen sequence, so
- * splitting on it yields one entry per repaint; the leading setup chunk and any
- * blank tail are dropped.
- *
- * @param string $output
- * The captured terminal output.
- *
- * @return list
- * The frame byte strings, in order.
- */
-function splitFrames(string $output): array {
- $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
- $parts = explode($clear, $output);
- $frames = array_values(array_filter($parts, static fn(string $s): bool => trim(Ansi::strip($s)) !== ''));
-
- // The in-memory capture stream applies no ONLCR translation, so the frames
- // carry bare line feeds; the emulator needs a carriage return to return to
- // column 0, or every row starts where the last one ended.
- return array_map(static fn(string $frame): string => str_replace("\n", "\r\n", str_replace("\r", '', $frame)), $frames);
-}
-
-/**
- * The width in columns the frames need, from the widest visible line.
- *
- * @param list $frames
- * The frames.
- *
- * @return int
- * The column count.
- */
-function castWidth(array $frames): int {
- $width = 0;
-
- foreach ($frames as $frame) {
- foreach (explode("\n", str_replace("\r", '', $frame)) as $line) {
- $width = max($width, Ansi::width($line));
- }
- }
-
- return $width;
-}
-
-/**
- * Assemble an asciicast v2 that plays the frames as an animation.
- *
- * @param list $frames
- * The captured frames.
- * @param int $rows
- * The terminal height.
- *
- * @return string
- * The cast file contents.
- */
-function buildCast(array $frames, int $rows): string {
- $clear = Ansi::ESC . '[2J' . Ansi::ESC . '[H';
- $lines = [json_encode(['version' => 2, 'width' => castWidth($frames), 'height' => $rows])];
-
- $last = count($frames) - 1;
- $time = 0.0;
- foreach ($frames as $index => $frame) {
- $lines[] = json_encode([round($time, 3), 'o', $clear . $frame]);
- // The final frame's hold is HOLD_LAST alone, so it does not advance here.
- if ($index < $last) {
- $time += $index === 0 ? HOLD_FIRST : HOLD_STEP;
- }
- }
- // Hold the final frame before the animation loops back to the first.
- $lines[] = json_encode([round($time + HOLD_LAST, 3), 'o', ' ']);
-
- return implode("\n", $lines) . "\n";
-}
-
-/**
- * Render a cast to an SVG with the shared svg-term renderer.
- *
- * @param string $cast_file
- * The input cast path.
- * @param string $svg_file
- * The output SVG path.
- * @param string $util_dir
- * The directory holding svg-term-render.js.
- * @param int|null $at
- * A timestamp in milliseconds to capture a single static frame, or NULL to
- * render the whole cast as an animation.
- */
-function renderCast(string $cast_file, string $svg_file, string $util_dir, ?int $at = NULL): void {
- // Clear any prior output first, so a failed render leaves no stale file for
- // the caller to mistake for (and re-slow) a fresh one.
- if (is_file($svg_file)) {
- unlink($svg_file);
- }
-
- $cmd = sprintf(
- 'node %s %s %s --line-height 1.1%s 2>&1',
- escapeshellarg($util_dir . '/svg-term-render.js'),
- escapeshellarg($cast_file),
- escapeshellarg($svg_file),
- $at !== NULL ? sprintf(' --at %d', $at) : ''
- );
- $output = shell_exec($cmd);
-
- if (!file_exists($svg_file) || filesize($svg_file) === 0) {
- throw new \RuntimeException('Failed to render SVG: ' . $svg_file . "\n" . ($output ?? ''));
- }
-}
-
-/**
- * Print an informational message unless quietened.
- *
- * @param string $message
- * The message.
- */
-function info(string $message): void {
- if (getenv('SCRIPT_QUIET') !== '1') {
- print $message . PHP_EOL;
- }
-}
-
-// Entrypoint.
-ini_set('display_errors', '1');
-
-if (PHP_SAPI !== 'cli') {
- die('This script can be only ran from the command line.');
-}
-
-$util_dir = __DIR__;
-$assets_dir = dirname(__DIR__) . '/assets';
-$tmp_dir = dirname(__DIR__, 2) . '/.artifacts/tmp/widget-svgs';
-$tree = dirname(__DIR__, 2) . '/playground/sample-project';
-
-if (!is_dir($tmp_dir)) {
- mkdir($tmp_dir, 0755, TRUE);
-}
-
-$specs = widgetSpecs($tree);
-$only = array_slice($argv, 1);
-$names = $only === [] ? array_keys($specs) : $only;
-
-info('Rendering ' . count($names) . ' widget animation(s)...');
-
-foreach ($names as $name) {
- if (!isset($specs[$name])) {
- throw new \RuntimeException('Unknown widget: ' . $name);
- }
-
- renderWidget($name, $specs[$name], $assets_dir, $util_dir, $tmp_dir);
- renderStaticVariants($name, $specs[$name], $assets_dir, $util_dir, $tmp_dir);
-}
-
-info('Done.');
diff --git a/docs/util/svg-light-twin.php b/docs/util/svg-light-twin.php
index 0d9d49d5..67dd0eb2 100644
--- a/docs/util/svg-light-twin.php
+++ b/docs/util/svg-light-twin.php
@@ -10,7 +10,7 @@
* both backgrounds, so only the surface and foreground greys invert.
* Deterministic and exact: a twin shares its dark source's geometry.
*
- * Shared by update-assets.php and render-widget-svgs.php so every generator
+ * Shared by update-assets.php and render-field-svgs.php so every generator
* emits its complete dark/light pair the moment a dark SVG lands - there is
* no separate twin pass to run or subject list to maintain.
* render-theme-svgs.php does not use it: theme previews render their light
diff --git a/docs/util/svg-slowdown.php b/docs/util/svg-slowdown.php
index 073a03df..6e3588ae 100644
--- a/docs/util/svg-slowdown.php
+++ b/docs/util/svg-slowdown.php
@@ -4,7 +4,7 @@
* @file
* Shared playback-speed helper for the SVG generators.
*
- * Both update-assets.php and render-widget-svgs.php emit animated SVGs and slow
+ * Both update-assets.php and render-field-svgs.php emit animated SVGs and slow
* them to the same factor, so the constant and the scaler live here and are
* required by both.
*/
diff --git a/docs/util/update-assets.php b/docs/util/update-assets.php
index 7707ebb1..5478953b 100644
--- a/docs/util/update-assets.php
+++ b/docs/util/update-assets.php
@@ -6,11 +6,11 @@
* Generate every terminal SVG asset - the single entry point.
*
* Records terminal sessions for the playground panel demos (the panel TUI
- * runners) and the widget montage, then converts the recordings to animated
+ * runners) and the field montage, then converts the recordings to animated
* SVGs; it also renders the option-group, password-reveal and discovery static
- * frames. Every per-widget card - both its animations and its static
+ * frames. Every per-field card - both its animations and its static
* display-mode screenshots - is rendered deterministically by
- * render-widget-svgs.php, the built-in theme previews by render-theme-svgs.php,
+ * render-field-svgs.php, the built-in theme previews by render-theme-svgs.php,
* the progress primitive by render-progress-svgs.php and the output primitives
* by render-output-svgs.php; a no-argument run spawns all four alongside the
* recording workers, so one command regenerates the whole set. Each dark SVG
@@ -36,7 +36,7 @@
* Usage:
* @code
* php docs/util/update-assets.php
- * php docs/util/update-assets.php --record widget-select
+ * php docs/util/update-assets.php --record field-select
* @endcode
*/
@@ -46,6 +46,11 @@
define('TERMINAL_COLS', 80);
define('TERMINAL_ROWS', 24);
+// The narrowest terminal an open editor's hint line survives whole on: it is
+// the theme's own default frame width, and below it a hint that does not fit
+// is cut mid-word instead of being dropped as a whole hint.
+define('HINT_COLS', 76);
+
// Maximum idle time in recordings (seconds).
define('MAX_IDLE_TIME', 3);
@@ -58,16 +63,16 @@
define('FRAME_SETTLE_MS', 500);
// The playback-speed factor (ANIMATION_SLOWDOWN) and the slowAnimation() scaler
-// are shared with render-widget-svgs.php, as is the light-twin derivation.
+// are shared with render-field-svgs.php, as is the light-twin derivation.
require_once __DIR__ . '/svg-slowdown.php';
require_once __DIR__ . '/svg-light-twin.php';
/**
- * The expect body walking the all-widgets montage field by field.
+ * The expect body walking the all-fields montage field by field.
*
- * The montage form (playground/02-widgets-all-widgets.php) is one panel with
- * every widget type. Fields edit inline and accepting keeps the cursor on
- * the field, so each step is: open with Enter, drive the widget with its own
+ * The montage form (playground/02-fields-all-fields.php) is one panel with
+ * every field type. Fields edit inline and accepting keeps the cursor on
+ * the field, so each step is: open with Enter, drive the field with its own
* keys, accept, then arrow down to the next field. The calendar is the one
* standalone field - its month grid takes the whole screen and returns to
* the panel on accept.
@@ -75,10 +80,10 @@
* @return string
* The expect script body.
*/
-function allWidgetsInteraction(): string {
+function allFieldsInteraction(): string {
return <<<'EXPECT'
# Wait for the hub, then drill into the montage panel.
-expect "Widgets" {
+expect "Fields" {
pause 2000
safe_send "\r"
}
@@ -229,9 +234,10 @@ function allWidgetsInteraction(): string {
wait_and_enter
arrow_down
-# Pause: acknowledge.
+# Pause: the gate opens on the first Enter and continues on the second.
pause 800
safe_send "\r"
+wait_and_enter
# Back to the hub and submit.
press_escape
@@ -616,12 +622,7 @@ function keyBindingsVimInteraction(): string {
safe_send "j"
pause 600
safe_send "k"
-pause 600
-
-# The ? overlay lists whatever is bound; any key dismisses it.
-safe_send "?"
-pause 3000
-press_escape
+pause 1500
# Open the select under the cursor and pick the next option with j.
pause 800
@@ -1030,7 +1031,7 @@ function getJobs(string $project_dir): array {
// colour follows the NO_COLOR convention.
$env_variants = ['' => '', '-ascii' => 'LC_ALL=C ', '-no-ansi' => 'NO_COLOR=1 ', '-ascii-no-ansi' => 'LC_ALL=C NO_COLOR=1 '];
- // The all-widgets montage: every widget on one panel, walked field by
+ // The all-fields montage: every field on one panel, walked field by
// field, in all display modes. "Pause" is the last field walked, so its
// label proves the whole sequence was recorded. The screen is sized for
// the content: all sixteen fields fit without scrolling, and the rows
@@ -1038,9 +1039,9 @@ function getJobs(string $project_dir): array {
// narrower terminal a badged row overflows, wraps, and every frame below
// it renders torn.
foreach ($env_variants as $suffix => $env) {
- $jobs['widgets' . $suffix] = [
- 'command' => 'env LINES=25 COLUMNS=80 ' . $env . 'php ' . $project_dir . '/playground/02-widgets-all-widgets.php',
- 'interact' => allWidgetsInteraction(),
+ $jobs['fields' . $suffix] = [
+ 'command' => 'env LINES=25 COLUMNS=80 ' . $env . 'php ' . $project_dir . '/playground/02-fields-all-fields.php',
+ 'interact' => allFieldsInteraction(),
'rows' => 25,
'cols' => 80,
'verify' => 'Pause',
@@ -1072,10 +1073,10 @@ function getJobs(string $project_dir): array {
// Inline editing: each editor opens in place on its panel row, with the
// standalone calendar as the full-screen contrast.
$jobs['inline-editing'] = [
- 'command' => 'env LINES=20 COLUMNS=64 php ' . $project_dir . '/playground/04-inline-editing.php',
+ 'command' => 'env LINES=20 COLUMNS=' . HINT_COLS . ' php ' . $project_dir . '/playground/04-inline-editing.php',
'interact' => inlineEditingInteraction(),
'rows' => 20,
- 'cols' => 64,
+ 'cols' => HINT_COLS,
'verify' => 'Harvest date',
];
@@ -1092,10 +1093,10 @@ function getJobs(string $project_dir): array {
// Conditional fields: picking herbs and the large box makes fields appear
// and disappear; the herb bundle only renders once herbs are selected.
$jobs['conditional-fields'] = [
- 'command' => 'env LINES=22 COLUMNS=72 php ' . $project_dir . '/playground/05-form-logic-conditional-fields.php',
+ 'command' => 'env LINES=22 COLUMNS=' . HINT_COLS . ' php ' . $project_dir . '/playground/05-form-logic-conditional-fields.php',
'interact' => conditionalFieldsInteraction(),
'rows' => 22,
- 'cols' => 72,
+ 'cols' => HINT_COLS,
'verify' => 'Herb bundle',
];
@@ -1104,10 +1105,10 @@ function getJobs(string $project_dir): array {
// renders once the first one did, so it proves the chain opened past its
// head; "Courier" would not, matching the monospace font stack as well.
$jobs['conditional-indent'] = [
- 'command' => 'env LINES=22 COLUMNS=72 php ' . $project_dir . '/playground/05-form-logic-conditional-indent.php',
+ 'command' => 'env LINES=22 COLUMNS=' . HINT_COLS . ' php ' . $project_dir . '/playground/05-form-logic-conditional-indent.php',
'interact' => conditionalIndentInteraction(),
'rows' => 22,
- 'cols' => 72,
+ 'cols' => HINT_COLS,
'verify' => 'Weekly delivery?',
];
@@ -1115,15 +1116,15 @@ function getJobs(string $project_dir): array {
// the editor and withholds the submit for the untouched basket, so the
// derived message and a declared one both appear before the form completes.
$jobs['field-behaviour'] = [
- 'command' => 'env LINES=18 COLUMNS=72 php ' . $project_dir . '/playground/06-field-behaviour-closures.php',
+ 'command' => 'env LINES=18 COLUMNS=' . HINT_COLS . ' php ' . $project_dir . '/playground/06-field-behaviour-closures.php',
'interact' => fieldBehaviourInteraction(),
'rows' => 18,
- 'cols' => 72,
+ 'cols' => HINT_COLS,
'verify' => 'Stall name is required.',
];
- // The vim key-bindings preset: j/k navigation and the ? help overlay. The
- // taller screen leaves the overlay room to list the bound keys.
+ // The vim key-bindings preset: j and k drive the panel browser and the
+ // option list, alongside the arrows the preset keeps.
$jobs['key-bindings-vim'] = [
'command' => 'env LINES=22 COLUMNS=72 php ' . $project_dir . '/playground/10-key-bindings-vim.php',
'interact' => keyBindingsVimInteraction(),
@@ -1133,12 +1134,14 @@ function getJobs(string $project_dir): array {
];
// Translations: the Ukrainian catalog localizes the chrome and the labels;
- // the translated Fruits label proves the localized render was captured.
+ // the translated Fruits label proves the localized render was captured. The
+ // localized hints run longer than the English ones, so the frame shows as
+ // many whole hints as fit and drops the rest.
$jobs['translations'] = [
- 'command' => 'env LINES=20 COLUMNS=64 php ' . $project_dir . '/playground/12-translations.php',
+ 'command' => 'env LINES=20 COLUMNS=' . HINT_COLS . ' php ' . $project_dir . '/playground/12-translations.php',
'interact' => translationsInteraction(),
'rows' => 20,
- 'cols' => 64,
+ 'cols' => HINT_COLS,
'verify' => 'Фрукти',
];
@@ -1252,11 +1255,11 @@ function getJobs(string $project_dir): array {
// the reveal hint, anchored to the moment Tab flips the display to plaintext.
// The masked value hides "melon7", so the plaintext only appears once
// revealed - anchoring on it captures the revealed frame, not the initial one.
- $jobs['widget-password-reveal'] = [
- 'command' => 'php ' . $project_dir . '/playground/02-widgets-password-reveal.php',
+ $jobs['field-password-reveal'] = [
+ 'command' => 'php ' . $project_dir . '/playground/02-fields-password-reveal.php',
'interact' => <<<'EXPECT'
# Drill into the field, reveal the value with Tab, hold the plaintext frame, then accept.
-expect "Password widget" {
+expect "Password field" {
pause 1000
safe_send "\r"
pause 800
@@ -1268,13 +1271,13 @@ function getJobs(string $project_dir): array {
}
EXPECT,
'rows' => 12,
- 'cols' => 44,
+ 'cols' => HINT_COLS,
'at_needle' => 'melon7',
];
- // Every per-widget card - the animated unicode-colour hero README.md embeds
+ // Every per-field card - the animated unicode-colour hero README.md embeds
// and all four static display-mode screenshots the documentation pages show -
- // is rendered deterministically by render-widget-svgs.php, so no per-widget
+ // is rendered deterministically by render-field-svgs.php, so no per-field
// recordings run here.
// Option-kind demos: a select and a multiselect showing group headings,
@@ -1291,11 +1294,11 @@ function getJobs(string $project_dir): array {
foreach ($env_variants as $suffix => $env) {
// spawn does not parse VAR=value prefixes, so route them through env.
- $jobs['widget-' . $demo . $suffix] = [
- 'command' => 'env ' . $env . 'php ' . $project_dir . '/playground/02-widgets-' . $demo . '.php',
+ $jobs['field-' . $demo . $suffix] = [
+ 'command' => 'env ' . $env . 'php ' . $project_dir . '/playground/02-fields-' . $demo . '.php',
'interact' => $interact,
'rows' => $meta['rows'],
- 'cols' => 44,
+ 'cols' => HINT_COLS,
'at_needle' => $meta['needle'],
];
}
@@ -1343,7 +1346,7 @@ function main(): void {
$workers[$name] = sprintf('php %s --record %s', escapeshellarg($script_path), escapeshellarg($name));
}
- $workers['widget-svgs'] = sprintf('php %s', escapeshellarg($script_dir . '/render-widget-svgs.php'));
+ $workers['field-svgs'] = sprintf('php %s', escapeshellarg($script_dir . '/render-field-svgs.php'));
$workers['theme-svgs'] = sprintf('php %s', escapeshellarg($script_dir . '/render-theme-svgs.php'));
$workers['progress-svgs'] = sprintf('php %s', escapeshellarg($script_dir . '/render-progress-svgs.php'));
$workers['output-svgs'] = sprintf('php %s', escapeshellarg($script_dir . '/render-output-svgs.php'));
diff --git a/playground/01-quickstart.php b/playground/01-quickstart.php
index 2d56aa1f..29b6440f 100644
--- a/playground/01-quickstart.php
+++ b/playground/01-quickstart.php
@@ -21,7 +21,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -69,7 +69,7 @@
// aborts the session; the partial answers are never returned.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
// A headless run without the required "name" lands here.
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
diff --git a/playground/02-fields-all-fields.php b/playground/02-fields-all-fields.php
new file mode 100644
index 00000000..0d75e3f5
--- /dev/null
+++ b/playground/02-fields-all-fields.php
@@ -0,0 +1,93 @@
+panel('fields', 'Fields', function (PanelBuilder $p): void {
+ $p->note('note', 'Note')->description('A read-only card - the cursor skips it and it collects nothing.');
+ $p->text('text', 'Text')->default('Pear');
+ $p->template('template', 'Template')->pattern('{{orchard}}-{{fruit}}-{{grade}}')->default('valley-pear-a');
+ $p->number('number', 'Number')->default(1200);
+ $p->rating('rating', 'Rating')->default(4)->captions([1 => 'Poor', 3 => 'Fair', 5 => 'Excellent']);
+ // The month grid wants the whole screen, so it opts out of inline
+ // editing; every other field here edits in place on its row.
+ $p->calendar('calendar', 'Calendar')->default('2026-07-15')->standalone();
+ $p->textarea('textarea', 'Textarea')->default('Crisp and sweet' . chr(10) . 'Hint of citrus');
+ $p->password('password', 'Password')->default('melon7');
+ $p->select('select', 'Select')->default('apple')->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'cherry' => 'Cherry',
+ ]);
+ $p->select('multiselect', 'MultiSelect')->multiple()->default(['apple'])->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ $p->reorder('reorder', 'Reorder')->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ $p->suggest('suggest', 'Suggest')->options([
+ 'Apple' => 'Apple',
+ 'Apricot' => 'Apricot',
+ 'Banana' => 'Banana',
+ 'Cherry' => 'Cherry',
+ 'Mango' => 'Mango',
+ ]);
+ $p->search('search', 'Search')->default('carrot')->options([
+ 'carrot' => 'Carrot',
+ 'potato' => 'Potato',
+ 'onion' => 'Onion',
+ 'pepper' => 'Pepper',
+ ]);
+ $p->search('multisearch', 'MultiSearch')->multiple()->default(['apple'])->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ $p->confirm('confirm', 'Confirm')->default(TRUE);
+ $p->toggle('toggle', 'Toggle')->default('ripe')->options([
+ 'ripe' => 'Ripe',
+ 'unripe' => 'Unripe',
+ ]);
+ $p->pause('pause', 'Pause');
+ });
+
+try {
+ // The rounded border frames the whole browser - the house look of the
+ // panel demos; playground/03-panels-bordered.php shows it on its own.
+ $answers = (new Tui($form))->theme('default', ['border' => Border::Rounded])->run();
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
+
+// The summary groups the answers by panel with provenance badges.
+echo $answers->toSummary() . PHP_EOL;
diff --git a/playground/02-fields-calendar.php b/playground/02-fields-calendar.php
new file mode 100644
index 00000000..306a7dcf
--- /dev/null
+++ b/playground/02-fields-calendar.php
@@ -0,0 +1,38 @@
+minDate()/->maxDate() bound the selectable range and
+ * ->weekStart() picks the first weekday column.
+ *
+ * Usage:
+ * php playground/02-fields-calendar.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Calendar field')
+ ->panel('main', 'Calendar', function (PanelBuilder $p): void {
+ $p->calendar('harvest', 'Harvest date')->default('2026-07-15');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-confirm.php b/playground/02-fields-confirm.php
new file mode 100644
index 00000000..9a35e4b8
--- /dev/null
+++ b/playground/02-fields-confirm.php
@@ -0,0 +1,36 @@
+panel('main', 'Confirm', function (PanelBuilder $p): void {
+ $p->confirm('organic', 'Organic only?')->default(TRUE);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-filepicker-multiple-limited.php b/playground/02-fields-filepicker-multiple-limited.php
new file mode 100644
index 00000000..157925ef
--- /dev/null
+++ b/playground/02-fields-filepicker-multiple-limited.php
@@ -0,0 +1,39 @@
+multiple() with selection limits: a minimum and maximum count.
+ *
+ * ->minSelections(2)->maxSelections(3) bounds how many paths may be checked.
+ * The active limit shows as a hint above the browser, and accepting a count
+ * outside the range is rejected inline until it is satisfied. The field still
+ * collects the list of chosen paths.
+ *
+ * Usage:
+ * php playground/02-fields-filepicker-multiple-limited.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('File picker field')
+ ->panel('main', 'File picker', function (PanelBuilder $p): void {
+ // Pick at least two and at most three paths.
+ $p->filePicker('price_lists', 'Price lists')->multiple()->minSelections(2)->maxSelections(3)->startIn(__DIR__ . '/sample-project');
+ });
+
+try {
+ // Interactive on a terminal; resolved (empty) when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-filepicker-multiple.php b/playground/02-fields-filepicker-multiple.php
new file mode 100644
index 00000000..29996c50
--- /dev/null
+++ b/playground/02-fields-filepicker-multiple.php
@@ -0,0 +1,37 @@
+multiple(): several paths from one browse.
+ *
+ * Space toggles the highlighted entry while browsing continues, so picks can
+ * span directories; Enter accepts the set. The field collects a list of
+ * paths.
+ *
+ * Usage:
+ * php playground/02-fields-filepicker-multiple.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('File picker field')
+ ->panel('main', 'File picker', function (PanelBuilder $p): void {
+ $p->filePicker('price_lists', 'Price lists')->multiple()->startIn(__DIR__ . '/sample-project');
+ });
+
+try {
+ // Interactive on a terminal; resolved (empty) when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-filepicker.php b/playground/02-fields-filepicker.php
new file mode 100644
index 00000000..4d5d060d
--- /dev/null
+++ b/playground/02-fields-filepicker.php
@@ -0,0 +1,40 @@
+startIn(), limits it to files with ->filesOnly(), to CSV with
+ * ->extensions() and to a size with ->maxSize() - a pick that breaks a limit
+ * is rejected inline; ->directoriesOnly() and ->showHidden() are the other
+ * filters. The field collects the selected path as a string.
+ *
+ * Usage:
+ * php playground/02-fields-filepicker.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('File picker field')
+ ->panel('main', 'File picker', function (PanelBuilder $p): void {
+ $p->filePicker('price_list', 'Price list')->startIn(__DIR__ . '/sample-project')->filesOnly()->extensions(['csv'])->maxSize(50);
+ });
+
+try {
+ // Interactive on a terminal; resolved (empty) when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-note.php b/playground/02-fields-note.php
new file mode 100644
index 00000000..33a60c4b
--- /dev/null
+++ b/playground/02-fields-note.php
@@ -0,0 +1,41 @@
+border()` frames the card. Here the summary note
+ * echoes the item, and the collected JSON carries only the field's value.
+ *
+ * Usage:
+ * php playground/02-fields-note.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$form = Form::create('Note field')
+ ->panel('main', 'Order', function (PanelBuilder $p): void {
+ $p->note('intro', 'Fresh produce order')->description('This card is read-only - the cursor skips it and it collects nothing.');
+ $p->text('item', 'Item')->default('Pear');
+ $p->note('summary', 'Ready to pack')->description('Packing {{item}} into the basket.')->border();
+ });
+
+try {
+ // Interactive on a terminal; headless otherwise - either way the notes are
+ // absent from the JSON, which carries only the item.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-number.php b/playground/02-fields-number.php
new file mode 100644
index 00000000..92cd06ea
--- /dev/null
+++ b/playground/02-fields-number.php
@@ -0,0 +1,37 @@
+panel('main', 'Number', function (PanelBuilder $p): void {
+ $p->number('weight', 'Basket weight (g)')->default(1200)->min(200)->max(9000)->step(100);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-password-reveal.php b/playground/02-fields-password-reveal.php
new file mode 100644
index 00000000..9cc4ff20
--- /dev/null
+++ b/playground/02-fields-password-reveal.php
@@ -0,0 +1,37 @@
+revealable(): a Tab-toggled plaintext peek.
+ *
+ * The value renders masked as usual; Tab flips the editor to plaintext and
+ * back, for checking a typed secret before accepting it. The collected value
+ * is identical either way.
+ *
+ * Usage:
+ * php playground/02-fields-password-reveal.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Password field')
+ ->panel('main', 'Password', function (PanelBuilder $p): void {
+ $p->password('code', 'Order code')->default('melon7')->revealable();
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-password.php b/playground/02-fields-password.php
new file mode 100644
index 00000000..e770495b
--- /dev/null
+++ b/playground/02-fields-password.php
@@ -0,0 +1,38 @@
+revealable() for a Tab-toggled
+ * plaintext peek (see password-reveal.php) and ->confirmation() to ask for
+ * the value twice and reject a mismatch.
+ *
+ * Usage:
+ * php playground/02-fields-password.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Password field')
+ ->panel('main', 'Password', function (PanelBuilder $p): void {
+ $p->password('code', 'Order code')->default('melon7');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-pause.php b/playground/02-fields-pause.php
new file mode 100644
index 00000000..182b6fbd
--- /dev/null
+++ b/playground/02-fields-pause.php
@@ -0,0 +1,37 @@
+panel('main', 'Pause', function (PanelBuilder $p): void {
+ $p->pause('review', 'Review your basket');
+ });
+
+try {
+ // Interactive on a terminal; auto-acknowledged when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-progress.php b/playground/02-fields-progress.php
new file mode 100644
index 00000000..f4245b83
--- /dev/null
+++ b/playground/02-fields-progress.php
@@ -0,0 +1,46 @@
+steps() for an indeterminate spinner).
+ * The row collects no value - it is a place to do work inside the form, beside
+ * the fields it depends on. Unattended runs skip it.
+ *
+ * Usage:
+ * php playground/02-fields-progress.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Primitive\ProgressReporter;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$items = ['Apple', 'Carrot', 'Tomato', 'Spinach', 'Pear', 'Beet'];
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Progress field')
+ ->panel('main', 'Progress', function (PanelBuilder $p) use ($items): void {
+ $p->progress('pack', 'Packing the box')->steps(count($items))->run(function (ProgressReporter $reporter) use ($items): void {
+ foreach ($items as $item) {
+ usleep(220000);
+ $reporter->advance('packed ' . $item);
+ }
+ });
+ });
+
+try {
+ // Interactive on a terminal; the row is skipped when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-rating.php b/playground/02-fields-rating.php
new file mode 100644
index 00000000..723b72f4
--- /dev/null
+++ b/playground/02-fields-rating.php
@@ -0,0 +1,44 @@
+panel('main', 'Rating', function (PanelBuilder $p): void {
+ // The ends default to one and five; captioning them names the scale without
+ // claiming a reading for every step in between.
+ $p->rating('freshness', 'Freshness')->default(4)->captions([
+ 1 => 'Poor',
+ 3 => 'Fair',
+ 5 => 'Excellent',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-reorder.php b/playground/02-fields-reorder.php
new file mode 100644
index 00000000..99e2c322
--- /dev/null
+++ b/playground/02-fields-reorder.php
@@ -0,0 +1,42 @@
+panel('main', 'Reorder', function (PanelBuilder $p): void {
+ // The declared order is the starting order.
+ $p->reorder('basket', 'Basket')->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-search-multiple-limited.php b/playground/02-fields-search-multiple-limited.php
new file mode 100644
index 00000000..638f8092
--- /dev/null
+++ b/playground/02-fields-search-multiple-limited.php
@@ -0,0 +1,44 @@
+multiple() with selection limits: a minimum and maximum count.
+ *
+ * ->minSelections(2)->maxSelections(3) bounds how many options may be checked.
+ * The active limit shows as a hint above the list, and accepting a count
+ * outside the range is rejected inline until it is satisfied. The field still
+ * collects the list of checked values.
+ *
+ * Usage:
+ * php playground/02-fields-search-multiple-limited.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Bounded MultiSearch')
+ ->panel('main', 'MultiSearch', function (PanelBuilder $p): void {
+ // Pick at least two and at most three of the options.
+ $p->search('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-search-multiple.php b/playground/02-fields-search-multiple.php
new file mode 100644
index 00000000..73dc3a0f
--- /dev/null
+++ b/playground/02-fields-search-multiple.php
@@ -0,0 +1,42 @@
+multiple(): fuzzy filter plus checkboxes.
+ *
+ * Typing narrows the ranked list, Space toggles the highlighted option and
+ * the filter stays put, so several picks chain naturally: type, Space, type,
+ * Space, Enter. The field collects the checked values.
+ *
+ * Usage:
+ * php playground/02-fields-search-multiple.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('MultiSearch field')
+ ->panel('main', 'MultiSearch', function (PanelBuilder $p): void {
+ $p->search('basket', 'Basket')->multiple()->default(['apple'])->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-search.php b/playground/02-fields-search.php
new file mode 100644
index 00000000..b44063c6
--- /dev/null
+++ b/playground/02-fields-search.php
@@ -0,0 +1,43 @@
+panel('main', 'Search', function (PanelBuilder $p): void {
+ $p->search('vegetable', 'Vegetable')->default('carrot')->options([
+ 'carrot' => 'Carrot',
+ 'potato' => 'Potato',
+ 'onion' => 'Onion',
+ 'pepper' => 'Pepper',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select-descriptions.php b/playground/02-fields-select-descriptions.php
new file mode 100644
index 00000000..5dc81e4e
--- /dev/null
+++ b/playground/02-fields-select-descriptions.php
@@ -0,0 +1,43 @@
+option(..., description: ...), or resolve it for the highlighted value with
+ * ->describeOptions().
+ *
+ * Usage:
+ * php playground/02-fields-select-descriptions.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$form = Form::create('Option descriptions')
+ ->panel('main', 'Select', function (PanelBuilder $p): void {
+ // A description travels with each option and shows for the highlighted one.
+ $p->select('fruit', 'Fruit')->default('apple')
+ ->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')
+ ->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')
+ ->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select-groups.php b/playground/02-fields-select-groups.php
new file mode 100644
index 00000000..c1f2fa12
--- /dev/null
+++ b/playground/02-fields-select-groups.php
@@ -0,0 +1,43 @@
+option() can be interleaved with
+ * ->heading() and ->separator() rows; a disabled option shows its reason
+ * beside the label. The non-selectable rows are visual only - the cursor
+ * skips them.
+ *
+ * Usage:
+ * php playground/02-fields-select-groups.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Select with groups')
+ ->panel('main', 'Select', function (PanelBuilder $p): void {
+ $p->select('fruit', 'Fruit')->default('apple')
+ ->heading('Fruit')
+ ->option('apple', 'Apple')
+ ->option('banana', 'Banana')
+ ->separator()
+ ->option('cherry', 'Cherry', disabled: TRUE, disabled_reason: 'out of season');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select-multiple-groups.php b/playground/02-fields-select-multiple-groups.php
new file mode 100644
index 00000000..44cbe5b1
--- /dev/null
+++ b/playground/02-fields-select-multiple-groups.php
@@ -0,0 +1,45 @@
+heading(), ->separator() and disabled-option rows as the single
+ * select, under ->multiple(): Space toggles, the cursor skips the
+ * non-selectable rows, and the field collects the checked values.
+ *
+ * Usage:
+ * php playground/02-fields-select-multiple-groups.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('MultiSelect with groups')
+ ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
+ $p->select('basket', 'Basket')->multiple()->default(['apple'])
+ ->heading('Fruit')
+ ->option('apple', 'Apple')
+ ->option('banana', 'Banana')
+ ->separator()
+ ->heading('Vegetables')
+ ->option('carrot', 'Carrot')
+ ->option('tomato', 'Tomato')
+ ->option('leek', 'Leek', disabled: TRUE, disabled_reason: 'out of season');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select-multiple-limited.php b/playground/02-fields-select-multiple-limited.php
new file mode 100644
index 00000000..5e6519ba
--- /dev/null
+++ b/playground/02-fields-select-multiple-limited.php
@@ -0,0 +1,43 @@
+multiple() with selection limits: a minimum and maximum count.
+ *
+ * ->minSelections(2)->maxSelections(3) bounds how many options may be checked.
+ * The active limit shows as a hint above the list, and accepting a count
+ * outside the range is rejected inline until it is satisfied. The field still
+ * collects the list of checked values.
+ *
+ * Usage:
+ * php playground/02-fields-select-multiple-limited.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Bounded MultiSelect')
+ ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
+ // Pick at least two and at most three of the options.
+ $p->select('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select-multiple.php b/playground/02-fields-select-multiple.php
new file mode 100644
index 00000000..68d22909
--- /dev/null
+++ b/playground/02-fields-select-multiple.php
@@ -0,0 +1,42 @@
+multiple(): any number of checked options.
+ *
+ * Space toggles the highlighted option, typing narrows the list by substring,
+ * Right/Left check or clear everything visible, Enter accepts. The field
+ * collects a list of the checked option values.
+ *
+ * Usage:
+ * php playground/02-fields-select-multiple.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('MultiSelect field')
+ ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
+ // The default pre-checks values, so it is a list here.
+ $p->select('basket', 'Basket')->multiple()->default(['apple'])->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-select.php b/playground/02-fields-select.php
new file mode 100644
index 00000000..b11aa433
--- /dev/null
+++ b/playground/02-fields-select.php
@@ -0,0 +1,42 @@
+pageSize() pages around the cursor. The field collects the selected
+ * option value (a string), never the label.
+ *
+ * Usage:
+ * php playground/02-fields-select.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Select field')
+ ->panel('main', 'Select', function (PanelBuilder $p): void {
+ // Options are a value => label map; the default names a value.
+ $p->select('fruit', 'Fruit')->default('apple')->options([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'cherry' => 'Cherry',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-suggest.php b/playground/02-fields-suggest.php
new file mode 100644
index 00000000..d0d1b7f9
--- /dev/null
+++ b/playground/02-fields-suggest.php
@@ -0,0 +1,46 @@
+ghost() adds an inline preview of the leading match after the caret,
+ * accepted with Tab or the right arrow; the ranked list stays available.
+ *
+ * Usage:
+ * php playground/02-fields-suggest.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Suggest field')
+ ->panel('main', 'Suggest', function (PanelBuilder $p): void {
+ $p->suggest('fruit', 'Fruit')->options([
+ 'Apple' => 'Apple',
+ 'Apricot' => 'Apricot',
+ 'Banana' => 'Banana',
+ 'Cherry' => 'Cherry',
+ 'Mango' => 'Mango',
+ ])->ghost();
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-table.php b/playground/02-fields-table.php
new file mode 100644
index 00000000..6b084787
--- /dev/null
+++ b/playground/02-fields-table.php
@@ -0,0 +1,47 @@
+table(headers,
+ * rows). The grid honours the active theme - its border style, colour and
+ * Unicode switches - and its cells take the same `{{field}}` templating a
+ * note's title and body do. Like every note it is presentational: the cursor
+ * skips it, it collects nothing, and headless runs omit it, so the JSON here
+ * is empty.
+ *
+ * Usage:
+ * php playground/02-fields-table.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$headers = ['Fruit', 'Color', 'In stock'];
+$rows = [
+ ['Apple', 'Red', '12'],
+ ['Pear', 'Green', '5'],
+ ['Plum', 'Purple', '120'],
+];
+
+$form = Form::create('Table')
+ ->panel('main', 'Stock', function (PanelBuilder $p) use ($headers, $rows): void {
+ $p->note('stock', 'Basket contents')->description('Everything picked so far:')->table($headers, $rows);
+ });
+
+try {
+ // Interactive on a terminal; headless otherwise - either way the note (and
+ // its table) is absent from the JSON, which carries no collected value.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-template.php b/playground/02-fields-template.php
new file mode 100644
index 00000000..24dabfc9
--- /dev/null
+++ b/playground/02-fields-template.php
@@ -0,0 +1,48 @@
+panel('main', 'Template', function (PanelBuilder $p): void {
+ // ->pattern() declares the shape; ->slot() labels one slot and gives it a
+ // validator of its own, checked apart from the others.
+ $p->template('crate', 'Crate label')
+ ->pattern('{{orchard}}-{{fruit}}-{{grade}}')
+ ->default('valley-pear-a')
+ ->slot('orchard', 'Orchard')
+ ->slot('fruit', 'Fruit')
+ ->slot('grade', 'Grade', static fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c');
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ $answers = (new Tui($form))->run();
+
+ echo $answers->toJson() . PHP_EOL;
+ // The whole label and the pieces it was built from, side by side.
+ echo json_encode($answers->parts('crate')) . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-text.php b/playground/02-fields-text.php
new file mode 100644
index 00000000..2fde1666
--- /dev/null
+++ b/playground/02-fields-text.php
@@ -0,0 +1,38 @@
+panel('main', 'Text', function (PanelBuilder $p): void {
+ // ->complete() adds Tab-completion over a fixed word list; typing stays
+ // free-form, the list only helps.
+ $p->text('item', 'Item')->default('Pear')->complete(['Pear', 'Peach', 'Plum']);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-textarea.php b/playground/02-fields-textarea.php
new file mode 100644
index 00000000..99c11882
--- /dev/null
+++ b/playground/02-fields-textarea.php
@@ -0,0 +1,37 @@
+externalEditor(), Ctrl-E hands the draft to $VISUAL/$EDITOR
+ * and reads the saved file back into the field.
+ *
+ * Usage:
+ * php playground/02-fields-textarea.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+// One field on one panel: the smallest form that exercises the field.
+$form = Form::create('Textarea field')
+ ->panel('main', 'Textarea', function (PanelBuilder $p): void {
+ $p->textarea('notes', 'Tasting notes')->default('Crisp and sweet' . chr(10) . 'Hint of citrus')->externalEditor();
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-fields-toggle.php b/playground/02-fields-toggle.php
new file mode 100644
index 00000000..f4bdd045
--- /dev/null
+++ b/playground/02-fields-toggle.php
@@ -0,0 +1,41 @@
+panel('main', 'Toggle', function (PanelBuilder $p): void {
+ // Exactly two options; the collected value is one of the keys.
+ $p->toggle('ripeness', 'Ripeness')->default('ripe')->options([
+ 'ripe' => 'Ripe',
+ 'unripe' => 'Unripe',
+ ]);
+ });
+
+try {
+ // Interactive on a terminal; resolved from the default when piped.
+ echo (new Tui($form))->run()->toJson() . PHP_EOL;
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
diff --git a/playground/02-widgets-all-widgets.php b/playground/02-widgets-all-widgets.php
deleted file mode 100644
index 9b39748d..00000000
--- a/playground/02-widgets-all-widgets.php
+++ /dev/null
@@ -1,92 +0,0 @@
-panel('widgets', 'Widgets', function (PanelBuilder $p): void {
- $p->note('note', 'Note')->description('A read-only card - the cursor skips it and it collects nothing.');
- $p->text('text', 'Text')->default('Pear');
- $p->template('template', 'Template')->pattern('{{orchard}}-{{fruit}}-{{grade}}')->default('valley-pear-a');
- $p->number('number', 'Number')->default(1200);
- $p->rating('rating', 'Rating')->default(4)->captions([1 => 'Poor', 3 => 'Fair', 5 => 'Excellent']);
- // The month grid wants the whole screen, so it opts out of inline
- // editing; every other field here edits in place on its row.
- $p->calendar('calendar', 'Calendar')->default('2026-07-15')->standalone();
- $p->textarea('textarea', 'Textarea')->default('Crisp and sweet' . chr(10) . 'Hint of citrus');
- $p->password('password', 'Password')->default('melon7');
- $p->select('select', 'Select')->default('apple')->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'cherry' => 'Cherry',
- ]);
- $p->select('multiselect', 'MultiSelect')->multiple()->default(['apple'])->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- $p->reorder('reorder', 'Reorder')->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- $p->suggest('suggest', 'Suggest')->options([
- 'Apple' => 'Apple',
- 'Apricot' => 'Apricot',
- 'Banana' => 'Banana',
- 'Cherry' => 'Cherry',
- 'Mango' => 'Mango',
- ]);
- $p->search('search', 'Search')->default('carrot')->options([
- 'carrot' => 'Carrot',
- 'potato' => 'Potato',
- 'onion' => 'Onion',
- 'pepper' => 'Pepper',
- ]);
- $p->search('multisearch', 'MultiSearch')->multiple()->default(['apple'])->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- $p->confirm('confirm', 'Confirm')->default(TRUE);
- $p->toggle('toggle', 'Toggle')->default('ripe')->options([
- 'ripe' => 'Ripe',
- 'unripe' => 'Unripe',
- ]);
- $p->pause('pause', 'Pause');
- });
-
-try {
- // The rounded border frames the whole browser - the house look of the
- // panel demos; playground/03-panels-bordered.php shows it on its own.
- $answers = (new Tui($form))->theme('default', ['border' => 'rounded'])->run();
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
-
-// The summary groups the answers by panel with provenance badges.
-echo $answers->toSummary() . PHP_EOL;
diff --git a/playground/02-widgets-calendar.php b/playground/02-widgets-calendar.php
deleted file mode 100644
index dae578e7..00000000
--- a/playground/02-widgets-calendar.php
+++ /dev/null
@@ -1,38 +0,0 @@
-minDate()/->maxDate() bound the selectable range and
- * ->weekStart() picks the first weekday column.
- *
- * Usage:
- * php playground/02-widgets-calendar.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Calendar widget')
- ->panel('main', 'Calendar', function (PanelBuilder $p): void {
- $p->calendar('harvest', 'Harvest date')->default('2026-07-15');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-confirm.php b/playground/02-widgets-confirm.php
deleted file mode 100644
index e82ed8d6..00000000
--- a/playground/02-widgets-confirm.php
+++ /dev/null
@@ -1,36 +0,0 @@
-panel('main', 'Confirm', function (PanelBuilder $p): void {
- $p->confirm('organic', 'Organic only?')->default(TRUE);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-filepicker-multiple-limited.php b/playground/02-widgets-filepicker-multiple-limited.php
deleted file mode 100644
index 5857a58d..00000000
--- a/playground/02-widgets-filepicker-multiple-limited.php
+++ /dev/null
@@ -1,39 +0,0 @@
-multiple() with selection limits: a minimum and maximum count.
- *
- * ->minSelections(2)->maxSelections(3) bounds how many paths may be checked.
- * The active limit shows as a hint above the browser, and accepting a count
- * outside the range is rejected inline until it is satisfied. The field still
- * collects the list of chosen paths.
- *
- * Usage:
- * php playground/02-widgets-filepicker-multiple-limited.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('File picker widget')
- ->panel('main', 'File picker', function (PanelBuilder $p): void {
- // Pick at least two and at most three paths.
- $p->filePicker('price_lists', 'Price lists')->multiple()->minSelections(2)->maxSelections(3)->startIn(__DIR__ . '/sample-project');
- });
-
-try {
- // Interactive on a terminal; resolved (empty) when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-filepicker-multiple.php b/playground/02-widgets-filepicker-multiple.php
deleted file mode 100644
index 51b317f0..00000000
--- a/playground/02-widgets-filepicker-multiple.php
+++ /dev/null
@@ -1,37 +0,0 @@
-multiple(): several paths from one browse.
- *
- * Space toggles the highlighted entry while browsing continues, so picks can
- * span directories; Enter accepts the set. The field collects a list of
- * paths.
- *
- * Usage:
- * php playground/02-widgets-filepicker-multiple.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('File picker widget')
- ->panel('main', 'File picker', function (PanelBuilder $p): void {
- $p->filePicker('price_lists', 'Price lists')->multiple()->startIn(__DIR__ . '/sample-project');
- });
-
-try {
- // Interactive on a terminal; resolved (empty) when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-filepicker.php b/playground/02-widgets-filepicker.php
deleted file mode 100644
index 57e0c25e..00000000
--- a/playground/02-widgets-filepicker.php
+++ /dev/null
@@ -1,40 +0,0 @@
-startIn(), limits it to files with ->filesOnly(), to CSV with
- * ->extensions() and to a size with ->maxSize() - a pick that breaks a limit
- * is rejected inline; ->directoriesOnly() and ->showHidden() are the other
- * filters. The field collects the selected path as a string.
- *
- * Usage:
- * php playground/02-widgets-filepicker.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('File picker widget')
- ->panel('main', 'File picker', function (PanelBuilder $p): void {
- $p->filePicker('price_list', 'Price list')->startIn(__DIR__ . '/sample-project')->filesOnly()->extensions(['csv'])->maxSize(50);
- });
-
-try {
- // Interactive on a terminal; resolved (empty) when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-note.php b/playground/02-widgets-note.php
deleted file mode 100644
index 8d6bb583..00000000
--- a/playground/02-widgets-note.php
+++ /dev/null
@@ -1,41 +0,0 @@
-border()` frames the card. Here the summary note
- * echoes the item, and the collected JSON carries only the field's value.
- *
- * Usage:
- * php playground/02-widgets-note.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-$form = Form::create('Note field')
- ->panel('main', 'Order', function (PanelBuilder $p): void {
- $p->note('intro', 'Fresh produce order')->description('This card is read-only - the cursor skips it and it collects nothing.');
- $p->text('item', 'Item')->default('Pear');
- $p->note('summary', 'Ready to pack')->description('Packing {{item}} into the basket.')->border();
- });
-
-try {
- // Interactive on a terminal; headless otherwise - either way the notes are
- // absent from the JSON, which carries only the item.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-number.php b/playground/02-widgets-number.php
deleted file mode 100644
index 345c101a..00000000
--- a/playground/02-widgets-number.php
+++ /dev/null
@@ -1,37 +0,0 @@
-panel('main', 'Number', function (PanelBuilder $p): void {
- $p->number('weight', 'Basket weight (g)')->default(1200)->min(200)->max(9000)->step(100);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-password-reveal.php b/playground/02-widgets-password-reveal.php
deleted file mode 100644
index 00baea4b..00000000
--- a/playground/02-widgets-password-reveal.php
+++ /dev/null
@@ -1,37 +0,0 @@
-revealable(): a Tab-toggled plaintext peek.
- *
- * The value renders masked as usual; Tab flips the editor to plaintext and
- * back, for checking a typed secret before accepting it. The collected value
- * is identical either way.
- *
- * Usage:
- * php playground/02-widgets-password-reveal.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Password widget')
- ->panel('main', 'Password', function (PanelBuilder $p): void {
- $p->password('code', 'Order code')->default('melon7')->revealable();
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-password.php b/playground/02-widgets-password.php
deleted file mode 100644
index e4fe2b50..00000000
--- a/playground/02-widgets-password.php
+++ /dev/null
@@ -1,38 +0,0 @@
-revealable() for a Tab-toggled
- * plaintext peek (see password-reveal.php) and ->confirmation() to ask for
- * the value twice and reject a mismatch.
- *
- * Usage:
- * php playground/02-widgets-password.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Password widget')
- ->panel('main', 'Password', function (PanelBuilder $p): void {
- $p->password('code', 'Order code')->default('melon7');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-pause.php b/playground/02-widgets-pause.php
deleted file mode 100644
index def36b71..00000000
--- a/playground/02-widgets-pause.php
+++ /dev/null
@@ -1,37 +0,0 @@
-panel('main', 'Pause', function (PanelBuilder $p): void {
- $p->pause('review', 'Review your basket');
- });
-
-try {
- // Interactive on a terminal; auto-acknowledged when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-progress.php b/playground/02-widgets-progress.php
deleted file mode 100644
index cad16424..00000000
--- a/playground/02-widgets-progress.php
+++ /dev/null
@@ -1,46 +0,0 @@
-steps() for an indeterminate spinner).
- * The row collects no value - it is a place to do work inside the form, beside
- * the fields it depends on. Unattended runs skip it.
- *
- * Usage:
- * php playground/02-widgets-progress.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Primitive\ProgressReporter;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-$items = ['Apple', 'Carrot', 'Tomato', 'Spinach', 'Pear', 'Beet'];
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Progress widget')
- ->panel('main', 'Progress', function (PanelBuilder $p) use ($items): void {
- $p->progress('pack', 'Packing the box')->steps(count($items))->run(function (ProgressReporter $reporter) use ($items): void {
- foreach ($items as $item) {
- usleep(220000);
- $reporter->advance('packed ' . $item);
- }
- });
- });
-
-try {
- // Interactive on a terminal; the row is skipped when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-rating.php b/playground/02-widgets-rating.php
deleted file mode 100644
index 91ced58b..00000000
--- a/playground/02-widgets-rating.php
+++ /dev/null
@@ -1,44 +0,0 @@
-panel('main', 'Rating', function (PanelBuilder $p): void {
- // The ends default to one and five; captioning them names the scale without
- // claiming a reading for every step in between.
- $p->rating('freshness', 'Freshness')->default(4)->captions([
- 1 => 'Poor',
- 3 => 'Fair',
- 5 => 'Excellent',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-reorder.php b/playground/02-widgets-reorder.php
deleted file mode 100644
index f5e9d1b3..00000000
--- a/playground/02-widgets-reorder.php
+++ /dev/null
@@ -1,42 +0,0 @@
-panel('main', 'Reorder', function (PanelBuilder $p): void {
- // The declared order is the starting order.
- $p->reorder('basket', 'Basket')->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-search-multiple-limited.php b/playground/02-widgets-search-multiple-limited.php
deleted file mode 100644
index 17337cb5..00000000
--- a/playground/02-widgets-search-multiple-limited.php
+++ /dev/null
@@ -1,44 +0,0 @@
-multiple() with selection limits: a minimum and maximum count.
- *
- * ->minSelections(2)->maxSelections(3) bounds how many options may be checked.
- * The active limit shows as a hint above the list, and accepting a count
- * outside the range is rejected inline until it is satisfied. The field still
- * collects the list of checked values.
- *
- * Usage:
- * php playground/02-widgets-search-multiple-limited.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Bounded MultiSearch')
- ->panel('main', 'MultiSearch', function (PanelBuilder $p): void {
- // Pick at least two and at most three of the options.
- $p->search('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-search-multiple.php b/playground/02-widgets-search-multiple.php
deleted file mode 100644
index e613d279..00000000
--- a/playground/02-widgets-search-multiple.php
+++ /dev/null
@@ -1,42 +0,0 @@
-multiple(): fuzzy filter plus checkboxes.
- *
- * Typing narrows the ranked list, Space toggles the highlighted option and
- * the filter stays put, so several picks chain naturally: type, Space, type,
- * Space, Enter. The field collects the checked values.
- *
- * Usage:
- * php playground/02-widgets-search-multiple.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('MultiSearch widget')
- ->panel('main', 'MultiSearch', function (PanelBuilder $p): void {
- $p->search('basket', 'Basket')->multiple()->default(['apple'])->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-search.php b/playground/02-widgets-search.php
deleted file mode 100644
index 1cbff53c..00000000
--- a/playground/02-widgets-search.php
+++ /dev/null
@@ -1,43 +0,0 @@
-panel('main', 'Search', function (PanelBuilder $p): void {
- $p->search('vegetable', 'Vegetable')->default('carrot')->options([
- 'carrot' => 'Carrot',
- 'potato' => 'Potato',
- 'onion' => 'Onion',
- 'pepper' => 'Pepper',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select-descriptions.php b/playground/02-widgets-select-descriptions.php
deleted file mode 100644
index e338c73a..00000000
--- a/playground/02-widgets-select-descriptions.php
+++ /dev/null
@@ -1,43 +0,0 @@
-option(..., description: ...), or resolve it for the highlighted value with
- * ->describeOptions().
- *
- * Usage:
- * php playground/02-widgets-select-descriptions.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-$form = Form::create('Option descriptions')
- ->panel('main', 'Select', function (PanelBuilder $p): void {
- // A description travels with each option and shows for the highlighted one.
- $p->select('fruit', 'Fruit')->default('apple')
- ->option('apple', 'Apple', description: 'Crisp and sweet, the everyday choice.')
- ->option('banana', 'Banana', description: 'Rich in potassium; ripens off the tree.')
- ->option('cherry', 'Cherry', description: 'Short season; best eaten fresh.');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select-groups.php b/playground/02-widgets-select-groups.php
deleted file mode 100644
index 0160002d..00000000
--- a/playground/02-widgets-select-groups.php
+++ /dev/null
@@ -1,43 +0,0 @@
-option() can be interleaved with
- * ->heading() and ->separator() rows; a disabled option shows its reason
- * beside the label. The non-selectable rows are visual only - the cursor
- * skips them.
- *
- * Usage:
- * php playground/02-widgets-select-groups.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Select with groups')
- ->panel('main', 'Select', function (PanelBuilder $p): void {
- $p->select('fruit', 'Fruit')->default('apple')
- ->heading('Fruit')
- ->option('apple', 'Apple')
- ->option('banana', 'Banana')
- ->separator()
- ->option('cherry', 'Cherry', disabled: TRUE, disabled_reason: 'out of season');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select-multiple-groups.php b/playground/02-widgets-select-multiple-groups.php
deleted file mode 100644
index b83fb854..00000000
--- a/playground/02-widgets-select-multiple-groups.php
+++ /dev/null
@@ -1,45 +0,0 @@
-heading(), ->separator() and disabled-option rows as the single
- * select, under ->multiple(): Space toggles, the cursor skips the
- * non-selectable rows, and the field collects the checked values.
- *
- * Usage:
- * php playground/02-widgets-select-multiple-groups.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('MultiSelect with groups')
- ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
- $p->select('basket', 'Basket')->multiple()->default(['apple'])
- ->heading('Fruit')
- ->option('apple', 'Apple')
- ->option('banana', 'Banana')
- ->separator()
- ->heading('Vegetables')
- ->option('carrot', 'Carrot')
- ->option('tomato', 'Tomato')
- ->option('leek', 'Leek', disabled: TRUE, disabled_reason: 'out of season');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select-multiple-limited.php b/playground/02-widgets-select-multiple-limited.php
deleted file mode 100644
index 4a34c846..00000000
--- a/playground/02-widgets-select-multiple-limited.php
+++ /dev/null
@@ -1,43 +0,0 @@
-multiple() with selection limits: a minimum and maximum count.
- *
- * ->minSelections(2)->maxSelections(3) bounds how many options may be checked.
- * The active limit shows as a hint above the list, and accepting a count
- * outside the range is rejected inline until it is satisfied. The field still
- * collects the list of checked values.
- *
- * Usage:
- * php playground/02-widgets-select-multiple-limited.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Bounded MultiSelect')
- ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
- // Pick at least two and at most three of the options.
- $p->select('basket', 'Basket')->multiple()->minSelections(2)->maxSelections(3)->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select-multiple.php b/playground/02-widgets-select-multiple.php
deleted file mode 100644
index 1faeb8c7..00000000
--- a/playground/02-widgets-select-multiple.php
+++ /dev/null
@@ -1,42 +0,0 @@
-multiple(): any number of checked options.
- *
- * Space toggles the highlighted option, typing narrows the list by substring,
- * Right/Left check or clear everything visible, Enter accepts. The field
- * collects a list of the checked option values.
- *
- * Usage:
- * php playground/02-widgets-select-multiple.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('MultiSelect widget')
- ->panel('main', 'MultiSelect', function (PanelBuilder $p): void {
- // The default pre-checks values, so it is a list here.
- $p->select('basket', 'Basket')->multiple()->default(['apple'])->options([
- 'apple' => 'Apple',
- 'carrot' => 'Carrot',
- 'tomato' => 'Tomato',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-select.php b/playground/02-widgets-select.php
deleted file mode 100644
index 7faaa076..00000000
--- a/playground/02-widgets-select.php
+++ /dev/null
@@ -1,42 +0,0 @@
-pageSize() pages around the cursor. The field collects the selected
- * option value (a string), never the label.
- *
- * Usage:
- * php playground/02-widgets-select.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Select widget')
- ->panel('main', 'Select', function (PanelBuilder $p): void {
- // Options are a value => label map; the default names a value.
- $p->select('fruit', 'Fruit')->default('apple')->options([
- 'apple' => 'Apple',
- 'banana' => 'Banana',
- 'cherry' => 'Cherry',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-suggest.php b/playground/02-widgets-suggest.php
deleted file mode 100644
index cf0ab1c9..00000000
--- a/playground/02-widgets-suggest.php
+++ /dev/null
@@ -1,46 +0,0 @@
-ghost() adds an inline preview of the leading match after the caret,
- * accepted with Tab or the right arrow; the ranked list stays available.
- *
- * Usage:
- * php playground/02-widgets-suggest.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Suggest widget')
- ->panel('main', 'Suggest', function (PanelBuilder $p): void {
- $p->suggest('fruit', 'Fruit')->options([
- 'Apple' => 'Apple',
- 'Apricot' => 'Apricot',
- 'Banana' => 'Banana',
- 'Cherry' => 'Cherry',
- 'Mango' => 'Mango',
- ])->ghost();
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-table.php b/playground/02-widgets-table.php
deleted file mode 100644
index 2d0328d1..00000000
--- a/playground/02-widgets-table.php
+++ /dev/null
@@ -1,47 +0,0 @@
-table(headers,
- * rows). The grid honours the active theme - its border style, colour and
- * Unicode switches - and its cells take the same `{{field}}` templating a
- * note's title and body do. Like every note it is presentational: the cursor
- * skips it, it collects nothing, and headless runs omit it, so the JSON here
- * is empty.
- *
- * Usage:
- * php playground/02-widgets-table.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-$headers = ['Fruit', 'Colour', 'In stock'];
-$rows = [
- ['Apple', 'Red', '12'],
- ['Pear', 'Green', '5'],
- ['Plum', 'Purple', '120'],
-];
-
-$form = Form::create('Table')
- ->panel('main', 'Stock', function (PanelBuilder $p) use ($headers, $rows): void {
- $p->note('stock', 'Basket contents')->description('Everything picked so far:')->table($headers, $rows);
- });
-
-try {
- // Interactive on a terminal; headless otherwise - either way the note (and
- // its table) is absent from the JSON, which carries no collected value.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-template.php b/playground/02-widgets-template.php
deleted file mode 100644
index 3b2c9ef4..00000000
--- a/playground/02-widgets-template.php
+++ /dev/null
@@ -1,48 +0,0 @@
-panel('main', 'Template', function (PanelBuilder $p): void {
- // ->pattern() declares the shape; ->slot() labels one slot and gives it a
- // validator of its own, checked apart from the others.
- $p->template('crate', 'Crate label')
- ->pattern('{{orchard}}-{{fruit}}-{{grade}}')
- ->default('valley-pear-a')
- ->slot('orchard', 'Orchard')
- ->slot('fruit', 'Fruit')
- ->slot('grade', 'Grade', static fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c');
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- $answers = (new Tui($form))->run();
-
- echo $answers->toJson() . PHP_EOL;
- // The whole label and the pieces it was built from, side by side.
- echo json_encode($answers->parts('crate')) . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-text.php b/playground/02-widgets-text.php
deleted file mode 100644
index 4c6cf908..00000000
--- a/playground/02-widgets-text.php
+++ /dev/null
@@ -1,38 +0,0 @@
-panel('main', 'Text', function (PanelBuilder $p): void {
- // ->complete() adds Tab-completion over a fixed word list; typing stays
- // free-form, the list only helps.
- $p->text('item', 'Item')->default('Pear')->complete(['Pear', 'Peach', 'Plum']);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-textarea.php b/playground/02-widgets-textarea.php
deleted file mode 100644
index d5d42b40..00000000
--- a/playground/02-widgets-textarea.php
+++ /dev/null
@@ -1,37 +0,0 @@
-externalEditor(), Ctrl-E hands the draft to $VISUAL/$EDITOR
- * and reads the saved file back into the field.
- *
- * Usage:
- * php playground/02-widgets-textarea.php
- */
-
-declare(strict_types=1);
-
-use DrevOps\Tui\Builder\Form;
-use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\InterruptException;
-use DrevOps\Tui\Tui;
-
-require __DIR__ . '/../vendor/autoload.php';
-
-// One field on one panel: the smallest form that exercises the widget.
-$form = Form::create('Textarea widget')
- ->panel('main', 'Textarea', function (PanelBuilder $p): void {
- $p->textarea('notes', 'Tasting notes')->default('Crisp and sweet' . chr(10) . 'Hint of citrus')->externalEditor();
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/02-widgets-toggle.php b/playground/02-widgets-toggle.php
deleted file mode 100644
index 048b1204..00000000
--- a/playground/02-widgets-toggle.php
+++ /dev/null
@@ -1,41 +0,0 @@
-panel('main', 'Toggle', function (PanelBuilder $p): void {
- // Exactly two options; the collected value is one of the keys.
- $p->toggle('ripeness', 'Ripeness')->default('ripe')->options([
- 'ripe' => 'Ripe',
- 'unripe' => 'Unripe',
- ]);
- });
-
-try {
- // Interactive on a terminal; resolved from the default when piped.
- echo (new Tui($form))->run()->toJson() . PHP_EOL;
-}
-catch (InterruptException) {
- // Leave quietly on Ctrl-C.
- exit(130);
-}
diff --git a/playground/03-panels-bordered.php b/playground/03-panels-bordered.php
index 6bc08c81..d82946ea 100644
--- a/playground/03-panels-bordered.php
+++ b/playground/03-panels-bordered.php
@@ -5,12 +5,12 @@
* Bordered panels: the whole panel browser wrapped in a border frame.
*
* The padded rounded box shown here is also the default look; this demo sets
- * it explicitly to name the options. The border is a theme display option set
- * as a plain string - 'rounded', 'line', 'double' or 'none' - alongside the
- * 'spacing' option ('compact',
- * 'normal' or 'padded'). The theme draws the hub, breadcrumb header, fields
- * and key-hint footer inside the frame, and every drilled-in sub-panel keeps
- * it. A typo in an option value throws at startup, not mid-session.
+ * it explicitly to name the options. The border is a theme display option
+ * carrying a Border case - Rounded, Line, Double or None - alongside the
+ * 'spacing' option, whose cases are Compact, Normal and Padded. Both are
+ * closed sets, so an unknown value cannot be written in the first place. The
+ * theme draws the hub, breadcrumb header, fields and key-hint footer inside
+ * the frame, and every drilled-in sub-panel keeps it.
*
* Usage:
* php playground/03-panels-bordered.php
@@ -20,8 +20,10 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
+use DrevOps\Tui\Theme\Spacing;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -54,11 +56,12 @@
});
try {
- // 'rounded' + 'padded' is the frame the documentation demos use; swap the
- // strings for 'double', 'line' or 'none' and 'normal' or 'compact' to
- // compare. clearOnExit(FALSE) keeps the final frame on screen.
+ // Rounded and padded is the frame the documentation demos use; swap in
+ // Border::Double, Border::Line or Border::None and Spacing::Normal or
+ // Spacing::Compact to compare. clearOnExit(FALSE) keeps the final frame on
+ // screen.
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded', 'spacing' => 'padded'])
+ ->theme('default', ['border' => Border::Rounded, 'spacing' => Spacing::Padded])
->clearOnExit(FALSE)
->run();
}
@@ -66,7 +69,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/03-panels-borderless.php b/playground/03-panels-borderless.php
index 76a6f48b..70458b88 100644
--- a/playground/03-panels-borderless.php
+++ b/playground/03-panels-borderless.php
@@ -4,8 +4,8 @@
* @file
* Borderless panels: the same form as bordered.php, without the frame.
*
- * The default look is a padded rounded box; the explicit 'none' border and
- * 'normal' spacing strip it back to bare rows. Run this next to bordered.php to
+ * The default look is a padded rounded box; an explicit Border::None and
+ * Spacing::Normal strip it back to bare rows. Run this next to bordered.php to
* compare the two looks; the form, fields and keys are identical.
*
* Usage:
@@ -16,8 +16,10 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
+use DrevOps\Tui\Theme\Spacing;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -51,13 +53,13 @@
try {
// The default look is a padded rounded box; this demo opts out of both
// explicitly to show the bare, frameless rendering.
- $answers = (new Tui($form))->theme('default', ['border' => 'none', 'spacing' => 'normal'])->clearOnExit(FALSE)->run();
+ $answers = (new Tui($form))->theme('default', ['border' => Border::None, 'spacing' => Spacing::Normal])->clearOnExit(FALSE)->run();
}
catch (InterruptException) {
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/03-panels-fullscreen.php b/playground/03-panels-fullscreen.php
index d28c9441..22fea67f 100644
--- a/playground/03-panels-fullscreen.php
+++ b/playground/03-panels-fullscreen.php
@@ -5,15 +5,14 @@
* Fullscreen: the panel browser stretched to the whole terminal screen.
*
* Fullscreen is a facade switch (->fullscreen()); where the content sits
- * inside the stretched frame is a pair of theme options - 'halign' ('left',
- * 'center' or 'right') and 'valign' ('top', 'middle' or 'bottom') - set as
- * plain strings alongside the border, here picked by the --halign and
- * --valign flags so every alignment is one run away. A 'max_width' cap
- * (--max-width) floats the frame like a dialog at the chosen anchor, and
- * below 'min_width' / 'min_height' (the width is measured from the form's
- * own content unless set) the TUI shows a resize notice instead of a broken
- * layout. The form arranges its panels with ->layout(1, 2), so the stretched
- * screen shows the grid the layout example walks through.
+ * inside the stretched frame is a pair of theme options - 'halign' (an HAlign
+ * case) and 'valign' (a VAlign case) - set alongside the border, here picked
+ * by the --halign and --valign flags so every alignment is one run away. A
+ * 'max_width' cap (--max-width) floats the frame like a dialog at the chosen
+ * anchor, and below 'min_width' / 'min_height' (the width is measured from
+ * the form's own content unless set) the TUI shows a resize notice instead of
+ * a broken layout. The form arranges its panels with ->layout(1, 2), so the
+ * stretched screen shows the grid the layout example walks through.
*
* Usage:
* php playground/03-panels-fullscreen.php # centered
@@ -26,16 +25,25 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
+use DrevOps\Tui\Theme\HAlign;
+use DrevOps\Tui\Theme\VAlign;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
$options = getopt('', ['halign::', 'valign::', 'max-width::']);
-$halign = array_key_exists('halign', $options) && is_string($options['halign']) ? $options['halign'] : 'center';
-$valign = array_key_exists('valign', $options) && is_string($options['valign']) ? $options['valign'] : 'middle';
$max_width = array_key_exists('max-width', $options) && is_numeric($options['max-width']) ? (int) $options['max-width'] : 0;
+$across = is_string($options['halign'] ?? NULL) ? $options['halign'] : '';
+$down = is_string($options['valign'] ?? NULL) ? $options['valign'] : '';
+
+// An alignment is one of a fixed set, so a flag is read straight into the case
+// that names it, and one nobody offers is refused by name here rather than
+// reaching the theme as a string it would have to vet.
+$halign = $across === '' ? HAlign::Center : HAlign::tryFrom($across) ?? throw new InvalidArgumentException('Unknown --halign: use left, center or right.');
+$valign = $down === '' ? VAlign::Middle : VAlign::tryFrom($down) ?? throw new InvalidArgumentException('Unknown --valign: use top, middle or bottom.');
$form = Form::create('Market stall')
->layout(1, 2)
@@ -66,9 +74,14 @@
});
try {
- // A typo in an alignment value throws at startup, not mid-session.
+ // A bad option value throws at startup, not mid-session.
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded', 'halign' => $halign, 'valign' => $valign, 'max_width' => $max_width])
+ ->theme('default', [
+ 'border' => Border::Rounded,
+ 'halign' => $halign,
+ 'valign' => $valign,
+ 'max_width' => $max_width,
+ ])
->fullscreen()
->run();
}
@@ -76,7 +89,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/03-panels-layout.php b/playground/03-panels-layout.php
index 1ab71402..080e8ff6 100644
--- a/playground/03-panels-layout.php
+++ b/playground/03-panels-layout.php
@@ -21,8 +21,9 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -59,7 +60,7 @@
// Swap layout(1, 2) above for layout(3), layout(2, 1) or - with a fourth
// panel - layout(2, 2) to compare the arrangements.
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded'])
+ ->theme('default', ['border' => Border::Rounded])
->clearOnExit(FALSE)
->run();
}
@@ -67,7 +68,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/03-panels-modal.php b/playground/03-panels-modal.php
index 13b93c1c..3d453b76 100644
--- a/playground/03-panels-modal.php
+++ b/playground/03-panels-modal.php
@@ -18,8 +18,9 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -56,7 +57,7 @@
try {
// Keep the final frame on screen after the TUI exits.
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded'])
+ ->theme('default', ['border' => Border::Rounded])
->clearOnExit(FALSE)
->run();
}
@@ -64,7 +65,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/03-panels-nested.php b/playground/03-panels-nested.php
index a4ee7471..d8d9aec2 100644
--- a/playground/03-panels-nested.php
+++ b/playground/03-panels-nested.php
@@ -17,10 +17,11 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Condition\Condition;
use DrevOps\Tui\Derive\Derive;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -67,7 +68,7 @@
// The rounded border frames the hub and every drilled-in screen; keep the
// final frame on screen after the TUI exits instead of clearing it.
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded'])
+ ->theme('default', ['border' => Border::Rounded])
->clearOnExit(FALSE)
->run();
}
@@ -75,7 +76,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/04-inline-editing.php b/playground/04-inline-editing.php
index ec47d665..72efd097 100644
--- a/playground/04-inline-editing.php
+++ b/playground/04-inline-editing.php
@@ -6,9 +6,9 @@
*
* Press Enter on a field and the editor appears where the value sits - the
* confirm's Yes/No, the number's input, the select's option list - driven by
- * the widget's own keys and collapsing back on accept or cancel. Inline is
+ * the field's own keys and collapsing back on accept or cancel. Inline is
* the default for every field; ->standalone() opts a field out to a
- * full-screen editor, which suits large widgets like the calendar's month
+ * full-screen editor, which suits large fields like the calendar's month
* grid.
*
* Usage:
@@ -19,7 +19,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -53,7 +53,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/05-form-logic-conditional-fields.php b/playground/05-form-logic-conditional-fields.php
index cb81f8e5..a74d2b76 100644
--- a/playground/05-form-logic-conditional-fields.php
+++ b/playground/05-form-logic-conditional-fields.php
@@ -18,8 +18,8 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Condition\Condition;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -57,7 +57,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/05-form-logic-conditional-indent.php b/playground/05-form-logic-conditional-indent.php
index a396fcd1..2affab1b 100644
--- a/playground/05-form-logic-conditional-indent.php
+++ b/playground/05-form-logic-conditional-indent.php
@@ -18,8 +18,8 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Condition\Condition;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -58,7 +58,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/05-form-logic-derived-values.php b/playground/05-form-logic-derived-values.php
index d5f73788..cb9c9f49 100644
--- a/playground/05-form-logic-derived-values.php
+++ b/playground/05-form-logic-derived-values.php
@@ -19,8 +19,8 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Derive\Derive;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -49,7 +49,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/05-form-logic-fixup-rules.php b/playground/05-form-logic-fixup-rules.php
index 5c28d4c5..1d9aaef2 100644
--- a/playground/05-form-logic-fixup-rules.php
+++ b/playground/05-form-logic-fixup-rules.php
@@ -18,8 +18,8 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Condition\Condition;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Model\Fixup;
use DrevOps\Tui\Tui;
@@ -47,7 +47,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/06-field-behaviour-closures.php b/playground/06-field-behaviour-closures.php
index 31e13d88..2873c846 100644
--- a/playground/06-field-behaviour-closures.php
+++ b/playground/06-field-behaviour-closures.php
@@ -20,7 +20,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Handler\Context;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -61,7 +61,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/06-field-behaviour-handlers.php b/playground/06-field-behaviour-handlers.php
index bbbeff52..45e8c415 100644
--- a/playground/06-field-behaviour-handlers.php
+++ b/playground/06-field-behaviour-handlers.php
@@ -21,7 +21,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -46,7 +46,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/07-discovery.php b/playground/07-discovery.php
index fd6bffe1..a25449d5 100644
--- a/playground/07-discovery.php
+++ b/playground/07-discovery.php
@@ -22,12 +22,12 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Discovery\Dotenv;
use DrevOps\Tui\Discovery\JsonValue;
use DrevOps\Tui\Discovery\PathExists;
use DrevOps\Tui\Discovery\Scan;
use DrevOps\Tui\Discovery\ScanType;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -59,7 +59,7 @@
// second argument points the run at the directory to inspect.
$answers = (new Tui($form))->collect('', __DIR__ . '/sample-project', TRUE);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/08-headless-agent-help.php b/playground/08-headless-agent-help.php
index 2f45964d..b9665a27 100644
--- a/playground/08-headless-agent-help.php
+++ b/playground/08-headless-agent-help.php
@@ -4,11 +4,11 @@
* @file
* Agent help: generated instructions for driving the form unattended.
*
- * The agentHelp() call renders a plain-text cheat sheet for the form: every
- * question with its type, options and default, plus how to answer via the
- * prompts JSON and the per-field environment variables and where each ranks
- * in the precedence order. Print it from your tool's --help so automation
- * (or an agent) can answer the form without reading its source.
+ * The agentHelp() call describes the answers as a JSON Schema: every question
+ * typed by its id, carrying its allowed values, its title, its default, the
+ * environment variable that sets it, and - at the root - which of the answer
+ * sources wins. Print it from your tool's --help so automation (or an agent)
+ * can answer the form without reading its source.
*
* Usage:
* php playground/08-headless-agent-help.php
diff --git a/playground/08-headless-collect.php b/playground/08-headless-collect.php
index dbdc58fd..333de4cc 100644
--- a/playground/08-headless-collect.php
+++ b/playground/08-headless-collect.php
@@ -17,7 +17,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -47,7 +47,7 @@
try {
$answers = (new Tui($form))->collect($prompts);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
// A missing required answer or a value failing validation lands here.
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
diff --git a/playground/09-themes-custom.php b/playground/09-themes-custom.php
index b6180717..80f83d1f 100644
--- a/playground/09-themes-custom.php
+++ b/playground/09-themes-custom.php
@@ -18,8 +18,9 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
use DrevOps\Tui\Tui;
use Playground\Themes\OceanTheme;
@@ -51,14 +52,14 @@
// The banner comes from the form; the theme class and the border are set
// on the facade. The version renders below the banner.
$answers = (new Tui($form))
- ->theme(OceanTheme::class, ['border' => 'rounded'])
+ ->theme(OceanTheme::class, ['border' => Border::Rounded])
->run('', '1.0.0');
}
catch (InterruptException) {
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/09-themes-elements.php b/playground/09-themes-elements.php
new file mode 100644
index 00000000..29d56607
--- /dev/null
+++ b/playground/09-themes-elements.php
@@ -0,0 +1,93 @@
+theme() a closure instead of a name gives it a
+ * ThemeBuilder, and the overrides are grouped by the block that declares them -
+ * so the block's prefix is implied, and ->separator() means one thing under
+ * ->breadcrumb() and another under ->legend().
+ *
+ * Three kinds of thing can be restated. A glyph takes two arguments, the mark
+ * and its ASCII stand-in, so a patch cannot leave one display mode working and
+ * the other broken. Text takes one, because a phrase the reader parses is not
+ * something a terminal fails to draw. A colour takes the palette parts in
+ * order.
+ *
+ * It is a patch, not a replacement: every element nobody names keeps the
+ * selected theme's own answer, which is why the unpicked entries below still
+ * carry the mark the theme draws for them.
+ *
+ * Usage:
+ * php playground/09-themes-elements.php
+ *
+ * # The ASCII stand-ins, on any terminal:
+ * LC_ALL=C php playground/09-themes-elements.php
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Override\BreadcrumbOverrides;
+use DrevOps\Tui\Theme\Override\FieldOverrides;
+use DrevOps\Tui\Theme\Override\LegendOverrides;
+use DrevOps\Tui\Theme\Sgr;
+use DrevOps\Tui\Theme\ThemeBuilder;
+use DrevOps\Tui\Tui;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$form = Form::create('Element overrides')
+ ->panel('order', 'Order', function (PanelBuilder $p): void {
+ $p->panel('basket', 'Basket', function (PanelBuilder $sp): void {
+ // Two segments in the trail, so the breadcrumb has a separator to draw.
+ $sp->select('produce', 'Produce')->multiple()->default(['apple', 'carrot'])->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+ });
+ $p->text('name', 'Order name')->default('Weekly')->help('Anything you will recognise on the crate.');
+ });
+
+try {
+ // The name picks the theme; the closure states what that theme draws
+ // differently. Everything it does not name is left exactly as it was.
+ $answers = (new Tui($form))
+ ->theme('default')
+ ->theme(static fn(ThemeBuilder $t): ThemeBuilder => $t
+ ->breadcrumb(static fn(BreadcrumbOverrides $b): BreadcrumbOverrides => $b
+ ->separator('»', '->'))
+ ->legend(static fn(LegendOverrides $l): LegendOverrides => $l
+ ->separator('•', '|')
+ ->key(Sgr::Bold, Sgr::BrightCyan))
+ ->field(static fn(FieldOverrides $f): FieldOverrides => $f
+ // The mark saying which row has the cursor, and which entry inside an
+ // open one has it - two different marks, so two calls.
+ ->selector('▶', '=>')
+ ->entrySelector('▸', '->')
+ // The mark an entry carries once it is picked.
+ ->entryMarker('▣', '[x]')
+ // The mark showing where the next keystroke lands.
+ ->caret('▎', '|')
+ // Text rather than a glyph: one argument, no stand-in to state.
+ ->valueSeparator(' / ')))
+ ->clearOnExit(FALSE)
+ ->run();
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
+catch (CollectException $exception) {
+ fwrite(STDERR, $exception->getMessage() . PHP_EOL);
+ exit(1);
+}
+
+// Elements are how a block is drawn, so the answers are untouched by any of it.
+echo $answers->toSummary() . PHP_EOL;
diff --git a/playground/09-themes-field-boxed.php b/playground/09-themes-field-boxed.php
index 555534e4..8f986348 100644
--- a/playground/09-themes-field-boxed.php
+++ b/playground/09-themes-field-boxed.php
@@ -2,14 +2,14 @@
/**
* @file
- * The 'boxed' field style: a filled input bar behind the value.
+ * The boxed field style: a filled input bar behind the value.
*
* The 'field' theme option styles the input line of the single-line editors
- * (text, number, password) while a value is typed: 'boxed' fills a
+ * (text, number, password) while a value is typed: FieldStyle::Boxed fills a
* fixed-width background block - visible even when the field is empty, the
- * MS-DOS installer look. 'flat' (a plain caret) is the default and
- * 'underline' is the third style (see field-underline.php). Press Enter on a
- * field to open its editor and see the bar.
+ * MS-DOS installer look. FieldStyle::Flat (a plain caret) is the default and
+ * FieldStyle::Underline is the third style (see field-underline.php). Press
+ * Enter on a field to open its editor and see the bar.
*
* Usage:
* php playground/09-themes-field-boxed.php
@@ -19,8 +19,9 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\FieldStyle;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -36,13 +37,13 @@
});
try {
- $answers = (new Tui($form))->theme('default', ['field' => 'boxed'])->run();
+ $answers = (new Tui($form))->theme('default', ['field' => FieldStyle::Boxed])->run();
}
catch (InterruptException) {
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/09-themes-field-underline.php b/playground/09-themes-field-underline.php
index 375231d7..3754ecbb 100644
--- a/playground/09-themes-field-underline.php
+++ b/playground/09-themes-field-underline.php
@@ -2,13 +2,13 @@
/**
* @file
- * The 'underline' field style: the input line drawn as an underline.
+ * The underline field style: the input line drawn as an underline.
*
* The 'field' theme option styles the input line of the single-line editors
- * (text, number, password) while a value is typed: 'underline' underlines
- * the entry area. 'flat' (a plain caret) is the default and 'boxed' is the
- * filled-bar style (see field-boxed.php). Press Enter on a field to open its
- * editor and see the style.
+ * (text, number, password) while a value is typed: FieldStyle::Underline
+ * underlines the entry area. FieldStyle::Flat (a plain caret) is the default
+ * and FieldStyle::Boxed is the filled-bar style (see field-boxed.php). Press
+ * Enter on a field to open its editor and see the style.
*
* Usage:
* php playground/09-themes-field-underline.php
@@ -18,8 +18,9 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\FieldStyle;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -35,13 +36,13 @@
});
try {
- $answers = (new Tui($form))->theme('default', ['field' => 'underline'])->run();
+ $answers = (new Tui($form))->theme('default', ['field' => FieldStyle::Underline])->run();
}
catch (InterruptException) {
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/09-themes-options.php b/playground/09-themes-options.php
index 6ac7e3f8..1556ff09 100644
--- a/playground/09-themes-options.php
+++ b/playground/09-themes-options.php
@@ -2,14 +2,16 @@
/**
* @file
- * Theme options: built-in and theme-invented, all as plain strings.
+ * Theme options: the built-in ones, and one a theme invents for itself.
*
* Display options are one string-keyed array on ->theme(): 'spacing' and
- * 'border' are built-ins, 'accent' is declared by the AccentTheme in themes/.
- * Every value is validated against the theme's option schema, so a typo throws
- * at startup. The theme itself is registered under a short alias with
- * ThemeManager::register() - the third selection route besides a built-in name
- * and a class name (see 09-themes-custom.php).
+ * 'border' are built-ins, each carrying a case of its own enum, and 'accent'
+ * is declared by the AccentTheme in themes/, whose allowed values are the
+ * plain strings that theme enumerates in its option schema. Every value is
+ * validated against that schema, so a typo throws at startup. The theme itself
+ * is registered under a short alias with ThemeManager::register() - the third
+ * selection route besides a built-in name and a class name (see
+ * 09-themes-custom.php).
*
* Usage:
* php playground/09-themes-options.php
@@ -19,8 +21,10 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
+use DrevOps\Tui\Theme\Spacing;
use DrevOps\Tui\Theme\ThemeManager;
use DrevOps\Tui\Tui;
use Playground\Themes\AccentTheme;
@@ -50,8 +54,8 @@
try {
$answers = (new Tui($form))
->theme('accent', [
- 'spacing' => 'padded',
- 'border' => 'rounded',
+ 'spacing' => Spacing::Padded,
+ 'border' => Border::Rounded,
'accent' => 'warm',
])
->run();
@@ -60,7 +64,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/10-key-bindings-custom.php b/playground/10-key-bindings-custom.php
index f7cfe1d1..16f2e2ee 100644
--- a/playground/10-key-bindings-custom.php
+++ b/playground/10-key-bindings-custom.php
@@ -5,7 +5,7 @@
* Key bindings: retuning single bindings on top of a preset.
*
* Each override is a Binding naming a scope (the base map, navigation, or one
- * widget type), an action, and the keys that trigger it. Overrides apply on
+ * field type), an action, and the keys that trigger it. Overrides apply on
* top of the named preset; a conflicting or un-typeable binding throws when
* the facade is configured, not mid-session, so a bad map cannot ship.
*
@@ -17,7 +17,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Binding;
use DrevOps\Tui\Input\KeyName;
@@ -47,7 +47,7 @@
// Quit with x as well as q.
new Binding(Scope::navigation(), Action::Quit, 'x'),
// In the single-choice list, Tab accepts too (Enter still does). A
- // scope can target one widget type without touching the others.
+ // scope can target one field type without touching the others.
new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter),
])
->run();
@@ -56,7 +56,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/10-key-bindings-vim.php b/playground/10-key-bindings-vim.php
index 1faba9ec..58e1325b 100644
--- a/playground/10-key-bindings-vim.php
+++ b/playground/10-key-bindings-vim.php
@@ -18,7 +18,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -50,7 +50,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/11-display-modes-ascii.php b/playground/11-display-modes-ascii.php
index ee5a9a79..b1b32f36 100644
--- a/playground/11-display-modes-ascii.php
+++ b/playground/11-display-modes-ascii.php
@@ -4,7 +4,7 @@
* @file
* ASCII glyphs: the textual fallback for non-Unicode terminals.
*
- * Widgets pull their glyphs from the theme as Unicode/ASCII pairs - the
+ * Fields pull their glyphs from the theme as Unicode/ASCII pairs - the
* radio, checkbox, marker, caret and scroll indicators all degrade to plain
* characters. Unicode support is auto-detected from the locale (LC_ALL,
* LC_CTYPE, LANG); ->unicode(FALSE) forces the textual set to see it on any
@@ -18,7 +18,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -47,7 +47,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/11-display-modes-glyph-gallery.php b/playground/11-display-modes-glyph-gallery.php
index b43f8487..0f0bf179 100644
--- a/playground/11-display-modes-glyph-gallery.php
+++ b/playground/11-display-modes-glyph-gallery.php
@@ -2,12 +2,12 @@
/**
* @file
- * Glyph gallery: every widget rendered in Unicode and ASCII, side by side.
+ * Glyph gallery: every field rendered in Unicode and ASCII, side by side.
*
- * Widgets pull their glyphs from the theme, so the same widget renders with
+ * Fields pull their glyphs from the theme, so the same field renders with
* Unicode glyphs under a Unicode theme and ASCII glyphs under an ASCII one -
* exactly how the TUI adapts to the terminal locale. This is a static
- * render, not an interactive form: each widget's view() is captured under
+ * render, not an interactive form: each field's view() is captured under
* both themes so the difference is visible in one screen.
*
* Usage:
@@ -16,21 +16,21 @@
declare(strict_types=1);
+use DrevOps\Tui\Field\Calendar;
+use DrevOps\Tui\Field\Confirm;
+use DrevOps\Tui\Field\FieldInterface;
+use DrevOps\Tui\Field\Number;
+use DrevOps\Tui\Field\Password;
+use DrevOps\Tui\Field\Pause;
+use DrevOps\Tui\Field\Reorder;
+use DrevOps\Tui\Field\Search;
+use DrevOps\Tui\Field\Select;
+use DrevOps\Tui\Field\Suggest;
+use DrevOps\Tui\Field\Text;
+use DrevOps\Tui\Field\Textarea;
+use DrevOps\Tui\Field\Toggle;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Utils\Strings;
-use DrevOps\Tui\Widget\CalendarWidget;
-use DrevOps\Tui\Widget\ConfirmWidget;
-use DrevOps\Tui\Widget\NumberWidget;
-use DrevOps\Tui\Widget\PasswordWidget;
-use DrevOps\Tui\Widget\PauseWidget;
-use DrevOps\Tui\Widget\ReorderWidget;
-use DrevOps\Tui\Widget\SearchWidget;
-use DrevOps\Tui\Widget\SelectWidget;
-use DrevOps\Tui\Widget\SuggestWidget;
-use DrevOps\Tui\Widget\TextareaWidget;
-use DrevOps\Tui\Widget\TextWidget;
-use DrevOps\Tui\Widget\ToggleWidget;
-use DrevOps\Tui\Widget\WidgetInterface;
require __DIR__ . '/../vendor/autoload.php';
@@ -39,56 +39,56 @@
$ascii = new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]);
/**
- * The widgets to showcase, each built freshly (widgets are stateful).
+ * The fields to showcase, each built freshly (fields are stateful).
*
- * @var array
+ * @var array
*/
-$widgets = [
- 'Text' => static fn(): WidgetInterface => new TextWidget('Pear'),
- 'Number' => static fn(): WidgetInterface => new NumberWidget('1200'),
- 'Calendar' => static fn(): WidgetInterface => new CalendarWidget('2026-07-15'),
- 'Textarea' => static fn(): WidgetInterface => new TextareaWidget('Crisp and sweet' . chr(10) . 'Hint of citrus'),
- 'Password' => static fn(): WidgetInterface => new PasswordWidget('melon7'),
- 'Select' => static fn(): WidgetInterface => new SelectWidget([
+$fields = [
+ 'Text' => static fn(): FieldInterface => new Text('Pear'),
+ 'Number' => static fn(): FieldInterface => new Number('1200'),
+ 'Calendar' => static fn(): FieldInterface => new Calendar('2026-07-15'),
+ 'Textarea' => static fn(): FieldInterface => new Textarea('Crisp and sweet' . chr(10) . 'Hint of citrus'),
+ 'Password' => static fn(): FieldInterface => new Password('melon7'),
+ 'Select' => static fn(): FieldInterface => new Select([
'apple' => 'Apple',
'banana' => 'Banana',
'cherry' => 'Cherry',
], 'apple'),
- 'MultiSelect' => static fn(): WidgetInterface => new SelectWidget([
+ 'MultiSelect' => static fn(): FieldInterface => new Select([
'apple' => 'Apple',
'carrot' => 'Carrot',
'tomato' => 'Tomato',
], ['apple', 'carrot'], TRUE),
- 'Reorder' => static fn(): WidgetInterface => new ReorderWidget([
+ 'Reorder' => static fn(): FieldInterface => new Reorder([
'apple' => 'Apple',
'carrot' => 'Carrot',
'tomato' => 'Tomato',
]),
- 'Suggest' => static fn(): WidgetInterface => new SuggestWidget([
+ 'Suggest' => static fn(): FieldInterface => new Suggest([
'Apple',
'Apricot',
'Banana',
'Cherry',
'Mango',
], 'Ap'),
- 'Search' => static fn(): WidgetInterface => new SearchWidget([
+ 'Search' => static fn(): FieldInterface => new Search([
'carrot' => 'Carrot',
'potato' => 'Potato',
'onion' => 'Onion',
'pepper' => 'Pepper',
], 'carrot'),
- 'MultiSearch' => static fn(): WidgetInterface => new SearchWidget([
+ 'MultiSearch' => static fn(): FieldInterface => new Search([
'apple' => 'Apple',
'banana' => 'Banana',
'carrot' => 'Carrot',
'tomato' => 'Tomato',
], ['apple'], TRUE),
- 'Confirm' => static fn(): WidgetInterface => new ConfirmWidget(TRUE),
- 'Toggle' => static fn(): WidgetInterface => new ToggleWidget([
+ 'Confirm' => static fn(): FieldInterface => new Confirm(TRUE),
+ 'Toggle' => static fn(): FieldInterface => new Toggle([
'ripe' => 'Ripe',
'unripe' => 'Unripe',
], 'ripe'),
- 'Pause' => static fn(): WidgetInterface => new PauseWidget(),
+ 'Pause' => static fn(): FieldInterface => new Pause(),
];
// Lay two rendered views out as columns, the left one padded to align.
@@ -114,7 +114,7 @@
echo $columns('UNICODE', 'TEXTUAL (ASCII)') . PHP_EOL;
echo str_repeat('-', 60) . PHP_EOL . PHP_EOL;
-foreach ($widgets as $name => $make) {
+foreach ($fields as $name => $make) {
echo $name . PHP_EOL;
echo $columns($make()->view($unicode), $make()->view($ascii)) . PHP_EOL . PHP_EOL;
}
diff --git a/playground/11-display-modes-markdown.php b/playground/11-display-modes-markdown.php
index 372bc6d9..f4fa6506 100644
--- a/playground/11-display-modes-markdown.php
+++ b/playground/11-display-modes-markdown.php
@@ -22,7 +22,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -48,7 +48,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/11-display-modes-mode-auto.php b/playground/11-display-modes-mode-auto.php
index f5ea2543..1f666c28 100644
--- a/playground/11-display-modes-mode-auto.php
+++ b/playground/11-display-modes-mode-auto.php
@@ -18,7 +18,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -44,7 +44,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/11-display-modes-mode-forced.php b/playground/11-display-modes-mode-forced.php
index 390bb2b2..009c4ed7 100644
--- a/playground/11-display-modes-mode-forced.php
+++ b/playground/11-display-modes-mode-forced.php
@@ -16,7 +16,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Theme\Mode;
use DrevOps\Tui\Tui;
@@ -43,7 +43,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/11-display-modes-no-color.php b/playground/11-display-modes-no-color.php
index 7c0074b1..e3da89f4 100644
--- a/playground/11-display-modes-no-color.php
+++ b/playground/11-display-modes-no-color.php
@@ -17,7 +17,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;
@@ -46,7 +46,7 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/12-specification-screen.php b/playground/12-specification-screen.php
new file mode 100644
index 00000000..7be3fb7b
--- /dev/null
+++ b/playground/12-specification-screen.php
@@ -0,0 +1,159 @@
+panel('delivery', 'Delivery', function (PanelBuilder $p): void {
+ $p->text('courier', 'Courier')->default('Valley Runs')->help('Every crate is weighed and labelled at the packing bench.');
+ $p->markup('weighing', 'Weighed at the packing bench.');
+ $p->number('weight', 'Basket weight')->default(1200)->min(200)->max(9000);
+ $p->select('basket', 'Basket contents')->multiple()->option('apple', 'Apple')->option('carrot', 'Carrot')->default(['apple']);
+ });
+
+// Wide enough that the editor's legend is read rather than clipped: a region
+// hands back the rows it was given, so anything past them is cut.
+$theme = new DefaultTheme(72);
+$panel = $form->root();
+$screen = (new Assembler())->assemble($panel);
+$breadcrumb = $screen->in('header')->blocks()[0];
+$legend = $screen->in('footer')->blocks()[0];
+$router = new KeyRouter($panel);
+
+$frame = static function (string $said) use ($router, $breadcrumb, $legend, $screen, $theme): void {
+ // The legend is written from the innermost binder rather than beside it, so
+ // it says something different the moment a field takes the keys.
+ if ($legend instanceof Legend) {
+ $router->refresh($legend);
+ }
+
+ if ($breadcrumb instanceof Breadcrumb) {
+ $breadcrumb->trail(...$router->trail());
+ }
+
+ print $said . "\n\n";
+ print (new ScreenRenderer($theme))->render($screen, 10, 72) . "\n\n";
+};
+
+print "On screen\n\n";
+$frame('The cursor rests on the first block that takes it.');
+
+print "Driven by keys, one at a time\n\n";
+
+$router->handle(Key::named(KeyName::Enter));
+$frame('Enter goes into the panel, and the trail grows a segment.');
+
+$router->handle(Key::named(KeyName::Down));
+$router->handle(Key::named(KeyName::Down));
+$frame('Down twice, skipping the markup that never takes the cursor.');
+
+$router->handle(Key::named(KeyName::Enter));
+$frame('Enter opens the field, and the legend belongs to the editor now.');
+
+$router->handle(Key::named(KeyName::Down));
+$router->handle(Key::named(KeyName::Space));
+$frame('Space toggles an entry, because the editor is what binds it.');
+
+$router->handle(Key::named(KeyName::Escape));
+$frame('Escape closes it, discarding both the toggle and those keys.');
+
+print "Collected headlessly, with no screen at all\n\n";
+
+$readable = static fn(mixed $part): string => is_scalar($part) ? (string) $part : '';
+
+foreach ((new Collector())->collect($panel) as $id => $value) {
+ printf(" %-8s %s\n", $id, is_array($value) ? implode(', ', array_map($readable, $value)) : var_export($value, TRUE));
+}
+
+print "\nA value the field refuses\n\n";
+
+try {
+ (new Collector())->collect($panel, ['weight' => 10]);
+}
+catch (CollectException $exception) {
+ print ' ' . $exception->getMessage() . "\n";
+}
+
+print "\nA warning beside the breadcrumb, without nesting a layout\n\n";
+
+// Blocks run across the header rather than down it, so two sit side by side.
+$screen->in('header')->flow(Axis::Columns)->add(new Markup('note', '(read-only preview)'));
+print (new ScreenRenderer($theme))->render($screen, 10, 72) . "\n";
+
+print "\nDriven as a session, from the first frame to the submit\n\n";
+
+// A second declaration, so the session opens on a form nobody has typed into.
+$driven = Form::create('Orchard')
+ ->panel('delivery', 'Delivery', function (PanelBuilder $p): void {
+ $p->text('courier', 'Courier')->default('Valley Runs');
+ $p->number('weight', 'Basket weight')->default(1200)->min(200)->max(9000);
+ });
+
+// The session reads keys from a terminal and writes frames back to it, so it is
+// driven here through the scripted terminal the test harness wraps.
+$session = (new ScreenTester($driven->root()))->rows(9)->cols(72);
+
+try {
+ $collected = $session->run(
+ Key::named(KeyName::Enter),
+ Key::named(KeyName::Enter),
+ ' Coast',
+ Key::named(KeyName::Enter),
+ Key::named(KeyName::Escape),
+ Key::named(KeyName::Down),
+ Key::named(KeyName::Enter),
+ );
+}
+catch (InterruptException $exception) {
+ // Ctrl-C aborts from anywhere and the cancel button raises the same
+ // exception's subclass, so every way of ending without a submit leaves here.
+ print $exception->getMessage() . "\n";
+
+ exit(130);
+}
+
+foreach ([1, 3, 5] as $index) {
+ print $session->frame($index) . "\n\n";
+}
+
+print "Collected by the session\n\n";
+
+foreach (['courier', 'weight'] as $id) {
+ printf(" %-8s %s\n", $id, var_export($collected->value($id), TRUE));
+}
diff --git a/playground/12-translations.php b/playground/12-translations.php
index 0b12ef6c..352db0a4 100644
--- a/playground/12-translations.php
+++ b/playground/12-translations.php
@@ -15,15 +15,24 @@
* hub summary, so selecting a different number of fruits shows Ukrainian's
* one/few/many forms - see uk.php for the rule that chooses between them.
*
+ * The catalog is not a property of the screen, so it survives having none: an
+ * unattended run resolves the answers from the defaults and prints the same
+ * localized summary, which is the whole Ukrainian session in one line of
+ * output and no terminal.
+ *
* Usage:
* php playground/12-translations.php
+ *
+ * # The same session with no terminal at all: panels, labels and summary,
+ * # localized end to end.
+ * php playground/12-translations.php < /dev/null
*/
declare(strict_types=1);
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
-use DrevOps\Tui\Engine\EngineException;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Translation\Translator;
use DrevOps\Tui\Tui;
@@ -66,11 +75,12 @@
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
-// The summary renders through the same catalog; the collected values are
-// untouched - only the presentation is localized.
+// The summary renders through the same catalog, interactively or not; the
+// collected values are untouched - only the presentation is localized, so the
+// ids and the answers stay the language-neutral ones the form declared.
echo $answers->toSummary() . PHP_EOL;
diff --git a/playground/14-produce-box.php b/playground/14-produce-box.php
index d0ef9f38..99aed12a 100644
--- a/playground/14-produce-box.php
+++ b/playground/14-produce-box.php
@@ -6,7 +6,7 @@
*
* The capstone example - each numbered playground directory shows one
* feature in isolation; this walkthrough combines them the way a real
- * consumer would: two panels of mixed widgets, a derived-value chain,
+ * consumer would: two panels of mixed fields, a derived-value chain,
* conditional fields, declared behaviour closures, and the bordered panel
* TUI, collected through the one facade call.
*
@@ -21,11 +21,12 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
use DrevOps\Tui\Condition\Condition;
use DrevOps\Tui\Derive\Derive;
-use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\Handler\Context;
use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Theme\Border;
use DrevOps\Tui\Tui;
require __DIR__ . '/../vendor/autoload.php';
@@ -84,14 +85,14 @@
// context and run() picks interactive or unattended
// (playground/08-headless-*).
$answers = (new Tui($form))
- ->theme('default', ['border' => 'rounded'])
+ ->theme('default', ['border' => Border::Rounded])
->run('', '1.0.0');
}
catch (InterruptException) {
// Leave quietly on Ctrl-C.
exit(130);
}
-catch (EngineException $exception) {
+catch (CollectException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
diff --git a/playground/20-layouts-custom.php b/playground/20-layouts-custom.php
new file mode 100644
index 00000000..7429d677
--- /dev/null
+++ b/playground/20-layouts-custom.php
@@ -0,0 +1,97 @@
+layout(), and on a panel through the
+ * builder's ->layout(). One registration serves both, which is what makes a
+ * layout reusable where a region is not.
+ *
+ * layouts/StallLayout.php runs its regions across, sharing the width between a
+ * produce column that scrolls and a delivery column that does not, and
+ * arranges the panel; layouts/MarketLayout.php runs its regions down, giving
+ * two of them a fixed number of rows, and arranges the screen. A name nothing
+ * answers to throws where ->layout() is written, not mid-session.
+ *
+ * Usage:
+ * php playground/20-layouts-custom.php
+ *
+ * # Unattended: the arrangement is drawing, so the answers resolve the same.
+ * php playground/20-layouts-custom.php < /dev/null
+ */
+
+declare(strict_types=1);
+
+use DrevOps\Tui\Builder\Form;
+use DrevOps\Tui\Builder\PanelBuilder;
+use DrevOps\Tui\CollectException;
+use DrevOps\Tui\InterruptException;
+use DrevOps\Tui\Screen\Layout\LayoutManager;
+use DrevOps\Tui\Theme\Border;
+use DrevOps\Tui\Tui;
+use Playground\Layouts\MarketLayout;
+use Playground\Layouts\StallLayout;
+
+require __DIR__ . '/../vendor/autoload.php';
+// The requires make the classes loadable; a real consumer would autoload them.
+require __DIR__ . '/layouts/StallLayout.php';
+require __DIR__ . '/layouts/MarketLayout.php';
+
+// Registered once, named everywhere after. Passing the class itself to
+// ->layout() works too, and needs no registration at all.
+LayoutManager::register('stall', StallLayout::class);
+LayoutManager::register('market', MarketLayout::class);
+
+$form = Form::create('Market stall')
+ ->buttons(TRUE, 'Place order', 'Cancel')
+ ->panel('order', 'Order', function (PanelBuilder $p): void {
+ // Declared before anything is placed, so every block below knows the
+ // regions it may go in.
+ $p->layout('stall');
+
+ // A block says which region it belongs to; the ones after it keep that
+ // region until another is named.
+ $p->in('produce');
+ $p->text('item', 'Item')->default('Pear');
+ $p->number('crates', 'Crates')->default(6)->min(1)->max(99);
+ $p->select('basket', 'Basket')->multiple()->default(['apple'])->options([
+ 'apple' => 'Apple',
+ 'carrot' => 'Carrot',
+ 'tomato' => 'Tomato',
+ ]);
+
+ $p->in('delivery');
+ $p->confirm('gift', 'Gift wrap?')->default(FALSE);
+ $p->suggest('day', 'Delivery day')->default('Friday')->options([
+ 'Monday' => 'Monday',
+ 'Wednesday' => 'Wednesday',
+ 'Friday' => 'Friday',
+ ]);
+ });
+
+try {
+ // The screen's arrangement is a separate choice from the panel's: this one
+ // keeps a header, a content region and a footer, so the trail, the form and
+ // the key hints all have somewhere to go.
+ $answers = (new Tui($form))
+ ->layout('market')
+ ->theme('default', ['border' => Border::Rounded])
+ ->clearOnExit(FALSE)
+ ->run();
+}
+catch (InterruptException) {
+ // Leave quietly on Ctrl-C.
+ exit(130);
+}
+catch (CollectException $exception) {
+ fwrite(STDERR, $exception->getMessage() . PHP_EOL);
+ exit(1);
+}
+
+// Regions arrange drawing and nothing else, so the answers read exactly as
+// they would under any other layout.
+echo $answers->toSummary() . PHP_EOL;
diff --git a/playground/20-layouts-region-flow.php b/playground/20-layouts-region-flow.php
new file mode 100644
index 00000000..75203a49
--- /dev/null
+++ b/playground/20-layouts-region-flow.php
@@ -0,0 +1,71 @@
+panel('delivery', 'Delivery', function (PanelBuilder $p): void {
+ $p->text('courier', 'Courier')->default('Valley Runs');
+ $p->number('weight', 'Basket weight')->default(1200)->min(200)->max(9000);
+ });
+
+// The blank row that normally shows between one block and the next is the
+// theme's padding rather than the region's doing, and it would take a row of
+// the header all by itself. Stacking the rows against each other keeps the
+// frames about the flow.
+$theme = new DefaultTheme(72, ['spacing' => Spacing::Normal]);
+$panel = $form->root();
+
+// Each frame gets a screen of its own: a region holds the blocks somebody put
+// in it, so the same one cannot be flowed two ways at once.
+$frame = static function (string $said, Axis $flow, string $layout) use ($panel, $theme): void {
+ $screen = (new Assembler())->assemble($panel, $layout);
+
+ // A region never knows which kind of block it was given, which is why a note
+ // goes in beside a trail exactly as a field goes in beside a field.
+ $screen->in('header')->flow($flow)->add(new Markup('preview', '(read-only preview)'));
+
+ print $said . "\n\n";
+ print (new ScreenRenderer($theme))->render($screen, 9, 72) . "\n\n";
+};
+
+$frame('Down a one-row header: the note is clipped, having nowhere to go.', Axis::Rows, 'default');
+$frame('Across that same header: the trail and the note share the one row.', Axis::Columns, 'default');
+$frame('Down a two-row header: the note stacks under the trail, as declared.', Axis::Rows, 'market');
diff --git a/playground/README.md b/playground/README.md
index 2ce3dab1..69f42dd9 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -2,7 +2,7 @@
Runnable examples of the `drevops/tui` engine, one file per example, grouped by a numbered `NN-topic-` prefix that follows the [documentation](https://phptui.dev) order. Every script is self-contained: it requires the Composer autoloader directly, declares its whole form inline and handles its own output, so any single file can be copied out as a starting point. Most take no CLI options - each demonstrates exactly one thing, and variants are separate scripts - with one exception: [`03-panels-fullscreen.php`](03-panels-fullscreen.php) picks its alignment with `--halign`/`--valign` (plus `--max-width`) rather than spreading nine near-identical files.
-Reusable helper classes the scripts load sit in [`themes/`](themes) and [`handlers/`](handlers); the fixtures the examples read from are in [`sample-project/`](sample-project) - one example project the file-picker and discovery demos share - and [`translations/`](translations).
+Reusable helper classes the scripts load sit in [`themes/`](themes), [`layouts/`](layouts) and [`handlers/`](handlers); the fixtures the examples read from are in [`sample-project/`](sample-project) - one example project the file-picker and discovery demos share - and [`translations/`](translations).
```bash
composer install
@@ -16,24 +16,26 @@ Every interactive script also runs unattended: pipe stdin (or run it from CI) an
| Group | Feature | Scripts |
|---|---|---|
| `01-quickstart` | The documentation's quick-start form: the fluent builder, one panel, five fields, `run()` picking interactive or unattended. | [`01-quickstart.php`](01-quickstart.php) |
-| `02-widgets-*` | Every widget as a one-field form, plus the whole gallery on one panel. | one script per widget (`02-widgets-.php`), plus [`02-widgets-all-widgets.php`](02-widgets-all-widgets.php) |
+| `02-fields-*` | Every field as a one-field form, plus the whole gallery on one panel. | one script per field (`02-fields-.php`), plus [`02-fields-all-fields.php`](02-fields-all-fields.php) |
| `03-panels-*` | The full-screen panel browser: drill-in hubs, modal dialogs, the border frame, side-by-side panel grids, the fullscreen stretch with its alignment flags. | [`03-panels-nested.php`](03-panels-nested.php), [`03-panels-modal.php`](03-panels-modal.php), [`03-panels-bordered.php`](03-panels-bordered.php), [`03-panels-borderless.php`](03-panels-borderless.php), [`03-panels-layout.php`](03-panels-layout.php), [`03-panels-fullscreen.php`](03-panels-fullscreen.php) |
| `04-inline-editing` | Editors opening in place on the panel row; `->standalone()` opting a field out to full-screen. | [`04-inline-editing.php`](04-inline-editing.php) |
| `05-form-logic-*` | Answers that react to other answers, settling to a fixpoint. | [`05-form-logic-derived-values.php`](05-form-logic-derived-values.php), [`05-form-logic-conditional-fields.php`](05-form-logic-conditional-fields.php), [`05-form-logic-conditional-indent.php`](05-form-logic-conditional-indent.php), [`05-form-logic-fixup-rules.php`](05-form-logic-fixup-rules.php) |
| `06-field-behaviour-*` | Required fields with a derived or declared message, dynamic defaults, validation and transforms - as field closures and as reusable handler classes. | [`06-field-behaviour-closures.php`](06-field-behaviour-closures.php), [`06-field-behaviour-handlers.php`](06-field-behaviour-handlers.php) (loads [`handlers/OrderCode.php`](handlers/OrderCode.php)) |
| `07-discovery` | Update-mode discovery against the bundled `sample-project/` directory: dotenv, JSON dot-path, path-exists and directory-scan rules, plus a custom env prefix. | [`07-discovery.php`](07-discovery.php) |
| `08-headless-*` | Unattended collection from a JSON payload and environment variables; the JSON schema, answer validation, generated agent help and folding it into a consumer's help. | [`08-headless-collect.php`](08-headless-collect.php), [`08-headless-schema.php`](08-headless-schema.php), [`08-headless-agent-help.php`](08-headless-agent-help.php), [`08-headless-agent-cli.php`](08-headless-agent-cli.php) |
-| `09-themes-*` | The six built-in themes, a custom theme class, theme options and the field input styles. | one script per built-in theme (`09-themes-.php`), plus [`09-themes-custom.php`](09-themes-custom.php), [`09-themes-options.php`](09-themes-options.php), [`09-themes-field-boxed.php`](09-themes-field-boxed.php), [`09-themes-field-underline.php`](09-themes-field-underline.php) (load [`themes/OceanTheme.php`](themes/OceanTheme.php), [`themes/AccentTheme.php`](themes/AccentTheme.php)) |
+| `09-themes-*` | The six built-in themes, a custom theme class, per-element overrides without one, theme options and the field input styles. | one script per built-in theme (`09-themes-.php`), plus [`09-themes-custom.php`](09-themes-custom.php), [`09-themes-elements.php`](09-themes-elements.php), [`09-themes-options.php`](09-themes-options.php), [`09-themes-field-boxed.php`](09-themes-field-boxed.php), [`09-themes-field-underline.php`](09-themes-field-underline.php) (load [`themes/OceanTheme.php`](themes/OceanTheme.php), [`themes/AccentTheme.php`](themes/AccentTheme.php)) |
| `10-key-bindings-*` | The `vim` preset and per-binding overrides on top of a preset. | [`10-key-bindings-vim.php`](10-key-bindings-vim.php), [`10-key-bindings-custom.php`](10-key-bindings-custom.php) |
| `11-display-modes-*` | Dark/light detection and forcing, ASCII glyphs, colour off, a static Unicode-vs-ASCII gallery, and rich text (markdown and clickable links) degrading with the display switches. | [`11-display-modes-mode-auto.php`](11-display-modes-mode-auto.php), [`11-display-modes-mode-forced.php`](11-display-modes-mode-forced.php), [`11-display-modes-ascii.php`](11-display-modes-ascii.php), [`11-display-modes-no-color.php`](11-display-modes-no-color.php), [`11-display-modes-glyph-gallery.php`](11-display-modes-glyph-gallery.php), [`11-display-modes-markdown.php`](11-display-modes-markdown.php) |
-| `12-translations` | Chrome and questions localized through a consumer catalog, English fallback. | [`12-translations.php`](12-translations.php), `translations/es.php`, `translations/uk.php` |
+| `12-translations` | Chrome and questions localized through a consumer catalog, English fallback - interactively, and end to end with no terminal at all. | [`12-translations.php`](12-translations.php), `translations/es.php`, `translations/uk.php` |
+| `12-specification-screen` | The [specification](https://phptui.dev/specification) made runnable: the levels a screen is built from, keys travelling inward to the innermost binder, and the same tree collected with no screen at all. | [`12-specification-screen.php`](12-specification-screen.php) |
| `13-testing` | The scripted-keystroke harness: drive the real TUI without a terminal, read back answers and rendered frames. | [`13-testing.php`](13-testing.php) |
-| `14-produce-box` | The capstone: panels, widgets, derivation, conditions and behaviour composed into one real form. | [`14-produce-box.php`](14-produce-box.php) |
+| `14-produce-box` | The capstone: panels, fields, derivation, conditions and behaviour composed into one real form. | [`14-produce-box.php`](14-produce-box.php) |
| `15-progress-*` | The progress primitive - a spinner when the length is unknown, a determinate bar when it is - theme-drawn, animating on a TTY and degrading to a plain line when piped or headless. | [`15-progress-spinner.php`](15-progress-spinner.php), [`15-progress-bar.php`](15-progress-bar.php) |
| `16-loading-data` | Loading a panel's data on demand: a field's `->options()` and a panel's `->preload()` taking a callback, resolved the first time the panel opens with a themed `Loading…` on the field. | [`16-loading-data.php`](16-loading-data.php) |
| `17-query-options` | Options that follow the query: `->optionsFrom()` called again on every query change with a themed `Loading…` while it runs, a per-query cache, and `->minQuery()` holding the call back until the query is long enough. | [`17-query-options.php`](17-query-options.php) |
| `18-output-*` | The output primitives - a titled box and card, an aligned table, the five status lines, a definition list, wrapped prose, rules and a banner - theme-drawn chrome for around a form run, dropping their colour when piped or redirected. | [`18-output-box.php`](18-output-box.php), [`18-output-status.php`](18-output-status.php), [`18-output-definitions.php`](18-output-definitions.php), [`18-output-table.php`](18-output-table.php), [`18-output-text.php`](18-output-text.php) |
| `19-dynamic-options` | Options that follow the answers: an `->options()` callback taking the run context, called again whenever they change, narrowing one field's choices by another's answer and dropping a choice the narrowed list no longer holds - reporting rather than dropping one that was supplied headlessly. | [`19-dynamic-options.php`](19-dynamic-options.php) |
+| `20-layouts-*` | Arrangement: a consumer's own `AbstractLayout` subclass registered by name and picked for a panel and for the screen, and a region running its blocks across rather than down. | [`20-layouts-custom.php`](20-layouts-custom.php), [`20-layouts-region-flow.php`](20-layouts-region-flow.php) (load [`layouts/StallLayout.php`](layouts/StallLayout.php), [`layouts/MarketLayout.php`](layouts/MarketLayout.php)) |
## Running the examples
@@ -53,9 +55,9 @@ BOX_SEASON=winter php playground/07-discovery.php
Display modes follow the terminal and the standard environment conventions, so no script needs flags for them:
```bash
-NO_COLOR=1 php playground/02-widgets-select.php # colour off
-LC_ALL=C php playground/02-widgets-select.php # ASCII glyphs
-COLORFGBG='0;15' php playground/02-widgets-select.php # hint a light background
+NO_COLOR=1 php playground/02-fields-select.php # colour off
+LC_ALL=C php playground/02-fields-select.php # ASCII glyphs
+COLORFGBG='0;15' php playground/02-fields-select.php # hint a light background
```
## How the TUI picks a theme
@@ -67,13 +69,25 @@ Set it on the `Tui` facade with `->theme(...)`, lowest friction first:
3. **Built-in name** - `->theme('midnight')` (or `frost`, `ember`, `mono`, `default` or `dos`). Dark or light is a separate `mode` display option, not a theme, so a built-in adapts to both. One script per theme, `09-themes-.php`.
4. **Auto-detect** - leave it unset (or `->theme('auto')`) and the `default` theme is used, with the interactive TUI picking the dark or light `mode` from the terminal background (an OSC 11 query, then `COLORFGBG`, then a dark default). Setting `mode` explicitly opts out. This is what [`11-display-modes-mode-auto.php`](11-display-modes-mode-auto.php) demonstrates.
+Whatever the theme, `->theme(fn(ThemeBuilder $t) => ...)` restates individual elements on top of it - a separator, a selector, an entry marker, a caret - grouped by the block that declares them, with each glyph given as the mark and its ASCII stand-in. Anything left unnamed keeps the theme's own answer, which is why this is a patch and a subclass is a replacement. This is what [`09-themes-elements.php`](09-themes-elements.php) does.
+
+## How the TUI picks a layout
+
+A layout arranges: it names its regions, sizes them and says which of them scroll. Set it on the `Tui` facade with `->layout(...)` for the screen, or on a panel with the builder's `->layout(...)` for that panel's own blocks - the same names reach both, which is what makes a layout reusable.
+
+1. **Shipped name** - `->layout('two-column')`, or `default` (a fixed header, a scrolling content region, a fixed footer). `panel` is the single-region arrangement a panel takes when it names none.
+2. **Register a short name** - `LayoutManager::register('stall', StallLayout::class)`, then `->layout('stall')`.
+3. **Name the class** - `->layout('\Your\LayoutClass')`, instantiated directly with no registration.
+
+Both routes are in [`20-layouts-custom.php`](20-layouts-custom.php). A name nothing answers to throws where `->layout()` is written, not mid-session. Which blocks land where is decided by whatever assembles the screen, so a layout that keeps no `header` simply shows no trail rather than being refused.
+
## How the TUI sets key bindings
Set them on the `Tui` facade with `->keys(...)`, mirroring `->theme(...)`:
1. **A preset name** - `->keys('vim')` for the built-in vim navigation, or a name registered with `KeyMapManager::register('name', MyKeyMap::class)`.
2. **A preset class** - `->keys('\Your\KeyMapClass')`, instantiated directly with no registration.
-3. **Overrides** - `->keys('default', [new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab)])` retunes individual bindings on top of a preset. A binding names a scope (the base, navigation, or a widget type), an action and its keys.
+3. **Overrides** - `->keys('default', [new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab)])` retunes individual bindings on top of a preset. A binding names a scope (the base, navigation, or a field type), an action and its keys.
4. **Defaults** - leave it unset for the built-in bindings. This is what most examples do.
Conflicting or un-typeable bindings throw when the facade is configured, so a bad key map is caught at declaration time, not mid-session. Both override styles live in [`10-key-bindings-vim.php`](10-key-bindings-vim.php) and [`10-key-bindings-custom.php`](10-key-bindings-custom.php).
diff --git a/playground/layouts/MarketLayout.php b/playground/layouts/MarketLayout.php
new file mode 100644
index 00000000..6715dfe5
--- /dev/null
+++ b/playground/layouts/MarketLayout.php
@@ -0,0 +1,39 @@
+region('header')->fixed(2);
+ // Declaring neither size is a share of one, so the form takes whatever the
+ // two fixed rows leave, and scrolls once it has more rows than that.
+ $this->region('content')->scrolls();
+ $this->region('footer')->fixed(1);
+ }
+
+}
diff --git a/playground/layouts/StallLayout.php b/playground/layouts/StallLayout.php
new file mode 100644
index 00000000..c11821b1
--- /dev/null
+++ b/playground/layouts/StallLayout.php
@@ -0,0 +1,37 @@
+region('produce')->flex(3)->scrolls();
+ $this->region('delivery')->flex(2);
+ }
+
+}
diff --git a/playground/sample-project/README.md b/playground/sample-project/README.md
index 4f1a7d43..e555e3b2 100644
--- a/playground/sample-project/README.md
+++ b/playground/sample-project/README.md
@@ -1,3 +1,3 @@
# Example project
-A small example project the playground demos read from - no real data, just enough structure to exercise two features. The file-picker demos (`02-widgets-filepicker*.php`) browse it for files: price lists (`baskets/*.csv`, `harvest.csv`), the `pantry.yaml` stock reference and a `deliveries/` log. The discovery demo (`07-discovery.php`) scans it for update-mode defaults: `box.json`, `.env` and the `baskets/` subdirectories.
+A small example project the playground demos read from - no real data, just enough structure to exercise two features. The file-picker demos (`02-fields-filepicker*.php`) browse it for files: price lists (`baskets/*.csv`, `harvest.csv`), the `pantry.yaml` stock reference and a `deliveries/` log. The discovery demo (`07-discovery.php`) scans it for update-mode defaults: `box.json`, `.env` and the `baskets/` subdirectories.
diff --git a/playground/themes/AccentTheme.php b/playground/themes/AccentTheme.php
index 87776f52..03d13eb6 100644
--- a/playground/themes/AccentTheme.php
+++ b/playground/themes/AccentTheme.php
@@ -29,11 +29,11 @@ protected function optionSchema(): array {
* {@inheritdoc}
*/
#[\Override]
- public function value(string $text, bool $selected = FALSE): string {
+ protected function value(string $text, bool $emphatic = FALSE): string {
return match ($this->option('accent', 'cool')) {
- 'warm' => $this->paint($this->emphasize(Sgr::of(Sgr::Yellow), $selected), $text),
- 'mono' => $this->paint($this->emphasize(Sgr::of(Sgr::Grey), $selected), $text),
- default => parent::value($text, $selected),
+ 'warm' => $this->paint($this->emphasize(Sgr::of(Sgr::Yellow), $emphatic), $text),
+ 'mono' => $this->paint($this->emphasize(Sgr::of(Sgr::Grey), $emphatic), $text),
+ default => parent::value($text, $emphatic),
};
}
diff --git a/playground/themes/OceanTheme.php b/playground/themes/OceanTheme.php
index cee1f173..aabc002c 100644
--- a/playground/themes/OceanTheme.php
+++ b/playground/themes/OceanTheme.php
@@ -4,30 +4,24 @@
namespace Playground\Themes;
-use DrevOps\Tui\Answers\Answers;
-use DrevOps\Tui\Input\Hint;
-use DrevOps\Tui\Input\ScopedKeyMap;
-use DrevOps\Tui\Model\Field;
-use DrevOps\Tui\Model\Panel;
-use DrevOps\Tui\Render\Ansi;
-use DrevOps\Tui\Render\Navigator;
+use DrevOps\Tui\Input\Key;
+use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Theme\Sgr;
/**
* A custom theme that overrides as much as it sensibly can.
*
- * It demonstrates two kinds of override, both shown below:
- * - the appearance atoms - one method per colour and glyph (title(), value(),
- * marker(), arrow()…), each overridden on its own;
- * - any render*() and summarizePanel() method - to change how an element is
- * laid out from those atoms.
+ * It demonstrates the two sizes of override, both shown below:
+ * - the palette - one protected method per hue (accent(), value(),
+ * description()…), repainting every element drawn from it at once;
+ * - the per-block elements a block composes its row from (fieldLabel(),
+ * panelTitle(), breadcrumbLabel()…), each restyled on its own.
*
- * It extends DefaultTheme, so anything left un-overridden (e.g. renderBody(),
- * renderFrame()) falls back to the default theme, including its dark/light
- * mode. Select it with its class name (`\Playground\Themes\OceanTheme`), or
- * register a short name with ThemeManager::register('ocean',
- * OceanTheme::class).
+ * It extends DefaultTheme, so anything left un-overridden falls back to the
+ * default theme, including its dark/light mode. Select it with its class name
+ * (`\Playground\Themes\OceanTheme`), or register a short name with
+ * ThemeManager::register('ocean', OceanTheme::class).
*/
class OceanTheme extends DefaultTheme {
@@ -35,39 +29,31 @@ class OceanTheme extends DefaultTheme {
* {@inheritdoc}
*/
#[\Override]
- public function title(string $text): string {
- return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text);
+ protected function accent(): string {
+ return Sgr::of(Sgr::Bold, Sgr::BrightCyan);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function value(string $text, bool $selected = FALSE): string {
- return $this->paint($this->emphasize(Sgr::of(Sgr::BrightCyan), $selected), $text);
+ protected function value(string $text, bool $emphatic = FALSE): string {
+ return $this->paint($this->emphasize(Sgr::of(Sgr::BrightCyan), $emphatic), $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function description(string $text, bool $selected = FALSE): string {
- return $this->paint($this->emphasize(Sgr::of(Sgr::Blue), $selected), $text);
+ protected function description(string $text): string {
+ return $this->paint(Sgr::of(Sgr::Blue), $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function badge(string $text, bool $selected = FALSE): string {
- return $this->paint($this->emphasize(Sgr::of(Sgr::Reverse, Sgr::Cyan), $selected), $text);
- }
-
- /**
- * {@inheritdoc}
- */
- #[\Override]
- public function breadcrumb(string $text): string {
+ protected function footer(string $text): string {
return $this->paint(Sgr::of(Sgr::Cyan), $text);
}
@@ -75,210 +61,180 @@ public function breadcrumb(string $text): string {
* {@inheritdoc}
*/
#[\Override]
- public function footer(string $text): string {
- return $this->paint(Sgr::of(Sgr::Cyan), $text);
+ protected function indicator(string $text): string {
+ return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function cursor(string $text): string {
- return $this->paint(Sgr::of(Sgr::Bold, Sgr::Reverse, Sgr::BrightCyan), $text);
+ protected function marker(bool $selected): string {
+ return $selected ? $this->paint($this->accent(), $this->hasUnicode() ? '➤' : '>') : ' ';
}
/**
- * {@inheritdoc}
+ * The mark this theme stands between one thing and the next.
+ *
+ * @return string
+ * The mark.
*/
- #[\Override]
- public function indicator(string $text): string {
- return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text);
+ protected function divider(): string {
+ return '/';
}
/**
- * {@inheritdoc}
+ * The mark this theme leads a following-on line with.
+ *
+ * @return string
+ * The mark.
*/
- #[\Override]
- public function highlight(string $text): string {
- return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text);
+ protected function lead(): string {
+ return $this->hasUnicode() ? '•' : '*';
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function marker(bool $selected): string {
- return $selected ? $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $this->hasUnicode() ? '➤' : '>') : ' ';
- }
+ public function keyGlyph(Key $key): string {
+ if ($key->is(KeyName::Enter)) {
+ return $this->hasUnicode() ? '⏎' : '<';
+ }
- /**
- * {@inheritdoc}
- */
- #[\Override]
- public function arrow(): string {
- return $this->hasUnicode() ? '»' : '>';
+ return parent::keyGlyph($key);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function separator(): string {
- return '/';
+ public function chromeOverflowMarker(bool $above): string {
+ return $this->indicator($above ? ($this->hasUnicode() ? '▴' : '^') : ($this->hasUnicode() ? '▾' : 'v'));
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function enter(): string {
- return $this->hasUnicode() ? '⏎' : '<';
+ public function fieldBadge(string $text): string {
+ return $this->paint(Sgr::of(Sgr::Reverse, Sgr::Cyan), $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function dot(): string {
- return $this->hasUnicode() ? '•' : '*';
+ public function fieldCaret(): string {
+ return $this->paint($this->accent(), $this->hasUnicode() ? '▎' : '|');
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function indicatorUp(): string {
- return $this->hasUnicode() ? '▴' : '^';
+ public function fieldEntryMarker(bool $chosen, bool $exclusive = FALSE): string {
+ if ($exclusive) {
+ return $chosen ? $this->paint($this->accent(), $this->hasUnicode() ? '◉' : '(o)') : ($this->hasUnicode() ? '◯' : '( )');
+ }
+
+ return $chosen ? $this->fieldValue($this->hasUnicode() ? '▣' : '[x]') : ($this->hasUnicode() ? '▢' : '[ ]');
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function indicatorDown(): string {
- return $this->hasUnicode() ? '▾' : 'v';
+ public function fieldLabel(string $text): string {
+ return $this->label($text) . ':';
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function radio(bool $on): string {
- return $on ? $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $this->hasUnicode() ? '◉' : '(o)') : ($this->hasUnicode() ? '◯' : '( )');
+ public function fieldDescription(string $text): string {
+ return $this->description($this->lead() . ' ' . $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function check(bool $on): string {
- return $on ? $this->value($this->hasUnicode() ? '▣' : '[x]') : ($this->hasUnicode() ? '▢' : '[ ]');
+ public function actionSelected(string $label): string {
+ return $this->paint(Sgr::of(Sgr::Bold, Sgr::Reverse, Sgr::BrightCyan), '« ' . $label . ' »');
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function caret(): string {
- return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $this->hasUnicode() ? '▎' : '|');
+ public function actionButton(string $label): string {
+ return $this->label('« ' . $label . ' »');
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderFieldLine(Field $field, Answers $answers, bool $selected): array {
- $prefix = $this->marker($selected) . ' ' . $this->label($field->label) . ': ';
- $indent = str_repeat(' ', Ansi::width($prefix));
-
- // A multi-line value (a textarea) lays out one row per line: the first
- // rides the label row, the rest align under the value column, so no row
- // carries an embedded newline.
- $lines = [];
-
- foreach (explode("\n", $this->normalizeLines($this->renderFieldValue($field, $answers->value($field->id)))) as $index => $value_line) {
- $lines[] = ($index === 0 ? $prefix : $indent) . $this->value($value_line);
- }
-
- return $lines;
+ public function actionSeparator(): string {
+ return ' ';
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderPanelLine(Panel $panel, bool $selected): string {
- $count = count($panel->fields) + count($panel->panels);
-
- return $this->marker($selected) . ' ' . $this->title($panel->title) . ' ' . $this->description($this->arrow() . ' ' . $count . ' item' . ($count === 1 ? '' : 's'));
+ public function panelDescend(): string {
+ return $this->description($this->hasUnicode() ? '»' : '>');
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderDescriptionLine(string $description, bool $selected): string {
- return ' ' . $this->description($this->dot() . ' ' . $description, $selected);
+ public function panelDescription(string $text): string {
+ return $this->description($this->lead() . ' ' . $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function summarizePanel(Panel $panel, Answers $answers): string {
- $parts = [];
-
- foreach ($panel->fields as $field) {
- if ($answers->has($field->id)) {
- // A summary is one line, so a multi-line value folds to a single row.
- $parts[] = str_replace("\n", ' ', $this->normalizeLines($this->renderFieldValue($field, $answers->value($field->id))));
- }
- }
-
- return implode(' ' . $this->separator() . ' ', array_slice($parts, 0, 3));
+ public function panelSummary(string $text): string {
+ return $this->description(($this->hasUnicode() ? '»' : '>') . ' ' . $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderSummaryLine(string $summary, bool $selected): string {
- return ' ' . $this->description($this->arrow() . ' ' . $summary, $selected);
+ public function panelSummarySeparator(): string {
+ return $this->divider();
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderBreadcrumbLine(Navigator $navigator): string {
- return $this->breadcrumb('≈ ' . implode(' ' . $this->separator() . ' ', $navigator->breadcrumb()));
+ public function breadcrumbLabel(string $text): string {
+ return $this->paint(Sgr::of(Sgr::Cyan), $text);
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderHints(ScopedKeyMap $keys, Hint ...$hints): string {
- $sep = ' ' . $this->dot() . ' ';
-
- $fragments = array_filter(array_map(fn(Hint $hint): string => $this->keysHint($keys, $hint->label, ...$hint->actions), $hints));
-
- return $this->footer(implode($sep, $fragments));
+ public function breadcrumbSeparator(): string {
+ return $this->breadcrumbLabel($this->divider());
}
/**
* {@inheritdoc}
*/
#[\Override]
- public function renderButtonBar(array $labels, int $selected): string {
- $buttons = [];
-
- foreach ($labels as $index => $label) {
- $text = '« ' . $label . ' »';
- $buttons[] = $index === $selected ? $this->cursor($text) : $this->label($text);
- }
-
- return ' ' . implode(' ', $buttons);
+ public function legendSeparator(): string {
+ return $this->footer(' ' . $this->lead() . ' ');
}
/**
@@ -289,7 +245,7 @@ public function renderBanner(string $logo, string $version): string {
$lines = [];
foreach (explode("\n", $logo) as $line) {
- $lines[] = $this->title($line);
+ $lines[] = $this->markupTitle($line);
}
if ($version !== '') {
diff --git a/rector.php b/rector.php
index 4afa86d9..df17f31a 100644
--- a/rector.php
+++ b/rector.php
@@ -21,6 +21,7 @@
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector;
use Rector\Naming\Rector\Assign\RenameVariableToMatchMethodCallReturnTypeRector;
+use Rector\Naming\Rector\Class_\RenamePropertyToMatchTypeRector;
use Rector\Naming\Rector\ClassMethod\RenameVariableToMatchNewTypeRector;
use Rector\Naming\Rector\Foreach_\RenameForeachValueVariableToMatchExprVariableRector;
use Rector\Naming\Rector\Foreach_\RenameForeachValueVariableToMatchMethodCallReturnTypeRector;
@@ -61,7 +62,7 @@
// the two are named for different things here: the parameter for the seed
// value a caller passes, the property for the live answer it becomes.
ClassPropertyAssignToConstructorPromotionRector::class => [
- __DIR__ . '/src/Widget/ConfirmWidget.php',
+ __DIR__ . '/src/Field/Confirm.php',
],
CompleteDynamicPropertiesRector::class,
CountArrayToEmptyArrayComparisonRector::class,
@@ -78,10 +79,17 @@
RenameForeachValueVariableToMatchMethodCallReturnTypeRector::class,
// Conflicts with Drupal's snake_case parameter naming (enforced by PHPCS).
RenameParamToMatchTypeRector::class,
+ // A primitive holds the theme and is typed to the narrower set of pieces it
+ // draws through; the property is named for the collaborator rather than for
+ // the narrowing, so a reader sees the theme it is.
+ RenamePropertyToMatchTypeRector::class => [
+ __DIR__ . '/src/Primitive/Output.php',
+ __DIR__ . '/src/Primitive/Progress.php',
+ ],
// Rector analyses a trait file on its own, so it cannot see the composing
// class's list property type and would cast strings to string.
NullToStrictStringFuncCallArgRector::class => [
- __DIR__ . '/src/Widget/Capability/CompletionCapableTrait.php',
+ __DIR__ . '/src/Field/Capability/CompletionCapableTrait.php',
],
RenameVariableToMatchMethodCallReturnTypeRector::class,
RenameVariableToMatchNewTypeRector::class,
diff --git a/src/Answers/Answers.php b/src/Answers/Answers.php
index 7a9d2556..ef89b78b 100644
--- a/src/Answers/Answers.php
+++ b/src/Answers/Answers.php
@@ -4,8 +4,7 @@
namespace DrevOps\Tui\Answers;
-use DrevOps\Tui\Model\FormDefinition;
-use DrevOps\Tui\Model\Panel;
+use DrevOps\Tui\Block\Panel;
use DrevOps\Tui\Render\Terminal;
/**
@@ -16,9 +15,9 @@
* present. Provenance is one of default / detected / edited / derived /
* override.
*
- * Answer sets produced by the engine and the panel TUI are self-describing:
- * each answer carries a snapshot of its question (label, kind, panel trail)
- * in items(), so summaries and processing need no form configuration.
+ * An answer set is self-describing: each answer carries a snapshot of its
+ * question (label, kind, panel trail) in items(), so summaries and processing
+ * need no form configuration.
*
* @package DrevOps\Tui\Answers
*/
@@ -43,13 +42,14 @@ public function __construct(
}
/**
- * Build a self-describing answer set from a form definition.
+ * Build a self-describing answer set from a declared block tree.
*
- * Walks the panel tree in form order and snapshots each active question
- * (label, kind, panel trail) into its answer.
+ * The tree's root is the form itself rather than a panel somebody declared,
+ * so it contributes no heading: the trail each answer carries starts at the
+ * panel it was asked in.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The form definition the answers were collected against.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
* @param array $values
* The answer values keyed by question id.
* @param array $provenance
@@ -58,20 +58,20 @@ public function __construct(
* @return self
* The answer set.
*/
- public static function forForm(FormDefinition $form, array $values, array $provenance): self {
- $items = [];
+ public static function forTree(Panel $root, array $values, array $provenance): self {
+ $items = self::snapshot($root, [], $values, $provenance);
- foreach ($form->panels as $panel) {
- $items = [...$items, ...self::walkPanel($panel, [], $values, $provenance)];
+ foreach ($root->children() as $panel) {
+ $items = [...$items, ...self::walkTree($panel, [], $values, $provenance)];
}
return new self($values, $provenance, $items);
}
/**
- * Walk a panel and its sub-panels, snapshotting each active field.
+ * Walk a panel block and the panels beneath it, snapshotting each answer.
*
- * @param \DrevOps\Tui\Model\Panel $panel
+ * @param \DrevOps\Tui\Block\Panel $panel
* The panel to walk.
* @param list $trail
* The titles of the ancestor panels, outermost first.
@@ -83,20 +83,42 @@ public static function forForm(FormDefinition $form, array $values, array $prove
* @return array
* The self-describing answers keyed by question id.
*/
- protected static function walkPanel(Panel $panel, array $trail, array $values, array $provenance): array {
- $trail[] = $panel->title;
+ protected static function walkTree(Panel $panel, array $trail, array $values, array $provenance): array {
+ $trail[] = $panel->title();
+
+ $items = self::snapshot($panel, $trail, $values, $provenance);
+
+ foreach ($panel->children() as $child) {
+ $items = [...$items, ...self::walkTree($child, $trail, $values, $provenance)];
+ }
+ return $items;
+ }
+
+ /**
+ * Snapshot the questions one panel asked, in the order it asked them.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel.
+ * @param list $trail
+ * The titles of the panels the questions live under, outermost first.
+ * @param array $values
+ * The answer values keyed by question id.
+ * @param array $provenance
+ * The provenance keyed by question id.
+ *
+ * @return array
+ * The self-describing answers keyed by question id.
+ */
+ protected static function snapshot(Panel $panel, array $trail, array $values, array $provenance): array {
$items = [];
- foreach ($panel->fields as $field) {
- if (!array_key_exists($field->id, $values)) {
+
+ foreach ($panel->fields() as $field) {
+ if (!array_key_exists($field->id(), $values)) {
continue;
}
- $items[$field->id] = new Answer($field->id, $values[$field->id], $provenance[$field->id] ?? Provenance::Default, $field->label, $field->type, $trail, $field->templateParts($values[$field->id]));
- }
-
- foreach ($panel->panels as $subpanel) {
- $items = [...$items, ...self::walkPanel($subpanel, $trail, $values, $provenance)];
+ $items[$field->id()] = new Answer($field->id(), $values[$field->id()], $provenance[$field->id()] ?? Provenance::Default, $field->label(), $field->type(), $trail, $field->templateParts($values[$field->id()]));
}
return $items;
diff --git a/src/Answers/SummaryFormatter.php b/src/Answers/SummaryFormatter.php
index 479ff468..0a6162d2 100644
--- a/src/Answers/SummaryFormatter.php
+++ b/src/Answers/SummaryFormatter.php
@@ -33,7 +33,7 @@ public function __construct(protected bool $hyperlinks = FALSE) {
* Format the answers grouped by their panel trails.
*
* @param \DrevOps\Tui\Answers\Answers $answers
- * The answer set (as produced by the engine or the panel TUI).
+ * The answer set, however it was collected.
*
* @return string
* The formatted summary.
diff --git a/src/Block/AbstractBlock.php b/src/Block/AbstractBlock.php
new file mode 100644
index 00000000..03e925c1
--- /dev/null
+++ b/src/Block/AbstractBlock.php
@@ -0,0 +1,48 @@
+ $elements
+ * The elements interface this block declares.
+ * @param string $subject
+ * What could not be drawn, as the phrase the failure names it by.
+ *
+ * @return T
+ * The theme, able to draw this block.
+ *
+ * @throws \InvalidArgumentException
+ * When the theme does not implement the elements.
+ *
+ * @template T of object
+ */
+ protected function elements(ThemeInterface $theme, string $elements, string $subject): object {
+ if (!$theme instanceof $elements) {
+ throw new \InvalidArgumentException(sprintf('%s cannot draw %s: it does not implement %s.', $theme::class, $subject, $elements));
+ }
+
+ return $theme;
+ }
+
+}
diff --git a/src/Block/Actions.php b/src/Block/Actions.php
new file mode 100644
index 00000000..4d1f6730
--- /dev/null
+++ b/src/Block/Actions.php
@@ -0,0 +1,169 @@
+
+ */
+ protected array $buttons = [];
+
+ /**
+ * The name of the button the cursor rests on, if any does.
+ */
+ protected ?string $selected = NULL;
+
+ /**
+ * The name of the button that was pressed, if one was.
+ */
+ protected ?string $activated = NULL;
+
+ /**
+ * The reason the form cannot be ended yet, if there is one.
+ */
+ protected ?string $refusal = NULL;
+
+ /**
+ * Declare a button.
+ *
+ * @param string $name
+ * The name it is addressed by.
+ * @param string $label
+ * The label it draws.
+ *
+ * @return static
+ * The block.
+ */
+ public function action(string $name, string $label): static {
+ $this->buttons[$name] = $label;
+ $this->selected ??= $name;
+
+ return $this;
+ }
+
+ /**
+ * The names of the buttons, in declaration order.
+ *
+ * @return list
+ * The names.
+ */
+ public function names(): array {
+ return array_keys($this->buttons);
+ }
+
+ /**
+ * Rest the cursor on a button.
+ *
+ * @param string $name
+ * The button name.
+ *
+ * @return static
+ * The block.
+ */
+ public function select(string $name): static {
+ if (!isset($this->buttons[$name])) {
+ throw new \InvalidArgumentException(sprintf('Unknown action "%s". This block declares: %s.', $name, implode(', ', $this->names())));
+ }
+
+ $this->selected = $name;
+
+ return $this;
+ }
+
+ /**
+ * The button the cursor rests on.
+ *
+ * @return string|null
+ * The name, or NULL while no button is declared.
+ */
+ public function selected(): ?string {
+ return $this->selected;
+ }
+
+ /**
+ * Withhold the end of the form, and say why.
+ *
+ * @param string|null $reason
+ * The reason, or NULL to allow it again.
+ *
+ * @return static
+ * The block.
+ */
+ public function refuse(?string $reason): static {
+ $this->refusal = $reason;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function refusal(): ?string {
+ return $this->refusal;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function activate(): bool {
+ // Refusing is the whole point of withholding the submit: the button stays
+ // unpressed and the reason stands until whatever it names is answered.
+ if ($this->refusal !== NULL || $this->selected === NULL) {
+ return FALSE;
+ }
+
+ $this->activated = $this->selected;
+
+ return TRUE;
+ }
+
+ /**
+ * The button that was pressed.
+ *
+ * @return string|null
+ * The name, or NULL while none was.
+ */
+ public function activated(): ?string {
+ return $this->activated;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, ActionsElementsInterface::class, 'actions');
+ $parts = [];
+
+ foreach ($this->buttons as $name => $label) {
+ $parts[] = $name === $this->selected ? $elements->actionSelected($label) : $elements->actionButton($label);
+ }
+
+ return implode($elements->actionSeparator(), $parts);
+ }
+
+}
diff --git a/src/Block/BlockInterface.php b/src/Block/BlockInterface.php
new file mode 100644
index 00000000..d2ee0c78
--- /dev/null
+++ b/src/Block/BlockInterface.php
@@ -0,0 +1,32 @@
+
+ */
+ protected array $segments;
+
+ /**
+ * Construct a breadcrumb.
+ *
+ * @param string ...$segments
+ * The panel titles, from the root to where you are.
+ */
+ public function __construct(string ...$segments) {
+ $this->segments = array_values($segments);
+ }
+
+ /**
+ * The trail this breadcrumb draws.
+ *
+ * @param string ...$segments
+ * The panel titles, from the root to where you are.
+ *
+ * @return $this
+ * The block.
+ */
+ public function trail(string ...$segments): self {
+ $this->segments = array_values($segments);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, BreadcrumbElementsInterface::class, 'a breadcrumb');
+ // A trail is made of the panel titles a form declared, so each resolves
+ // through the active language exactly as it does on the row it names.
+ $labels = array_map(static fn(string $segment): string => $elements->breadcrumbLabel(Translator::t($segment)), $this->segments);
+
+ return implode(' ' . $elements->breadcrumbSeparator() . ' ', $labels);
+ }
+
+}
diff --git a/src/Block/Capability/ActivateCapableInterface.php b/src/Block/Capability/ActivateCapableInterface.php
new file mode 100644
index 00000000..bd0ccaa0
--- /dev/null
+++ b/src/Block/Capability/ActivateCapableInterface.php
@@ -0,0 +1,26 @@
+
+ * The fragments, in the order they are advertised.
+ */
+ public function hints(): array;
+
+}
diff --git a/src/Block/Capability/BindCapableTrait.php b/src/Block/Capability/BindCapableTrait.php
new file mode 100644
index 00000000..25db04e9
--- /dev/null
+++ b/src/Block/Capability/BindCapableTrait.php
@@ -0,0 +1,88 @@
+formKeys = $keys;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function bindings(): ScopedKeyMap {
+ return $this->keyMap()->scope($this->keyScope());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function binds(Key $key): bool {
+ return $this->boundAction($key) instanceof Action;
+ }
+
+ /**
+ * The bindings this block resolves its scope against.
+ *
+ * @return \DrevOps\Tui\Input\KeyMap
+ * The bindings it was given, else the default preset.
+ */
+ protected function keyMap(): KeyMap {
+ return $this->formKeys ??= KeyMapManager::create();
+ }
+
+ /**
+ * What a key means here.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key.
+ *
+ * @return \DrevOps\Tui\Input\Action|null
+ * The action it triggers, or NULL when it triggers none here.
+ */
+ protected function boundAction(Key $key): ?Action {
+ $bindings = $this->bindings();
+
+ foreach (Action::cases() as $action) {
+ if ($bindings->matches($key, $action)) {
+ return $action;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * The scope this block's keys resolve in.
+ *
+ * @return \DrevOps\Tui\Input\Scope
+ * The scope.
+ */
+ abstract protected function keyScope(): Scope;
+
+}
diff --git a/src/Block/Capability/CaptureCapableInterface.php b/src/Block/Capability/CaptureCapableInterface.php
new file mode 100644
index 00000000..5c2e5688
--- /dev/null
+++ b/src/Block/Capability/CaptureCapableInterface.php
@@ -0,0 +1,73 @@
+ $answers): bool` deciding for itself.
+ *
+ * @return static
+ * The block.
+ */
+ public function when(\Closure|ConditionInterface $when): static;
+
+ /**
+ * What decides whether this block is there at all.
+ *
+ * @return \Closure|\DrevOps\Tui\Condition\ConditionInterface|null
+ * The condition, or NULL when the block is always there.
+ */
+ public function condition(): \Closure|ConditionInterface|null;
+
+ /**
+ * Whether this block is there.
+ *
+ * @param array $answers
+ * The answers collected so far, keyed by the id each is held under.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isActive(array $answers = []): bool;
+
+ /**
+ * Take this block off the screen, because the answers say it is not there.
+ *
+ * Whether it is there is decided against the answers, and where those answers
+ * are is not a block's business - so a block is told rather than asked, and
+ * the telling is separate from the deciding.
+ *
+ * @return static
+ * The block.
+ */
+ public function hide(): static;
+
+ /**
+ * Put this block back on the screen.
+ *
+ * @return static
+ * The block.
+ */
+ public function reveal(): static;
+
+ /**
+ * Whether this block is off the screen.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isHidden(): bool;
+
+}
diff --git a/src/Block/Capability/DependCapableTrait.php b/src/Block/Capability/DependCapableTrait.php
new file mode 100644
index 00000000..50463852
--- /dev/null
+++ b/src/Block/Capability/DependCapableTrait.php
@@ -0,0 +1,94 @@
+when = $when;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function condition(): \Closure|ConditionInterface|null {
+ return $this->when;
+ }
+
+ /**
+ * The declared rule deciding whether this block is there at all.
+ *
+ * A rule the block decides for itself cannot be read, only asked, so it is
+ * absent here: anything describing the form has to be able to say which
+ * answers a dependency is about.
+ *
+ * @return \DrevOps\Tui\Condition\ConditionInterface|null
+ * The rule, or NULL when the block is always there or decides for itself.
+ */
+ public function rule(): ?ConditionInterface {
+ return $this->when instanceof ConditionInterface ? $this->when : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hide(): static {
+ $this->hidden = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function reveal(): static {
+ $this->hidden = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isHidden(): bool {
+ return $this->hidden;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isActive(array $answers = []): bool {
+ if ($this->when instanceof ConditionInterface) {
+ return $this->when->matches($answers);
+ }
+
+ // A closure that declares no parameter ignores what it is handed, so both
+ // shapes of condition are called the same way.
+ return !$this->when instanceof \Closure || (bool) ($this->when)($answers);
+ }
+
+}
diff --git a/src/Block/Capability/DescendCapableInterface.php b/src/Block/Capability/DescendCapableInterface.php
new file mode 100644
index 00000000..eb8623a6
--- /dev/null
+++ b/src/Block/Capability/DescendCapableInterface.php
@@ -0,0 +1,42 @@
+focused = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function blur(): static {
+ $this->focused = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isFocused(): bool {
+ return $this->focused;
+ }
+
+}
diff --git a/src/Block/Capability/OverlayCapableInterface.php b/src/Block/Capability/OverlayCapableInterface.php
new file mode 100644
index 00000000..1e47656a
--- /dev/null
+++ b/src/Block/Capability/OverlayCapableInterface.php
@@ -0,0 +1,33 @@
+
+ */
+ protected array $entries = [];
+
+ /**
+ * What owes the field one set of rows, resolved once.
+ */
+ protected ?\Closure $loader = NULL;
+
+ /**
+ * What resolves the rows from the answers, run again as they change.
+ */
+ protected ?\Closure $resolver = NULL;
+
+ /**
+ * What resolves the rows from a live query, run again as it changes.
+ */
+ protected ?\Closure $source = NULL;
+
+ /**
+ * The query length below which a query source is not called at all.
+ */
+ protected int $queryMinLength = 0;
+
+ /**
+ * What this field will accept, stated before anything is refused.
+ */
+ protected ?string $constraint = NULL;
+
+ /**
+ * Why the last value was refused, until one is acceptable again.
+ */
+ protected ?string $refusal = NULL;
+
+ /**
+ * The explanatory text drawn under the field while it is open.
+ */
+ protected string $description = '';
+
+ /**
+ * The long-form text behind its help key.
+ */
+ protected string $help = '';
+
+ /**
+ * The ghost text shown in the editor while its buffer is empty.
+ */
+ protected string $placeholder = '';
+
+ /**
+ * The word for how the answer came to be, drawn beside it.
+ */
+ protected string $badge = '';
+
+ /**
+ * How many answers had to be given before this one is asked at all.
+ */
+ protected int $depth = 0;
+
+ /**
+ * Whether an answer is owed.
+ */
+ protected bool $required = FALSE;
+
+ /**
+ * What is said when a required answer is missing.
+ */
+ protected string $requiredMessage = '';
+
+ /**
+ * What refuses a value, and says why.
+ *
+ * @var \Closure(mixed): ?string|null
+ */
+ protected ?\Closure $validate = NULL;
+
+ /**
+ * What normalizes an accepted value before it is held.
+ *
+ * @var \Closure(mixed): mixed|null
+ */
+ protected ?\Closure $transform = NULL;
+
+ /**
+ * What refuses a value wherever this kind of answer is asked for.
+ *
+ * @var \Closure(mixed): ?string|null
+ */
+ protected ?\Closure $reusableValidate = NULL;
+
+ /**
+ * What normalizes this kind of answer wherever it is asked for.
+ *
+ * @var \Closure(mixed): mixed|null
+ */
+ protected ?\Closure $reusableTransform = NULL;
+
+ /**
+ * What computes this field's answer from the others, or NULL for none.
+ */
+ protected ?Derive $derive = NULL;
+
+ /**
+ * What detects an existing answer outside the form, or NULL for none.
+ */
+ protected DiscoverInterface|\Closure|null $discover = NULL;
+
+ /**
+ * How large a number may be, or NULL when its magnitude is unbounded.
+ */
+ protected ?NumberBounds $bounds = NULL;
+
+ /**
+ * How early or late a date may be, or NULL when its range is unbounded.
+ */
+ protected ?DateBounds $dateBounds = NULL;
+
+ /**
+ * How many values a list may hold, or NULL when its count is unbounded.
+ */
+ protected ?SelectionBounds $selectionBounds = NULL;
+
+ /**
+ * The type, extension and size limits on a valid pick.
+ */
+ protected FilePickerConstraints $pickerConstraints;
+
+ /**
+ * The directory a picker opens at and cannot ascend above.
+ */
+ protected string $pickerStart = '';
+
+ /**
+ * Whether a picker shows dot-entries when it opens.
+ */
+ protected bool $pickerShowHidden = FALSE;
+
+ /**
+ * The fixed shape whose slots are filled in, or NULL when there is none.
+ */
+ protected ?Template $template = NULL;
+
+ /**
+ * The caption of a point on a scale, keyed by the point.
+ *
+ * @var array
+ */
+ protected array $captions = [];
+
+ /**
+ * How many rows show at once before the list pages, or NULL for the default.
+ */
+ protected ?int $pageSize = NULL;
+
+ /**
+ * The inline completion candidates, or what computes them.
+ *
+ * @var list|\Closure
+ */
+ protected array|\Closure $completion = [];
+
+ /**
+ * Whether the leading match is previewed as ghost text after the caret.
+ */
+ protected bool $ghost = FALSE;
+
+ /**
+ * Whether the answer is several values rather than one.
+ */
+ protected bool $multiple = FALSE;
+
+ /**
+ * Whether the editor offers a reveal/hide toggle over a masked value.
+ */
+ protected bool $revealable = FALSE;
+
+ /**
+ * Whether the editor asks twice and refuses a mismatch.
+ */
+ protected bool $confirm = FALSE;
+
+ /**
+ * Whether the editor may hand off to the user's own editor.
+ */
+ protected bool $externalEditor = FALSE;
+
+ /**
+ * Whether there is an editor of the reader's own to hand off to.
+ */
+ protected bool $handoff = FALSE;
+
+ /**
+ * Whether the answer it holds was taken rather than merely settled.
+ */
+ protected bool $accepted = FALSE;
+
+ /**
+ * Where the editor is drawn: in place on the panel, or full-screen.
+ */
+ protected RenderMode $renderMode = RenderMode::Inline;
+
+ /**
+ * The environment variable answering this field, replacing the derived one.
+ */
+ protected string $envName = '';
+
+ /**
+ * The further environment variables it answers to, in precedence order.
+ *
+ * @var list
+ */
+ protected array $envAliases = [];
+
+ /**
+ * The static default standing in for a computed one in machine output.
+ */
+ protected mixed $schemaDefault = NULL;
+
+ /**
+ * Whether a static default was declared, so a declared NULL is one.
+ */
+ protected bool $hasSchemaDefault = FALSE;
+
+ /**
+ * Construct a field.
+ *
+ * @param string $id
+ * The id it is addressed by.
+ * @param string $label
+ * The name it draws.
+ * @param \DrevOps\Tui\Model\FieldType $fieldType
+ * The kind of answer it collects, which is what decides the editor it
+ * opens onto and the keys that editor binds.
+ */
+ public function __construct(
+ protected string $id,
+ protected string $label,
+ protected FieldType $fieldType = FieldType::Text,
+ ) {
+ $this->pickerConstraints = new FilePickerConstraints();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function id(): string {
+ return $this->id;
+ }
+
+ /**
+ * The name this field draws.
+ *
+ * @return string
+ * The label.
+ */
+ public function label(): string {
+ return $this->label;
+ }
+
+ /**
+ * The kind of answer this field collects.
+ *
+ * @return \DrevOps\Tui\Model\FieldType
+ * The type.
+ */
+ public function type(): FieldType {
+ return $this->fieldType;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function mode(): Mode {
+ return $this->mode;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The kind is what decides the editor, so opening is where the declaration
+ * becomes something that collects.
+ */
+ public function open(): static {
+ // A kind that only draws has nothing to open onto, so it stays as it is
+ // rather than failing at the keystroke that reached it.
+ if ($this->fieldType->isDisplayOnly()) {
+ return $this;
+ }
+
+ $this->mode = Mode::Edit;
+ $this->draft = $this->value;
+ $this->editor = $this->editorFor($this->value);
+ $this->accepted = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function close(): static {
+ $this->mode = Mode::View;
+ $this->draft = NULL;
+ $this->editor = NULL;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Accepting is the editor's to offer and the field's to refuse: a refused
+ * value leaves the field open on what was offered, so the reason on its error
+ * line is about something still in front of you.
+ */
+ public function capture(Key $key): bool {
+ if (!$this->editor instanceof FieldInterface) {
+ return FALSE;
+ }
+
+ $this->editor->handle($key);
+
+ if ($this->editor->isCancelled()) {
+ $this->close();
+
+ return TRUE;
+ }
+
+ if (!$this->editor->isComplete()) {
+ $this->draft = $this->editor->value();
+
+ return TRUE;
+ }
+
+ $offered = $this->editor->value();
+
+ if (!$this->accept($offered)) {
+ $this->draft = $offered;
+ $this->editor = $this->editorFor($offered);
+ }
+
+ return TRUE;
+ }
+
+ /**
+ * What this field opened onto.
+ *
+ * @return \DrevOps\Tui\Field\FieldInterface|null
+ * The editor, or NULL while the field is settled.
+ */
+ public function editor(): ?FieldInterface {
+ return $this->editor;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function draft(mixed $draft): static {
+ $this->draft = $draft;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Declaring a starting point is not offering a value, so nothing is refused
+ * here: a default the form author wrote is not a value a person typed.
+ */
+ public function default(mixed $value): static {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A refused value leaves the answer where it was and the field open, with the
+ * reason on its error line.
+ */
+ public function accept(mixed $value = NULL): bool {
+ $offered = func_num_args() === 0 ? $this->draft : $value;
+
+ $refusal = $this->refuses($offered, $this->reusableValidate);
+
+ if ($refusal !== NULL) {
+ $this->refusal = $refusal;
+
+ return FALSE;
+ }
+
+ $transform = $this->transform ?? $this->reusableTransform;
+
+ $this->refusal = NULL;
+ $this->value = $transform instanceof \Closure ? $transform($offered) : $offered;
+ $this->draft = NULL;
+ $this->mode = Mode::View;
+ $this->editor = NULL;
+ $this->accepted = TRUE;
+
+ return TRUE;
+ }
+
+ /**
+ * Whether the answer this field holds was taken rather than merely settled.
+ *
+ * Taking an answer is an act even where the answer is what it already was, so
+ * this says an offer was accepted and not that a value changed.
+ *
+ * @return bool
+ * TRUE when the last thing to happen here was an answer being taken.
+ */
+ public function hasAccepted(): bool {
+ return $this->accepted;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value(): mixed {
+ return $this->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function required(bool $required = TRUE, string $message = ''): static {
+ $this->required = $required;
+ $this->requiredMessage = $message;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isRequired(): bool {
+ return $this->required;
+ }
+
+ /**
+ * What is said when a required answer is missing.
+ *
+ * @return string
+ * The message, empty when one is derived from the label instead.
+ */
+ public function requiredMessage(): string {
+ return $this->requiredMessage;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function requiredViolation(mixed $value): ?string {
+ // Strict comparison, so a FALSE confirmation and a zero are answers rather
+ // than omissions.
+ if (!$this->required || !in_array($value, ['', [], NULL], TRUE)) {
+ return NULL;
+ }
+
+ if ($this->requiredMessage !== '') {
+ return Translator::t($this->requiredMessage);
+ }
+
+ return Translator::t('@label is required.', ['@label' => Translator::t($this->label)]);
+ }
+
+ /**
+ * Refuse values, and say why.
+ *
+ * @param \Closure $validate
+ * Given the offered value, returns the reason it is refused or NULL.
+ *
+ * @return static
+ * The field.
+ */
+ public function validate(\Closure $validate): static {
+ $this->validate = $validate;
+
+ return $this;
+ }
+
+ /**
+ * What refuses a value, and says why.
+ *
+ * @return \Closure|null
+ * The validator, or NULL when nothing of its own refuses a value.
+ */
+ public function validator(): ?\Closure {
+ return $this->validate;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function transform(\Closure $transform): static {
+ $this->transform = $transform;
+
+ return $this;
+ }
+
+ /**
+ * What normalizes an accepted value before it is held.
+ *
+ * @return \Closure|null
+ * The transformer, or NULL when a value is held as it was offered.
+ */
+ public function transformer(): ?\Closure {
+ return $this->transform;
+ }
+
+ /**
+ * Offer behaviour written once for this kind of answer.
+ *
+ * A rule written for a kind of answer applies wherever that answer is asked
+ * for, and nothing in a declaration can know whether one exists - so it is
+ * offered here rather than declared. What the field declares always wins.
+ *
+ * @param \Closure|null $validate
+ * What refuses a value and says why, or NULL when nothing is reused.
+ * @param \Closure|null $transform
+ * What normalizes an accepted value, or NULL when nothing is reused.
+ *
+ * @return static
+ * The field.
+ */
+ public function reuse(?\Closure $validate, ?\Closure $transform): static {
+ $this->reusableValidate = $validate;
+ $this->reusableTransform = $transform;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function multiple(bool $multiple = TRUE): static {
+ $this->multiple = $multiple;
+
+ return $this;
+ }
+
+ /**
+ * Whether the answer is several values rather than one.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isMultiple(): bool {
+ return $this->multiple;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function collectsList(): bool {
+ return $this->multiple || $this->fieldType === FieldType::Reorder;
+ }
+
+ /**
+ * Whether a value has the shape this field collects.
+ *
+ * @param mixed $value
+ * The candidate value.
+ *
+ * @return bool
+ * TRUE when the value's type is the one the kind answers with.
+ */
+ public function acceptsValue(mixed $value): bool {
+ return match (TRUE) {
+ $this->fieldType === FieldType::Confirm, $this->fieldType === FieldType::Pause => is_bool($value),
+ $this->collectsList() => is_array($value),
+ // A scale has no point between its points, so only a whole number names
+ // one - where a number field takes any numeric entry and rounds it.
+ $this->fieldType === FieldType::Rating => is_int($value),
+ $this->fieldType->collectsInteger() => is_int($value) || is_float($value),
+ // An empty string is an unset date, left to the required check; any
+ // other value must be a strict `Y-m-d` calendar date.
+ $this->fieldType === FieldType::Calendar => is_string($value) && ($value === '' || DateBounds::parse($value) instanceof \DateTimeImmutable),
+ default => is_string($value),
+ };
+ }
+
+ /**
+ * The human name of the shape this field collects, as a fragment.
+ *
+ * @return string
+ * The fragment (e.g. "a string", "a list"), translated.
+ */
+ public function valueKind(): string {
+ return match (TRUE) {
+ $this->fieldType === FieldType::Confirm, $this->fieldType === FieldType::Pause => Translator::t('a boolean'),
+ $this->collectsList() => Translator::t('a list'),
+ $this->fieldType === FieldType::Rating => Translator::t('a whole number'),
+ $this->fieldType->collectsInteger() => Translator::t('a number'),
+ $this->fieldType === FieldType::Calendar => Translator::t('a date (YYYY-MM-DD)'),
+ default => Translator::t('a string'),
+ };
+ }
+
+ /**
+ * A value restated against the entries as they now stand.
+ *
+ * A choice outlives the rows it was picked from: a set that follows the
+ * answers narrows as they change, leaving a value that is no longer offered,
+ * a ranking that no longer covers the set, or a toggle sitting on a state
+ * that is gone. This drops what the set no longer holds, completes a ranking
+ * back to a full permutation and returns a toggle to its first state, so a
+ * value always describes the rows in front of it.
+ *
+ * A suggest field's rows are hints rather than a closed set, so its value is
+ * never restated against them.
+ *
+ * @param mixed $value
+ * The current value.
+ *
+ * @return mixed
+ * The value the current entries can carry.
+ */
+ public function reconcileValue(mixed $value): mixed {
+ if (!$this->fieldType->supportsOptions() || $this->fieldType === FieldType::Suggest) {
+ return $value;
+ }
+
+ $selectable = $this->selectableValues();
+
+ if ($this->fieldType === FieldType::Reorder) {
+ return self::canonicalOrder($selectable, self::stringList($value));
+ }
+
+ if ($this->isMultiChoice()) {
+ return array_values(array_filter(self::stringList($value), static fn(string $item): bool => in_array($item, $selectable, TRUE)));
+ }
+
+ $current = is_scalar($value) ? (string) $value : '';
+
+ if (in_array($current, $selectable, TRUE)) {
+ return $current;
+ }
+
+ // A toggle is always in one of its states, so a value the set no longer
+ // offers falls back to the first row rather than to nothing.
+ return $this->fieldType === FieldType::Toggle ? ($selectable[0] ?? '') : '';
+ }
+
+ /**
+ * Whether the answer is several picks from the declared rows.
+ *
+ * Narrower than {@see collectsList()}: a multiple file picker collects a list
+ * too, but its items come from the filesystem rather than from the rows.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isMultiChoice(): bool {
+ return $this->fieldType === FieldType::Reorder || ($this->multiple && $this->fieldType->constrainsToOptions());
+ }
+
+ /**
+ * Compute this field's answer from the others.
+ *
+ * @param \DrevOps\Tui\Derive\Derive $derive
+ * The rule.
+ *
+ * @return static
+ * The field.
+ */
+ public function derive(Derive $derive): static {
+ $this->derive = $derive;
+
+ return $this;
+ }
+
+ /**
+ * What computes this field's answer from the others.
+ *
+ * @return \DrevOps\Tui\Derive\Derive|null
+ * The rule, or NULL when the answer is not computed.
+ */
+ public function derivation(): ?Derive {
+ return $this->derive;
+ }
+
+ /**
+ * Detect an answer that already exists outside the form.
+ *
+ * @param \DrevOps\Tui\Discovery\DiscoverInterface|\Closure $discover
+ * The rule, or an `fn (string $directory): mixed` detector of its own.
+ *
+ * @return static
+ * The field.
+ */
+ public function discover(DiscoverInterface|\Closure $discover): static {
+ $this->discover = $discover;
+
+ return $this;
+ }
+
+ /**
+ * What detects an answer that already exists outside the form.
+ *
+ * @return \DrevOps\Tui\Discovery\DiscoverInterface|\Closure|null
+ * The rule, or NULL when nothing is detected.
+ */
+ public function discovery(): DiscoverInterface|\Closure|null {
+ return $this->discover;
+ }
+
+ /**
+ * Set the environment variable that answers this field.
+ *
+ * Absolute, so a name published elsewhere is reproduced exactly rather than
+ * carrying the form's prefix.
+ *
+ * @param string $name
+ * The variable name.
+ *
+ * @return static
+ * The field.
+ *
+ * @throws \InvalidArgumentException
+ * When the name could not be set portably from a shell.
+ */
+ public function env(string $name): static {
+ $this->assertEnvName($name);
+ $this->envName = $name;
+
+ return $this;
+ }
+
+ /**
+ * The environment variable that answers this field.
+ *
+ * @return string
+ * The name, empty when the mechanical one stands.
+ */
+ public function envName(): string {
+ return $this->envName;
+ }
+
+ /**
+ * Set the further environment variables this field also answers to.
+ *
+ * Consulted in order after the canonical name, so a naming scheme can change
+ * without breaking the variables already published.
+ *
+ * @param array $names
+ * The alias names, most preferred first.
+ *
+ * @return static
+ * The field.
+ *
+ * @throws \InvalidArgumentException
+ * When a name could not be set portably from a shell, or an alias repeats
+ * a name it would never be reached behind.
+ */
+ public function envAliases(array $names): static {
+ $aliases = array_values($names);
+ $seen = [];
+
+ foreach ($aliases as $alias) {
+ $this->assertEnvName($alias);
+
+ if ($alias === $this->envName || isset($seen[$alias])) {
+ throw new \InvalidArgumentException(sprintf('Field "%s" declares the environment variable "%s" twice; only the first would ever be reached, so declare it once.', $this->id, $alias));
+ }
+
+ $seen[$alias] = TRUE;
+ }
+
+ $this->envAliases = $aliases;
+
+ return $this;
+ }
+
+ /**
+ * The further environment variables this field also answers to.
+ *
+ * @return list
+ * The names, in precedence order.
+ */
+ public function aliases(): array {
+ return $this->envAliases;
+ }
+
+ /**
+ * Advertise a static default where the declared one cannot be resolved.
+ *
+ * A default computed from the answers has no value until there are some, so
+ * machine-readable output stands this in for it rather than resolving it.
+ *
+ * @param mixed $value
+ * The static value.
+ *
+ * @return static
+ * The field.
+ */
+ public function schemaDefault(mixed $value): static {
+ $this->schemaDefault = $value;
+ $this->hasSchemaDefault = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * The static default advertised in machine-readable output.
+ *
+ * @return mixed
+ * The value; meaningful only when one was declared.
+ */
+ public function schemaDefaultValue(): mixed {
+ return $this->schemaDefault;
+ }
+
+ /**
+ * Whether a static default was declared, so a declared NULL reads as one.
+ *
+ * @return bool
+ * TRUE when it was.
+ */
+ public function hasSchemaDefault(): bool {
+ return $this->hasSchemaDefault;
+ }
+
+ /**
+ * Offer an entry for edit mode to open onto.
+ *
+ * Declaring a value twice replaces the row in place, so the set stays unique
+ * and stays in the order it was first declared in.
+ *
+ * @param string $value
+ * The value it stands for.
+ * @param string $label
+ * The label it draws; empty draws the value.
+ * @param string $description
+ * What the entry means, shown beside the list.
+ * @param bool $disabled
+ * Whether the entry is drawn but cannot be picked.
+ * @param string $disabled_reason
+ * Why it cannot be picked.
+ *
+ * @return static
+ * The field.
+ */
+ public function entry(string $value, string $label = '', string $description = '', bool $disabled = FALSE, string $disabled_reason = ''): static {
+ $entry = new Option($value, $label === '' ? $value : $label, $description, OptionKind::Option, $disabled, $disabled_reason);
+
+ foreach ($this->entries as $index => $existing) {
+ if ($existing->kind === OptionKind::Option && $existing->value === $value) {
+ $this->entries[$index] = $entry;
+
+ return $this;
+ }
+ }
+
+ $this->entries[] = $entry;
+
+ return $this;
+ }
+
+ /**
+ * Head the entries that follow with a group label.
+ *
+ * @param string $label
+ * The heading.
+ *
+ * @return static
+ * The field.
+ */
+ public function heading(string $label): static {
+ $this->entries[] = new Option('', $label, '', OptionKind::Heading);
+
+ return $this;
+ }
+
+ /**
+ * Divide the entries either side of it.
+ *
+ * @return static
+ * The field.
+ */
+ public function separator(): static {
+ $this->entries[] = new Option('', '', '', OptionKind::Separator);
+
+ return $this;
+ }
+
+ /**
+ * The rows edit mode opens onto.
+ *
+ * @return list<\DrevOps\Tui\Model\Option>
+ * The rows, in the order they were declared.
+ */
+ public function entries(): array {
+ return $this->entries;
+ }
+
+ /**
+ * The entry standing for a value.
+ *
+ * A row is found by the value it carries rather than by where it sits, so a
+ * numeric-looking value stays the string it was declared as.
+ *
+ * @param string $value
+ * The value.
+ *
+ * @return \DrevOps\Tui\Model\Option|null
+ * The entry, or NULL when nothing carries that value. Headings and
+ * separators carry none and are never returned.
+ */
+ public function entryOf(string $value): ?Option {
+ foreach ($this->entries as $entry) {
+ if ($entry->kind === OptionKind::Option && $entry->value === $value) {
+ return $entry;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * The values that can be picked, in the order they are drawn.
+ *
+ * @return list
+ * The values, excluding headings, separators and disabled entries.
+ */
+ public function selectableValues(): array {
+ return Option::selectableValues($this->entries);
+ }
+
+ /**
+ * Load the entries on demand, once.
+ *
+ * @param \Closure $loader
+ * An `fn (): array` returning the value => label map.
+ *
+ * @return static
+ * The field.
+ */
+ public function load(\Closure $loader): static {
+ $this->loader = $loader;
+
+ return $this;
+ }
+
+ /**
+ * What owes the field one set of entries.
+ *
+ * @return \Closure|null
+ * The loader, or NULL when the entries stand as declared.
+ */
+ public function loader(): ?\Closure {
+ return $this->loader;
+ }
+
+ /**
+ * Resolve the entries from the answers, again whenever they change.
+ *
+ * @param \Closure $resolver
+ * An `fn (\DrevOps\Tui\Handler\Context $context): array`
+ * returning the value => label map, read from the answers the context
+ * carries and the run it describes.
+ *
+ * @return static
+ * The field.
+ */
+ public function resolve(\Closure $resolver): static {
+ $this->resolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * What resolves the entries from the answers.
+ *
+ * @return \Closure|null
+ * The resolver, or NULL when the entries do not follow the answers.
+ */
+ public function resolver(): ?\Closure {
+ return $this->resolver;
+ }
+
+ /**
+ * Resolve the entries from a live query, again whenever it changes.
+ *
+ * Unlike a loader it is asked again as the query changes, so the candidates
+ * can come from a backend that filters for itself.
+ *
+ * @param \Closure $source
+ * An
+ * `fn (string $query, array $answers): array`
+ * returning the value => label map.
+ *
+ * @return static
+ * The field.
+ */
+ public function query(\Closure $source): static {
+ $this->source = $source;
+
+ return $this;
+ }
+
+ /**
+ * What resolves the entries from a live query.
+ *
+ * @return \Closure|null
+ * The source, or NULL when the entries do not follow a query.
+ */
+ public function source(): ?\Closure {
+ return $this->source;
+ }
+
+ /**
+ * Keep a query source quiet until the query is worth sending.
+ *
+ * @param int $length
+ * The number of characters below which it is not called, at least one.
+ *
+ * @return static
+ * The field.
+ *
+ * @throws \InvalidArgumentException
+ * When the length is below one character.
+ */
+ public function minQuery(int $length): static {
+ if ($length < 1) {
+ throw new \InvalidArgumentException(sprintf('Field "%s" declares a minimum query length of %d; it must be at least one character.', $this->id, $length));
+ }
+
+ $this->queryMinLength = $length;
+
+ return $this;
+ }
+
+ /**
+ * The query length below which a query source is not called at all.
+ *
+ * @return int
+ * The length; zero calls it for the empty query too.
+ */
+ public function queryMinLength(): int {
+ return $this->queryMinLength;
+ }
+
+ /**
+ * Replace the entries with a set a loader, resolver or query resolved to.
+ *
+ * @param mixed $entries
+ * What the callable returned; anything but a map of strings settles to no
+ * entries, because consumer code running mid-session has no good way to
+ * report a mistake.
+ *
+ * @return static
+ * The field.
+ */
+ public function settle(mixed $entries): static {
+ $this->entries = Option::resolved($entries);
+ // A loader owes the field one list and has now given it; a resolver and a
+ // query source answer again as their input changes, so they stand.
+ $this->loader = NULL;
+
+ return $this;
+ }
+
+ /**
+ * Whether the entries stand as declared.
+ *
+ * @return bool
+ * FALSE while a loader, a resolver or a query source still owes them, so
+ * there is nothing yet to count or to check a value against.
+ */
+ public function hasSettledEntries(): bool {
+ return !$this->loader instanceof \Closure && !$this->resolver instanceof \Closure && !$this->source instanceof \Closure;
+ }
+
+ /**
+ * Whether the entries follow the answers rather than standing.
+ *
+ * @return bool
+ * TRUE when they are resolved from the answers or from a live query, so no
+ * one list describes the field.
+ */
+ public function hasDynamicEntries(): bool {
+ return $this->resolver instanceof \Closure || $this->source instanceof \Closure;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function constrain(string $constraint): static {
+ $this->constraint = $constraint;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function constraint(): ?string {
+ return $this->constraint;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function bounds(NumberBounds $bounds): static {
+ $this->bounds = $bounds;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function numberBounds(): ?NumberBounds {
+ return $this->bounds;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function dates(DateBounds $bounds): static {
+ $this->dateBounds = $bounds;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function dateBounds(): ?DateBounds {
+ return $this->dateBounds;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function selections(SelectionBounds $bounds): static {
+ $this->selectionBounds = $bounds;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function selectionBounds(): ?SelectionBounds {
+ return $this->selectionBounds;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function boundsViolation(mixed $value): ?string {
+ return $this->bounds?->violation($value) ?? $this->dateBounds?->violation($value) ?? $this->selectionBounds?->violation($value);
+ }
+
+ /**
+ * Limit what may be picked from the filesystem.
+ *
+ * @param \DrevOps\Tui\Model\FilePickerConstraints $constraints
+ * The type, extension and size limits.
+ *
+ * @return static
+ * The field.
+ */
+ public function picker(FilePickerConstraints $constraints): static {
+ $this->pickerConstraints = $constraints;
+
+ return $this;
+ }
+
+ /**
+ * What may be picked from the filesystem.
+ *
+ * @return \DrevOps\Tui\Model\FilePickerConstraints
+ * The limits; unconstrained when none were declared.
+ */
+ public function pickerConstraints(): FilePickerConstraints {
+ return $this->pickerConstraints;
+ }
+
+ /**
+ * The limit a supplied path falls outside, as a fragment.
+ *
+ * @param mixed $value
+ * The candidate path, or the list of them.
+ *
+ * @return string|null
+ * The fragment (e.g. "an existing file"), or NULL when every path meets
+ * every limit.
+ */
+ public function pickerViolation(mixed $value): ?string {
+ // Only a field that browses the filesystem measures a path against it, so
+ // limits left on any other kind govern nothing.
+ if ($this->fieldType !== FieldType::FilePicker) {
+ return NULL;
+ }
+
+ return $this->pickerConstraints->violation($value);
+ }
+
+ /**
+ * Open the browser at a directory it cannot ascend above.
+ *
+ * @param string $directory
+ * The directory; empty falls back to the working directory.
+ *
+ * @return static
+ * The field.
+ */
+ public function startIn(string $directory): static {
+ $this->pickerStart = $directory;
+
+ return $this;
+ }
+
+ /**
+ * The directory the browser opens at.
+ *
+ * @return string
+ * The directory, empty when it opens where the form runs.
+ */
+ public function pickerStart(): string {
+ return $this->pickerStart;
+ }
+
+ /**
+ * Show dot-entries when the browser opens.
+ *
+ * @param bool $show
+ * Whether they are shown.
+ *
+ * @return static
+ * The field.
+ */
+ public function showHidden(bool $show = TRUE): static {
+ $this->pickerShowHidden = $show;
+
+ return $this;
+ }
+
+ /**
+ * Whether dot-entries are shown when the browser opens.
+ *
+ * @return bool
+ * TRUE when they are.
+ */
+ public function showsHidden(): bool {
+ return $this->pickerShowHidden;
+ }
+
+ /**
+ * Fix the shape of the answer, leaving named slots to fill in.
+ *
+ * @param \DrevOps\Tui\Model\Template $template
+ * The shape.
+ *
+ * @return static
+ * The field.
+ */
+ public function pattern(Template $template): static {
+ $this->template = $template;
+
+ return $this;
+ }
+
+ /**
+ * The shape the answer is filled into.
+ *
+ * @return \DrevOps\Tui\Model\Template|null
+ * The template, or NULL when the answer has no fixed shape.
+ */
+ public function template(): ?Template {
+ return $this->template;
+ }
+
+ /**
+ * Why a value does not fit the declared shape, else NULL.
+ *
+ * An empty string is an unfilled shape, left to the required check; any other
+ * value must have the shape and pass every slot's own validator.
+ *
+ * @param mixed $value
+ * The candidate value.
+ *
+ * @return string|null
+ * The message, or NULL when the value fits or no shape is declared.
+ */
+ public function templateError(mixed $value): ?string {
+ if (!$this->template instanceof Template || !is_string($value) || $value === '') {
+ return NULL;
+ }
+
+ return $this->template->error($value);
+ }
+
+ /**
+ * The values of the shape's slots, read back out of an assembled answer.
+ *
+ * @param mixed $value
+ * The assembled answer.
+ *
+ * @return array
+ * The value of each slot keyed by slot name; empty when no shape is
+ * declared or the answer does not have it.
+ */
+ public function templateParts(mixed $value): array {
+ if (!$this->template instanceof Template || !is_string($value)) {
+ return [];
+ }
+
+ return $this->template->extract($value);
+ }
+
+ /**
+ * Name what the points of a scale mean.
+ *
+ * @param array $captions
+ * The caption of each point, keyed by the point; points may be captioned
+ * sparsely, and an uncaptioned point still answers with its number.
+ *
+ * @return static
+ * The field.
+ *
+ * @throws \InvalidArgumentException
+ * When a caption names a point outside the scale.
+ */
+ public function captions(array $captions): static {
+ foreach (array_keys($captions) as $point) {
+ if ($this->bounds instanceof NumberBounds && !$this->bounds->contains($point)) {
+ throw new \InvalidArgumentException(sprintf('Field "%s" captions the point %d, which is outside its scale of %s.', $this->id, $point, $this->bounds->describe()));
+ }
+ }
+
+ $this->captions = $captions;
+
+ return $this;
+ }
+
+ /**
+ * What the points of the scale mean.
+ *
+ * @return array
+ * The caption of each captioned point, keyed by the point.
+ */
+ public function ratingCaptions(): array {
+ return $this->captions;
+ }
+
+ /**
+ * The reason a value is not among the entries, as a fragment, else NULL.
+ *
+ * A fragment rather than a sentence, so each caller frames it its own way.
+ *
+ * @param mixed $value
+ * The candidate value - one value, or the list of them.
+ *
+ * @return string|null
+ * The fragment, or NULL when nothing constrains the value or every item is
+ * among the entries.
+ */
+ public function entryError(mixed $value): ?string {
+ // A field that declares no entries constrains nothing - but one whose
+ // entries follow a query or the answers is constrained by whatever they
+ // resolved to, and resolving to nothing means the value does not exist.
+ if (!$this->fieldType->constrainsToOptions() || ($this->entries === [] && !$this->hasDynamicEntries())) {
+ return NULL;
+ }
+
+ if (!$this->isMultiChoice()) {
+ return $this->scalarEntryError(is_scalar($value) ? (string) $value : '');
+ }
+
+ if (!is_array($value)) {
+ return Translator::t('value must be a list');
+ }
+
+ foreach ($value as $item) {
+ $error = $this->scalarEntryError(is_scalar($item) ? (string) $item : '');
+
+ if ($error !== NULL) {
+ return $error;
+ }
+ }
+
+ return $this->fieldType === FieldType::Reorder ? $this->rankingError($value) : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function refusal(): ?string {
+ return $this->refusal;
+ }
+
+ /**
+ * Set the explanatory text drawn under this field while it is open.
+ *
+ * @param string $description
+ * The description.
+ *
+ * @return static
+ * The field.
+ */
+ public function description(string $description): static {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * The explanatory text drawn under this field while it is open.
+ *
+ * @return string
+ * The description, empty when it offers none.
+ */
+ public function descriptionText(): string {
+ return $this->description;
+ }
+
+ /**
+ * Set the long-form text behind this field's help key.
+ *
+ * @param string $help
+ * The help.
+ *
+ * @return static
+ * The field.
+ */
+ public function help(string $help): static {
+ $this->help = $help;
+
+ return $this;
+ }
+
+ /**
+ * The long-form text behind this field's help key.
+ *
+ * @return string
+ * The help, empty when it offers none.
+ */
+ public function helpText(): string {
+ return $this->help;
+ }
+
+ /**
+ * Set the ghost text shown while the editor's buffer is empty.
+ *
+ * @param string $placeholder
+ * The ghost text; it never becomes a value.
+ *
+ * @return static
+ * The field.
+ */
+ public function placeholder(string $placeholder): static {
+ $this->placeholder = $placeholder;
+
+ return $this;
+ }
+
+ /**
+ * The ghost text shown while the editor's buffer is empty.
+ *
+ * @return string
+ * The ghost text, empty when the buffer stays bare.
+ */
+ public function placeholderText(): string {
+ return $this->placeholder;
+ }
+
+ /**
+ * Say how the answer this field holds came to be.
+ *
+ * The field holds the word rather than works it out: how an answer came to be
+ * is a fact about the collection, and a field knows only what it is holding.
+ *
+ * @param string $badge
+ * The word; empty leaves the answer unmarked.
+ *
+ * @return static
+ * The field.
+ */
+ public function badge(string $badge): static {
+ $this->badge = $badge;
+
+ return $this;
+ }
+
+ /**
+ * The word for how the answer this field holds came to be.
+ *
+ * @return string
+ * The word, empty when the answer is unmarked.
+ */
+ public function badgeText(): string {
+ return $this->badge;
+ }
+
+ /**
+ * Say how many answers had to be given before this one is asked at all.
+ *
+ * A rule may name a field on any panel, so how deep a question sits is a
+ * fact about the whole form rather than about the field or the panel holding
+ * it: it can only be worked out once the tree is finished, and it is written
+ * back here.
+ *
+ * @param int $depth
+ * The links in the chain of rules leading to this field; zero for a
+ * question that is always asked.
+ *
+ * @return static
+ * The field.
+ */
+ public function nest(int $depth): static {
+ $this->depth = max(0, $depth);
+
+ return $this;
+ }
+
+ /**
+ * How many answers had to be given before this one is asked at all.
+ *
+ * @return int
+ * The links in the chain, zero for a question that is always asked.
+ */
+ public function nesting(): int {
+ return $this->depth;
+ }
+
+ /**
+ * Complete what is being typed from a set of candidates.
+ *
+ * @param list|\Closure $source
+ * The candidates, or an
+ * `fn (array $answers): list` computing them from the
+ * answers collected so far.
+ *
+ * @return static
+ * The field.
+ */
+ public function complete(array|\Closure $source): static {
+ $this->completion = $source;
+
+ return $this;
+ }
+
+ /**
+ * What completes what is being typed.
+ *
+ * @return list|\Closure
+ * The candidates, or what computes them; empty offers no completion.
+ */
+ public function completion(): array|\Closure {
+ return $this->completion;
+ }
+
+ /**
+ * Preview the leading match as ghost text after the caret.
+ *
+ * @param bool $ghost
+ * Whether it is previewed.
+ *
+ * @return static
+ * The field.
+ */
+ public function ghost(bool $ghost = TRUE): static {
+ $this->ghost = $ghost;
+
+ return $this;
+ }
+
+ /**
+ * Whether the leading match is previewed as ghost text.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function hasGhost(): bool {
+ return $this->ghost;
+ }
+
+ /**
+ * Bound how many rows show at once before the list pages.
+ *
+ * @param int $size
+ * The rows shown at once, at least one.
+ *
+ * @return static
+ * The field.
+ *
+ * @throws \InvalidArgumentException
+ * When the size is below one row.
+ */
+ public function paginate(int $size): static {
+ if ($size < 1) {
+ throw new \InvalidArgumentException(sprintf('Field "%s" declares a page of %d rows; a page shows at least one.', $this->id, $size));
+ }
+
+ $this->pageSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * How many rows show at once before the list pages.
+ *
+ * @return int|null
+ * The rows, or NULL for as many as the editor shows by default.
+ */
+ public function pageSize(): ?int {
+ return $this->pageSize;
+ }
+
+ /**
+ * Offer a reveal/hide toggle over a masked value.
+ *
+ * @param bool $revealable
+ * Whether the toggle is offered.
+ *
+ * @return static
+ * The field.
+ */
+ public function revealable(bool $revealable = TRUE): static {
+ $this->revealable = $revealable;
+
+ return $this;
+ }
+
+ /**
+ * Whether a reveal/hide toggle is offered over a masked value.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isRevealable(): bool {
+ return $this->revealable;
+ }
+
+ /**
+ * Ask twice, and refuse a mismatch before accepting.
+ *
+ * @param bool $confirm
+ * Whether it asks twice.
+ *
+ * @return static
+ * The field.
+ */
+ public function confirmation(bool $confirm = TRUE): static {
+ $this->confirm = $confirm;
+
+ return $this;
+ }
+
+ /**
+ * Whether the editor asks twice and refuses a mismatch.
+ *
+ * @return bool
+ * TRUE when it does.
+ */
+ public function hasConfirmation(): bool {
+ return $this->confirm;
+ }
+
+ /**
+ * Allow the editor to hand off to the user's own editor.
+ *
+ * @param bool $enabled
+ * Whether the handoff is offered.
+ *
+ * @return static
+ * The field.
+ */
+ public function externalEditor(bool $enabled = TRUE): static {
+ $this->externalEditor = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Whether the editor may hand off to the user's own editor.
+ *
+ * @return bool
+ * TRUE when it may.
+ */
+ public function hasExternalEditor(): bool {
+ return $this->externalEditor;
+ }
+
+ /**
+ * Say whether there is an editor of the reader's own to hand off to.
+ *
+ * Declaring the handoff says the field would use one; this says whether there
+ * is one to use, which nothing in a declaration can know.
+ *
+ * @param bool $available
+ * Whether one can be launched.
+ *
+ * @return static
+ * The field.
+ */
+ public function handoff(bool $available = TRUE): static {
+ $this->handoff = $available;
+
+ return $this;
+ }
+
+ /**
+ * Whether there is an editor of the reader's own to hand off to.
+ *
+ * @return bool
+ * TRUE when one can be launched.
+ */
+ public function hasHandoff(): bool {
+ return $this->handoff;
+ }
+
+ /**
+ * Open the editor full-screen rather than in place on the panel.
+ *
+ * @param bool $standalone
+ * TRUE for the full-screen editor; FALSE to edit in place.
+ *
+ * @return static
+ * The field.
+ */
+ public function standalone(bool $standalone = TRUE): static {
+ $this->renderMode = $standalone ? RenderMode::Standalone : RenderMode::Inline;
+
+ return $this;
+ }
+
+ /**
+ * Where the editor is drawn.
+ *
+ * @return \DrevOps\Tui\Model\RenderMode
+ * In place on the panel, or full-screen on its own.
+ */
+ public function renderMode(): RenderMode {
+ return $this->renderMode;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A settled field takes no key at all: the cursor and what it opens belong to
+ * the panel, so every key travels outward until something is open.
+ */
+ public function binds(Key $key): bool {
+ if ($this->mode !== Mode::Edit) {
+ return FALSE;
+ }
+
+ // Every printable key is something being typed where the kind takes typed
+ // input, which is why the help key reaches an open text field and travels
+ // outward from a closed one without either being written as an exception.
+ if ($key->isChar() && $this->keyScope()->consumesText()) {
+ return TRUE;
+ }
+
+ return $this->boundAction($key) instanceof Action;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * While it is open the field is its editor, so the keys it answers to are the
+ * ones the editor was wired with rather than a second copy of them.
+ */
+ public function bindings(): ScopedKeyMap {
+ return $this->editor instanceof FieldInterface ? $this->editor->keys() : $this->keyMap()->scope($this->keyScope());
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A settled field advertises nothing, because nothing reaches it.
+ */
+ public function hints(): array {
+ return $this->editor instanceof FieldInterface ? $this->editor->hints() : [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, FieldElementsInterface::class, 'a field');
+
+ // Help is never drawn in the row that offers it: it can run to paragraphs,
+ // so the row marks that there is something to ask for and nothing more.
+ $marker = $this->help === '' ? '' : ' ' . $elements->fieldHelpMarker();
+ // The gutter leads the label rather than each row, which is what makes it
+ // the single source of the indent: every row the field contributes lines
+ // up under a label that already carries it.
+ // The question is as much a string a reader reads as the chrome around it,
+ // so it resolves through the active language wherever it is drawn.
+ $label = $elements->fieldIndent($this->depth) . $elements->fieldSelector($this->isFocused()) . ' ' . $elements->fieldLabel(Translator::t($this->label)) . $marker;
+
+ return $this->mode === Mode::View
+ ? $this->settledLines($theme, $elements, $label)
+ : $this->openLines($theme, $elements, $label);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function keyScope(): Scope {
+ return Scope::field($this->fieldType, $this->multiple);
+ }
+
+ /**
+ * The editor this field's kind opens onto, seeded with a value.
+ *
+ * @param mixed $current
+ * The value it starts from.
+ *
+ * @return \DrevOps\Tui\Field\FieldInterface
+ * The editor.
+ */
+ protected function editorFor(mixed $current): FieldInterface {
+ return (new FieldFactory($this->keyMap(), $this->handoff))->open($this, $current);
+ }
+
+ /**
+ * The reason an offered value is refused, or NULL when it is acceptable.
+ *
+ * Emptiness is answered first, so a required field says so rather than
+ * letting an empty list read as a count violation; the field's own validator
+ * runs after the declared limits, so it sees a value that already fits them.
+ *
+ * @param mixed $value
+ * The offered value.
+ * @param \Closure|null $reusable
+ * Behaviour to refuse with where the field declares none of its own, so a
+ * rule written once for a kind of answer applies wherever that answer is
+ * asked for. What the field declares always wins.
+ *
+ * @return string|null
+ * The reason, or NULL when nothing refuses it.
+ */
+ public function refuses(mixed $value, ?\Closure $reusable = NULL): ?string {
+ $missing = $this->requiredViolation($value);
+
+ if ($missing !== NULL) {
+ return $missing;
+ }
+
+ $outside = $this->boundsViolation($value) ?? $this->pickerViolation($value);
+
+ if ($outside !== NULL) {
+ return Translator::t('must be @constraint.', ['@constraint' => $outside]);
+ }
+
+ // The shape is checked before the field's own validator, which reads the
+ // value as an assembled template and would otherwise see a foreign string.
+ $misshapen = $this->templateError($value);
+
+ if ($misshapen !== NULL) {
+ return $misshapen;
+ }
+
+ $validate = $this->validate ?? $reusable;
+ $refusal = $validate instanceof \Closure ? $validate($value) : NULL;
+
+ // A validator that answers with nothing has not said why, and a refusal
+ // nobody can read is no refusal at all.
+ return is_string($refusal) && $refusal !== '' ? $refusal : $this->entryError($value);
+ }
+
+ /**
+ * Coerce a value to a list of strings, dropping every non-string item.
+ *
+ * @param mixed $value
+ * The value.
+ *
+ * @return list
+ * The string items, in order; empty when the value is not a list.
+ */
+ public static function stringList(mixed $value): array {
+ if (!is_array($value)) {
+ return [];
+ }
+
+ $out = [];
+
+ foreach ($value as $item) {
+ if (is_string($item)) {
+ $out[] = $item;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Order a set of values, completing and de-duplicating a desired ordering.
+ *
+ * The desired values that belong to the allowed set come first - in the given
+ * order, de-duplicated - then every allowed value the desired list omits is
+ * appended in its declared order, so a partial or dirty ordering still
+ * resolves to a full permutation.
+ *
+ * @param list $allowed
+ * The full set of values, in declared order.
+ * @param list $desired
+ * The requested ordering; values outside the allowed set are ignored and
+ * repeats collapsed.
+ *
+ * @return list
+ * The allowed values in the resolved order.
+ */
+ public static function canonicalOrder(array $allowed, array $desired): array {
+ $set = array_fill_keys($allowed, TRUE);
+
+ $order = [];
+ $seen = [];
+
+ foreach ($desired as $value) {
+ if (isset($set[$value]) && !isset($seen[$value])) {
+ $order[] = $value;
+ $seen[$value] = TRUE;
+ }
+ }
+
+ foreach ($allowed as $value) {
+ if (!isset($seen[$value])) {
+ $order[] = $value;
+ $seen[$value] = TRUE;
+ }
+ }
+
+ return $order;
+ }
+
+ /**
+ * The reason one value is not among the entries, as a fragment, else NULL.
+ *
+ * @param string $value
+ * The candidate value.
+ *
+ * @return string|null
+ * The fragment naming the value when it is disabled or unknown, or NULL
+ * when it can be picked.
+ */
+ protected function scalarEntryError(string $value): ?string {
+ if (in_array($value, $this->selectableValues(), TRUE)) {
+ return NULL;
+ }
+
+ // Listing what is allowed is what makes the message useful, so when a query
+ // found nothing there is no list to offer and naming the value is all that
+ // can honestly be said.
+ if ($this->entries === []) {
+ return Translator::t('value "@value" was not found', ['@value' => $value]);
+ }
+
+ $entry = $this->entryOf($value);
+
+ if ($entry instanceof Option && $entry->disabled) {
+ if ($entry->disabledReason === '') {
+ return Translator::t('option "@value" is disabled', ['@value' => $value]);
+ }
+
+ return Translator::t('option "@value" is disabled: @reason', [
+ '@value' => $value,
+ '@reason' => $entry->disabledReason,
+ ]);
+ }
+
+ return Translator::t('value "@value" is not one of: @options', [
+ '@value' => $value,
+ '@options' => implode(', ', $this->selectableValues()),
+ ]);
+ }
+
+ /**
+ * The reason a ranking is not a full ordering of the entries, else NULL.
+ *
+ * Membership is checked by the caller, so a ranking that is a full ordering
+ * has as many items as there are entries, with no repeats.
+ *
+ * @param array $items
+ * The ranking, already known to hold only values that can be picked.
+ *
+ * @return string|null
+ * The fragment, or NULL when the ranking covers every entry once.
+ */
+ protected function rankingError(array $items): ?string {
+ $selectable = $this->selectableValues();
+
+ $seen = [];
+ foreach ($items as $item) {
+ $seen[is_scalar($item) ? (string) $item : ''] = TRUE;
+ }
+
+ if (count($items) === count($selectable) && count($seen) === count($items)) {
+ return NULL;
+ }
+
+ return Translator::t('must rank every option exactly once (@options)', ['@options' => implode(', ', $selectable)]);
+ }
+
+ /**
+ * Reject an environment variable name that could not be honoured.
+ *
+ * @param string $name
+ * The declared name.
+ *
+ * @throws \InvalidArgumentException
+ * When the name could not be set portably from a shell.
+ */
+ protected function assertEnvName(string $name): void {
+ if (preg_match(self::ENV_NAME_PATTERN, $name) !== 1) {
+ throw new \InvalidArgumentException(sprintf('Field "%s" declares the environment variable name "%s", which is not portable; use letters, digits and underscores, starting with a letter or underscore.', $this->id, $name));
+ }
+ }
+
+ /**
+ * The answer this field is holding, as one line of readable text.
+ *
+ * The same reading the row draws, folded to a single line and left unstyled,
+ * so somewhere that quotes an answer rather than asking for it - a panel
+ * saying what it holds - reads it exactly as the row does.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The answer as it reads.
+ */
+ public function valueText(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, FieldElementsInterface::class, 'a field');
+
+ return str_replace("\n", ' ', $this->readable($theme, $elements));
+ }
+
+ /**
+ * The rows this field draws while it is settled.
+ *
+ * One line is the common case, and an answer carrying line breaks is the
+ * reason it is not the only one: a row with a newline inside it would break
+ * the frame it is drawn in, so each line of the answer is a row of its own,
+ * aligned under the first.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ * @param string $label
+ * The already-styled label.
+ *
+ * @return string
+ * The rows.
+ */
+ protected function settledLines(ThemeInterface $theme, FieldElementsInterface $elements, string $label): string {
+ $prefix = $label . ' ';
+ // Measured from the label as it was drawn, not from its source text: it may
+ // carry a help marker and styling, and only its visible width lines the
+ // rows up under it.
+ $indent = str_repeat(' ', Ansi::width($prefix));
+
+ $lines = [];
+
+ foreach (explode("\n", $this->readable($theme, $elements)) as $row) {
+ // Each row is styled on its own, so no colour span ever crosses a row
+ // boundary and leaks into the one below it.
+ $lines[] = rtrim(($lines === [] ? $prefix : $indent) . $elements->fieldValue($row));
+ }
+
+ $lines[0] = $this->badged($theme, $elements, $lines[0]);
+
+ // What is being asked is worth saying whether or not the row is open: a
+ // reader decides what to answer before opening anything.
+ foreach ($this->explanation($theme, $elements) as $line) {
+ $lines[] = $indent . $line;
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * One row with the word for how the answer came to be at its far edge.
+ *
+ * The badge belongs in a column of its own, so the badges down a panel line
+ * up rather than tracking the length of each answer. Where that column ends
+ * is a fact about the frame rather than about any block, and the theme is
+ * what both the block and the frame already read it from - the same width
+ * that decides where a card wraps and how wide a scale is drawn. Asking the
+ * theme keeps one width across the whole frame; measuring the row here would
+ * be a block working out its own space, and taking it from the region would
+ * be a second answer free to disagree with the first.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ * @param string $row
+ * The row the badge trails.
+ *
+ * @return string
+ * The row, unchanged while the answer has nothing to say about itself.
+ */
+ protected function badged(ThemeInterface $theme, FieldElementsInterface $elements, string $row): string {
+ // The badge trails the answer rather than the label: it says something
+ // about the answer, and a row with no answer yet has nothing to say.
+ if ($this->badge === '') {
+ return $row;
+ }
+
+ return Ansi::alignRight($row, $elements->fieldBadge(' ' . $this->badge . ' '), $theme->contentWidth());
+ }
+
+ /**
+ * The rows this field draws while it is open.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ * @param string $label
+ * The already-styled label.
+ *
+ * @return string
+ * The rows.
+ */
+ protected function openLines(ThemeInterface $theme, FieldElementsInterface $elements, string $label): string {
+ $lines = [];
+ // Measured from the label as it was drawn, not from its source text: it may
+ // carry a help marker and styling, and only its visible width lines the
+ // rows up under it.
+ $indent = str_repeat(' ', Ansi::width($label) + 2);
+
+ foreach ($this->valueRegion($theme, $elements) as $row) {
+ $lines[] = rtrim(($lines === [] ? $label . ' ' : $indent) . $row);
+ }
+
+ foreach ($this->explanation($theme, $elements) as $line) {
+ $lines[] = $indent . $line;
+ }
+
+ // The two share one line and never appear together: a constraint says what
+ // is acceptable, and an error replaces it the instant something is not.
+ if ($this->refusal !== NULL) {
+ $lines[] = $indent . $elements->fieldError($this->refusal);
+ }
+ elseif ($this->constraint !== NULL) {
+ $lines[] = $indent . $elements->fieldConstraint($this->constraint);
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * The rows this field's explanation comes to, as they are drawn.
+ *
+ * The explanation is secondary to the question it explains, so a theme with
+ * no room to spare drops it rather than crowding the answer with it.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ *
+ * @return list
+ * The rows, none when there is nothing to explain or no room to explain it.
+ */
+ protected function explanation(ThemeInterface $theme, FieldElementsInterface $elements): array {
+ if ($this->description === '') {
+ return [];
+ }
+
+ if ($theme instanceof OccupyCapableInterface && $theme->spacing() === Spacing::Compact) {
+ return [];
+ }
+
+ $markup = $this->elements($theme, MarkupElementsInterface::class, 'a description');
+
+ return Prose::lines(Translator::t($this->description), $markup, $elements->fieldDescription(...));
+ }
+
+ /**
+ * What fills the region right of the label while the field is open.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ *
+ * @return list
+ * The rows, at least one.
+ */
+ protected function valueRegion(ThemeInterface $theme, FieldElementsInterface $elements): array {
+ // The label and the selector stay put; only this region changes shape - and
+ // once something is open, its shape is whatever that thing draws.
+ if ($this->editor instanceof FieldInterface) {
+ return explode("\n", $this->editor->view($theme));
+ }
+
+ if ($this->entries === []) {
+ return [$elements->fieldValue($this->readable($theme, $elements))];
+ }
+
+ return array_map(fn(Option $entry): string => $this->entryLine($elements, $entry), $this->entries);
+ }
+
+ /**
+ * One entry as it is drawn.
+ *
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Model\Option $entry
+ * The entry.
+ *
+ * @return string
+ * The drawn entry; empty for a divider, which is a gap and nothing else.
+ */
+ protected function entryLine(FieldElementsInterface $theme, Option $entry): string {
+ if ($entry->kind === OptionKind::Heading) {
+ return $theme->fieldCaption($entry->label);
+ }
+
+ if ($entry->kind === OptionKind::Separator) {
+ return $theme->fieldEntrySeparator();
+ }
+
+ // Marking and naming are two elements: the mark records what was picked
+ // and the text says what it was, so a theme can restyle either alone.
+ $chosen = $this->isChosen($entry);
+ $line = $theme->fieldEntryMarker($chosen, !$this->multiple) . ' ' . $theme->fieldEntry($entry->label, $chosen);
+
+ // Why an entry cannot be picked belongs beside it, or a row that is drawn
+ // and refuses the cursor reads as a fault rather than a decision.
+ return $entry->disabled && $entry->disabledReason !== '' ? $line . ' ' . $theme->fieldEntryNote($entry->disabledReason) : $line;
+ }
+
+ /**
+ * Whether an entry is the one picked.
+ *
+ * @param \DrevOps\Tui\Model\Option $entry
+ * The entry.
+ *
+ * @return bool
+ * TRUE when the answer being drawn holds its value.
+ */
+ protected function isChosen(Option $entry): bool {
+ $shown = $this->mode === Mode::Edit ? $this->draft : $this->value;
+
+ if (!is_array($shown)) {
+ return is_scalar($shown) && (string) $shown === $entry->value;
+ }
+
+ foreach ($shown as $item) {
+ if (is_scalar($item) && (string) $item === $entry->value) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * The answer as it reads.
+ *
+ * What is held is rarely what a reader should be shown: a decision is a word
+ * rather than a flag, a secret is never printed at all, a grade reads as the
+ * scale it was picked on, and several answers read as one run. Every one of
+ * those rules lives here, so the row, and the region an open field hands to
+ * whatever has no rows of its own, can never disagree about one answer.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The same theme, narrowed to the elements a field draws with.
+ *
+ * @return string
+ * The value, or the draft while one is being typed; newlines separate the
+ * lines an answer that carries them reads as.
+ */
+ protected function readable(ThemeInterface $theme, FieldElementsInterface $elements): string {
+ $shown = $this->mode === Mode::Edit ? $this->draft : $this->value;
+
+ // Rows nobody has resolved yet are not an absent answer: the field says the
+ // set is still coming rather than reading as empty until it lands.
+ if ($this->loader instanceof \Closure) {
+ return $elements->fieldLoading();
+ }
+
+ // A secret never prints, and a mask as long as the answer would give its
+ // length away, so the run is a fixed one whatever is behind it.
+ if ($this->fieldType === FieldType::Password) {
+ return is_string($shown) && $shown !== '' ? ValueFormatter::mask($elements->fieldMask()) : '';
+ }
+
+ // A grade reads as its scale whether or not the editor is open, so what a
+ // settled row says and what opening it shows are the same thing.
+ if ($this->fieldType === FieldType::Rating) {
+ return $this->scale($elements, $shown);
+ }
+
+ if (is_bool($shown)) {
+ return $shown ? Translator::t('yes') : Translator::t('no');
+ }
+
+ if (is_array($shown)) {
+ return implode($elements->fieldValueSeparator(), array_map(static fn(mixed $part): string => is_scalar($part) ? (string) $part : '', $shown));
+ }
+
+ // A carriage return would send the cursor back to the start of the row and
+ // overprint what is already there, and it counts toward the width the badge
+ // column is measured against; folding it to the newline the rows split on
+ // keeps both right.
+ return is_scalar($shown) ? str_replace(["\r\n", "\r"], "\n", (string) $shown) : '';
+ }
+
+ /**
+ * The point a scale is sitting on, drawn as the whole scale.
+ *
+ * @param \DrevOps\Tui\Block\Element\FieldElementsInterface $elements
+ * The theme, narrowed to the elements a field draws with.
+ * @param mixed $value
+ * The chosen point.
+ *
+ * @return string
+ * The drawn scale.
+ */
+ protected function scale(FieldElementsInterface $elements, mixed $value): string {
+ // A declared scale carries both its ends, so the fallbacks only catch one
+ // that carries neither: it degrades to a single point rather than taking
+ // the frame it is drawn in down with it.
+ $min = $this->bounds->min ?? 0;
+ $point = is_int($value) || is_float($value) ? (int) $value : $min;
+ $caption = $this->captions[$point] ?? '';
+
+ return $elements->fieldScale($point, $min, $this->bounds->max ?? 0, $caption === '' ? '' : Translator::t($caption));
+ }
+
+}
diff --git a/src/Block/Legend.php b/src/Block/Legend.php
new file mode 100644
index 00000000..24da9efe
--- /dev/null
+++ b/src/Block/Legend.php
@@ -0,0 +1,184 @@
+
+ */
+ protected array $entries = [];
+
+ /**
+ * The bindings the entries are read out of, once it has some.
+ */
+ protected ?ScopedKeyMap $keyMap = NULL;
+
+ /**
+ * What the bound keys do, in the order they are advertised.
+ *
+ * @var list<\DrevOps\Tui\Input\Hint>
+ */
+ protected array $hints = [];
+
+ /**
+ * Advertise a key.
+ *
+ * @param string $key
+ * The key as it is written.
+ * @param string $does
+ * What pressing it does.
+ *
+ * @return $this
+ * The block.
+ */
+ public function entry(string $key, string $does): self {
+ $this->entries[] = ['key' => $key, 'does' => $does];
+
+ return $this;
+ }
+
+ /**
+ * Advertise whatever a set of bindings makes live.
+ *
+ * @param \DrevOps\Tui\Input\ScopedKeyMap $keys
+ * The bindings a key press resolves against.
+ * @param \DrevOps\Tui\Input\Hint ...$hints
+ * What those keys do, each naming the actions whose keys illustrate it.
+ *
+ * @return $this
+ * The block.
+ */
+ public function advertise(ScopedKeyMap $keys, Hint ...$hints): self {
+ $this->clear();
+ $this->keyMap = $keys;
+ $this->hints = array_values($hints);
+
+ return $this;
+ }
+
+ /**
+ * Forget every key advertised so far.
+ *
+ * @return $this
+ * The block.
+ */
+ public function clear(): self {
+ $this->entries = [];
+ $this->keyMap = NULL;
+ $this->hints = [];
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, LegendElementsInterface::class, 'a legend');
+ $separator = ' ' . $elements->legendSeparator() . ' ';
+ $joint = Ansi::width($separator);
+ $parts = [];
+ $width = 0;
+
+ foreach ($this->written($theme) as $entry) {
+ $does = Translator::t('to @action', ['@action' => Translator::t($entry['does'])]);
+ $part = $elements->legendKey($entry['key']) . ' ' . $elements->legendDescription($does);
+ $taken = $parts === [] ? Ansi::width($part) : $width + $joint + Ansi::width($part);
+
+ // A hint cut mid-word reads as a different word, so a legend out of room
+ // drops whole hints from the end - the earliest are the ones a reader
+ // reaches for first - keeping at least one however narrow the frame.
+ if ($parts !== [] && $taken > $theme->contentWidth()) {
+ break;
+ }
+
+ $parts[] = $part;
+ $width = $taken;
+ }
+
+ return implode($separator, $parts);
+ }
+
+ /**
+ * The entries to draw: the ones written by hand, else the bound ones.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme, which is what says how a key is written.
+ *
+ * @return list
+ * The entries.
+ */
+ protected function written(ThemeInterface $theme): array {
+ if (!$this->keyMap instanceof ScopedKeyMap) {
+ return $this->entries;
+ }
+
+ $out = [];
+
+ foreach ($this->hints as $hint) {
+ $glyphs = $this->glyphs($theme, $hint);
+
+ // An action nothing reaches has nothing to advertise, so its fragment is
+ // dropped rather than drawn as a label with no key in front of it.
+ if ($glyphs === '') {
+ continue;
+ }
+
+ $out[] = ['key' => $glyphs, 'does' => $hint->label];
+ }
+
+ return $out;
+ }
+
+ /**
+ * The keys illustrating one fragment, as the theme writes them.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Input\Hint $hint
+ * The fragment.
+ *
+ * @return string
+ * The keys, empty when none of its actions is bound here.
+ */
+ protected function glyphs(ThemeInterface $theme, Hint $hint): string {
+ $glyphs = [];
+
+ foreach ($hint->actions as $action) {
+ $key = $this->keyMap?->primary($action);
+
+ if ($key instanceof Key) {
+ $glyphs[] = $theme->keyGlyph($key);
+ }
+ }
+
+ return implode('/', $glyphs);
+ }
+
+}
diff --git a/src/Block/Markup.php b/src/Block/Markup.php
new file mode 100644
index 00000000..8f602656
--- /dev/null
+++ b/src/Block/Markup.php
@@ -0,0 +1,218 @@
+id;
+ }
+
+ /**
+ * Set the content this block draws.
+ *
+ * @param string $body
+ * The content; newlines separate lines.
+ *
+ * @return static
+ * The block.
+ */
+ public function body(string $body): static {
+ $this->body = $body;
+
+ return $this;
+ }
+
+ /**
+ * The content this block draws.
+ *
+ * @return string
+ * The body, empty when it carries none.
+ */
+ public function bodyText(): string {
+ return $this->body;
+ }
+
+ /**
+ * Set the title above this block's body.
+ *
+ * @param string $title
+ * The title; empty draws the body alone.
+ *
+ * @return static
+ * The block.
+ */
+ public function title(string $title): static {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * The title above this block's body.
+ *
+ * @return string
+ * The title, empty when the body draws alone.
+ */
+ public function titleText(): string {
+ return $this->title;
+ }
+
+ /**
+ * Draw this block inside a border.
+ *
+ * @param bool $bordered
+ * Whether it is boxed.
+ *
+ * @return static
+ * The block.
+ */
+ public function bordered(bool $bordered = TRUE): static {
+ $this->bordered = $bordered;
+
+ return $this;
+ }
+
+ /**
+ * Whether this block is drawn inside a border.
+ *
+ * @return bool
+ * TRUE when it is.
+ */
+ public function isBordered(): bool {
+ return $this->bordered;
+ }
+
+ /**
+ * Lay this block's content out as a grid beneath its body.
+ *
+ * @param list $headers
+ * The header cells; empty draws the grid with no header row.
+ * @param list> $rows
+ * The body rows, each a list of cells.
+ *
+ * @return static
+ * The block.
+ */
+ public function table(array $headers, array $rows): static {
+ $this->table = new TableSpec($headers, $rows);
+
+ return $this;
+ }
+
+ /**
+ * The grid drawn beneath this block's body.
+ *
+ * @return \DrevOps\Tui\Model\TableSpec|null
+ * The grid, or NULL when it carries none.
+ */
+ public function tableSpec(): ?TableSpec {
+ return $this->table;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, MarkupElementsInterface::class, 'markup');
+
+ if ($this->bordered || $this->table instanceof TableSpec) {
+ // An empty body is no body at all here: a card that was handed one blank
+ // line would spend a row on it, where prose simply draws the blank line.
+ $body = $this->body === '' ? [] : $this->lines();
+ $headers = $this->table instanceof TableSpec ? $this->table->headers : [];
+ $rows = $this->table instanceof TableSpec ? $this->table->rows : [];
+
+ // The one card renderer, so a bordered note and a standalone box are
+ // restyled together rather than drifting apart.
+ $pieces = $this->elements($theme, PrimitiveElementsInterface::class, 'a card');
+
+ return implode("\n", $pieces->renderCard(Translator::t($this->title), $body, $headers, $rows, $this->bordered));
+ }
+
+ $lines = [];
+
+ if ($this->title !== '') {
+ $lines[] = $elements->markupTitle(Translator::t($this->title));
+ }
+
+ foreach (Prose::lines(Translator::t($this->body), $elements) as $line) {
+ $lines[] = $line;
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * The body as its physical lines.
+ *
+ * @return list
+ * The lines.
+ */
+ protected function lines(): array {
+ // A Windows-authored body carries CRLF endings, and a surviving carriage
+ // return would send the cursor back to column 0 and overprint the row.
+ return explode("\n", str_replace(["\r\n", "\r"], "\n", $this->body));
+ }
+
+}
diff --git a/src/Block/Mode.php b/src/Block/Mode.php
new file mode 100644
index 00000000..63493c18
--- /dev/null
+++ b/src/Block/Mode.php
@@ -0,0 +1,24 @@
+
+ */
+ protected array $grid = [];
+
+ /**
+ * Construct a panel.
+ *
+ * @param string $id
+ * The id it is addressed by.
+ * @param string $title
+ * The title it carries into the trail.
+ */
+ public function __construct(
+ protected string $id,
+ protected string $title,
+ ) {
+ $this->buttons = new Buttons();
+ }
+
+ /**
+ * The id this panel is addressed by.
+ *
+ * @return string
+ * The id.
+ */
+ public function id(): string {
+ return $this->id;
+ }
+
+ /**
+ * The title this panel carries into the trail.
+ *
+ * @return string
+ * The title.
+ */
+ public function title(): string {
+ return $this->title;
+ }
+
+ /**
+ * Set the standing text under this panel's title.
+ *
+ * @param string $description
+ * The description.
+ *
+ * @return static
+ * The panel.
+ */
+ public function description(string $description): static {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * The standing text under this panel's title.
+ *
+ * @return string
+ * The description, empty when it carries none.
+ */
+ public function descriptionText(): string {
+ return $this->description;
+ }
+
+ /**
+ * Label the way out of this panel.
+ *
+ * @param \DrevOps\Tui\Model\Buttons $buttons
+ * The pair that closes it.
+ *
+ * @return static
+ * The panel.
+ *
+ * @throws \InvalidArgumentException
+ * When the pair is hidden on a panel that draws over what is behind it,
+ * which would strand it with no way out.
+ */
+ public function buttons(Buttons $buttons): static {
+ $this->assertWayOut($this->modal, $buttons);
+ $this->buttons = $buttons;
+
+ return $this;
+ }
+
+ /**
+ * The way out of this panel.
+ *
+ * @return \DrevOps\Tui\Model\Buttons
+ * The pair that closes it.
+ */
+ public function currentButtons(): Buttons {
+ return $this->buttons;
+ }
+
+ /**
+ * Prepare this panel before it is first entered.
+ *
+ * @param \Closure $work
+ * An `fn (): void` doing the preparation, such as one fetch several of the
+ * panel's fields then read.
+ *
+ * @return static
+ * The panel.
+ */
+ public function preload(\Closure $work): static {
+ $this->preload = $work;
+
+ return $this;
+ }
+
+ /**
+ * What prepares this panel before it is first entered.
+ *
+ * @return \Closure|null
+ * The preparation, or NULL when there is none left to do.
+ */
+ public function preparation(): ?\Closure {
+ return $this->preload;
+ }
+
+ /**
+ * Do what was to be done before this panel is first entered.
+ *
+ * @return bool
+ * Whether anything was done. Preparation happens once, so every call after
+ * the first answers FALSE.
+ */
+ public function prepare(): bool {
+ if (!$this->preload instanceof \Closure) {
+ return FALSE;
+ }
+
+ ($this->preload)();
+ $this->preload = NULL;
+
+ return TRUE;
+ }
+
+ /**
+ * Arrange this panel's blocks with a layout.
+ *
+ * @param \DrevOps\Tui\Screen\Layout\LayoutInterface $layout
+ * The layout.
+ *
+ * @return static
+ * The panel.
+ */
+ public function layout(LayoutInterface $layout): static {
+ $this->layout = $layout;
+
+ return $this;
+ }
+
+ /**
+ * The layout arranging this panel's blocks.
+ *
+ * @return \DrevOps\Tui\Screen\Layout\LayoutInterface
+ * The layout.
+ */
+ public function currentLayout(): LayoutInterface {
+ if (!$this->layout instanceof LayoutInterface) {
+ throw new \LogicException(sprintf('Panel "%s" has no layout, so it has no regions to place a block in.', $this->id));
+ }
+
+ return $this->layout;
+ }
+
+ /**
+ * The region of a given name, to place blocks in.
+ *
+ * @param string $name
+ * The region name.
+ *
+ * @return \DrevOps\Tui\Screen\Region
+ * The region.
+ */
+ public function in(string $name): Region {
+ return $this->currentLayout()->in($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enter(): static {
+ $this->entered = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function leave(): static {
+ $this->entered = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isEntered(): bool {
+ return $this->entered;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws \InvalidArgumentException
+ * When its buttons are hidden, which would leave it drawn over everything
+ * with no way out.
+ */
+ public function modal(): static {
+ $this->assertWayOut(TRUE, $this->buttons);
+ $this->modal = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isModal(): bool {
+ return $this->modal;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A nested panel is a row you select rather than somewhere you are, so it
+ * takes no key until you have gone into it - which is what leaves the keys
+ * that move the cursor past it reaching the panel it sits in.
+ */
+ public function binds(Key $key): bool {
+ return $this->entered && $this->boundAction($key) instanceof Action;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * These are the keys you have while you are in a panel rather than inside
+ * anything it holds, which is why moving the cursor, going into a nested
+ * panel and coming back out again all resolve here.
+ */
+ public function hints(): array {
+ // Windows sitting beside each other are moved between in two directions
+ // rather than one, so what the keys do depends on how they are arranged.
+ $move = $this->grid === []
+ ? new Hint('move', Action::MoveUp, Action::MoveDown)
+ : new Hint('move', Action::MoveUp, Action::MoveDown, Action::MoveLeft, Action::MoveRight);
+
+ return [
+ $move,
+ new Hint('select', Action::Activate),
+ new Hint('go back', Action::Back),
+ ];
+ }
+
+ /**
+ * Sit the panels nested in this one side by side.
+ *
+ * @param int ...$rows
+ * One entry per visual row, naming how many nested panels share it, top to
+ * bottom; none leaves them one under another.
+ *
+ * @return static
+ * The panel.
+ */
+ public function grid(int ...$rows): static {
+ $this->grid = array_values($rows);
+
+ // How its rows run is what arranges them, and what arranges the blocks in a
+ // region is the region: saying it there is what lets whatever draws one
+ // read the shape off the region it was handed.
+ if ($this->layout instanceof LayoutInterface) {
+ $this->place()->grid(...$this->grid);
+ }
+
+ return $this;
+ }
+
+ /**
+ * How the panels nested in this one sit side by side.
+ *
+ * @return list
+ * The count of each visual row, empty when they run one under another.
+ */
+ public function gridRows(): array {
+ return $this->grid;
+ }
+
+ /**
+ * The fields this panel holds, in the order they were placed.
+ *
+ * Its own only: a nested panel is somewhere you go rather than something this
+ * one holds, so what it asks belongs to it.
+ *
+ * @return list<\DrevOps\Tui\Block\Field>
+ * The fields.
+ */
+ public function fields(): array {
+ $fields = [];
+
+ foreach ($this->blocks() as $block) {
+ if ($block instanceof Field) {
+ $fields[] = $block;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * The ids of the rows this panel holds, in the order they were placed.
+ *
+ * Every row that carries one, whether it collects an answer or only shows
+ * something: an id that shows is still an id the form knows, which is what
+ * tells a stray answer apart from one meant for a row that takes none.
+ *
+ * @return list
+ * The ids.
+ */
+ public function ids(): array {
+ $ids = [];
+
+ foreach ($this->blocks() as $block) {
+ if ($block instanceof Field || $block instanceof Markup || $block instanceof Progress) {
+ $ids[] = $block->id();
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * The panels you can descend into from this one.
+ *
+ * @return list<\DrevOps\Tui\Block\Panel>
+ * The sub-panels, in the order they were placed.
+ */
+ public function children(): array {
+ $children = [];
+
+ foreach ($this->blocks() as $block) {
+ if ($block instanceof self) {
+ $children[] = $block;
+ }
+ }
+
+ return $children;
+ }
+
+ /**
+ * Everything placed in this panel, region by region, in placement order.
+ *
+ * @return list<\DrevOps\Tui\Block\BlockInterface>
+ * The blocks; empty while the panel has no layout to hold any.
+ */
+ public function blocks(): array {
+ if (!$this->layout instanceof LayoutInterface) {
+ return [];
+ }
+
+ $blocks = [];
+
+ foreach ($this->layout->names() as $name) {
+ foreach ($this->layout->in($name)->blocks() as $block) {
+ $blocks[] = $block;
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A row you select: the way in, what it is called, and enough of what is
+ * behind it to decide whether to go there.
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->guard($theme);
+ $lines = [$this->headline($elements)];
+
+ foreach ($this->guidance($theme, $elements) as $line) {
+ $lines[] = self::INDENT . $line;
+ }
+
+ $summary = $this->summary($theme, $elements);
+
+ if ($summary !== '') {
+ $lines[] = self::INDENT . $elements->panelSummary($summary);
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * This panel drawn as a window into it rather than as a row.
+ *
+ * Where a row says what is behind it in one line, a window shows the rows
+ * themselves - which is what a panel sitting beside its siblings has the
+ * space for, and what makes a grid of them read as several views of the same
+ * form rather than as a list turned sideways.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The window; newlines separate rows.
+ */
+ public function preview(ThemeInterface $theme): string {
+ $elements = $this->guard($theme);
+ $lines = [$this->headline($elements)];
+
+ foreach ($this->guidance($theme, $elements) as $line) {
+ $lines[] = self::INDENT . $line;
+ }
+
+ foreach ($this->blocks() as $block) {
+ if ($block instanceof DependCapableInterface && $block->isHidden()) {
+ continue;
+ }
+
+ $drawn = $block->render($theme);
+
+ if ($drawn === '') {
+ continue;
+ }
+
+ foreach (explode("\n", $drawn) as $line) {
+ $lines[] = $line;
+ }
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * The region this panel's own rows are drawn in.
+ *
+ * @return \DrevOps\Tui\Screen\Region
+ * The region: the one its layout names for a panel's rows, else the first
+ * region it declares.
+ */
+ public function place(): Region {
+ $names = $this->currentLayout()->names();
+
+ return $this->in(in_array(self::ROWS, $names, TRUE) ? self::ROWS : ($names[0] ?? self::ROWS));
+ }
+
+ /**
+ * The theme this panel draws through, refusing to draw an entered one.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return \DrevOps\Tui\Block\Element\PanelElementsInterface
+ * The theme, narrowed to the elements a panel draws with.
+ *
+ * @throws \LogicException
+ * When the panel is the one you are in, which draws nothing of its own.
+ */
+ protected function guard(ThemeInterface $theme): PanelElementsInterface {
+ // Show and Focus are a nested panel's, not an entered one's: once you are
+ // in it, its blocks draw and it draws nothing of its own.
+ if ($this->entered) {
+ throw new \LogicException(sprintf('Panel "%s" is entered, so its blocks draw rather than the panel itself.', $this->id));
+ }
+
+ return $this->elements($theme, PanelElementsInterface::class, 'a panel');
+ }
+
+ /**
+ * The row that names this panel and says it leads somewhere.
+ *
+ * @param \DrevOps\Tui\Block\Element\PanelElementsInterface $elements
+ * The theme, narrowed to the elements a panel draws with.
+ *
+ * @return string
+ * The row.
+ */
+ protected function headline(PanelElementsInterface $elements): string {
+ return $elements->panelSelector($this->isFocused()) . ' ' . $elements->panelTitle(Translator::t($this->title)) . ' ' . $elements->panelDescend();
+ }
+
+ /**
+ * The rows this panel's standing text comes to, as they are drawn.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\PanelElementsInterface $elements
+ * The same theme, narrowed to the elements a panel draws with.
+ *
+ * @return list
+ * The rows, none when it carries no standing text or there is no room for
+ * it: the text is secondary to the rows it introduces, so a theme with
+ * nothing to spare drops it.
+ */
+ protected function guidance(ThemeInterface $theme, PanelElementsInterface $elements): array {
+ if ($this->description === '' || $this->terse($theme)) {
+ return [];
+ }
+
+ $markup = $this->elements($theme, MarkupElementsInterface::class, 'a description');
+
+ return Prose::lines(Translator::t($this->description), $markup, $elements->panelDescription(...));
+ }
+
+ /**
+ * What this panel is holding, as one line of answers.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Element\PanelElementsInterface $elements
+ * The same theme, narrowed to the elements a panel draws with.
+ *
+ * @return string
+ * The answers, empty when it holds none, when none of them is there, or
+ * when the theme has no room to say them.
+ */
+ protected function summary(ThemeInterface $theme, PanelElementsInterface $elements): string {
+ if ($this->terse($theme)) {
+ return '';
+ }
+
+ $answers = [];
+
+ foreach ($this->fields() as $field) {
+ if ($field->type()->isDisplayOnly()) {
+ continue;
+ }
+
+ // A row the answers took off the screen says nothing about the panel,
+ // because it is not there to say it.
+ if ($field->isHidden()) {
+ continue;
+ }
+
+ $answers[] = $this->held($theme, $field);
+
+ // A row summarizes rather than lists, so it stops well before it would
+ // be read as the panel itself.
+ if (count($answers) >= self::SUMMARY_ANSWERS) {
+ break;
+ }
+ }
+
+ return implode(' ' . $elements->panelSummarySeparator() . ' ', $answers);
+ }
+
+ /**
+ * One answer, as the row that stands for a whole panel says it.
+ *
+ * A handful of picks reads as the picks themselves; more than that would be
+ * the panel's whole content spelled out on the line meant to stand for it,
+ * so past a handful the line says how many were picked instead.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Block\Field $field
+ * The field holding the answer.
+ *
+ * @return string
+ * The answer as it reads on the panel's own row.
+ */
+ protected function held(ThemeInterface $theme, Field $field): string {
+ $value = $field->value();
+
+ if (!is_array($value) || count($value) <= self::SUMMARY_ITEMS) {
+ return $field->valueText($theme);
+ }
+
+ return Translator::formatPlural(count($value), '1 item selected', '@count items selected');
+ }
+
+ /**
+ * Whether the theme has no room for anything but the rows themselves.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return bool
+ * TRUE when it says so.
+ */
+ protected function terse(ThemeInterface $theme): bool {
+ return $theme instanceof OccupyCapableInterface && $theme->spacing() === Spacing::Compact;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function keyScope(): Scope {
+ return Scope::navigation();
+ }
+
+ /**
+ * Reject a panel that would draw over everything with no way out.
+ *
+ * @param bool $modal
+ * Whether it draws over what is behind it.
+ * @param \DrevOps\Tui\Model\Buttons $buttons
+ * The pair that closes it.
+ *
+ * @throws \InvalidArgumentException
+ * When such a panel hides its buttons.
+ */
+ protected function assertWayOut(bool $modal, Buttons $buttons): void {
+ if ($modal && !$buttons->show) {
+ throw new \InvalidArgumentException(sprintf('Panel "%s" draws over what is behind it, so its buttons are its only way out and cannot be hidden.', $this->id));
+ }
+ }
+
+}
diff --git a/src/Block/Progress.php b/src/Block/Progress.php
new file mode 100644
index 00000000..91d63e55
--- /dev/null
+++ b/src/Block/Progress.php
@@ -0,0 +1,250 @@
+id;
+ }
+
+ /**
+ * The caption naming the work.
+ *
+ * @return string
+ * The caption.
+ */
+ public function caption(): string {
+ return $this->caption;
+ }
+
+ /**
+ * Say how many steps the work has, which is what earns it a bar.
+ *
+ * @param int $total
+ * The steps.
+ *
+ * @return static
+ * The block.
+ */
+ public function steps(int $total): static {
+ if ($total < 1) {
+ throw new \InvalidArgumentException('Work with no steps cannot report progress; leave the total unset for a spinner.');
+ }
+
+ $this->total = $total;
+
+ return $this;
+ }
+
+ /**
+ * The steps the work has, which is what earns it a bar.
+ *
+ * @return int|null
+ * The steps, or NULL when the length is unknown.
+ */
+ public function total(): ?int {
+ return $this->total;
+ }
+
+ /**
+ * The steps done.
+ *
+ * @return int
+ * The count.
+ */
+ public function current(): int {
+ return $this->current;
+ }
+
+ /**
+ * Say what the work is doing right now.
+ *
+ * @param string $label
+ * The label, shown after the bar or the spinner.
+ *
+ * @return static
+ * The block.
+ */
+ public function label(string $label): static {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ /**
+ * What the work says it is doing.
+ *
+ * @return string
+ * The label, empty when it says nothing.
+ */
+ public function labelText(): string {
+ return $this->label;
+ }
+
+ /**
+ * Set the work this block runs.
+ *
+ * @param \Closure $work
+ * An `fn(\DrevOps\Tui\Primitive\ProgressReporter $reporter): void` doing
+ * the work, calling `advance()` on the reporter once per step.
+ *
+ * @return static
+ * The block.
+ */
+ public function work(\Closure $work): static {
+ $this->work = $work;
+
+ return $this;
+ }
+
+ /**
+ * The work activating this block runs.
+ *
+ * @return \Closure|null
+ * The work, or NULL when activating it does nothing.
+ */
+ public function workload(): ?\Closure {
+ return $this->work;
+ }
+
+ /**
+ * Report progress.
+ *
+ * @param int $steps
+ * The steps done since the last report.
+ * @param string|null $label
+ * What is being done now, or NULL to leave the label as it stands.
+ *
+ * @return static
+ * The block.
+ */
+ public function advance(int $steps = 1, ?string $label = NULL): static {
+ // Work can report a step backwards, and a spinner frame is an index: both
+ // are clamped here so no caller can drive the block into a state it could
+ // not draw.
+ $this->current = max(0, $this->current + $steps);
+ $this->frame = max(0, $this->frame + $steps);
+
+ if ($this->total !== NULL) {
+ $this->current = min($this->current, $this->total);
+ }
+
+ if ($label !== NULL) {
+ $this->label = $label;
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function activate(): bool {
+ if (!$this->work instanceof \Closure) {
+ return FALSE;
+ }
+
+ // The work is handed a reporter rather than the block, so it says only that
+ // one more step is done and never reaches the state behind the indicator.
+ ($this->work)(new ProgressReporter(function (?string $label): void {
+ $this->advance(1, $label);
+ }));
+
+ return TRUE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render(ThemeInterface $theme): string {
+ $elements = $this->elements($theme, ProgressElementsInterface::class, 'progress');
+ $caption = $elements->progressCaption($this->caption);
+ // The caption names the work and stays put; the label is what that work is
+ // doing right now, so it trails the indicator and changes under it.
+ $label = $this->label === '' ? '' : ' ' . $elements->progressCaption($this->label);
+
+ if ($this->total === NULL) {
+ return $elements->progressSpinner($this->frame) . ' ' . $caption . $label;
+ }
+
+ $filled = (int) round($this->current / $this->total * self::TRACK_WIDTH);
+
+ return $caption . ' ' . $elements->progressTrack($filled, self::TRACK_WIDTH) . ' ' . $elements->progressCount($this->current, $this->total) . $label;
+ }
+
+}
diff --git a/src/Block/Prose.php b/src/Block/Prose.php
new file mode 100644
index 00000000..e201bbc9
--- /dev/null
+++ b/src/Block/Prose.php
@@ -0,0 +1,100 @@
+
+ * The drawn lines.
+ */
+ public static function lines(string $source, MarkupElementsInterface $theme, ?\Closure $plain = NULL): array {
+ $plain ??= $theme->markupLine(...);
+ $lines = [];
+
+ foreach (Parser::parse($source, self::markdown($theme)) as $line) {
+ $drawn = $line->bullet ? $plain($theme->markupBullet() . ' ') : '';
+
+ foreach ($line->segments as $segment) {
+ $drawn .= self::span($segment, $theme, $plain);
+ }
+
+ $lines[] = $drawn;
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Draw one span with the element that owns it.
+ *
+ * @param \DrevOps\Tui\Render\MarkupSegment $segment
+ * The span.
+ * @param \DrevOps\Tui\Block\Element\MarkupElementsInterface $theme
+ * The theme.
+ * @param \Closure $plain
+ * What draws a span carrying no markup of its own.
+ *
+ * @return string
+ * The drawn span.
+ */
+ protected static function span(MarkupSegment $segment, MarkupElementsInterface $theme, \Closure $plain): string {
+ return match ($segment->kind) {
+ MarkupKind::Bold => $theme->markupStrong($segment->text),
+ MarkupKind::Emphasis => $theme->markupEmphasis($segment->text),
+ MarkupKind::Code => $theme->markupCode($segment->text),
+ MarkupKind::Link => $theme->markupLink($segment->text, $segment->url),
+ // Every link is already a span of its own, so the styling left to do here
+ // is whatever the surrounding text is drawn in.
+ MarkupKind::Text => $plain($segment->text),
+ };
+ }
+
+ /**
+ * Whether the theme draws the markdown subset or leaves it literal.
+ *
+ * @param \DrevOps\Tui\Block\Element\MarkupElementsInterface $theme
+ * The theme.
+ *
+ * @return bool
+ * TRUE when it draws it.
+ */
+ protected static function markdown(MarkupElementsInterface $theme): bool {
+ return $theme instanceof MarkdownCapableInterface && $theme->hasMarkdown();
+ }
+
+}
diff --git a/src/Block/Tree.php b/src/Block/Tree.php
new file mode 100644
index 00000000..655a1b37
--- /dev/null
+++ b/src/Block/Tree.php
@@ -0,0 +1,80 @@
+
+ * The fields, in declaration order.
+ */
+ public static function fields(Panel $panel): array {
+ $fields = $panel->fields();
+
+ foreach ($panel->children() as $child) {
+ $fields = [...$fields, ...self::fields($child)];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Every panel beneath a panel, the panel itself included.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to walk.
+ *
+ * @return list<\DrevOps\Tui\Block\Panel>
+ * The panels, in declaration order, outermost first.
+ */
+ public static function panels(Panel $panel): array {
+ $panels = [$panel];
+
+ foreach ($panel->children() as $child) {
+ $panels = [...$panels, ...self::panels($child)];
+ }
+
+ return $panels;
+ }
+
+ /**
+ * The ids of every row a panel and the panels beneath it hold.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to walk.
+ *
+ * @return list
+ * The ids, in declaration order.
+ */
+ public static function ids(Panel $panel): array {
+ $ids = $panel->ids();
+
+ foreach ($panel->children() as $child) {
+ $ids = [...$ids, ...self::ids($child)];
+ }
+
+ return $ids;
+ }
+
+}
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index d69b722c..651054c1 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -4,26 +4,31 @@
namespace DrevOps\Tui\Builder;
+use DrevOps\Tui\Block\Field;
+use DrevOps\Tui\Block\Markup;
+use DrevOps\Tui\Block\Progress;
use DrevOps\Tui\Condition\ConditionInterface;
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Discovery\DiscoverInterface;
use DrevOps\Tui\Model\DateBounds;
-use DrevOps\Tui\Model\Field;
use DrevOps\Tui\Model\FieldType;
use DrevOps\Tui\Model\FilePickerConstraints;
use DrevOps\Tui\Model\FilePickerMode;
use DrevOps\Tui\Model\FormException;
use DrevOps\Tui\Model\NumberBounds;
-use DrevOps\Tui\Model\Option;
-use DrevOps\Tui\Model\OptionKind;
-use DrevOps\Tui\Model\RenderMode;
use DrevOps\Tui\Model\SelectionBounds;
-use DrevOps\Tui\Model\TableSpec;
use DrevOps\Tui\Model\Template;
use DrevOps\Tui\Model\Weekday;
/**
- * A fluent builder for a single Field.
+ * A fluent builder for a single block that answers, shows or runs.
+ *
+ * What is declared lands on a block as it is written, and the kind of block is
+ * the kind of answer asked for: a question builds the field that collects one,
+ * a note builds the markup that shows content, and a progress row builds the
+ * block that runs work. A declaration stated in parts - a range, a shape, a set
+ * of limits - is assembled when the declaration is finished, because only a
+ * finished one can be measured for the contradictions it must not hold.
*
* @package DrevOps\Tui\Builder
*/
@@ -40,19 +45,9 @@ final class FieldBuilder {
protected const int RATING_MAX = 5;
/**
- * The help text.
- */
- protected string $description = '';
-
- /**
- * How to answer the question.
- */
- protected string $hint = '';
-
- /**
- * The ghost text shown while the editor's buffer is empty.
+ * The block carrying the declaration.
*/
- protected string $placeholder = '';
+ protected Field|Markup|Progress $block;
/**
* Whether an explicit default was set (otherwise the type default is used).
@@ -64,120 +59,6 @@ final class FieldBuilder {
*/
protected mixed $default = NULL;
- /**
- * Whether a representative default for machine-readable output was set.
- */
- protected bool $hasSchemaDefault = FALSE;
-
- /**
- * The representative default shown in machine-readable output, when set.
- */
- protected mixed $schemaDefault = NULL;
-
- /**
- * The option rows, in display order.
- *
- * @var list<\DrevOps\Tui\Model\Option>
- */
- protected array $options = [];
-
- /**
- * A loader for the options, or NULL for static options.
- */
- protected ?\Closure $optionsLoader = NULL;
-
- /**
- * A query source for the options, or NULL when they do not follow the query.
- */
- protected ?\Closure $optionsSource = NULL;
-
- /**
- * A resolver for the options, or NULL when they do not follow the answers.
- */
- protected ?\Closure $optionsResolver = NULL;
-
- /**
- * The query length below which the query source is not called.
- */
- protected int $queryMinLength = 0;
-
- /**
- * The step count for a progress bar, or NULL for an indeterminate spinner.
- */
- protected ?int $progressSteps = NULL;
-
- /**
- * The work a progress row runs when activated, or NULL for none.
- */
- protected ?\Closure $progressWork = NULL;
-
- /**
- * Whether a value is required.
- */
- protected bool $required = FALSE;
-
- /**
- * The message shown when a required value is missing, empty to derive one.
- */
- protected string $requiredMessage = '';
-
- /**
- * Whether the field collects several values as a list rather than one.
- */
- protected bool $multiple = FALSE;
-
- /**
- * The conditional-visibility rule.
- */
- protected ?ConditionInterface $when = NULL;
-
- /**
- * The derive rule.
- */
- protected ?Derive $derive = NULL;
-
- /**
- * The discovery rule, or a custom detector closure.
- */
- protected DiscoverInterface|\Closure|null $discover = NULL;
-
- /**
- * The declared validator.
- */
- protected ?\Closure $validate = NULL;
-
- /**
- * The declared transformer.
- */
- protected ?\Closure $transform = NULL;
-
- /**
- * The inline ghost-text completion source (a list or a closure).
- *
- * @var list|\Closure
- */
- protected array|\Closure $completion = [];
-
- /**
- * Whether a suggest field previews its leading prefix match as ghost-text.
- */
- protected bool $ghost = FALSE;
-
- /**
- * Whether a password editor offers a reveal/hide toggle.
- */
- protected bool $revealable = FALSE;
-
- /**
- * Whether a password editor prompts for the value twice.
- */
- protected bool $confirm = FALSE;
-
- /**
- * Whether the field may hand off to the user's $EDITOR.
- */
- protected bool $externalEditor = FALSE;
-
/**
* The number field's inclusive minimum, when declared.
*/
@@ -193,13 +74,6 @@ final class FieldBuilder {
*/
protected ?int $step = NULL;
- /**
- * Rating only: the caption of a point on the scale, keyed by the point.
- *
- * @var array
- */
- protected array $captions = [];
-
/**
* Multiple only: the minimum number of selections, when declared.
*/
@@ -215,11 +89,6 @@ final class FieldBuilder {
*/
protected FilePickerMode $pickerMode = FilePickerMode::Any;
- /**
- * File picker only: the start directory and the floor it cannot ascend above.
- */
- protected string $pickerStart = '';
-
/**
* File picker only: the extensions selectable files are limited to.
*
@@ -227,21 +96,11 @@ final class FieldBuilder {
*/
protected array $pickerExtensions = [];
- /**
- * File picker only: whether dot-entries are shown when the browser opens.
- */
- protected bool $pickerShowHidden = FALSE;
-
/**
* File picker only: the maximum selectable file size in bytes, when declared.
*/
protected ?int $pickerMaxSize = NULL;
- /**
- * Choice widgets only: the visible page size, when declared.
- */
- protected ?int $pageSize = NULL;
-
/**
* The date field's inclusive earliest date (ISO `Y-m-d`), when declared.
*/
@@ -257,21 +116,6 @@ final class FieldBuilder {
*/
protected ?Weekday $weekStart = NULL;
- /**
- * Where the field's editor is drawn: inline in the panel, or full-screen.
- */
- protected RenderMode $render = RenderMode::Inline;
-
- /**
- * Note only: whether the card is drawn inside a themed border.
- */
- protected bool $bordered = FALSE;
-
- /**
- * Note only: a presentational table rendered beneath the card, when declared.
- */
- protected ?TableSpec $table = NULL;
-
/**
* Template only: the fixed shape whose slots are filled in, when declared.
*/
@@ -291,18 +135,6 @@ final class FieldBuilder {
*/
protected array $slotValidators = [];
- /**
- * The environment variable answering the field, when it declares its own.
- */
- protected string $envName = '';
-
- /**
- * The further environment variables answering the field, in precedence order.
- *
- * @var list
- */
- protected array $envAliases = [];
-
/**
* Construct a field builder.
*
@@ -311,9 +143,64 @@ final class FieldBuilder {
* @param string $label
* The human-readable label.
* @param \DrevOps\Tui\Model\FieldType $fieldType
- * The widget type.
+ * The field type.
*/
public function __construct(protected string $id, protected string $label, protected FieldType $fieldType) {
+ $this->block = match ($fieldType) {
+ // A note only shows content, and a progress row only runs work: neither
+ // collects, so neither builds the block that does.
+ FieldType::Note => new Markup($id, '', $label),
+ FieldType::Progress => new Progress($id, $label),
+ default => new Field($id, $label, $fieldType),
+ };
+ }
+
+ /**
+ * The block this builder is declaring.
+ *
+ * @return \DrevOps\Tui\Block\Field|\DrevOps\Tui\Block\Markup|\DrevOps\Tui\Block\Progress
+ * The block, whose identity never changes, so it can be placed in a region
+ * as it is declared.
+ */
+ public function block(): Field|Markup|Progress {
+ return $this->block;
+ }
+
+ /**
+ * Finish the declaration, writing what was stated in parts onto the block.
+ *
+ * @throws \DrevOps\Tui\Model\FormException
+ * When the finished declaration contradicts itself.
+ */
+ public function seal(): void {
+ $default = $this->resolveDefault();
+ $bounds = $this->buildBounds();
+ $picker = $this->buildPickerConstraints();
+ $dates = $this->buildDateBounds();
+ $selections = $this->buildSelectionBounds();
+ $template = $this->buildTemplate();
+
+ if (!$this->block instanceof Field) {
+ return;
+ }
+
+ $this->block->default($default)->picker($picker);
+
+ if ($bounds instanceof NumberBounds) {
+ $this->block->bounds($bounds);
+ }
+
+ if ($dates instanceof DateBounds) {
+ $this->block->dates($dates);
+ }
+
+ if ($selections instanceof SelectionBounds) {
+ $this->block->selections($selections);
+ }
+
+ if ($template instanceof Template) {
+ $this->block->pattern($template);
+ }
}
/**
@@ -326,25 +213,27 @@ public function __construct(protected string $id, protected string $label, prote
* The builder.
*/
public function description(string $description): self {
- $this->description = $description;
+ // A note's body is the content its card draws, so the same call fills it.
+ $this->markup()?->body($description);
+ $this->field()?->description($description);
return $this;
}
/**
- * Set the hint: how to answer the question.
+ * Set the help: the long-form text behind the field's help key.
*
- * Sits beneath the description and is styled apart from it, so "what is being
- * asked" and "how to answer it" stay two separate texts.
+ * Where a description has to fit under the row, help opens on its own page,
+ * it can run to paragraphs and carry the detail a row has no space for.
*
- * @param string $hint
- * The hint (e.g. "Use arrows and Space to select").
+ * @param string $help
+ * The help text; blank lines separate paragraphs.
*
* @return $this
* The builder.
*/
- public function hint(string $hint): self {
- $this->hint = $hint;
+ public function help(string $help): self {
+ $this->field()?->help($help);
return $this;
}
@@ -363,7 +252,7 @@ public function hint(string $hint): self {
* The builder.
*/
public function placeholder(string $placeholder): self {
- $this->placeholder = $placeholder;
+ $this->field()?->placeholder($placeholder);
return $this;
}
@@ -401,8 +290,7 @@ public function default(mixed $default): self {
* The builder.
*/
public function schemaDefault(mixed $default): self {
- $this->hasSchemaDefault = TRUE;
- $this->schemaDefault = $default;
+ $this->field()?->schemaDefault($default);
return $this;
}
@@ -422,7 +310,7 @@ public function schemaDefault(mixed $default): self {
* The builder.
*/
public function env(string $name): self {
- $this->envName = $name;
+ $this->field()?->env($name);
return $this;
}
@@ -441,7 +329,7 @@ public function env(string $name): self {
* The builder.
*/
public function envAliases(array $names): self {
- $this->envAliases = array_values($names);
+ $this->field()?->envAliases($names);
return $this;
}
@@ -459,8 +347,7 @@ public function envAliases(array $names): self {
* The builder.
*/
public function required(bool $required = TRUE, string $message = ''): self {
- $this->required = $required;
- $this->requiredMessage = $message;
+ $this->field()?->required($required, $message);
return $this;
}
@@ -485,7 +372,7 @@ public function multiple(bool $multiple = TRUE): self {
throw new FormException(sprintf('Field "%s" of type "%s" does not collect several values; ->multiple() applies to select, search and file picker fields.', $this->id, $this->fieldType->value));
}
- $this->multiple = $multiple;
+ $this->field()?->multiple($multiple);
return $this;
}
@@ -500,7 +387,7 @@ public function multiple(bool $multiple = TRUE): self {
* The builder.
*/
public function revealable(bool $revealable = TRUE): self {
- $this->revealable = $revealable;
+ $this->field()?->revealable($revealable);
return $this;
}
@@ -515,7 +402,7 @@ public function revealable(bool $revealable = TRUE): self {
* The builder.
*/
public function confirmation(bool $confirm = TRUE): self {
- $this->confirm = $confirm;
+ $this->field()?->confirmation($confirm);
return $this;
}
@@ -523,7 +410,7 @@ public function confirmation(bool $confirm = TRUE): self {
/**
* Allow the field to hand off to the user's $EDITOR.
*
- * Honoured by the textarea widget: an available $EDITOR (or $VISUAL) can be
+ * Honoured by the textarea field: an available $EDITOR (or $VISUAL) can be
* launched to compose the value, falling back to inline editing otherwise.
*
* @param bool $enabled
@@ -533,7 +420,7 @@ public function confirmation(bool $confirm = TRUE): self {
* The builder.
*/
public function externalEditor(bool $enabled = TRUE): self {
- $this->externalEditor = $enabled;
+ $this->field()?->externalEditor($enabled);
return $this;
}
@@ -544,7 +431,7 @@ public function externalEditor(bool $enabled = TRUE): self {
* A field is edited inline by default - its editor expands in place on the
* panel when activated, and collapses back on accept or cancel. Declaring it
* standalone opens that same editor full-screen instead: the better fit for a
- * widget that wants the whole viewport, such as a long option list, a month
+ * field that wants the whole viewport, such as a long option list, a month
* calendar or a multi-line textarea.
*
* @param bool $standalone
@@ -554,7 +441,7 @@ public function externalEditor(bool $enabled = TRUE): self {
* The builder.
*/
public function standalone(bool $standalone = TRUE): self {
- $this->render = $standalone ? RenderMode::Standalone : RenderMode::Inline;
+ $this->field()?->standalone($standalone);
return $this;
}
@@ -569,7 +456,7 @@ public function standalone(bool $standalone = TRUE): self {
* The builder.
*/
public function border(bool $bordered = TRUE): self {
- $this->bordered = $bordered;
+ $this->markup()?->bordered($bordered);
return $this;
}
@@ -590,7 +477,7 @@ public function border(bool $bordered = TRUE): self {
* The builder.
*/
public function table(array $headers, array $rows): self {
- $this->table = new TableSpec($headers, $rows);
+ $this->markup()?->table($headers, $rows);
return $this;
}
@@ -656,7 +543,7 @@ public function step(int $step): self {
* The builder.
*/
public function captions(array $captions): self {
- $this->captions = $captions;
+ $this->field()?->captions($captions);
return $this;
}
@@ -705,7 +592,7 @@ public function maxSelections(int $max): self {
* The builder.
*/
public function startIn(string $directory): self {
- $this->pickerStart = $directory;
+ $this->field()?->startIn($directory);
return $this;
}
@@ -762,7 +649,7 @@ public function extensions(array $extensions): self {
* The builder.
*/
public function showHidden(bool $show = TRUE): self {
- $this->pickerShowHidden = $show;
+ $this->field()?->showHidden($show);
return $this;
}
@@ -793,10 +680,10 @@ public function maxSize(int $bytes): self {
}
/**
- * List widgets only: bound the visible option list to a page size.
+ * List fields only: bound the visible option list to a page size.
*
* Longer lists page around the cursor rather than overflowing the viewport.
- * Honoured by the select, suggest, search, reorder and file picker widgets;
+ * Honoured by the select, suggest, search, reorder and file picker fields;
* ignored by other types.
*
* @param int $size
@@ -813,7 +700,7 @@ public function pageSize(int $size): self {
throw new FormException(sprintf('Field "%s" declares a non-positive page size %d.', $this->id, $size));
}
- $this->pageSize = $size;
+ $this->field()?->paginate($size);
return $this;
}
@@ -867,13 +754,13 @@ public function weekStart(Weekday $weekday): self {
* Set the conditional-visibility rule.
*
* @param \DrevOps\Tui\Condition\ConditionInterface $condition
- * The condition gating the field, evaluated by the engine.
+ * The condition gating the field, evaluated as the answers settle.
*
* @return $this
* The builder.
*/
public function when(ConditionInterface $condition): self {
- $this->when = $condition;
+ $this->block->when($condition);
return $this;
}
@@ -882,13 +769,13 @@ public function when(ConditionInterface $condition): self {
* Set the derive rule.
*
* @param \DrevOps\Tui\Derive\Derive $derive
- * The derive rule, evaluated by the engine.
+ * The derive rule, evaluated as the answers settle.
*
* @return $this
* The builder.
*/
public function derive(Derive $derive): self {
- $this->derive = $derive;
+ $this->field()?->derive($derive);
return $this;
}
@@ -898,13 +785,13 @@ public function derive(Derive $derive): self {
*
* @param \DrevOps\Tui\Discovery\DiscoverInterface|\Closure $discover
* The discovery rule - or a custom `fn (Context): mixed` detector -
- * evaluated by the engine in update mode.
+ * evaluated in update mode.
*
* @return $this
* The builder.
*/
public function discover(DiscoverInterface|\Closure $discover): self {
- $this->discover = $discover;
+ $this->field()?->discover($discover);
return $this;
}
@@ -920,7 +807,7 @@ public function discover(DiscoverInterface|\Closure $discover): self {
* The builder.
*/
public function validate(\Closure $validator): self {
- $this->validate = $validator;
+ $this->field()?->validate($validator);
return $this;
}
@@ -936,7 +823,7 @@ public function validate(\Closure $validator): self {
* The builder.
*/
public function transform(\Closure $transformer): self {
- $this->transform = $transformer;
+ $this->field()?->transform($transformer);
return $this;
}
@@ -956,7 +843,7 @@ public function transform(\Closure $transformer): self {
* The builder.
*/
public function complete(array|\Closure $source): self {
- $this->completion = $source;
+ $this->field()?->complete($source);
return $this;
}
@@ -976,7 +863,7 @@ public function complete(array|\Closure $source): self {
* The builder.
*/
public function ghost(bool $ghost = TRUE): self {
- $this->ghost = $ghost;
+ $this->field()?->ghost($ghost);
return $this;
}
@@ -1046,20 +933,7 @@ public function slot(string $name, string $label = '', ?\Closure $validate = NUL
* The builder.
*/
public function option(string $value, string $label = '', string $description = '', bool $disabled = FALSE, string $disabled_reason = ''): self {
- $option = new Option($value, $label === '' ? $value : $label, $description, OptionKind::Option, $disabled, $disabled_reason);
-
- // Re-declaring a value replaces the earlier option in place, so the option
- // set stays unique; separators and headings carry no value and always
- // append.
- foreach ($this->options as $index => $existing) {
- if ($existing->kind === OptionKind::Option && $existing->value === $value) {
- $this->options[$index] = $option;
-
- return $this;
- }
- }
-
- $this->options[] = $option;
+ $this->field()?->entry($value, $label, $description, $disabled, $disabled_reason);
return $this;
}
@@ -1071,7 +945,7 @@ public function option(string $value, string $label = '', string $description =
* The builder.
*/
public function separator(): self {
- $this->options[] = new Option('', '', '', OptionKind::Separator);
+ $this->field()?->separator();
return $this;
}
@@ -1086,7 +960,7 @@ public function separator(): self {
* The builder.
*/
public function heading(string $label): self {
- $this->options[] = new Option('', $label, '', OptionKind::Heading);
+ $this->field()?->heading($label);
return $this;
}
@@ -1126,10 +1000,10 @@ public function options(array|\Closure $options): self {
// Reading the signature here, rather than at every call, keeps the two
// lifecycles apart without a reflection call mid-session.
if ((new \ReflectionFunction($options))->getNumberOfParameters() > 0) {
- $this->optionsResolver = $options;
+ $this->field()?->resolve($options);
}
else {
- $this->optionsLoader = $options;
+ $this->field()?->load($options);
}
return $this;
@@ -1165,7 +1039,7 @@ public function options(array|\Closure $options): self {
* The builder.
*/
public function optionsFrom(\Closure $source): self {
- $this->optionsSource = $source;
+ $this->field()?->query($source);
return $this;
}
@@ -1188,7 +1062,7 @@ public function minQuery(int $length): self {
throw new FormException(sprintf('Field "%s" declares a minimum query length of %d; it must be at least one character (omit minQuery() to query on every keystroke).', $this->id, $length));
}
- $this->queryMinLength = $length;
+ $this->field()?->minQuery($length);
return $this;
}
@@ -1210,7 +1084,7 @@ public function steps(int $steps): self {
throw new FormException(sprintf('Field "%s" declares %d progress steps; a determinate bar needs at least one step (omit steps() for a spinner).', $this->id, $steps));
}
- $this->progressSteps = $steps;
+ $this->progress()?->steps($steps);
return $this;
}
@@ -1227,63 +1101,49 @@ public function steps(int $steps): self {
* The builder.
*/
public function run(\Closure $work): self {
- $this->progressWork = $work;
+ $this->progress()?->work($work);
return $this;
}
/**
- * Build the immutable Field.
- *
- * @return \DrevOps\Tui\Model\Field
- * The field.
- */
- public function build(): Field {
- return new Field(
- $this->id,
- $this->label,
- $this->description,
- $this->fieldType,
- $this->resolveDefault(),
- $this->options,
- $this->required,
- $this->requiredMessage,
- $this->when,
- $this->derive,
- $this->discover,
- $this->validate,
- $this->transform,
- $this->revealable,
- $this->confirm,
- $this->externalEditor,
- $this->buildBounds(),
- $this->buildPickerConstraints(),
- $this->pickerStart,
- $this->pickerShowHidden,
- $this->pageSize,
- $this->completion,
- $this->buildDateBounds(),
- $this->render,
- $this->multiple,
- $this->bordered,
- $this->buildSelectionBounds(),
- $this->optionsLoader,
- $this->progressSteps,
- $this->progressWork,
- schemaDefault: $this->schemaDefault,
- hasSchemaDefault: $this->hasSchemaDefault,
- table: $this->table,
- template: $this->buildTemplate(),
- optionsSource: $this->optionsSource,
- queryMinLength: $this->queryMinLength,
- hint: $this->hint,
- placeholder: $this->placeholder,
- envName: $this->envName,
- envAliases: $this->envAliases,
- ghost: $this->ghost,
- ratingCaptions: $this->captions,
- optionsResolver: $this->optionsResolver,
- );
+ * The block as the field that collects, when it is one.
+ *
+ * @return \DrevOps\Tui\Block\Field|null
+ * The field, or NULL for a block that shows or runs instead.
+ */
+ protected function field(): ?Field {
+ return $this->block instanceof Field ? $this->block : NULL;
+ }
+
+ /**
+ * The block as the markup that shows, when it is one.
+ *
+ * @return \DrevOps\Tui\Block\Markup|null
+ * The markup, or NULL for a block that collects or runs instead.
+ */
+ protected function markup(): ?Markup {
+ return $this->block instanceof Markup ? $this->block : NULL;
+ }
+
+ /**
+ * The block as the work that runs, when it is one.
+ *
+ * @return \DrevOps\Tui\Block\Progress|null
+ * The progress row, or NULL for a block that collects or shows instead.
+ */
+ protected function progress(): ?Progress {
+ return $this->block instanceof Progress ? $this->block : NULL;
+ }
+
+ /**
+ * The rows the field opens onto, as they stand.
+ *
+ * @return list<\DrevOps\Tui\Model\Option>
+ * The rows; empty for a block that opens onto none.
+ */
+ protected function entries(): array {
+ return $this->field()?->entries() ?? [];
}
/**
@@ -1322,7 +1182,7 @@ protected function resolveDefault(): mixed {
// A multiple choice or file picker collects a list, so with nothing
// declared it defaults to no values.
- if ($this->multiple) {
+ if ($this->field()?->isMultiple() === TRUE) {
return [];
}
@@ -1336,8 +1196,10 @@ protected function resolveDefault(): mixed {
// option's value rather than an empty value that would not match either.
// The value is read off the option, not its array key, so a numeric-string
// value like "0" is not coerced to an int.
- if ($this->fieldType === FieldType::Toggle && $this->options !== []) {
- return reset($this->options)->value;
+ $entries = $this->entries();
+
+ if ($this->fieldType === FieldType::Toggle && $entries !== []) {
+ return reset($entries)->value;
}
return $this->defaultFor($this->fieldType);
@@ -1351,13 +1213,7 @@ protected function resolveDefault(): mixed {
* remaining options appended in declared order.
*/
protected function reorderDefault(): array {
- $values = [];
- foreach ($this->options as $option) {
- if ($option->selectable()) {
- $values[] = $option->value;
- }
- }
-
+ $values = $this->field()?->selectableValues() ?? [];
$desired = $this->hasDefault ? Field::stringList($this->default) : [];
return Field::canonicalOrder($values, $desired);
@@ -1435,7 +1291,7 @@ protected function buildSelectionBounds(): ?SelectionBounds {
return NULL;
}
- if (!$this->multiple) {
+ if ($this->field()?->isMultiple() !== TRUE) {
throw new FormException(sprintf('Field "%s" declares selection limits but is not multiple; ->minSelections()/->maxSelections() apply to multiple select, search and file picker fields.', $this->id));
}
@@ -1506,7 +1362,7 @@ protected function parseBoundDate(?string $value): ?\DateTimeImmutable {
}
/**
- * The engine default for a field type when none is declared.
+ * The default a field type settles on when none is declared.
*
* @param \DrevOps\Tui\Model\FieldType $type
* The field type.
diff --git a/src/Builder/Form.php b/src/Builder/Form.php
index fdd69841..f1b76e35 100644
--- a/src/Builder/Form.php
+++ b/src/Builder/Form.php
@@ -4,13 +4,17 @@
namespace DrevOps\Tui\Builder;
+use DrevOps\Tui\Block\Field;
+use DrevOps\Tui\Block\Panel;
+use DrevOps\Tui\Block\Tree;
+use DrevOps\Tui\Condition\ConditionInterface;
use DrevOps\Tui\Model\Buttons;
use DrevOps\Tui\Model\FieldType;
use DrevOps\Tui\Model\Fixup;
-use DrevOps\Tui\Model\FormDefinition;
use DrevOps\Tui\Model\FormException;
use DrevOps\Tui\Model\Option;
-use DrevOps\Tui\Model\Panel;
+use DrevOps\Tui\Model\Template;
+use DrevOps\Tui\Screen\Layout\PanelLayout;
/**
* A fluent builder declaring a form: its panels, fields and own chrome.
@@ -70,6 +74,11 @@ final class Form {
*/
protected array $layout = [];
+ /**
+ * The panel every declared panel hangs from, once the form is finished.
+ */
+ protected ?Panel $root = NULL;
+
/**
* Construct a form builder.
*
@@ -151,7 +160,7 @@ public function envPrefix(string $prefix): self {
* Add a post-settle fix-up rule.
*
* @param \DrevOps\Tui\Model\Fixup $fixup
- * The fix-up, evaluated by the engine.
+ * The fix-up, evaluated once the answers have settled.
*
* @return $this
* The builder.
@@ -204,118 +213,322 @@ public function layout(int ...$rows): self {
}
/**
- * Build the immutable form definition.
+ * The block tree this form declares.
+ *
+ * The panels hang from one root, so the whole declaration is reachable from a
+ * single block - the panel a screen starts in, and the one a headless
+ * collection walks. The tree is the declaration rather than a view of it, so
+ * it is written once and handed back as it stands.
*
- * @return \DrevOps\Tui\Model\FormDefinition
- * The form definition.
+ * @return \DrevOps\Tui\Block\Panel
+ * The root panel, carrying the form's own name.
+ *
+ * @throws \DrevOps\Tui\Model\FormException
+ * When a declaration contradicts itself.
*/
- public function build(): FormDefinition {
+ public function root(): Panel {
+ if ($this->root instanceof Panel) {
+ return $this->root;
+ }
+
LayoutGuard::assert($this->layout, count($this->panels), $this->title);
- $panels = array_map(static fn(PanelBuilder $panel): Panel => $panel->build(), $this->panels);
-
- $form = new FormDefinition(
- $this->title,
- $this->subject,
- $panels,
- $this->fixups,
- $this->envPrefix,
- $this->banner,
- new Buttons($this->buttons, $this->submitLabel, $this->cancelLabel),
- $this->layout,
- );
-
- $this->assertUniqueFieldIds($form);
- $this->assertToggleOptions($form);
- $this->assertReorderOptions($form);
- $this->assertModalPanels($form->panels);
-
- return $form;
+ // The root is the form itself rather than a panel somebody declared, so it
+ // is addressed by the name the form goes by.
+ $root = (new Panel($this->title, $this->title))->layout(new PanelLayout())->grid(...$this->layout);
+ $root->buttons(new Buttons($this->buttons, $this->submitLabel, $this->cancelLabel));
+
+ foreach ($this->panels as $panel) {
+ $panel->seal();
+ $root->in('content')->add($panel->block());
+ }
+
+ $this->assertUniqueFieldIds($root);
+ $this->assertFieldSurfaces($root);
+ $this->assertEntrySources($root);
+ $this->assertToggleEntries($root);
+ $this->assertReorderEntries($root);
+ $this->assertTemplateShapes($root);
+ $this->assertModalPanels($root);
+
+ $this->nestConditionals($root);
+
+ return $this->root = $root;
+ }
+
+ /**
+ * The subject this form configures.
+ *
+ * @return string
+ * The subject, empty when the form names none.
+ */
+ public function currentSubject(): string {
+ return $this->subject;
+ }
+
+ /**
+ * The start banner this form opens on.
+ *
+ * @return string
+ * The banner, empty when it opens straight onto the first frame.
+ */
+ public function currentBanner(): string {
+ return $this->banner;
+ }
+
+ /**
+ * The prefix namespacing this form's per-question env-variable overrides.
+ *
+ * @return string
+ * The prefix, empty when the facade default stands.
+ */
+ public function currentEnvPrefix(): string {
+ return $this->envPrefix;
+ }
+
+ /**
+ * The rules this form applies once its answers have settled.
+ *
+ * @return \DrevOps\Tui\Model\Fixup[]
+ * The rules, in declaration order.
+ */
+ public function currentFixups(): array {
+ return $this->fixups;
}
/**
* Assert that every field id is unique across the panel tree.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The built form definition.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
*/
- protected function assertUniqueFieldIds(FormDefinition $form): void {
+ protected function assertUniqueFieldIds(Panel $root): void {
$seen = [];
- foreach ($form->fields() as $field) {
- if (isset($seen[$field->id])) {
- throw new FormException(sprintf('Duplicate field id "%s".', $field->id));
+ foreach (Tree::ids($root) as $id) {
+ if (isset($seen[$id])) {
+ throw new FormException(sprintf('Duplicate field id "%s".', $id));
+ }
+
+ $seen[$id] = TRUE;
+ }
+ }
+
+ /**
+ * Say how many answers each question waits on before it is asked at all.
+ *
+ * A rule may name a field on any panel, so this is a fact about the whole
+ * form rather than about a panel or a field on its own: it can only be
+ * worked out once every panel has been placed, which is here.
+ *
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
+ */
+ protected function nestConditionals(Panel $root): void {
+ $fields = Tree::fields($root);
+ $by_id = [];
+
+ foreach ($fields as $field) {
+ $by_id[$field->id()] = $field;
+ }
+
+ $resolved = [];
+
+ foreach ($fields as $field) {
+ $field->nest($this->nesting($field, $by_id, $resolved, []));
+ }
+ }
+
+ /**
+ * How deep one field sits: one more than the deepest field it waits on.
+ *
+ * @param \DrevOps\Tui\Block\Field $field
+ * The field to measure.
+ * @param array $by_id
+ * Every field in the form, keyed by id.
+ * @param array $resolved
+ * The depths measured so far, so a field several rules name is walked once.
+ * @param array $walking
+ * The ids on the current walk, keyed by id.
+ *
+ * @return int
+ * The depth.
+ */
+ protected function nesting(Field $field, array $by_id, array &$resolved, array $walking): int {
+ if (array_key_exists($field->id(), $resolved)) {
+ return $resolved[$field->id()];
+ }
+
+ // A rule the field decides for itself names no question, so nothing can be
+ // said about what it waits on and it sits where an unconditional row does.
+ if (!$field->condition() instanceof ConditionInterface) {
+ return $resolved[$field->id()] = 0;
+ }
+
+ // A reference leading back to a field already on the walk closes a cycle.
+ // Such a rule can never hold a stable depth, so the back edge contributes
+ // none and the walk ends rather than deepening forever.
+ if (isset($walking[$field->id()])) {
+ return 0;
+ }
+
+ $walking[$field->id()] = TRUE;
+ $deepest = 0;
+
+ foreach ($field->condition()->fields() as $id) {
+ if (isset($by_id[$id])) {
+ $deepest = max($deepest, $this->nesting($by_id[$id], $by_id, $resolved, $walking));
+ }
+ }
+
+ return $resolved[$field->id()] = $deepest + 1;
+ }
+
+ /**
+ * Assert that nothing is declared on a field that never draws it.
+ *
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
+ */
+ protected function assertFieldSurfaces(Panel $root): void {
+ foreach (Tree::fields($root) as $field) {
+ if ($field->placeholderText() !== '' && !$field->type()->supportsPlaceholder()) {
+ throw new FormException(sprintf('Field "%s" of type "%s" shows no placeholder; only text, number, textarea, password, suggest and search fields have an input buffer to ghost.', $field->id(), $field->type()->value));
}
- $seen[$field->id] = TRUE;
+ if ($field->ratingCaptions() !== [] && $field->type() !== FieldType::Rating) {
+ throw new FormException(sprintf('Field "%s" of type "%s" draws no scale to caption; captions apply to rating fields.', $field->id(), $field->type()->value));
+ }
}
}
/**
- * Assert that every toggle field declares exactly two options.
+ * Assert that every field is offered exactly one set of rows it can hold.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The built form definition.
+ * A field's rows may stand as declared, arrive from a loader, follow the
+ * answers or follow a query, and each of the four replaces the others - so a
+ * field offered two of them has no one set, and a field offered any of them
+ * on a kind that shows no list has nowhere to put them.
+ *
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
*/
- protected function assertToggleOptions(FormDefinition $form): void {
- foreach ($form->fields() as $field) {
- if ($field->type !== FieldType::Toggle) {
+ protected function assertEntrySources(Panel $root): void {
+ foreach (Tree::fields($root) as $field) {
+ $offered = [
+ $field->entries() !== [],
+ $field->loader() instanceof \Closure,
+ $field->resolver() instanceof \Closure,
+ $field->source() instanceof \Closure,
+ ];
+
+ if ($field->source() instanceof \Closure && !$field->type()->supportsQuerySource()) {
+ throw new FormException(sprintf('Field "%s" of type "%s" cannot source its options from a query; only search and suggest fields show one.', $field->id(), $field->type()->value));
+ }
+
+ if (in_array(TRUE, $offered, TRUE) && !$field->type()->supportsOptions()) {
+ throw new FormException(sprintf('Field "%s" of type "%s" shows no options; only select, search, suggest, toggle and reorder fields have a list.', $field->id(), $field->type()->value));
+ }
+
+ if (count(array_filter($offered)) > 1) {
+ throw new FormException(sprintf('Field "%s" is offered more than one set of options; each replaces the others, so declare only one.', $field->id()));
+ }
+
+ if ($field->queryMinLength() > 0 && !$field->source() instanceof \Closure) {
+ throw new FormException(sprintf('Field "%s" declares a minimum query length but no query source to apply it to.', $field->id()));
+ }
+ }
+ }
+
+ /**
+ * Assert that every toggle field declares exactly two entries.
+ *
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
+ */
+ protected function assertToggleEntries(Panel $root): void {
+ foreach (Tree::fields($root) as $field) {
+ if ($field->type() !== FieldType::Toggle) {
continue;
}
// Rows that arrive later cannot be counted here, and the default they
// would be checked against is the one they will settle on.
- if (!$field->hasSettledOptions()) {
+ if (!$this->settled($field)) {
continue;
}
- if (count($field->options) !== 2) {
- throw new FormException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id, count($field->options)));
+ $entries = $field->entries();
+
+ if (count($entries) !== 2) {
+ throw new FormException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id(), count($entries)));
}
+ $default = $field->value();
+
// A dynamic default is a closure resolved at runtime; every literal
// default - whatever its type - must be one of the two option values,
- // otherwise the widget would silently coerce it and select the first.
- if ($field->default instanceof \Closure) {
+ // otherwise the field would silently coerce it and select the first.
+ if ($default instanceof \Closure) {
continue;
}
- $values = array_map(static fn(Option $option): string => $option->value, $field->options);
+ $values = array_map(static fn(Option $entry): string => $entry->value, $entries);
- if (!is_string($field->default) || !in_array($field->default, $values, TRUE)) {
- throw new FormException(sprintf('Toggle field "%s" default must be one of: %s.', $field->id, implode(', ', $values)));
+ if (!is_string($default) || !in_array($default, $values, TRUE)) {
+ throw new FormException(sprintf('Toggle field "%s" default must be one of: %s.', $field->id(), implode(', ', $values)));
}
}
}
/**
- * Assert that every reorder field declares at least two plain options.
+ * Assert that every reorder field declares at least two plain entries.
*
* A ranking arranges a flat list, so headings, separators and disabled rows
* have no place in it, and fewer than two items is nothing to reorder.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The built form definition.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
*/
- protected function assertReorderOptions(FormDefinition $form): void {
- foreach ($form->fields() as $field) {
- if ($field->type !== FieldType::Reorder) {
+ protected function assertReorderEntries(Panel $root): void {
+ foreach (Tree::fields($root) as $field) {
+ if ($field->type() !== FieldType::Reorder) {
continue;
}
// Rows that arrive later are not there to be counted or vetted here.
- if (!$field->hasSettledOptions()) {
+ if (!$this->settled($field)) {
continue;
}
- foreach ($field->options as $option) {
- if (!$option->selectable()) {
- throw new FormException(sprintf('Reorder field "%s" allows only plain options - no headings, separators or disabled rows.', $field->id));
+ $entries = $field->entries();
+
+ foreach ($entries as $entry) {
+ if (!$entry->selectable()) {
+ throw new FormException(sprintf('Reorder field "%s" allows only plain options - no headings, separators or disabled rows.', $field->id()));
}
}
- if (count($field->options) < 2) {
- throw new FormException(sprintf('Reorder field "%s" must have at least two options, %d given.', $field->id, count($field->options)));
+ if (count($entries) < 2) {
+ throw new FormException(sprintf('Reorder field "%s" must have at least two options, %d given.', $field->id(), count($entries)));
+ }
+ }
+ }
+
+ /**
+ * Assert that every template field declares the shape it fills in.
+ *
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
+ */
+ protected function assertTemplateShapes(Panel $root): void {
+ foreach (Tree::fields($root) as $field) {
+ if ($field->type() !== FieldType::Template) {
+ continue;
+ }
+
+ if (!$field->template() instanceof Template) {
+ throw new FormException(sprintf('Field "%s" is a template field but declares no pattern; add ->pattern() with the shape to fill in.', $field->id()));
}
}
}
@@ -327,17 +540,29 @@ protected function assertReorderOptions(FormDefinition $form): void {
* fields and a description, but nesting panels (or another modal) inside it
* has no defined layout.
*
- * @param \DrevOps\Tui\Model\Panel[] $panels
- * The panels to walk, recursively.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The panel every declared panel hangs from.
*/
- protected function assertModalPanels(array $panels): void {
- foreach ($panels as $panel) {
- if ($panel->isModal() && $panel->panels !== []) {
- throw new FormException(sprintf('Modal panel "%s" cannot contain sub-panels.', $panel->id));
+ protected function assertModalPanels(Panel $root): void {
+ foreach (Tree::panels($root) as $panel) {
+ if ($panel->isModal() && $panel->children() !== []) {
+ throw new FormException(sprintf('Modal panel "%s" cannot contain sub-panels.', $panel->id()));
}
-
- $this->assertModalPanels($panel->panels);
}
}
+ /**
+ * Whether a field's entries stand as declared.
+ *
+ * @param \DrevOps\Tui\Block\Field $field
+ * The field.
+ *
+ * @return bool
+ * FALSE while a loader, a resolver or a query source still owes the field
+ * its rows, so there is nothing yet to count or to check a default against.
+ */
+ protected function settled(Field $field): bool {
+ return !$field->loader() instanceof \Closure && !$field->resolver() instanceof \Closure && !$field->source() instanceof \Closure;
+ }
+
}
diff --git a/src/Builder/PanelBuilder.php b/src/Builder/PanelBuilder.php
index 9c3f1710..8daeb5d9 100644
--- a/src/Builder/PanelBuilder.php
+++ b/src/Builder/PanelBuilder.php
@@ -4,23 +4,32 @@
namespace DrevOps\Tui\Builder;
+use DrevOps\Tui\Block\BlockInterface;
+use DrevOps\Tui\Block\Markup;
+use DrevOps\Tui\Block\Panel;
use DrevOps\Tui\Model\Buttons;
-use DrevOps\Tui\Model\Field;
use DrevOps\Tui\Model\FieldType;
-use DrevOps\Tui\Model\Modal;
-use DrevOps\Tui\Model\Panel;
+use DrevOps\Tui\Model\FormException;
+use DrevOps\Tui\Screen\Layout\LayoutManager;
+use DrevOps\Tui\Screen\Layout\PanelLayout;
/**
- * A fluent builder for a Panel and its fields and sub-panels.
+ * A fluent builder for a panel: what it holds, and how it is arranged.
+ *
+ * Every level of the hierarchy has a default, so a three-field panel names no
+ * layout and no region: it takes the one region a panel wants most of the time
+ * and the fields go in it in the order they are written. Naming a layout is
+ * what a panel does only when it needs one, and then a block says which region
+ * it belongs to rather than depending on the order it was declared in.
*
* @package DrevOps\Tui\Builder
*/
final class PanelBuilder {
/**
- * The panel description.
+ * The panel being declared.
*/
- protected string $description = '';
+ protected Panel $panel;
/**
* The field builders, in declaration order.
@@ -37,21 +46,14 @@ final class PanelBuilder {
protected array $panels = [];
/**
- * The modal presentation config, or NULL for an ordinary drill-in panel.
- */
- protected ?Modal $modal = NULL;
-
- /**
- * The sub-panel grid rows, or empty for the row list.
- *
- * @var list
+ * The region blocks are added to until another is named.
*/
- protected array $layout = [];
+ protected string $region;
/**
- * A hook run once before the panel first opens, or NULL for none.
+ * Whether anything has been placed in the panel's layout yet.
*/
- protected ?\Closure $preload = NULL;
+ protected bool $placed = FALSE;
/**
* Construct a panel builder.
@@ -62,6 +64,37 @@ final class PanelBuilder {
* The panel title.
*/
public function __construct(protected string $id, protected string $title) {
+ $this->panel = (new Panel($id, $title))->layout(new PanelLayout());
+ $this->region = $this->firstRegion();
+ }
+
+ /**
+ * The panel this builder is declaring.
+ *
+ * @return \DrevOps\Tui\Block\Panel
+ * The panel, whose identity never changes, so it can be placed in a region
+ * as it is declared.
+ */
+ public function block(): Panel {
+ return $this->panel;
+ }
+
+ /**
+ * Finish the declaration, so everything it holds is finished too.
+ *
+ * @throws \DrevOps\Tui\Model\FormException
+ * When the declared grid does not match the panels it arranges.
+ */
+ public function seal(): void {
+ LayoutGuard::assert($this->panel->gridRows(), count($this->panels), $this->id);
+
+ foreach ($this->fields as $field) {
+ $field->seal();
+ }
+
+ foreach ($this->panels as $panel) {
+ $panel->seal();
+ }
}
/**
@@ -74,7 +107,7 @@ public function __construct(protected string $id, protected string $title) {
* The builder.
*/
public function description(string $description): self {
- $this->description = $description;
+ $this->panel->description($description);
return $this;
}
@@ -95,7 +128,25 @@ public function description(string $description): self {
* The builder.
*/
public function modal(string $submit_label = 'Submit', string $cancel_label = 'Cancel'): self {
- $this->modal = new Modal(new Buttons(TRUE, $submit_label, $cancel_label));
+ $this->panel->buttons(new Buttons(TRUE, $submit_label, $cancel_label))->modal();
+
+ return $this;
+ }
+
+ /**
+ * Add blocks to a named region from here on.
+ *
+ * @param string $name
+ * The region name.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function in(string $name): self {
+ // Reached now rather than when a block arrives, so a name that was never
+ // declared is caught where it was written.
+ $this->panel->in($name);
+ $this->region = $name;
return $this;
}
@@ -361,6 +412,31 @@ public function note(string $id, string $title = ''): FieldBuilder {
return $this->field($id, $title, FieldType::Note, FALSE);
}
+ /**
+ * Add markup: formatted content, and nothing else.
+ *
+ * Chain `->bordered()` to draw it inside a card and `->table()` to lay it out
+ * as a grid; both are presentation choices over the same block. `->when()`
+ * gates it on an earlier answer, which is what lets a warning appear only
+ * when one calls for it.
+ *
+ * @param string $id
+ * The block id.
+ * @param string $body
+ * The content; newlines separate lines.
+ * @param string $title
+ * An optional title above the body.
+ *
+ * @return \DrevOps\Tui\Block\Markup
+ * The markup block.
+ */
+ public function markup(string $id, string $body, string $title = ''): Markup {
+ $markup = new Markup($id, $body, $title);
+ $this->add($markup);
+
+ return $markup;
+ }
+
/**
* Add a progress row that runs work when activated, showing an indicator.
*
@@ -397,30 +473,62 @@ public function panel(string $id, string $title, \Closure $build): self {
$panel = new self($id, $title);
$build($panel);
$this->panels[] = $panel;
+ $this->add($panel->block());
return $this;
}
/**
- * Arrange this panel's sub-panels as a grid of side-by-side columns.
+ * Arrange this panel: by a named layout, or its sub-panels as a grid.
*
- * Each argument declares one visual row and names how many sub-panels sit
- * side by side in it; the sub-panels fill the rows in declaration order.
- * `layout(2)` puts two panels beside each other, `layout(2, 2)` makes four
- * windows, `layout(1, 2)` one full-width panel above two columns. Every
- * level of the panel tree declares its own layout, so a drilled-in panel
- * arranges its children independently.
+ * A name picks the arrangement of the panel's own blocks - a shipped layout,
+ * or any one a consumer registered - and each of its regions then takes the
+ * blocks that name it through
+ * {@see \DrevOps\Tui\Builder\PanelBuilder::in()}.
*
- * @param int ...$rows
- * The sub-panel count of each visual row, top to bottom.
+ * Counts arrange the sub-panels instead: each argument declares one visual
+ * row and names how many sub-panels sit side by side in it, filled in
+ * declaration order. `layout(2)` puts two panels beside each other,
+ * `layout(2, 2)` makes four windows, `layout(1, 2)` one full-width panel
+ * above two columns. Every level of the panel tree declares its own, so a
+ * drilled-in panel arranges its children independently.
+ *
+ * @param int|string ...$rows
+ * The layout name, or the sub-panel count of each visual row, top to
+ * bottom.
*
* @return $this
* The builder.
+ *
+ * @throws \DrevOps\Tui\Model\FormException
+ * When a name is mixed with counts, more than one name is given, or the
+ * panel already holds blocks the named layout has nowhere to put.
*/
- public function layout(int ...$rows): self {
- $this->layout = array_values($rows);
+ public function layout(int|string ...$rows): self {
+ $counts = [];
+ $names = [];
- return $this;
+ foreach ($rows as $row) {
+ if (is_int($row)) {
+ $counts[] = $row;
+
+ continue;
+ }
+
+ $names[] = $row;
+ }
+
+ if ($names === []) {
+ $this->panel->grid(...$counts);
+
+ return $this;
+ }
+
+ if ($counts !== []) {
+ throw new FormException(sprintf('Panel "%s" declares a layout name beside a grid of sub-panels; a panel is arranged one way or the other.', $this->id));
+ }
+
+ return $this->arrange($names);
}
/**
@@ -437,44 +545,87 @@ public function layout(int ...$rows): self {
* The builder.
*/
public function preload(\Closure $work): self {
- $this->preload = $work;
+ $this->panel->preload($work);
+
+ return $this;
+ }
+
+ /**
+ * Add a block to the region in hand.
+ *
+ * A region never knows which kind it was given, so anything drawn goes in the
+ * same way a field does.
+ *
+ * @param \DrevOps\Tui\Block\BlockInterface $block
+ * The block.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function add(BlockInterface $block): self {
+ $this->panel->in($this->region)->add($block);
+ $this->placed = TRUE;
return $this;
}
/**
- * Build the immutable Panel.
+ * Arrange the panel's own blocks with a named layout.
+ *
+ * @param list $names
+ * The declared names.
*
- * @return \DrevOps\Tui\Model\Panel
- * The panel.
+ * @return $this
+ * The builder.
*
* @throws \DrevOps\Tui\Model\FormException
- * When the declared layout does not match the sub-panels.
- */
- public function build(): Panel {
- LayoutGuard::assert($this->layout, count($this->panels), $this->id);
-
- return new Panel(
- $this->id,
- $this->title,
- $this->description,
- array_map(static fn(FieldBuilder $field): Field => $field->build(), $this->fields),
- array_map(static fn(PanelBuilder $panel): Panel => $panel->build(), $this->panels),
- $this->modal,
- $this->layout,
- $this->preload,
- );
+ * When more than one name is given, or the panel already holds blocks the
+ * named layout has nowhere to put.
+ */
+ protected function arrange(array $names): self {
+ if (count($names) > 1) {
+ throw new FormException(sprintf('Panel "%s" declares %d layouts; a panel is arranged by one.', $this->id, count($names)));
+ }
+
+ if ($this->placed) {
+ throw new FormException(sprintf('Panel "%s" declares a layout after placing blocks in the one it had; declare the layout first, so every block knows the regions it may go in.', $this->id));
+ }
+
+ $this->panel->layout(LayoutManager::create($names[0]));
+ $this->region = $this->firstRegion();
+
+ return $this;
+ }
+
+ /**
+ * The region a block goes in when it names none.
+ *
+ * @return string
+ * The first region the panel's layout declares.
+ *
+ * @throws \DrevOps\Tui\Model\FormException
+ * When the layout declares no region, so there is nowhere for a block to
+ * go.
+ */
+ protected function firstRegion(): string {
+ $names = $this->panel->currentLayout()->names();
+
+ if ($names === []) {
+ throw new FormException(sprintf('Panel "%s" is arranged by a layout declaring no region, so it has nowhere to put a block.', $this->id));
+ }
+
+ return $names[0];
}
/**
- * Create, register and return a field builder of a given type.
+ * Create, register and place a field builder of a given type.
*
* @param string $id
* The field id.
* @param string $label
* The label (defaults to the id).
* @param \DrevOps\Tui\Model\FieldType $type
- * The widget type.
+ * The field type.
* @param bool $label_fallback
* Whether an empty label falls back to the id (the default); FALSE keeps an
* empty label empty, for a note whose title is optional.
@@ -485,6 +636,7 @@ public function build(): Panel {
protected function field(string $id, string $label, FieldType $type, bool $label_fallback = TRUE): FieldBuilder {
$field = new FieldBuilder($id, $label === '' && $label_fallback ? $id : $label, $type);
$this->fields[] = $field;
+ $this->add($field->block());
return $field;
}
diff --git a/src/CollectException.php b/src/CollectException.php
new file mode 100644
index 00000000..df006f74
--- /dev/null
+++ b/src/CollectException.php
@@ -0,0 +1,20 @@
+ detected > derived > default. It never knows what any field means:
- * all behaviour comes from the form declaration, with the reusable static
- * validate()/transform() of a consumer class (resolved by field id) as the
- * fallback.
- *
- * @package DrevOps\Tui\Engine
- */
-class Engine {
-
- /**
- * The deriver for computed field values.
- */
- protected Deriver $deriver;
-
- /**
- * What each field's dynamic option set was last resolved from, and to.
- *
- * A settling pass that leaves the resolver's whole input as it was - the
- * answers and the rest of the run context - leaves the options it produced
- * valid, so it is called again only when what it reads has actually changed.
- * The rows are remembered alongside that input because a field's options are
- * settled state anyone may write: a schema surface resolving them against a
- * context of its own retires this memo rather than leaving the engine
- * convinced they are still its own.
- *
- * @var array,run:array{string,bool,string},rows:list<\DrevOps\Tui\Model\Option>}>
- */
- protected array $optionMemo = [];
-
- /**
- * Construct an engine.
- *
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The form definition to run.
- * @param \DrevOps\Tui\Handler\HandlerRegistry $handlers
- * The registry resolving a field id to its handler.
- */
- public function __construct(
- protected FormDefinition $form,
- protected HandlerRegistry $handlers,
- ) {
- $this->deriver = new Deriver();
- }
-
- /**
- * Collect the answers of the active fields.
- *
- * @param array $inputs
- * Pre-supplied values keyed by field id (from flags, env, prompts, ...).
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context (destination directory, update flag).
- *
- * @return \DrevOps\Tui\Answers\Answers
- * The self-describing answer set with values and provenance.
- */
- public function collect(array $inputs, Context $context): Answers {
- $fields = $this->form->fields();
-
- // Headless collection has no panel to resolve option loaders lazily, so
- // resolve them up front - the values need their options to validate.
- $this->loadOptions($fields);
-
- [$values, $sources] = $this->resolveAll($fields, $inputs, $context);
- $values = $this->transformInputs($fields, $values, $sources);
- [$rules, $pinned] = $this->deriveRules($fields, $sources);
- [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources));
- $this->loadQueryOptions($fields, $values, $active);
- $this->guardInputs($fields, $values, $sources, $active);
-
- return Answers::forForm($this->form, $this->activeAnswers($fields, $values, $active), $this->provenanceFor($fields, $sources, $active));
- }
-
- /**
- * Resolve each field's option loader to its options, in place.
- *
- * A loader runs once; the resolved options replace it, so a later pass and
- * the interactive panel both see settled options.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields.
- */
- public function loadOptions(array $fields): void {
- foreach ($fields as $field) {
- if (!$field->optionsLoader instanceof \Closure) {
- continue;
- }
-
- $field->options = Option::resolved(($field->optionsLoader)());
- $field->optionsLoader = NULL;
- }
- }
-
- /**
- * Resolve each active query source against the value supplied to its field.
- *
- * A query source describes a candidate set too large or too remote to hold,
- * so there is nothing to check a headless value against until something is
- * queried - and headlessly nothing is typed. The supplied value is therefore
- * the query, and the field is checked against what that query answers, so a
- * value that no query can produce is caught here rather than passed through
- * unchecked.
- *
- * Only the option-constrained types are worth a call: a suggest field's
- * candidates are hints, never a closed set, so its value is not checked
- * against them in either mode. A field with no value is left alone - there is
- * nothing to look up - and the field's minimum query length does not apply,
- * because it throttles typing, not a single lookup.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields.
- * @param array $values
- * The settled values keyed by field id.
- * @param array $active
- * Which fields are active, keyed by field id.
- */
- protected function loadQueryOptions(array $fields, array $values, array $active): void {
- foreach ($fields as $field) {
- if (!$field->optionsSource instanceof \Closure) {
- continue;
- }
- if (!$field->type->constrainsToOptions()) {
- continue;
- }
- if (!($active[$field->id] ?? FALSE)) {
- continue;
- }
-
- $rows = [];
-
- foreach ($this->queriesFor($field, $values[$field->id] ?? NULL) as $query) {
- try {
- $resolved = Option::resolved(($field->optionsSource)($query, $values));
- }
- catch (\Throwable $throwable) {
- // Interactively a source that cannot answer degrades to a message in
- // the field, but headlessly there is nobody to retype the query, so
- // the collection fails - as an engine error like every other, rather
- // than as whatever the consumer's backend happened to throw.
- throw $this->optionsError($field, $throwable);
- }
-
- foreach ($resolved as $row) {
- $rows[$row->value] = $row;
- }
- }
-
- $field->options = array_values($rows);
- }
- }
-
- /**
- * Resolve every dynamic option set against the answers, in place.
- *
- * A resolver reads the answers, so it is called again whenever they change
- * and skipped when they have not - a settling pass that alters nothing costs
- * nothing. The resolved set then decides the field's value: one that is no
- * longer offered is dropped, a ranking is completed and a toggle falls back,
- * so the answers never name an option that is not on offer. A value the
- * caller supplied is left alone for the input guard to report, rather than
- * disappearing without a word.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields.
- * @param array $values
- * The current values keyed by field id.
- * @param array $active
- * Which fields are active, keyed by field id.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context the resolvers are called with.
- * @param array $supplied
- * Field ids whose value was supplied by the caller.
- *
- * @return array
- * The values, reconciled against the resolved option sets.
- *
- * @throws \DrevOps\Tui\Engine\EngineException
- * When a resolver cannot answer.
- */
- protected function resolveDynamicOptions(array $fields, array $values, array $active, Context $context, array $supplied): array {
- $answers = $this->activeAnswers($fields, $values, $active);
- $resolved = new Context($context->directory, $answers, $context->update, $context->version);
-
- // Everything the resolver is handed, so a second run against another
- // directory - or in update mode - is not answered from the memo of the
- // first one.
- $run = [$context->directory, $context->update, $context->version];
-
- foreach ($fields as $field) {
- if (!$field->optionsResolver instanceof \Closure) {
- continue;
- }
-
- $memo = $this->optionMemo[$field->id] ?? NULL;
- if ($memo !== NULL && $memo['answers'] === $answers && $memo['run'] === $run && $memo['rows'] === $field->options) {
- continue;
- }
-
- try {
- $field->options = Option::resolved(($field->optionsResolver)($resolved));
- }
- catch (\Throwable $throwable) {
- throw $this->optionsError($field, $throwable);
- }
-
- $this->optionMemo[$field->id] = ['answers' => $answers, 'run' => $run, 'rows' => $field->options];
-
- if ($supplied[$field->id] ?? FALSE) {
- continue;
- }
-
- $values[$field->id] = $field->reconcileValue($values[$field->id] ?? NULL);
- }
-
- return $values;
- }
-
- /**
- * The fields whose value the caller supplied, keyed by field id.
- *
- * @param array $sources
- * The initial source per field id.
- *
- * @return array
- * TRUE for each field answered by a supplied input.
- */
- protected function suppliedInputs(array $sources): array {
- return array_map(static fn(Source $source): bool => $source === Source::Input, $sources);
- }
-
- /**
- * The engine error for consumer option code that could not answer.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field whose options were being resolved.
- * @param \Throwable $throwable
- * What the consumer code threw.
- *
- * @return \DrevOps\Tui\Engine\EngineException
- * The engine error naming the field.
- */
- protected function optionsError(Field $field, \Throwable $throwable): EngineException {
- // Not every code is an integer - a database driver's SQLSTATE is a string -
- // and consumer code decides which exception arrives here, so it is coerced
- // rather than allowed to fail the conversion instead of reporting.
- return new EngineException(Translator::t('Could not load options for field "@id": @error', [
- '@id' => $field->id,
- '@error' => $throwable->getMessage(),
- ]), (int) $throwable->getCode(), $throwable);
- }
-
- /**
- * The queries that look up a field's supplied value, one per item.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param mixed $value
- * The settled value.
- *
- * @return list
- * The non-empty queries; empty when there is no value to look up.
- */
- protected function queriesFor(Field $field, mixed $value): array {
- $items = $field->collectsList() ? Field::stringList($value) : [is_scalar($value) ? (string) $value : ''];
-
- return array_values(array_unique(array_filter($items, static fn(string $query): bool => $query !== '')));
- }
-
- /**
- * Resolve and settle every field's value, provenance and activation.
- *
- * The full-map twin of collect(): the same resolution and settling over the
- * whole form, but keeping every value-carrying field - an inactive one
- * retains its settled value and provenance, so a later activation change can
- * surface it without re-resolving - and skipping the input guard, which
- * belongs to the collection boundary. A display-only field (a note or a
- * progress row) carries no answer, so it appears only in the active map,
- * never the values.
- *
- * @param array $inputs
- * Pre-supplied values keyed by field id.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context.
- *
- * @return array{array,array,array}
- * The settled values, the provenance and the active map, keyed by field id;
- * the values and provenance cover every value-carrying field, active or
- * not, while the active map also carries the display-only fields.
- */
- public function resolveState(array $inputs, Context $context): array {
- $fields = $this->form->fields();
-
- [$values, $sources] = $this->resolveAll($fields, $inputs, $context);
- $values = $this->transformInputs($fields, $values, $sources);
- [$rules, $pinned] = $this->deriveRules($fields, $sources);
- [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources));
-
- $all = array_fill_keys(array_keys($sources), TRUE);
-
- return [$values, $this->provenanceFor($fields, $sources, $all), $active];
- }
-
- /**
- * Settle derived values, activation and fix-ups over an edited value set.
- *
- * The stabilization that runs at resolution time, re-runnable over values
- * that changed afterwards: derive rules recompute except where the pinned
- * map holds a field's value, conditions re-evaluate and fix-ups re-apply,
- * all to a fixpoint.
- *
- * @param array $values
- * The current values keyed by field id.
- * @param array $pinned
- * Derive-ruled field ids that must not be recomputed.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context the dynamic option sets resolve against.
- *
- * @return array{array,array}
- * The active map and the settled values.
- */
- public function settle(array $values, array $pinned, Context $context): array {
- $fields = $this->form->fields();
-
- // Nothing here was supplied: an edited value is the live one the user is
- // working with, so a narrowed option set reconciles it rather than
- // reporting it the way a headless input would be reported.
- return $this->stabilize($fields, $values, $this->ruleMap($fields), $pinned, $context, []);
- }
-
- /**
- * Resolve every field's initial value and its source, in field order.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $inputs
- * Pre-supplied values keyed by field id.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context.
- *
- * @return array{array,array}
- * The resolved values and their sources, each keyed by field id.
- */
- protected function resolveAll(array $fields, array $inputs, Context $context): array {
- $values = [];
- $sources = [];
-
- foreach ($fields as $field) {
- // A display-only field carries no answer, so it never enters the value
- // and source maps: it is neither resolved nor allowed to influence a
- // later field's context.
- if ($field->type->isDisplayOnly()) {
- continue;
- }
-
- $resolved = new Context($context->directory, $values, $context->update, $context->version);
- [$value, $source] = $this->resolveInitial($field, $inputs, $resolved);
- $sources[$field->id] = $source;
- $values[$field->id] = $value;
- }
-
- return [$values, $sources];
- }
-
- /**
- * The derive rules and the pinned map of externally-supplied derive targets.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $sources
- * The initial source per field id.
- *
- * @return array{array,array}
- * The derive rules and the pinned map, each keyed by field id.
- */
- protected function deriveRules(array $fields, array $sources): array {
- $rules = $this->ruleMap($fields);
-
- $pinned = [];
-
- foreach (array_keys($rules) as $id) {
- $pinned[$id] = in_array($sources[$id], [Source::Input, Source::Detected], TRUE);
- }
-
- return [$rules, $pinned];
- }
-
- /**
- * The derive rules of the derive-ruled fields, keyed by field id.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- *
- * @return array
- * The derive rules keyed by field id.
- */
- protected function ruleMap(array $fields): array {
- $rules = [];
-
- foreach ($fields as $field) {
- // A display-only field is absent from the value map, so it never
- // carries a derive rule that would resolve against a missing source.
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if ($field->derive !== NULL) {
- $rules[$field->id] = $field->derive;
- }
- }
-
- return $rules;
- }
-
- /**
- * Transform the supplied inputs so every later stage sees normalized values.
- *
- * Normalization happens before stabilization: conditions, derivations and
- * fix-ups must evaluate against the transformed value (e.g. a trimmed
- * string), not the raw input. Only supplied inputs transform: defaults and
- * derived values are the form's own, and discovered values were
- * validated (with a default fallback) at detection time.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $values
- * The resolved values keyed by field id.
- * @param array $sources
- * The initial source per field id.
- *
- * @return array
- * The values, with the supplied inputs transformed.
- */
- protected function transformInputs(array $fields, array $values, array $sources): array {
- foreach ($fields as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if ($sources[$field->id] === Source::Input) {
- $values[$field->id] = $this->transformValue($field, $values[$field->id]);
- }
- }
-
- return $values;
- }
-
- /**
- * Validate the active supplied inputs, throwing on the first error.
- *
- * Only supplied inputs pass through the guard: defaults and derived values
- * are the form's own, and discovered values were validated (with a
- * default fallback) at detection time.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $values
- * The settled values keyed by field id.
- * @param array $sources
- * The initial source per field id.
- * @param array $active
- * The active map.
- *
- * @throws \DrevOps\Tui\Engine\EngineException
- * When a supplied input fails its type, bounds, validator or options.
- */
- protected function guardInputs(array $fields, array $values, array $sources, array $active): void {
- foreach ($fields as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if (!($active[$field->id] ?? FALSE)) {
- continue;
- }
- if ($sources[$field->id] !== Source::Input) {
- continue;
- }
-
- $error = $this->validateValue($field, $values[$field->id]);
- if ($error !== NULL) {
- throw new EngineException(Translator::t('Invalid value for field "@id": @error', [
- '@id' => $field->id,
- '@error' => $error,
- ]));
- }
- }
- }
-
- /**
- * Resolve the initial value and its source for a field.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param array $inputs
- * Pre-supplied values keyed by field id.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context.
- *
- * @return array{mixed,\DrevOps\Tui\Engine\Source}
- * The resolved value and its source.
- */
- protected function resolveInitial(Field $field, array $inputs, Context $context): array {
- if (array_key_exists($field->id, $inputs)) {
- return [$inputs[$field->id], Source::Input];
- }
-
- if ($context->update) {
- $detected = $this->discoverValue($field, $context);
- if ($detected !== NULL && $this->acceptsDetected($field, $detected)) {
- return [$detected, Source::Detected];
- }
- }
-
- if ($field->default instanceof \Closure) {
- return [($field->default)($context), Source::Default];
- }
-
- return [$field->default, Source::Default];
- }
-
- /**
- * Whether a discovered value is safe to adopt for a field.
- *
- * Discovered values come from arbitrary project files, not the declaration,
- * so one that fails the field's type, emptiness, bounds or options falls back
- * to the default instead of poisoning the answers.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param mixed $value
- * The discovered value.
- *
- * @return bool
- * TRUE when the value passes the field's shape and constraints.
- */
- protected function acceptsDetected(Field $field, mixed $value): bool {
- return $field->acceptsValue($value) && $field->requiredViolation($value) === NULL && $field->boundsViolation($value) === NULL && $field->pickerViolation($value) === NULL && $field->templateError($value) === NULL && $field->optionError($value) === NULL;
- }
-
- /**
- * Validate a supplied value: required, type, bounds, validator, then options.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param mixed $value
- * The value to validate.
- *
- * @return string|null
- * An error message, or NULL when the value is valid.
- */
- protected function validateValue(Field $field, mixed $value): ?string {
- // Emptiness is answered first, so a required field says so rather than
- // letting NULL read as a type error or an empty list as a count violation.
- $missing = $field->requiredViolation($value);
- if ($missing !== NULL) {
- return $missing;
- }
-
- if (!$field->acceptsValue($value)) {
- return Translator::t('must be @constraint.', ['@constraint' => $field->valueKind()]);
- }
-
- $violation = $field->boundsViolation($value);
- if ($violation !== NULL) {
- return Translator::t('must be @constraint.', ['@constraint' => $violation]);
- }
-
- $picker = $field->pickerViolation($value);
- if ($picker !== NULL) {
- return Translator::t('must be @constraint.', ['@constraint' => $picker]);
- }
-
- // The shape is checked before the field's own validator, which reads the
- // value as an assembled template and would otherwise see a foreign string.
- $template = $field->templateError($value);
- if ($template !== NULL) {
- return $template;
- }
-
- $validator = $field->validate ?? $this->handlers->validator($field->id);
- $error = $validator instanceof \Closure ? $validator($value) : NULL;
- if (is_string($error) && $error !== '') {
- return $error;
- }
-
- return $field->optionError($value);
- }
-
- /**
- * Transform a value: the declared transformer, else a reusable static one.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param mixed $value
- * The accepted value.
- *
- * @return mixed
- * The transformed value.
- */
- protected function transformValue(Field $field, mixed $value): mixed {
- $transformer = $field->transform ?? $this->handlers->transformer($field->id);
-
- return $transformer instanceof \Closure ? $transformer($value) : $value;
- }
-
- /**
- * Detect a value from the declared discovery rule.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context.
- *
- * @return mixed
- * The detected value, or NULL.
- */
- protected function discoverValue(Field $field, Context $context): mixed {
- if ($field->discover instanceof DiscoverInterface) {
- return $field->discover->discover($context->directory);
- }
-
- if ($field->discover instanceof \Closure) {
- return ($field->discover)($context);
- }
-
- return NULL;
- }
-
- /**
- * Settle derived values, conditional activation and fix-ups to a fixpoint.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $values
- * The resolved values keyed by field id.
- * @param array $derive_rules
- * Derive rules keyed by field id.
- * @param array $pinned
- * Field ids that must not be re-derived (input or detected).
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context the dynamic option sets resolve against.
- * @param array $supplied
- * Field ids whose value was supplied by the caller, keyed by field id.
- *
- * @return array{array,array}
- * The active map and the settled values.
- */
- protected function stabilize(array $fields, array $values, array $derive_rules, array $pinned, Context $context, array $supplied): array {
- $active = [];
- foreach ($fields as $field) {
- $active[$field->id] = TRUE;
- }
-
- // A settled state exits below, so the bound only guards a non-converging
- // cycle: field-count passes cover the longest possible chain, plus two for
- // the activation and fix-up interplay.
- $limit = count($fields) + 2;
- for ($i = 0; $i <= $limit; $i++) {
- // Options resolve first: a set that follows the answers decides what the
- // conditions below then see, and what a value is still allowed to be.
- $values = $this->resolveDynamicOptions($fields, $values, $active, $context, $supplied);
-
- $derived = $this->deriver->derive($derive_rules, $values, $pinned);
-
- $next_active = [];
- $answers = $this->activeAnswers($fields, $derived, $active);
- foreach ($fields as $field) {
- $next_active[$field->id] = $field->when === NULL || $field->when->matches($answers);
- }
-
- $next_values = $this->applyFixups($derived, $this->activeAnswers($fields, $derived, $next_active));
-
- if ($next_active === $active && $next_values === $values) {
- return [$active, $values];
- }
-
- $active = $next_active;
- $values = $next_values;
- }
-
- // @codeCoverageIgnoreStart
- return [$active, $values];
- // @codeCoverageIgnoreEnd
- }
-
- /**
- * Compute the provenance of every active field.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $sources
- * The initial source per field id.
- * @param array $active
- * The active map.
- *
- * @return array
- * The provenance of each active field.
- */
- protected function provenanceFor(array $fields, array $sources, array $active): array {
- $provenance = [];
- foreach ($fields as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if (!($active[$field->id] ?? FALSE)) {
- continue;
- }
-
- $source = $sources[$field->id];
- $provenance[$field->id] = match (TRUE) {
- $source === Source::Detected => Provenance::Detected,
- $field->derive !== NULL && $source === Source::Input => Provenance::Override,
- $field->derive !== NULL => Provenance::Derived,
- $source === Source::Input => Provenance::Edited,
- default => Provenance::Default,
- };
- }
-
- return $provenance;
- }
-
- /**
- * Restrict values to the active fields, in field order.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The fields, in order.
- * @param array $values
- * The resolved values.
- * @param array $active
- * The active map.
- *
- * @return array
- * The answers of the active fields.
- */
- protected function activeAnswers(array $fields, array $values, array $active): array {
- $answers = [];
- foreach ($fields as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
-
- if ($active[$field->id] ?? FALSE) {
- $answers[$field->id] = $values[$field->id] ?? NULL;
- }
- }
-
- return $answers;
- }
-
- /**
- * Apply the form's fix-up rules to the values.
- *
- * A fix-up sets its target field's value when its guard matches (or when it
- * has no guard): a literal `to`, or a copy of the `from` field's value.
- *
- * @param array $values
- * The current values.
- * @param array $answers
- * The active answers the guards evaluate against.
- *
- * @return array
- * The values after fix-ups.
- */
- protected function applyFixups(array $values, array $answers): array {
- foreach ($this->form->fixups as $fixup) {
- if ($fixup->when instanceof ConditionInterface && !$fixup->when->matches($answers)) {
- continue;
- }
-
- // A display-only field carries no value, so a fix-up can neither write
- // to one nor copy from one - reading a note's absent value would write
- // NULL over the target's settled value. A mistargeted rule is ignored.
- if ($this->form->field($fixup->set)?->type->isDisplayOnly()) {
- continue;
- }
- if ($this->form->field($fixup->from)?->type->isDisplayOnly()) {
- continue;
- }
-
- $values[$fixup->set] = $fixup->from !== '' ? ($values[$fixup->from] ?? NULL) : $fixup->to;
- }
-
- return $values;
- }
-
-}
diff --git a/src/Engine/EngineException.php b/src/Engine/EngineException.php
deleted file mode 100644
index d040bbeb..00000000
--- a/src/Engine/EngineException.php
+++ /dev/null
@@ -1,14 +0,0 @@
-complete;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isCancelled(): bool {
+ return $this->cancelled;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function error(): ?string {
+ return $this->error;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value(): mixed {
+ return $this->complete ? $this->accepted : $this->liveValue();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hints(): array {
+ return [new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setKeys(ScopedKeyMap $keys): static {
+ $this->scoped = $keys;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setHandlers(?\Closure $validate = NULL, ?\Closure $transform = NULL): static {
+ $this->validate = $validate;
+ $this->transform = $transform;
+
+ return $this;
+ }
+
+ /**
+ * The in-progress value before acceptance.
+ *
+ * @return mixed
+ * The current, not-yet-accepted value.
+ */
+ abstract protected function liveValue(): mixed;
+
+ /**
+ * The scope whose default bindings apply when none are injected.
+ *
+ * Fields whose bindings differ from the base defaults override this; the
+ * base scope is the right fallback for the rest.
+ *
+ * @return \DrevOps\Tui\Input\Scope
+ * The field's binding scope.
+ */
+ protected function keyScope(): Scope {
+ return Scope::base();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function keys(): ScopedKeyMap {
+ return $this->scoped ??= KeyMapManager::create()->scope($this->keyScope());
+ }
+
+ /**
+ * Cancel the field when the key triggers the cancel action.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key to test.
+ *
+ * @return bool
+ * TRUE when the key cancelled the field.
+ */
+ protected function handleCancel(Key $key): bool {
+ if ($this->keys()->matches($key, Action::Cancel)) {
+ $this->cancelled = TRUE;
+
+ return TRUE;
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * Accept the live value when the key triggers the accept action.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key to test.
+ *
+ * @return bool
+ * TRUE when the key triggered the accept - it is consumed whether or not
+ * the value passed validation.
+ */
+ protected function handleAccept(Key $key): bool {
+ if ($this->keys()->matches($key, Action::Accept)) {
+ $this->accept($this->liveValue());
+
+ return TRUE;
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * The theme, narrowed to the elements a field draws with.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return \DrevOps\Tui\Block\Element\FieldElementsInterface
+ * The theme, able to draw a field.
+ *
+ * @throws \InvalidArgumentException
+ * When the theme does not implement the elements.
+ */
+ protected function elements(ThemeInterface $theme): FieldElementsInterface {
+ if (!$theme instanceof FieldElementsInterface) {
+ throw new \InvalidArgumentException(sprintf('%s cannot draw a field: it does not implement %s.', $theme::class, FieldElementsInterface::class));
+ }
+
+ return $theme;
+ }
+
+ /**
+ * Style one entry's label.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $label
+ * The entry label.
+ * @param bool $current
+ * Whether the entry's row holds the cursor.
+ * @param bool $chosen
+ * Whether the entry is picked.
+ *
+ * @return string
+ * The styled label.
+ */
+ protected function entryLabel(ThemeInterface $theme, string $label, bool $current, bool $chosen = FALSE): string {
+ return $this->elements($theme)->fieldEntry($label, $chosen, $current);
+ }
+
+ /**
+ * Render an exclusive entry row: the mark and the label beside it.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $label
+ * The entry label.
+ * @param bool $current
+ * Whether the entry's row holds the cursor.
+ *
+ * @return string
+ * The rendered row.
+ */
+ protected function renderExclusiveRow(ThemeInterface $theme, string $label, bool $current): string {
+ // Moving the cursor is what picks in an exclusive list, so the mark and the
+ // cursor say the same thing and the row draws only the mark.
+ return $this->elements($theme)->fieldEntryMarker($current, TRUE) . ' ' . $this->entryLabel($theme, $label, $current);
+ }
+
+ /**
+ * The shared fuzzy matcher.
+ *
+ * @return \DrevOps\Tui\Field\Matcher
+ * The matcher.
+ */
+ protected function matcher(): Matcher {
+ return $this->matcher ??= new Matcher();
+ }
+
+ /**
+ * Style an option label, emphasising the query-matched characters.
+ *
+ * The label is split into runs of matched and unmatched characters, each run
+ * styled on its own so no SGR code nests inside another: matched runs get the
+ * match colour, and the rest is drawn as the entry it belongs to. With no
+ * matched positions this is exactly {@see entryLabel()}.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $label
+ * The option label.
+ * @param list $positions
+ * The zero-based indices of the matched characters.
+ * @param bool $current
+ * Whether the option's row holds the cursor.
+ * @param bool $chosen
+ * Whether the option is picked.
+ *
+ * @return string
+ * The styled label.
+ */
+ protected function renderMatchedLabel(ThemeInterface $theme, string $label, array $positions, bool $current, bool $chosen = FALSE): string {
+ if ($positions === []) {
+ return $this->entryLabel($theme, $label, $current, $chosen);
+ }
+
+ $matched = array_fill_keys($positions, TRUE);
+ $out = '';
+ $run = '';
+ $run_matched = FALSE;
+
+ foreach (Strings::split($label) as $index => $char) {
+ $is_matched = isset($matched[$index]);
+
+ if ($run !== '' && $is_matched !== $run_matched) {
+ $out .= $this->styleRun($theme, $run, $run_matched, $current, $chosen);
+ $run = '';
+ }
+
+ $run .= $char;
+ $run_matched = $is_matched;
+ }
+
+ return $out . $this->styleRun($theme, $run, $run_matched, $current, $chosen);
+ }
+
+ /**
+ * Style one run of same-kind characters for {@see renderMatchedLabel()}.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $run
+ * The run of characters.
+ * @param bool $matched
+ * Whether the run's characters matched the query.
+ * @param bool $current
+ * Whether the option's row holds the cursor.
+ * @param bool $chosen
+ * Whether the option is picked.
+ *
+ * @return string
+ * The styled run.
+ */
+ protected function styleRun(ThemeInterface $theme, string $run, bool $matched, bool $current, bool $chosen): string {
+ if ($matched) {
+ return $this->elements($theme)->fieldEntryMatch($run);
+ }
+
+ return $this->entryLabel($theme, $run, $current, $chosen);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The frame every field shares, stacked so that each line sits nearest what
+ * it belongs to: the field's own body, the highlighted option's detail
+ * (choice fields only), then what the field expects of an answer, then why
+ * the last one was refused. The detail leads because it changes as the
+ * highlight moves - a line that follows the cursor belongs against the list
+ * it follows, not below a constraint that never moves. A field renders only
+ * its body via {@see renderBody()} and states its expectation via
+ * {@see renderConstraint()}.
+ */
+ public function view(ThemeInterface $theme): string {
+ $lines = [$this->renderBody($theme)];
+
+ $detail = $this->renderOptionDescription($theme, $this->highlightedDescription());
+ if ($detail !== '') {
+ $lines[] = $detail;
+ }
+
+ $constraint = $this->renderConstraint($theme);
+ if ($constraint !== '') {
+ $lines[] = $constraint;
+ }
+
+ if ($this->error !== NULL) {
+ $lines[] = $this->elements($theme)->fieldError($this->error);
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * What the field expects of an answer, before anything is refused.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The constraint line(s), or an empty string when the field declares no
+ * limits or an error has already replaced them.
+ */
+ protected function renderConstraint(ThemeInterface $theme): string {
+ return '';
+ }
+
+ /**
+ * The field's own rendered body, before the shared description and error.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The rendered body lines.
+ */
+ abstract protected function renderBody(ThemeInterface $theme): string;
+
+ /**
+ * The highlighted option's description; empty for fields without one.
+ *
+ * The choice fields override this (directly or via a capability trait) to
+ * surface the highlighted option's description; every other field inherits
+ * the empty default, so the shared frame adds no description line for it.
+ *
+ * @return string
+ * The description shown beneath the body, or an empty string.
+ */
+ protected function highlightedDescription(): string {
+ return '';
+ }
+
+ /**
+ * The narrowest content width at which an option description is still shown.
+ *
+ * Below this the panel is too narrow to render a readable description, so it
+ * is dropped rather than wrapped into unreadable fragments.
+ */
+ protected const int MIN_DESCRIPTION_WIDTH = 8;
+
+ /**
+ * Render an option description, wrapped to the panel width and dimmed.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $description
+ * The description text.
+ *
+ * @return string
+ * The wrapped, dimmed line(s), or an empty string when there is no
+ * description or the panel is too narrow to show one.
+ */
+ protected function renderOptionDescription(ThemeInterface $theme, string $description): string {
+ // Indented to start where an entry's own text starts, so it reads as
+ // belonging to the entry above it rather than to the list as a whole.
+ $indent = str_repeat(' ', $this->entryTextOffset($theme));
+ $width = $theme->contentWidth() - Strings::length($indent);
+
+ if ($description === '' || $width < self::MIN_DESCRIPTION_WIDTH) {
+ return '';
+ }
+
+ $elements = $this->elements($theme);
+
+ return implode("\n", array_map(static fn(string $line): string => $indent . $elements->fieldEntryDescription($line), Strings::wrap($description, $width)));
+ }
+
+ /**
+ * The column an entry's own text starts at, within the field's view.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return int
+ * The offset; zero for a field whose entries carry no leading glyphs.
+ */
+ protected function entryTextOffset(ThemeInterface $theme): int {
+ return 0;
+ }
+
+ /**
+ * Validate and, when valid, transform a value and complete the field.
+ *
+ * @param mixed $value
+ * The candidate value.
+ *
+ * @return bool
+ * TRUE when the value was accepted; FALSE when validation failed.
+ */
+ protected function accept(mixed $value): bool {
+ $error = $this->validate instanceof \Closure ? ($this->validate)($value) : NULL;
+ if (is_string($error) && $error !== '') {
+ $this->error = $error;
+
+ return FALSE;
+ }
+
+ $this->error = NULL;
+ $this->accepted = $this->transform instanceof \Closure ? ($this->transform)($value) : $value;
+ $this->complete = TRUE;
+
+ return TRUE;
+ }
+
+}
diff --git a/src/Field/Calendar.php b/src/Field/Calendar.php
new file mode 100644
index 00000000..98357d58
--- /dev/null
+++ b/src/Field/Calendar.php
@@ -0,0 +1,271 @@
+bounds = $bounds ?? new DateBounds();
+ $seed = DateBounds::parse($default) ?? new \DateTimeImmutable('today');
+ $this->cursor = $this->bounds->clamp($seed);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Calendar);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ $moved = $this->move($key, $keys);
+ if ($moved instanceof \DateTimeImmutable) {
+ $this->cursor = $this->bounds->clamp($moved);
+ }
+ }
+
+ /**
+ * The date a navigation key moves to before clamping, or NULL for no move.
+ *
+ * Day and week movement resolve through the injected key bindings, so the
+ * arrow keys, the vim preset and any consumer remap all reach them; the month
+ * and month-edge jumps have no action of their own and stay on their keys.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key to interpret.
+ * @param \DrevOps\Tui\Input\ScopedKeyMap $keys
+ * The resolved bindings for this field's scope.
+ *
+ * @return \DateTimeImmutable|null
+ * The unclamped target date, or NULL when the key does not navigate.
+ */
+ protected function move(Key $key, ScopedKeyMap $keys): ?\DateTimeImmutable {
+ return match (TRUE) {
+ $keys->matches($key, Action::MoveLeft) => $this->cursor->modify('-1 day'),
+ $keys->matches($key, Action::MoveRight) => $this->cursor->modify('+1 day'),
+ $keys->matches($key, Action::MoveUp) => $this->cursor->modify('-7 days'),
+ $keys->matches($key, Action::MoveDown) => $this->cursor->modify('+7 days'),
+ $key->is(KeyName::PageUp) => $this->shiftMonths(-1),
+ $key->is(KeyName::PageDown) => $this->shiftMonths(1),
+ $key->is(KeyName::Home) => $this->cursor->modify('first day of this month'),
+ $key->is(KeyName::End) => $this->cursor->modify('last day of this month'),
+ default => NULL,
+ };
+ }
+
+ /**
+ * The cursor moved by whole months, kept on a valid day-of-month.
+ *
+ * Anchoring on the first of the month before shifting avoids the day-of-month
+ * overflow that a naive "+1 month" produces (e.g. Jan 31 becoming Mar 3); the
+ * day is then re-applied, capped to the shorter month's length.
+ *
+ * @param int $months
+ * The signed number of months to move.
+ *
+ * @return \DateTimeImmutable
+ * The shifted date.
+ */
+ protected function shiftMonths(int $months): \DateTimeImmutable {
+ $day = (int) $this->cursor->format('j');
+ $first = $this->cursor->modify('first day of this month')->modify(sprintf('%+d months', $months));
+
+ return $first->setDate((int) $first->format('Y'), (int) $first->format('n'), min($day, (int) $first->format('t')));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Each position is one day, clamped to the declared range.
+ */
+ public function stepBy(int $delta): void {
+ $this->cursor = $this->bounds->clamp($this->cursor->modify(sprintf('%+d days', $delta)));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->cursor->format('Y-m-d');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $rows = array_merge([$this->heading($theme), $this->weekdayRow($theme)], $this->weekRows($theme));
+
+ return implode("\n", $rows);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Month (PgUp/PgDn) and month-edge (Home/End) jumps have no action of their
+ * own, so the footer advertises the binding-driven day/week motion.
+ */
+ #[\Override]
+ public function hints(): array {
+ return [
+ new Hint('move by day', Action::MoveLeft, Action::MoveRight),
+ new Hint('move by week', Action::MoveUp, Action::MoveDown),
+ ...parent::hints(),
+ ];
+ }
+
+ /**
+ * The centered "Month YYYY" heading over the calendar grid.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The themed, centered heading.
+ */
+ protected function heading(ThemeInterface $theme): string {
+ $title = Translator::t($this->cursor->format('F')) . ' ' . $this->cursor->format('Y');
+ $left = max(0, intdiv(self::GRID_WIDTH - Strings::length($title), 2));
+
+ return str_repeat(' ', $left) . $this->elements($theme)->fieldCaption($title);
+ }
+
+ /**
+ * The weekday heading row, ordered from the configured week-start day.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The themed weekday row.
+ */
+ protected function weekdayRow(ThemeInterface $theme): string {
+ $cells = array_map(static fn(Weekday $day): string => sprintf(' %2s ', $day->abbreviation()), $this->bounds->weekStart->sequence());
+
+ return $this->elements($theme)->fieldDescription(implode('', $cells));
+ }
+
+ /**
+ * The calendar grid rows for the visible month.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return list
+ * One string per week row.
+ */
+ protected function weekRows(ThemeInterface $theme): array {
+ $first = $this->cursor->modify('first day of this month');
+ $days = (int) $this->cursor->format('t');
+ $lead = $this->bounds->weekStart->columnOf(Weekday::fromDate($first));
+
+ $cells = array_fill(0, $lead, self::BLANK_CELL);
+ for ($day = 1; $day <= $days; $day++) {
+ $cells[] = $this->dayCell($theme, $first->setDate((int) $first->format('Y'), (int) $first->format('n'), $day), $day);
+ }
+
+ $rows = [];
+ foreach (array_chunk($cells, 7) as $week) {
+ $rows[] = implode('', array_pad($week, 7, self::BLANK_CELL));
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Render one day cell: bracketed at the cursor, dimmed when out of range.
+ *
+ * The cursor cell carries literal brackets so it stays distinguishable even
+ * with colour off, mirroring how the radio glyph marks a selection in ASCII.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DateTimeImmutable $date
+ * The cell's date.
+ * @param int $day
+ * The day-of-month number.
+ *
+ * @return string
+ * The four-column themed cell.
+ */
+ protected function dayCell(ThemeInterface $theme, \DateTimeImmutable $date, int $day): string {
+ if ($date->format('Y-m-d') === $this->cursor->format('Y-m-d')) {
+ return $this->entryLabel($theme, sprintf('[%2d]', $day), TRUE);
+ }
+
+ $cell = sprintf(' %2d ', $day);
+
+ return $this->bounds->contains($date) ? $cell : $this->elements($theme)->fieldEntryNote($cell);
+ }
+
+}
diff --git a/src/Widget/Capability/CompletionCapableInterface.php b/src/Field/Capability/CompletionCapableInterface.php
similarity index 83%
rename from src/Widget/Capability/CompletionCapableInterface.php
rename to src/Field/Capability/CompletionCapableInterface.php
index dd64e1e0..3671c747 100644
--- a/src/Widget/Capability/CompletionCapableInterface.php
+++ b/src/Field/Capability/CompletionCapableInterface.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
/**
- * A widget offering inline ghost-text completion of its buffer.
+ * A field offering inline ghost-text completion of its buffer.
*
* {@see CompletionCapableTrait} carries the default implementation.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
interface CompletionCapableInterface {
diff --git a/src/Widget/Capability/CompletionCapableTrait.php b/src/Field/Capability/CompletionCapableTrait.php
similarity index 92%
rename from src/Widget/Capability/CompletionCapableTrait.php
rename to src/Field/Capability/CompletionCapableTrait.php
index 94c74dd5..0067e324 100644
--- a/src/Widget/Capability/CompletionCapableTrait.php
+++ b/src/Field/Capability/CompletionCapableTrait.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
use DrevOps\Tui\Utils\Strings;
@@ -12,17 +12,17 @@
* Composes with {@see TextEditCapableTrait}: the buffer is completed to the
* first candidate it is a case-insensitive prefix of. Which candidates are
* offered, when they are offered at all, and how the accepted one lands in the
- * buffer are each overridable, so a widget whose buffer is not a plain caret
+ * buffer are each overridable, so a field whose buffer is not a plain caret
* line reuses the matching rule without inheriting the caret arithmetic.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
trait CompletionCapableTrait {
/**
* The best completion candidate for the current buffer, if any.
*
- * A candidate qualifies only when the buffer is non-empty, the widget is in a
+ * A candidate qualifies only when the buffer is non-empty, the field is in a
* state that offers a completion, and the buffer is a case-insensitive prefix
* of a strictly longer candidate; the first such candidate wins. Returns NULL
* when nothing completes, so the field behaves as a plain text input.
@@ -50,7 +50,7 @@ public function bestMatch(): ?string {
}
/**
- * Whether the widget's current state offers a completion at all.
+ * Whether the field's current state offers a completion at all.
*
* @return bool
* TRUE when the caret sits at the end of the buffer, so the ghost text
diff --git a/src/Field/Capability/ExternalEditCapableInterface.php b/src/Field/Capability/ExternalEditCapableInterface.php
new file mode 100644
index 00000000..2ae57d90
--- /dev/null
+++ b/src/Field/Capability/ExternalEditCapableInterface.php
@@ -0,0 +1,33 @@
+
* The visible rows.
diff --git a/src/Widget/Capability/OptionsCapableTrait.php b/src/Field/Capability/OptionsCapableTrait.php
similarity index 91%
rename from src/Widget/Capability/OptionsCapableTrait.php
rename to src/Field/Capability/OptionsCapableTrait.php
index e5841b06..84aca254 100644
--- a/src/Widget/Capability/OptionsCapableTrait.php
+++ b/src/Field/Capability/OptionsCapableTrait.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
use DrevOps\Tui\Model\Option;
use DrevOps\Tui\Model\OptionKind;
@@ -10,14 +10,14 @@
use DrevOps\Tui\Theme\ThemeInterface;
/**
- * Shared option-list behaviour for the choice widgets.
+ * Shared option-list behaviour for the choice fields.
*
* Holds the ordered option rows and centralizes the two things every choice
- * widget must agree on: the cursor only ever rests on a selectable row (so
+ * field must agree on: the cursor only ever rests on a selectable row (so
* separators, headings and disabled options are skipped), and those
* non-selectable rows render as visual-only structure.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
trait OptionsCapableTrait {
@@ -106,7 +106,7 @@ protected function stepCursor(array $rows, int $from, int $dir): int {
}
/**
- * The rows the widget currently shows.
+ * The rows the field currently shows.
*
* @return list<\DrevOps\Tui\Model\Option>
* The visible rows.
@@ -129,7 +129,7 @@ abstract public function visible(): array;
abstract public function renderOptionRow(ThemeInterface $theme, Option $option, bool $current): string;
/**
- * Render the visible rows as the widget's paged option-list body.
+ * Render the visible rows as the field's paged option-list body.
*
* @param \DrevOps\Tui\Theme\ThemeInterface $theme
* The theme.
@@ -159,14 +159,14 @@ protected function highlightedDescription(): string {
/**
* Render the visible option rows, dispatching structure rows centrally.
*
- * Headings and separators render identically in every choice widget; the
+ * Headings and separators render identically in every choice field; the
* closure renders an option row (including its disabled state), receiving
* the option and its absolute index within the rows.
*
* @param \DrevOps\Tui\Theme\ThemeInterface $theme
* The theme.
* @param list<\DrevOps\Tui\Model\Option> $rows
- * The rows the widget currently shows.
+ * The rows the field currently shows.
* @param \DrevOps\Tui\Render\Viewport $viewport
* The paging window over the rows.
* @param \Closure $render
@@ -209,7 +209,7 @@ protected function renderListRows(ThemeInterface $theme, array $rows, Viewport $
* The rendered row.
*/
protected function renderHeadingRow(ThemeInterface $theme, Option $option): string {
- return $theme->heading($option->label);
+ return $this->elements($theme)->fieldCaption($option->label);
}
/**
@@ -222,7 +222,7 @@ protected function renderHeadingRow(ThemeInterface $theme, Option $option): stri
* The rendered row.
*/
protected function renderSeparatorRow(ThemeInterface $theme): string {
- return $theme->divider();
+ return $this->elements($theme)->fieldEntrySeparator();
}
/**
@@ -243,7 +243,7 @@ protected function renderDisabledLabel(ThemeInterface $theme, Option $option): s
$text .= ' (' . $option->disabledReason . ')';
}
- return $theme->disabled($text);
+ return $this->elements($theme)->fieldEntryNote($text);
}
}
diff --git a/src/Field/Capability/PagingCapableInterface.php b/src/Field/Capability/PagingCapableInterface.php
new file mode 100644
index 00000000..9c2c4733
--- /dev/null
+++ b/src/Field/Capability/PagingCapableInterface.php
@@ -0,0 +1,24 @@
+pageSize;
+ }
+
+ /**
+ * Resolve the effective page size, rejecting a non-positive declared value.
+ *
+ * The builder rejects a non-positive page size, but a field may be
+ * constructed directly, so the invariant is enforced here too.
+ *
+ * @param int|null $page_size
+ * The declared page size, or NULL to use the default.
+ *
+ * @return int
+ * The effective page size.
+ *
+ * @throws \InvalidArgumentException
+ * When a declared page size is not positive.
+ */
+ protected function resolvePageSize(?int $page_size): int {
+ if ($page_size !== NULL && $page_size < 1) {
+ throw new \InvalidArgumentException(Translator::t('Page size must be a positive integer, @size given.', [
+ '@size' => $page_size,
+ ]));
+ }
+
+ return $page_size ?? self::DEFAULT_PAGE_SIZE;
+ }
+
+ /**
+ * Compute the cursor-visible paging window, storing its offset.
+ *
+ * @param int $total
+ * The total number of option rows.
+ * @param int $cursor
+ * The cursor row index (a negative cursor pins the window to the top).
+ *
+ * @return \DrevOps\Tui\Render\Viewport
+ * The window: its offset and whether rows are scrolled off above or below.
+ */
+ protected function pageViewport(int $total, int $cursor): Viewport {
+ $viewport = (new Scroller())->follow($total, $this->pageSize, max(0, $cursor), $this->offset);
+ $this->offset = $viewport->offset;
+
+ return $viewport;
+ }
+
+ /**
+ * Wrap rendered rows with the scroll indicators for a paging window.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param list $rows
+ * The rendered visible rows.
+ * @param \DrevOps\Tui\Render\Viewport $viewport
+ * The paging window.
+ *
+ * @return list
+ * The rows, with an indicator line for each scrolled-off side.
+ */
+ protected function wrapScrolled(ThemeInterface $theme, array $rows, Viewport $viewport): array {
+ $lines = [];
+
+ if ($viewport->hasAbove) {
+ $lines[] = ' ' . $this->overflow($theme)->chromeOverflowMarker(TRUE);
+ }
+
+ $lines = array_merge($lines, $rows);
+
+ if ($viewport->hasBelow) {
+ $lines[] = ' ' . $this->overflow($theme)->chromeOverflowMarker(FALSE);
+ }
+
+ return $lines;
+ }
+
+ /**
+ * The theme, narrowed to the mark that says content ran past an edge.
+ *
+ * The chrome's mark rather than one of the field's own: a list that outran
+ * its page and a region that outran the frame are the same fact, and a reader
+ * who learns the mark once should not have to learn it twice.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return \DrevOps\Tui\Block\Element\ChromeElementsInterface
+ * The theme, able to draw the mark.
+ *
+ * @throws \InvalidArgumentException
+ * When the theme does not implement the elements.
+ */
+ protected function overflow(ThemeInterface $theme): ChromeElementsInterface {
+ if (!$theme instanceof ChromeElementsInterface) {
+ throw new \InvalidArgumentException(sprintf('%s cannot draw an overflow mark: it does not implement %s.', $theme::class, ChromeElementsInterface::class));
+ }
+
+ return $theme;
+ }
+
+}
diff --git a/src/Widget/Capability/PlaceholderCapableInterface.php b/src/Field/Capability/PlaceholderCapableInterface.php
similarity index 79%
rename from src/Widget/Capability/PlaceholderCapableInterface.php
rename to src/Field/Capability/PlaceholderCapableInterface.php
index 1670d8f5..85b47606 100644
--- a/src/Widget/Capability/PlaceholderCapableInterface.php
+++ b/src/Field/Capability/PlaceholderCapableInterface.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
/**
* Ghost text shown inside an editor while its input is empty.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
interface PlaceholderCapableInterface {
@@ -18,7 +18,7 @@ interface PlaceholderCapableInterface {
* The placeholder text; empty shows none.
*
* @return static
- * The widget.
+ * The field.
*/
public function setPlaceholder(string $placeholder): static;
diff --git a/src/Widget/Capability/PlaceholderCapableTrait.php b/src/Field/Capability/PlaceholderCapableTrait.php
similarity index 91%
rename from src/Widget/Capability/PlaceholderCapableTrait.php
rename to src/Field/Capability/PlaceholderCapableTrait.php
index 91c48ebe..ebbd0ffc 100644
--- a/src/Widget/Capability/PlaceholderCapableTrait.php
+++ b/src/Field/Capability/PlaceholderCapableTrait.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
use DrevOps\Tui\Theme\ThemeInterface;
use DrevOps\Tui\Translation\Translator;
@@ -14,7 +14,7 @@
* never apply at once: a completion needs something typed, a placeholder needs
* nothing typed.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
trait PlaceholderCapableTrait {
@@ -65,7 +65,7 @@ protected function placeholderText(string $current): string {
protected function placeholderGhost(ThemeInterface $theme, string $current): string {
$text = $this->placeholderText($current);
- return $text === '' ? '' : $theme->ghost($text);
+ return $text === '' ? '' : $this->elements($theme)->fieldGhost($text);
}
}
diff --git a/src/Widget/Capability/QueryOptionsCapableInterface.php b/src/Field/Capability/QueryOptionsCapableInterface.php
similarity index 85%
rename from src/Widget/Capability/QueryOptionsCapableInterface.php
rename to src/Field/Capability/QueryOptionsCapableInterface.php
index e2a82391..9eb8ed3e 100644
--- a/src/Widget/Capability/QueryOptionsCapableInterface.php
+++ b/src/Field/Capability/QueryOptionsCapableInterface.php
@@ -2,22 +2,22 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
/**
- * A widget whose candidate list is resolved from its live query.
+ * A field whose candidate list is resolved from its live query.
*
- * The widget owns the query, the cache and the displayed state; it never
+ * The field owns the query, the cache and the displayed state; it never
* resolves anything itself, because a resolution blocks and only the panel loop
* may block and repaint. The loop asks {@see pendingQuery()} what still needs
* answering, calls the field's source, and hands the result back.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
interface QueryOptionsCapableInterface {
/**
- * Turn the widget's candidates over to a query source.
+ * Turn the field's candidates over to a query source.
*
* @param int $min_length
* The query length below which the source is not called, so a backend is
@@ -26,7 +26,7 @@ interface QueryOptionsCapableInterface {
public function driveByQuery(int $min_length = 0): void;
/**
- * Whether the widget's candidates come from a query source at all.
+ * Whether the field's candidates come from a query source at all.
*
* @return bool
* TRUE when a source is driving the list.
diff --git a/src/Widget/Capability/QueryOptionsCapableTrait.php b/src/Field/Capability/QueryOptionsCapableTrait.php
similarity index 85%
rename from src/Widget/Capability/QueryOptionsCapableTrait.php
rename to src/Field/Capability/QueryOptionsCapableTrait.php
index 36998c0a..2ac53408 100644
--- a/src/Widget/Capability/QueryOptionsCapableTrait.php
+++ b/src/Field/Capability/QueryOptionsCapableTrait.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget\Capability;
+namespace DrevOps\Tui\Field\Capability;
use DrevOps\Tui\Theme\ThemeInterface;
use DrevOps\Tui\Translation\Translator;
@@ -19,7 +19,7 @@
* The resolution itself belongs to the panel loop, which is the only place that
* may block and repaint.
*
- * @package DrevOps\Tui\Widget\Capability
+ * @package DrevOps\Tui\Field\Capability
*/
trait QueryOptionsCapableTrait {
@@ -64,7 +64,7 @@ trait QueryOptionsCapableTrait {
protected array $queryCache = [];
/**
- * Turn the widget's rows over to a query source.
+ * Turn the field's rows over to a query source.
*
* @param int $min_length
* The query length below which the source is not called.
@@ -154,7 +154,7 @@ protected function settle(string $query, array $rows): void {
}
/**
- * Take on a resolved query's rows as the widget's own candidates.
+ * Take on a resolved query's rows as the field's own candidates.
*
* @param list<\DrevOps\Tui\Model\Option> $rows
* The rows.
@@ -176,18 +176,22 @@ protected function queryStateLine(ThemeInterface $theme): ?string {
return NULL;
}
+ $elements = $this->elements($theme);
+
if ($this->queryLoading) {
- // The same indicator a lazily loaded field shows in its panel row, so
- // waiting reads the same wherever it happens.
- return $theme->renderLoading('');
+ // The same mark a lazily loaded field shows in its panel row, so waiting
+ // reads the same wherever it happens.
+ return $elements->fieldLoading();
}
if ($this->queryError !== '') {
- return $theme->error($this->queryError);
+ return $elements->fieldError($this->queryError);
}
if (Strings::length($this->query()) < $this->queryMinLength) {
- return $theme->description(Translator::formatPlural($this->queryMinLength, 'Type 1 character to search.', 'Type @count characters to search.'));
+ // The guidance voice, not the description's: this line shares its row
+ // with the error above, and states what the field expects.
+ return $elements->fieldConstraint(Translator::formatPlural($this->queryMinLength, 'Type 1 character to search.', 'Type @count characters to search.'));
}
return NULL;
diff --git a/src/Field/Capability/RevealCapableInterface.php b/src/Field/Capability/RevealCapableInterface.php
new file mode 100644
index 00000000..8a88ef54
--- /dev/null
+++ b/src/Field/Capability/RevealCapableInterface.php
@@ -0,0 +1,22 @@
+filter . $theme->caret();
+ $elements = $this->elements($theme);
+
+ return $elements->fieldDraft($this->filter) . $elements->fieldCaret();
}
}
diff --git a/src/Field/Capability/SelectionBoundedTrait.php b/src/Field/Capability/SelectionBoundedTrait.php
new file mode 100644
index 00000000..07d01cd0
--- /dev/null
+++ b/src/Field/Capability/SelectionBoundedTrait.php
@@ -0,0 +1,98 @@
+selectionBoundsError($value);
+ if ($error !== NULL) {
+ $this->error = $error;
+
+ return FALSE;
+ }
+
+ return parent::accept($value);
+ }
+
+ /**
+ * The inline error for a selection count outside the declared range, if any.
+ *
+ * The wording lives here once so a field that layers its own accept checks
+ * on top (the file picker's type/size limits) can reuse the count check
+ * without restating it.
+ *
+ * @param mixed $value
+ * The candidate value.
+ *
+ * @return string|null
+ * The error message when the count is out of range, else NULL.
+ */
+ protected function selectionBoundsError(mixed $value): ?string {
+ $violation = $this->selectionBounds?->violation($value);
+
+ return $violation === NULL ? NULL : Translator::t('Select @constraint.', ['@constraint' => $violation]);
+ }
+
+ /**
+ * The themed selection-count hint line, or an empty string when not shown.
+ *
+ * Reuses the accept-time wording so the persistent guidance and the inline
+ * error read the same; the hint gives way to the error line while a
+ * violation is showing, so the two never stack.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The bound line (e.g. "Select at least 2 items."), or '' when there are
+ * no bounds or an error is already showing.
+ */
+ protected function selectionHint(ThemeInterface $theme): string {
+ if (!$this->selectionBounds instanceof SelectionBounds || $this->error !== NULL) {
+ return '';
+ }
+
+ // The guidance voice, not the description's: this line states what the
+ // field expects, and drawn as a description it is indistinguishable from
+ // the highlighted option's own text sitting directly above it.
+ return $this->elements($theme)->fieldConstraint(Translator::t('Select @constraint.', ['@constraint' => $this->selectionBounds->describe()]));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderConstraint(ThemeInterface $theme): string {
+ return $this->selectionHint($theme);
+ }
+
+}
diff --git a/src/Field/Capability/SelectionCapableInterface.php b/src/Field/Capability/SelectionCapableInterface.php
new file mode 100644
index 00000000..add16a6b
--- /dev/null
+++ b/src/Field/Capability/SelectionCapableInterface.php
@@ -0,0 +1,24 @@
+elements($theme);
+
if ($this->multiple) {
if ($option->disabled) {
- return $theme->marker(FALSE) . ' ' . $theme->check(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option);
+ return $elements->fieldEntrySelector(FALSE) . ' ' . $elements->fieldEntryMarker(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option);
}
- return $theme->marker($current) . ' ' . $theme->check(isset($this->selected[$option->value])) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->matchPositions($option->label), $current);
+ $chosen = isset($this->selected[$option->value]);
+
+ return $elements->fieldEntrySelector($current) . ' ' . $elements->fieldEntryMarker($chosen) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->matchPositions($option->label), $current, $chosen);
}
if ($option->disabled) {
- return $theme->radio(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option);
+ return $elements->fieldEntryMarker(FALSE, TRUE) . ' ' . $this->renderDisabledLabel($theme, $option);
}
- return $theme->radio($current) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->matchPositions($option->label), $current);
+ // Moving the cursor is what picks in an exclusive list, so the mark and the
+ // cursor say the same thing and the row draws only the mark.
+ return $elements->fieldEntryMarker($current, TRUE) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->matchPositions($option->label), $current);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Measured from the glyphs the row actually draws rather than assumed: the
+ * leading run differs between the two modes, and again between a themed
+ * glyph and its textual stand-in.
+ */
+ #[\Override]
+ protected function entryTextOffset(ThemeInterface $theme): int {
+ $elements = $this->elements($theme);
+
+ $prefix = $this->multiple
+ ? $elements->fieldEntrySelector(TRUE) . ' ' . $elements->fieldEntryMarker(FALSE) . ' '
+ : $elements->fieldEntryMarker(FALSE, TRUE) . ' ';
+
+ return Ansi::width($prefix);
}
/**
@@ -346,7 +371,7 @@ public function hints(): array {
return [
new Hint('select', Action::Toggle),
new Hint('move', Action::MoveUp, Action::MoveDown),
- new Hint('none/all', Action::SelectNone, Action::SelectAll),
+ new Hint('select none or all', Action::SelectNone, Action::SelectAll),
...parent::hints(),
];
}
diff --git a/src/Field/Capability/StepCapableInterface.php b/src/Field/Capability/StepCapableInterface.php
new file mode 100644
index 00000000..35bef76f
--- /dev/null
+++ b/src/Field/Capability/StepCapableInterface.php
@@ -0,0 +1,25 @@
+caretSegments();
+ $elements = $this->elements($theme);
- return $before . $theme->caret() . $after;
+ return $elements->fieldDraft($before) . $elements->fieldCaret() . $elements->fieldDraft($after);
}
/**
@@ -168,7 +169,7 @@ protected function renderCaretLine(ThemeInterface $theme): string {
protected function renderInputLine(ThemeInterface $theme, string $ghost = ''): string {
[$before, $after] = $this->caretSegments();
- return $theme->renderInput($before, $after, $ghost);
+ return $this->elements($theme)->fieldInput($before, $after, $ghost);
}
}
diff --git a/src/Field/Confirm.php b/src/Field/Confirm.php
new file mode 100644
index 00000000..b9b4db47
--- /dev/null
+++ b/src/Field/Confirm.php
@@ -0,0 +1,114 @@
+current = $default;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Confirm);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Toggle)) {
+ $this->stepBy(1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Yes)) {
+ $this->current = TRUE;
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::No)) {
+ $this->current = FALSE;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The domain is the yes/no pair, so any odd step flips the value.
+ */
+ public function stepBy(int $delta): void {
+ if ($delta % 2 !== 0) {
+ $this->current = !$this->current;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->current;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->renderExclusiveRow($theme, Translator::t('Yes'), $this->current) . ' ' . $this->renderExclusiveRow($theme, Translator::t('No'), !$this->current);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function hints(): array {
+ return [
+ new Hint('answer yes or no', Action::Yes, Action::No),
+ new Hint('toggle', Action::Toggle),
+ ...parent::hints(),
+ ];
+ }
+
+}
diff --git a/src/Field/FieldFactory.php b/src/Field/FieldFactory.php
new file mode 100644
index 00000000..384b417e
--- /dev/null
+++ b/src/Field/FieldFactory.php
@@ -0,0 +1,289 @@
+keymap = $keymap ?? KeyMapManager::create();
+ }
+
+ /**
+ * Build the field a block opens onto, seeded with the value it holds.
+ *
+ * Nothing here is wired with a validator: what a block will not take is the
+ * block's own to refuse, so an offered value is measured once, where the
+ * answer is held, rather than twice with two chances to disagree.
+ *
+ * @param \DrevOps\Tui\Block\Field $block
+ * The block being opened.
+ * @param mixed $current
+ * The current value to seed the field with.
+ * @param array $answers
+ * The answers collected so far, passed to a text completion closure.
+ *
+ * @return \DrevOps\Tui\Field\FieldInterface
+ * The field.
+ *
+ * @throws \LogicException
+ * When the block's kind only draws, so there is nothing to open onto.
+ */
+ public function open(Field $block, mixed $current = NULL, array $answers = []): FieldInterface {
+ $entries = $this->translate($block->entries());
+
+ $field = match ($block->type()) {
+ FieldType::Confirm => new Confirm((bool) $current),
+ FieldType::Toggle => new Toggle($this->entryLabels($entries), $this->text($current)),
+ FieldType::Select => new Select($entries, $this->seed($block, $current), $block->isMultiple(), $block->pageSize(), $block->selectionBounds()),
+ FieldType::Reorder => new Reorder($entries, Field::stringList($current), $block->pageSize()),
+ FieldType::Suggest => new Suggest($block->selectableValues(), $this->text($current), $block->pageSize(), $this->entryDescriptions($entries), $block->hasGhost()),
+ FieldType::Search => new Search($entries, $this->seed($block, $current), $block->isMultiple(), $block->pageSize(), $block->selectionBounds()),
+ FieldType::FilePicker => new FilePicker($block->pickerStart(), $this->seed($block, $current), $block->pickerConstraints(), $block->showsHidden(), $block->isMultiple(), $block->pageSize(), $block->selectionBounds()),
+ FieldType::Number => new Number($this->number($current), $block->numberBounds()),
+ FieldType::Rating => $this->rating($block, $current),
+ FieldType::Calendar => new Calendar($this->text($current), $block->dateBounds()),
+ FieldType::Textarea => new Textarea($this->text($current), $block->hasExternalEditor() && $this->externalEditorAvailable),
+ FieldType::Password => new Password($this->text($current), $block->isRevealable(), $block->hasConfirmation()),
+ FieldType::Pause => new Pause(),
+ FieldType::Text => new Text($this->text($current), $this->completions($block, $answers)),
+ FieldType::Template => new Template($this->template($block), $this->text($current)),
+ FieldType::Note, FieldType::Progress => throw new \LogicException(sprintf('Field "%s" only draws, so there is nothing to open it onto.', $block->id())),
+ };
+
+ if ($field instanceof QueryOptionsCapableInterface && $block->source() instanceof \Closure) {
+ $field->driveByQuery($block->queryMinLength());
+ }
+
+ if ($field instanceof PlaceholderCapableInterface) {
+ $field->setPlaceholder($block->placeholderText());
+ }
+
+ return $field->setKeys($this->keymap->forField($block->type(), $block->isMultiple()));
+ }
+
+ /**
+ * The field seed value for a block: a scalar, or a list when multiple.
+ *
+ * @param \DrevOps\Tui\Block\Field $block
+ * The block.
+ * @param mixed $current
+ * The current value to seed the field with.
+ *
+ * @return string|list
+ * The string current value for a single field, or the list of string
+ * values for a multiple one.
+ */
+ protected function seed(Field $block, mixed $current): string|array {
+ return $block->isMultiple() ? Field::stringList($current) : $this->text($current);
+ }
+
+ /**
+ * Build a rating field over a block's scale, seeded with its value.
+ *
+ * @param \DrevOps\Tui\Block\Field $block
+ * The block.
+ * @param mixed $current
+ * The current value to seed the field with.
+ *
+ * @return \DrevOps\Tui\Field\Rating
+ * The field.
+ */
+ protected function rating(Field $block, mixed $current): Rating {
+ $scale = $block->numberBounds();
+
+ if (!$scale instanceof NumberBounds || $scale->min === NULL || $scale->max === NULL) {
+ throw new \LogicException(sprintf('Field "%s" is a rating field carrying no closed scale.', $block->id()));
+ }
+
+ $point = is_int($current) || is_float($current) ? (int) $current : $scale->min;
+
+ return new Rating($point, $scale->min, $scale->max, $this->localized($block->ratingCaptions()));
+ }
+
+ /**
+ * The shape a block's template field fills in.
+ *
+ * @param \DrevOps\Tui\Block\Field $block
+ * The block.
+ *
+ * @return \DrevOps\Tui\Model\Template
+ * The template.
+ */
+ protected function template(Field $block): TemplateModel {
+ $template = $block->template();
+
+ if (!$template instanceof TemplateModel) {
+ throw new \LogicException(sprintf('Field "%s" is a template field carrying no template.', $block->id()));
+ }
+
+ return $template;
+ }
+
+ /**
+ * Resolve a block's completion source to a concrete candidate list.
+ *
+ * @param \DrevOps\Tui\Block\Field $block
+ * The block.
+ * @param array $answers
+ * The answers collected so far.
+ *
+ * @return list
+ * The candidate strings; empty when the block declares no completion.
+ */
+ protected function completions(Field $block, array $answers): array {
+ $completion = $block->completion();
+ $source = $completion instanceof \Closure ? $completion($answers) : $completion;
+
+ return Field::stringList($source);
+ }
+
+ /**
+ * Coerce a current value to the string a text-seeded field starts from.
+ *
+ * @param mixed $current
+ * The current value.
+ *
+ * @return string
+ * The string value; empty when the value is not a string.
+ */
+ protected function text(mixed $current): string {
+ return is_string($current) ? $current : '';
+ }
+
+ /**
+ * Coerce a current value to the digit string the integer field starts from.
+ *
+ * @param mixed $current
+ * The current value.
+ *
+ * @return string
+ * The value as integer digits; empty when the value is not numeric.
+ */
+ protected function number(mixed $current): string {
+ return is_int($current) || is_float($current) ? (string) (int) $current : '';
+ }
+
+ /**
+ * A rating's captions, localized to the active language.
+ *
+ * Translated once here rather than at each draw, the way the option labels
+ * are, so the caption a field shows is the caption the panel row shows.
+ *
+ * @param array $captions
+ * The caption of each captioned point, keyed by the point.
+ *
+ * @return array
+ * The localized captions, keyed the same way.
+ */
+ protected function localized(array $captions): array {
+ return array_map(static fn(string $caption): string => $caption === '' ? '' : Translator::t($caption), $captions);
+ }
+
+ /**
+ * The selectable value => label map for a set of options.
+ *
+ * @param list<\DrevOps\Tui\Model\Option> $options
+ * The localized options.
+ *
+ * @return array
+ * The labels keyed by value, for fields that take a flat option map.
+ */
+ protected function entryLabels(array $options): array {
+ $out = [];
+
+ foreach ($options as $option) {
+ if ($option->selectable()) {
+ $out[$option->value] = $option->label;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * A set of options with their labels and disabled reasons translated.
+ *
+ * Translating once here, rather than at each field draw, keeps the list a
+ * field searches identical to the list it shows, so a match runs against the
+ * same text the user reads.
+ *
+ * @param list<\DrevOps\Tui\Model\Option> $options
+ * The options in display order.
+ *
+ * @return list<\DrevOps\Tui\Model\Option>
+ * The options in display order, localized to the active language.
+ */
+ protected function translate(array $options): array {
+ return array_map(static fn(Option $option): Option => new Option(
+ $option->value,
+ Translator::t($option->label),
+ $option->description !== '' ? Translator::t($option->description) : '',
+ $option->kind,
+ $option->disabled,
+ $option->disabledReason !== '' ? Translator::t($option->disabledReason) : '',
+ ), $options);
+ }
+
+ /**
+ * The description shown for each selectable option value, keyed by value.
+ *
+ * For the value-based suggest field, which carries no option rows: the
+ * localized per-option description keyed by its value.
+ *
+ * @param list<\DrevOps\Tui\Model\Option> $options
+ * The localized options.
+ *
+ * @return array
+ * The description for each selectable option value.
+ */
+ protected function entryDescriptions(array $options): array {
+ $out = [];
+
+ foreach ($options as $option) {
+ if (!$option->selectable()) {
+ continue;
+ }
+
+ $out[$option->value] = $option->description;
+ }
+
+ return $out;
+ }
+
+}
diff --git a/src/Field/FieldInterface.php b/src/Field/FieldInterface.php
new file mode 100644
index 00000000..3c531a3b
--- /dev/null
+++ b/src/Field/FieldInterface.php
@@ -0,0 +1,113 @@
+
+ * The ordered hint fragments.
+ */
+ public function hints(): array;
+
+ /**
+ * The bindings the field answers to.
+ *
+ * The same map that resolves a keystroke also resolves a hint into the key
+ * that illustrates it, so the two can never disagree about which keys are
+ * live.
+ *
+ * @return \DrevOps\Tui\Input\ScopedKeyMap
+ * The scoped bindings.
+ */
+ public function keys(): ScopedKeyMap;
+
+}
diff --git a/src/Field/FilePicker.php b/src/Field/FilePicker.php
new file mode 100644
index 00000000..e2c2a822
--- /dev/null
+++ b/src/Field/FilePicker.php
@@ -0,0 +1,734 @@
+ TRUE), used in multiple mode.
+ *
+ * @var array
+ */
+ protected array $selected = [];
+
+ /**
+ * The type, extension and size limits on a valid pick.
+ */
+ protected FilePickerConstraints $constraints;
+
+ /**
+ * The current type-to-filter text applied to the browsed directory.
+ */
+ protected string $filter = '';
+
+ /**
+ * The highlighted index within the visible entries.
+ */
+ protected int $cursor = 0;
+
+ /**
+ * Construct a file picker field.
+ *
+ * @param string $start
+ * The start directory; the browser opens here and cannot ascend above it.
+ * Empty falls back to the current working directory.
+ * @param string|list $default
+ * The pre-selected path (single) or paths (multiple). A single path opens
+ * the browser at its directory with the entry highlighted; in multiple mode
+ * every path seeds the selection.
+ * @param \DrevOps\Tui\Model\FilePickerConstraints|null $constraints
+ * The type, extension and size limits on a valid pick; NULL leaves the
+ * picker unconstrained.
+ * @param bool $showHidden
+ * Whether dot-entries are shown when the browser opens.
+ * @param bool $multiple
+ * Whether several paths may be selected (Space toggles, Enter accepts).
+ * @param int|null $page_size
+ * The number of entry rows shown at once before the list pages; NULL uses
+ * the default.
+ * @param \DrevOps\Tui\Model\SelectionBounds|null $selection_bounds
+ * The minimum/maximum selection counts enforced on accept, or NULL for no
+ * count limit.
+ */
+ public function __construct(
+ string $start = '',
+ string|array $default = '',
+ ?FilePickerConstraints $constraints = NULL,
+ protected bool $showHidden = FALSE,
+ protected bool $multiple = FALSE,
+ ?int $page_size = NULL,
+ ?SelectionBounds $selection_bounds = NULL,
+ ) {
+ $this->constraints = $constraints ?? new FilePickerConstraints();
+ $this->root = $this->trimTrailingSlash($start !== '' ? $start : $this->currentDirectory());
+ $this->cwd = $this->root;
+ $this->pageSize = $this->resolvePageSize($page_size);
+ $this->selectionBounds = $selection_bounds;
+
+ $this->seed($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::FilePicker, $this->multiple);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Accept)) {
+ $this->onEnter();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveUp)) {
+ $this->moveCursor(-1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveDown)) {
+ $this->moveCursor(1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveRight)) {
+ $this->descend();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveLeft)) {
+ $this->ascend();
+
+ return;
+ }
+
+ // Reveal doubles as the show-hidden toggle, mirroring the password reveal.
+ if ($keys->matches($key, Action::Reveal)) {
+ $this->toggleReveal();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Toggle)) {
+ $this->toggleSelection();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::DeleteBack)) {
+ $this->onBackspace();
+
+ return;
+ }
+
+ if ($key->isChar()) {
+ $this->filter .= $key->char ?? '';
+ $this->resetFilterCursor();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function filter(): string {
+ return $this->filter;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function resetFilterCursor(): void {
+ $this->cursor = 0;
+ $this->offset = 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ if ($this->multiple) {
+ return array_keys($this->selected);
+ }
+
+ $name = $this->currentName();
+
+ return $name === '' ? '' : $this->join($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $elements = $this->elements($theme);
+ $lines = [$elements->fieldCaption($this->crumb())];
+
+ if ($this->filter !== '') {
+ $lines[] = $elements->fieldDraft($this->filter) . $elements->fieldCaret();
+ }
+
+ $entries = $this->entries();
+
+ if ($entries === []) {
+ $lines[] = $elements->fieldEntryNote(Translator::t('(empty)'));
+ }
+
+ $viewport = $this->pageViewport(count($entries), $this->cursor);
+
+ $rows = [];
+
+ foreach (array_slice($entries, $viewport->offset, $this->pageSize) as $slot => $name) {
+ $rows[] = $this->renderRow($theme, $name, $viewport->offset + $slot === $this->cursor);
+ }
+
+ return implode("\n", array_merge($lines, $this->wrapScrolled($theme, $rows, $viewport)));
+ }
+
+ /**
+ * The themed constraint hint line, or an empty string when not shown.
+ *
+ * Mirrors the selection-count hint: the active type, extension and size
+ * limits are surfaced as a persistent line so they are visible before a pick
+ * breaks one, giving way to the inline error while a violation is showing so
+ * the two never stack.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The constraint line (e.g. "Files only. Max 2 MB."), or '' when the
+ * picker is unconstrained or an error is already showing.
+ */
+ protected function constraintHint(ThemeInterface $theme): string {
+ $describe = $this->constraints->describe();
+ if ($describe === '' || $this->error !== NULL) {
+ return '';
+ }
+
+ // The guidance voice, not the description's: this line states what the
+ // field expects, and shares its row with the error that replaces it.
+ return $this->elements($theme)->fieldConstraint($describe);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A picker states two limits at once - what may be picked, and how many -
+ * and either may stand alone.
+ */
+ #[\Override]
+ protected function renderConstraint(ThemeInterface $theme): string {
+ return implode("\n", array_filter([$this->constraintHint($theme), $this->selectionHint($theme)]));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The Toggle fragment resolves only in multiple mode, where Space is bound to
+ * it; Accept reads "select" for a single pick and "accept" for multiple.
+ */
+ #[\Override]
+ public function hints(): array {
+ return [
+ new Hint('select', Action::Toggle),
+ new Hint('move', Action::MoveUp, Action::MoveDown),
+ new Hint('open', Action::MoveRight),
+ new Hint('go up', Action::MoveLeft),
+ new Hint($this->multiple ? 'accept' : 'select', Action::Accept),
+ new Hint('show hidden', Action::Reveal),
+ new Hint('cancel', Action::Cancel),
+ ];
+ }
+
+ /**
+ * Seed the initial selection and browse location from the default.
+ *
+ * @param string|list $default
+ * The default path or paths.
+ */
+ protected function seed(string|array $default): void {
+ $paths = is_array($default) ? Field::stringList($default) : ($default === '' ? [] : [$default]);
+
+ if ($this->multiple) {
+ foreach ($paths as $path) {
+ if ($path !== '') {
+ $this->selected[$path] = TRUE;
+ }
+ }
+ }
+
+ $primary = $paths[0] ?? '';
+ if ($primary === '' || !str_starts_with($primary, $this->root . '/')) {
+ return;
+ }
+
+ $this->cwd = $this->parentOf($primary);
+ $this->highlight($this->baseName($primary));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Reject a pick that breaks a type, extension or size limit before the value
+ * is accepted, then defer to the selection-count check and the base accept so
+ * the three inline errors never stack.
+ */
+ #[\Override]
+ protected function accept(mixed $value): bool {
+ $violation = $this->constraints->violation($value);
+ if ($violation !== NULL) {
+ $this->error = Translator::t('Choose @constraint.', ['@constraint' => $violation]);
+
+ return FALSE;
+ }
+
+ $selection_error = $this->selectionBoundsError($value);
+ if ($selection_error !== NULL) {
+ $this->error = $selection_error;
+
+ return FALSE;
+ }
+
+ return parent::accept($value);
+ }
+
+ /**
+ * Accept the highlighted entry, the accumulated selection, or descend.
+ */
+ protected function onEnter(): void {
+ if ($this->multiple) {
+ $this->accept($this->liveValue());
+
+ return;
+ }
+
+ $name = $this->currentName();
+ if ($name === '') {
+ return;
+ }
+
+ if ($this->isSelectable($name)) {
+ $this->accept($this->join($name));
+
+ return;
+ }
+
+ if ($this->isDir($name)) {
+ $this->descend();
+ }
+ }
+
+ /**
+ * The directory the browser roots at when no start directory is declared.
+ *
+ * A seam so the fallback can come from somewhere other than the process
+ * working directory (e.g. a virtual filesystem).
+ *
+ * @return string
+ * The current working directory.
+ */
+ protected function currentDirectory(): string {
+ // @codeCoverageIgnoreStart
+ return (string) getcwd();
+ // @codeCoverageIgnoreEnd
+ }
+
+ /**
+ * Delete the last filter character, or ascend when the filter is empty.
+ */
+ protected function onBackspace(): void {
+ if ($this->filter !== '') {
+ $this->filter = Strings::substr($this->filter, 0, -1);
+ $this->resetFilterCursor();
+
+ return;
+ }
+
+ $this->ascend();
+ }
+
+ /**
+ * Move the highlight by a delta, clamped to the visible entries.
+ *
+ * @param int $delta
+ * The direction (negative up, positive down).
+ */
+ protected function moveCursor(int $delta): void {
+ $count = count($this->entries());
+ if ($count === 0) {
+ $this->cursor = 0;
+
+ return;
+ }
+
+ $this->cursor = max(0, min($count - 1, $this->cursor + $delta));
+ }
+
+ /**
+ * Descend into the highlighted directory.
+ */
+ protected function descend(): void {
+ $name = $this->currentName();
+ if ($name === '' || !$this->isDir($name)) {
+ return;
+ }
+
+ $this->cwd = $this->join($name);
+ $this->resetView();
+ }
+
+ /**
+ * Ascend to the parent directory, never above the start directory.
+ */
+ protected function ascend(): void {
+ if ($this->cwd === $this->root) {
+ return;
+ }
+
+ $left = $this->baseName($this->cwd);
+ $this->cwd = $this->parentOf($this->cwd);
+ $this->resetView();
+ $this->highlight($left);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Toggles whether dot-entries are shown, landing back at the top of the
+ * refreshed listing.
+ */
+ public function toggleReveal(): void {
+ $this->showHidden = !$this->showHidden;
+ $this->cursor = 0;
+ $this->offset = 0;
+ }
+
+ /**
+ * Toggle the highlighted entry in the selection, when it is selectable.
+ */
+ protected function toggleSelection(): void {
+ $name = $this->currentName();
+ if ($name === '' || !$this->isSelectable($name)) {
+ return;
+ }
+
+ $path = $this->join($name);
+ if (isset($this->selected[$path])) {
+ unset($this->selected[$path]);
+
+ return;
+ }
+
+ $this->selected[$path] = TRUE;
+ }
+
+ /**
+ * Reset the filter, highlight and scroll after changing directory.
+ */
+ protected function resetView(): void {
+ $this->filter = '';
+ $this->cursor = 0;
+ $this->offset = 0;
+ }
+
+ /**
+ * Move the highlight to a named entry, or the top when it is not visible.
+ *
+ * @param string $name
+ * The entry name.
+ */
+ protected function highlight(string $name): void {
+ $index = array_search($name, $this->entries(), TRUE);
+ $this->cursor = $index === FALSE ? 0 : $index;
+ }
+
+ /**
+ * The visible entry names in the browsed directory, directories first.
+ *
+ * @return list
+ * The entry names, sorted case-insensitively with directories before files.
+ */
+ protected function entries(): array {
+ if (!is_dir($this->cwd)) {
+ return [];
+ }
+
+ $raw = scandir($this->cwd);
+ // @codeCoverageIgnoreStart
+ if ($raw === FALSE) {
+ return [];
+ }
+ // @codeCoverageIgnoreEnd
+ $dirs = [];
+ $files = [];
+ foreach ($raw as $name) {
+ if ($name === '.') {
+ continue;
+ }
+ if ($name === '..') {
+ continue;
+ }
+ if (!$this->showHidden && str_starts_with($name, '.')) {
+ continue;
+ }
+
+ if (is_dir($this->cwd . '/' . $name)) {
+ $dirs[] = $name;
+
+ continue;
+ }
+ if ($this->constraints->mode === FilePickerMode::Directory) {
+ continue;
+ }
+ if (!$this->constraints->extensionAllowed($name)) {
+ continue;
+ }
+
+ $files[] = $name;
+ }
+
+ return array_merge($this->sortFilter($dirs), $this->sortFilter($files));
+ }
+
+ /**
+ * Apply the type-to-filter query and case-insensitive sort to a name list.
+ *
+ * @param list $names
+ * The entry names.
+ *
+ * @return list
+ * The filtered, sorted names.
+ */
+ protected function sortFilter(array $names): array {
+ if ($this->filter !== '') {
+ $needle = Strings::lower($this->filter);
+ $names = array_filter($names, static fn(string $name): bool => str_contains(Strings::lower($name), $needle));
+ }
+
+ usort($names, static fn(string $a, string $b): int => strcmp(Strings::lower($a), Strings::lower($b)));
+
+ return $names;
+ }
+
+ /**
+ * The highlighted entry name, or an empty string when there is none.
+ *
+ * @return string
+ * The entry name.
+ */
+ protected function currentName(): string {
+ $entries = $this->entries();
+
+ return $entries[$this->cursor] ?? '';
+ }
+
+ /**
+ * Whether an entry may be selected under the current mode.
+ *
+ * @param string $name
+ * The entry name.
+ *
+ * @return bool
+ * TRUE when the entry is selectable.
+ */
+ protected function isSelectable(string $name): bool {
+ return $this->constraints->allowsType($this->isDir($name));
+ }
+
+ /**
+ * Whether a browsed-directory entry is itself a directory.
+ *
+ * @param string $name
+ * The entry name.
+ *
+ * @return bool
+ * TRUE when the entry is a directory.
+ */
+ protected function isDir(string $name): bool {
+ return is_dir($this->join($name));
+ }
+
+ /**
+ * Render a single entry row.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $name
+ * The entry name.
+ * @param bool $current
+ * Whether the row holds the highlight.
+ *
+ * @return string
+ * The rendered row.
+ */
+ protected function renderRow(ThemeInterface $theme, string $name, bool $current): string {
+ $label = $this->isDir($name) ? $name . '/' : $name;
+ $elements = $this->elements($theme);
+ $row = $elements->fieldEntrySelector($current) . ' ';
+ $chosen = FALSE;
+
+ if ($this->multiple) {
+ $chosen = isset($this->selected[$this->join($name)]);
+ $box = $this->isSelectable($name) ? $elements->fieldEntryMarker($chosen) : $this->blankBox($theme);
+ $row .= $box . ' ';
+ }
+
+ return $row . $this->entryLabel($theme, $label, $current, $chosen);
+ }
+
+ /**
+ * A spacer the width of a checkbox, for entries that cannot be selected.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The spacer.
+ */
+ protected function blankBox(ThemeInterface $theme): string {
+ return str_repeat(' ', Strings::length(Ansi::strip($this->elements($theme)->fieldEntryMarker(FALSE))));
+ }
+
+ /**
+ * The breadcrumb of the browsed directory, relative to the start directory.
+ *
+ * @return string
+ * The breadcrumb.
+ */
+ protected function crumb(): string {
+ $base = $this->baseName($this->root);
+ if ($base === '') {
+ $base = $this->root;
+ }
+
+ return $base . substr($this->cwd, strlen($this->root));
+ }
+
+ /**
+ * Join an entry name onto the browsed directory.
+ *
+ * @param string $name
+ * The entry name.
+ *
+ * @return string
+ * The full path.
+ */
+ protected function join(string $name): string {
+ return $this->cwd === '/' ? '/' . $name : $this->cwd . '/' . $name;
+ }
+
+ /**
+ * The parent of a path, never shorter than the start directory.
+ *
+ * @param string $path
+ * The path.
+ *
+ * @return string
+ * The parent path, clamped to the start directory.
+ */
+ protected function parentOf(string $path): string {
+ $pos = strrpos($path, '/');
+ // @codeCoverageIgnoreStart
+ if ($pos === FALSE) {
+ return $this->root;
+ }
+ // @codeCoverageIgnoreEnd
+ $parent = $pos === 0 ? '/' : substr($path, 0, $pos);
+
+ return strlen($parent) < strlen($this->root) ? $this->root : $parent;
+ }
+
+ /**
+ * The last segment of a path.
+ *
+ * @param string $path
+ * The path.
+ *
+ * @return string
+ * The last segment.
+ */
+ protected function baseName(string $path): string {
+ $pos = strrpos($path, '/');
+
+ return $pos === FALSE ? $path : substr($path, $pos + 1);
+ }
+
+ /**
+ * Trim a trailing slash, keeping the filesystem root itself.
+ *
+ * @param string $path
+ * The path.
+ *
+ * @return string
+ * The trimmed path.
+ */
+ protected function trimTrailingSlash(string $path): string {
+ $trimmed = rtrim($path, '/');
+
+ return $trimmed === '' ? '/' : $trimmed;
+ }
+
+}
diff --git a/src/Widget/MatchResult.php b/src/Field/MatchResult.php
similarity index 93%
rename from src/Widget/MatchResult.php
rename to src/Field/MatchResult.php
index 277f8acd..ccb55fa8 100644
--- a/src/Widget/MatchResult.php
+++ b/src/Field/MatchResult.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget;
+namespace DrevOps\Tui\Field;
/**
* The outcome of matching a query against a candidate label.
@@ -10,7 +10,7 @@
* Carries the relevance score used to rank a candidate against its peers and
* the label character indices the query matched, used to highlight them.
*
- * @package DrevOps\Tui\Widget
+ * @package DrevOps\Tui\Field
*/
final readonly class MatchResult {
diff --git a/src/Widget/MatchTier.php b/src/Field/MatchTier.php
similarity index 93%
rename from src/Widget/MatchTier.php
rename to src/Field/MatchTier.php
index bee6a5a3..c1ca45b5 100644
--- a/src/Widget/MatchTier.php
+++ b/src/Field/MatchTier.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget;
+namespace DrevOps\Tui\Field;
/**
* How closely a candidate matches a query, coarsest band first.
@@ -10,7 +10,7 @@
* A tighter tier always outranks a looser one regardless of the finer
* within-tier refinement, so the tier's weight dominates the match score.
*
- * @package DrevOps\Tui\Widget
+ * @package DrevOps\Tui\Field
*/
enum MatchTier {
diff --git a/src/Widget/Matcher.php b/src/Field/Matcher.php
similarity index 98%
rename from src/Widget/Matcher.php
rename to src/Field/Matcher.php
index b0c16b44..dfd1ede3 100644
--- a/src/Widget/Matcher.php
+++ b/src/Field/Matcher.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget;
+namespace DrevOps\Tui\Field;
use DrevOps\Tui\Model\Option;
use DrevOps\Tui\Model\OptionKind;
@@ -17,11 +17,11 @@
* always ranks ahead of a looser one, then refined within a tier by how early
* and how contiguous the match is. Matching is case-insensitive and
* multibyte-aware, and every match reports the character indices it hit so a
- * widget can highlight them.
+ * field can highlight them.
*
* The matcher is stateless: one instance serves every candidate and query.
*
- * @package DrevOps\Tui\Widget
+ * @package DrevOps\Tui\Field
*/
final class Matcher {
@@ -38,7 +38,7 @@ final class Matcher {
* @param string $needle
* The query.
*
- * @return \DrevOps\Tui\Widget\MatchResult|null
+ * @return \DrevOps\Tui\Field\MatchResult|null
* The result, or NULL when the query is not a subsequence of the candidate.
* An empty query matches everything with a zero score and no positions.
*/
@@ -184,7 +184,7 @@ protected function rank(array $items, \Closure $text, string $needle): array {
* @param string $needle
* The case-folded query.
*
- * @return \DrevOps\Tui\Widget\MatchTier
+ * @return \DrevOps\Tui\Field\MatchTier
* The tier.
*/
protected function tier(string $haystack, string $needle): MatchTier {
diff --git a/src/Field/Number.php b/src/Field/Number.php
new file mode 100644
index 00000000..afa2db88
--- /dev/null
+++ b/src/Field/Number.php
@@ -0,0 +1,167 @@
+initTextBuffer($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Number);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->bounds instanceof NumberBounds) {
+ if ($keys->matches($key, Action::Increment)) {
+ $this->stepBy(1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Decrement)) {
+ $this->stepBy(-1);
+
+ return;
+ }
+ }
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ $this->handleTextEditKey($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Only a digit, or a leading minus not yet present, enters the buffer.
+ */
+ public function insert(string $text): void {
+ if ($text === '-') {
+ if ($this->cursor !== 0 || str_contains($this->buffer, '-')) {
+ return;
+ }
+ }
+ elseif (!ctype_digit($text)) {
+ return;
+ }
+
+ $this->insertText($text);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return (int) $this->buffer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function accept(mixed $value): bool {
+ $violation = $this->bounds?->violation($value);
+ if ($violation !== NULL) {
+ $this->error = Translator::t('Enter a number @constraint.', ['@constraint' => $violation]);
+
+ return FALSE;
+ }
+
+ return parent::accept($value);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Each position is one bounds step, clamped to the range; without bounds the
+ * value has no step to move by, so the call is inert.
+ */
+ public function stepBy(int $delta): void {
+ if (!$this->bounds instanceof NumberBounds || $delta === 0) {
+ return;
+ }
+
+ $this->buffer = (string) $this->bounds->step((int) $this->buffer, $delta);
+ $this->cursor = Strings::length($this->buffer);
+ $this->error = NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->renderInputLine($theme, $this->placeholderText($this->buffer));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The step keys are the non-obvious binding here - nothing else signals that
+ * they adjust the value - so they lead when bounds are set.
+ */
+ #[\Override]
+ public function hints(): array {
+ if (!$this->bounds instanceof NumberBounds) {
+ return parent::hints();
+ }
+
+ return [new Hint('adjust', Action::Increment, Action::Decrement), ...parent::hints()];
+ }
+
+}
diff --git a/src/Field/Password.php b/src/Field/Password.php
new file mode 100644
index 00000000..10be0e1a
--- /dev/null
+++ b/src/Field/Password.php
@@ -0,0 +1,199 @@
+initTextBuffer($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Password);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->revealable && $keys->matches($key, Action::Reveal)) {
+ $this->toggleReveal();
+
+ return;
+ }
+
+ if ($this->confirm && $keys->matches($key, Action::Accept)) {
+ $this->submit();
+
+ return;
+ }
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ $this->handleTextEditKey($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Cycles the live display between hidden, masked and plaintext; inert unless
+ * the reveal toggle is enabled. The stored value is never affected.
+ */
+ public function toggleReveal(): void {
+ if (!$this->revealable) {
+ return;
+ }
+
+ $this->display = $this->display->next();
+ }
+
+ /**
+ * Advance the two-step confirmation on Enter.
+ */
+ protected function submit(): void {
+ if ($this->firstEntry === NULL) {
+ $this->firstEntry = $this->buffer;
+ $this->buffer = '';
+ $this->cursor = 0;
+ $this->error = NULL;
+
+ return;
+ }
+
+ if ($this->buffer !== $this->firstEntry) {
+ $this->error = Translator::t('Passwords do not match.');
+ $this->reset();
+
+ return;
+ }
+
+ $this->accept($this->firstEntry);
+
+ // A validator may still reject the matched value; restart on failure so the
+ // shown error is not stranded against a completed field.
+ if (!$this->isComplete()) {
+ $this->reset();
+ }
+ }
+
+ /**
+ * Clear both entries and return to the first prompt, keeping any error.
+ */
+ protected function reset(): void {
+ $this->firstEntry = NULL;
+ $this->buffer = '';
+ $this->cursor = 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $rows = [$this->renderLine($theme)];
+
+ if ($this->firstEntry !== NULL) {
+ $rows[] = $this->elements($theme)->fieldState(Translator::t('re-enter to confirm'));
+ }
+
+ return implode("\n", $rows);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The reveal toggle is the non-obvious action, so it leads when the field
+ * is revealable; otherwise the base accept/cancel hints stand alone.
+ */
+ #[\Override]
+ public function hints(): array {
+ if (!$this->revealable) {
+ return parent::hints();
+ }
+
+ return [new Hint('reveal', Action::Reveal), ...parent::hints()];
+ }
+
+ /**
+ * Render the input line for the current display mode.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme supplying the mask and caret glyphs.
+ *
+ * @return string
+ * The rendered input line.
+ */
+ protected function renderLine(ThemeInterface $theme): string {
+ // An empty buffer hides nothing, so the placeholder shows in every display
+ // mode and disappears the moment a first character masks the entry.
+ $placeholder = $this->placeholderText($this->buffer);
+ $elements = $this->elements($theme);
+
+ return match ($this->display) {
+ PasswordDisplay::Hidden => $elements->fieldInput('', '', $placeholder),
+ PasswordDisplay::Masked => $elements->fieldInput(str_repeat($elements->fieldMask(), $this->cursor), str_repeat($elements->fieldMask(), Strings::length($this->buffer) - $this->cursor), $placeholder),
+ PasswordDisplay::Plaintext => $this->renderInputLine($theme, $placeholder),
+ };
+ }
+
+}
diff --git a/src/Widget/PasswordDisplay.php b/src/Field/PasswordDisplay.php
similarity index 86%
rename from src/Widget/PasswordDisplay.php
rename to src/Field/PasswordDisplay.php
index 5c7c3d4f..775e4550 100644
--- a/src/Widget/PasswordDisplay.php
+++ b/src/Field/PasswordDisplay.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-namespace DrevOps\Tui\Widget;
+namespace DrevOps\Tui\Field;
/**
- * How a password widget renders its buffer while editing.
+ * How a password field renders its buffer while editing.
*
* The stored value is never affected; this only controls the live view.
*
- * @package DrevOps\Tui\Widget
+ * @package DrevOps\Tui\Field
*/
enum PasswordDisplay {
diff --git a/src/Field/Pause.php b/src/Field/Pause.php
new file mode 100644
index 00000000..2f5b872e
--- /dev/null
+++ b/src/Field/Pause.php
@@ -0,0 +1,70 @@
+keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Accept)) {
+ $this->accept(TRUE);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $key = $this->keys()->primary(Action::Accept) ?? Key::named(KeyName::Enter);
+
+ return Translator::t('Press @key to continue', ['@key' => $this->entryLabel($theme, $theme->keyGlyph($key), TRUE)]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function hints(): array {
+ return [new Hint('continue', Action::Accept), new Hint('cancel', Action::Cancel)];
+ }
+
+}
diff --git a/src/Field/Rating.php b/src/Field/Rating.php
new file mode 100644
index 00000000..be0f39cb
--- /dev/null
+++ b/src/Field/Rating.php
@@ -0,0 +1,158 @@
+ $captions
+ * The caption of a point, keyed by the point; points may be uncaptioned.
+ */
+ public function __construct(int $default, protected int $min = 1, protected int $max = 5, protected array $captions = []) {
+ $this->point = $this->clamp($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Rating);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($keys->matches($key, Action::Increment)) {
+ $this->stepBy(1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Decrement)) {
+ $this->stepBy(-1);
+
+ return;
+ }
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ if ($key->isChar()) {
+ $this->applyChar($key->char ?? '');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Each position is one point, and the scale stops at the end it reaches.
+ */
+ public function stepBy(int $delta): void {
+ $this->point = $this->clamp($this->point + $delta);
+ }
+
+ /**
+ * Jump to the point a typed digit names.
+ *
+ * A digit the scale does not reach leaves the choice alone, so typing on a
+ * scale that starts above nine - or runs well past it - is inert rather than
+ * surprising.
+ *
+ * @param string $char
+ * The typed character.
+ */
+ protected function applyChar(string $char): void {
+ if (!ctype_digit($char)) {
+ return;
+ }
+
+ $point = (int) $char;
+ if ($point >= $this->min && $point <= $this->max) {
+ $this->point = $point;
+ }
+ }
+
+ /**
+ * Move a point onto the scale.
+ *
+ * @param int $point
+ * The candidate point.
+ *
+ * @return int
+ * The point, moved onto the nearest end it overshoots.
+ */
+ protected function clamp(int $point): int {
+ return max($this->min, min($this->max, $point));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->point;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->elements($theme)->fieldScale($this->point, $this->min, $this->max, $this->captions[$this->point] ?? '');
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The stepping keys lead: nothing about a row of points says which keys move
+ * along it.
+ */
+ #[\Override]
+ public function hints(): array {
+ return [new Hint('adjust', Action::Increment, Action::Decrement), ...parent::hints()];
+ }
+
+}
diff --git a/src/Field/Reorder.php b/src/Field/Reorder.php
new file mode 100644
index 00000000..c2653a75
--- /dev/null
+++ b/src/Field/Reorder.php
@@ -0,0 +1,261 @@
+
+ */
+ protected array $items;
+
+ /**
+ * The highlighted position in the arrangement.
+ */
+ protected int $cursor = 0;
+
+ /**
+ * Whether the highlighted item is held and moves with the cursor.
+ */
+ protected bool $grabbed = FALSE;
+
+ /**
+ * Construct a reorder field.
+ *
+ * @param array $options
+ * The items to rank, in display order - a list of options or the
+ * value => label shorthand map.
+ * @param list $default
+ * The initial order; values it omits are appended in declared order and
+ * unknown values are ignored, so the arrangement is always a full ranking.
+ * @param int|null $page_size
+ * The number of rows shown at once before the list pages; NULL uses the
+ * default.
+ */
+ public function __construct(array $options, array $default = [], ?int $page_size = NULL) {
+ $this->pageSize = $this->resolvePageSize($page_size);
+
+ $by_value = [];
+ foreach (Option::list($options) as $row) {
+ $by_value[$row->value] = $row;
+ }
+
+ $order = Field::canonicalOrder(array_keys($by_value), $default);
+ $this->items = array_map(static fn(string $value): Option => $by_value[$value], $order);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Reorder);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Accept)) {
+ // A held item drops on Accept, mirroring Space; nothing is committed
+ // while an item is held, so Enter never accepts mid-move.
+ if ($this->grabbed) {
+ $this->grabbed = FALSE;
+ }
+ else {
+ $this->accept($this->liveValue());
+ }
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Grab)) {
+ $this->grabbed = !$this->grabbed;
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveUp)) {
+ $this->move(-1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveDown)) {
+ $this->move(1);
+ }
+ }
+
+ /**
+ * Move the cursor, carrying the held item when one is grabbed.
+ *
+ * @param int $dir
+ * The direction: -1 up, +1 down.
+ */
+ protected function move(int $dir): void {
+ $target = $this->cursor + $dir;
+
+ if ($target < 0 || $target >= count($this->items)) {
+ return;
+ }
+
+ if ($this->grabbed) {
+ $items = $this->items;
+ [$items[$this->cursor], $items[$target]] = [$items[$target], $items[$this->cursor]];
+ $this->items = array_values($items);
+ }
+
+ $this->cursor = $target;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return array_map(static fn(Option $option): string => $option->value, $this->items);
+ }
+
+ /**
+ * The rows currently shown: the full arrangement, in its current order.
+ *
+ * @return list<\DrevOps\Tui\Model\Option>
+ * The visible rows.
+ */
+ public function visible(): array {
+ return $this->items;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $visible = $this->visible();
+ $viewport = $this->pageViewport(count($visible), $this->cursor);
+
+ $rows = [];
+
+ foreach (array_slice($visible, $viewport->offset, $this->pageSize) as $slot => $option) {
+ $rows[] = $this->renderOptionRow($theme, $option, $viewport->offset + $slot === $this->cursor);
+ }
+
+ return implode("\n", $this->wrapScrolled($theme, $rows, $viewport));
+ }
+
+ /**
+ * The description of the highlighted item, empty for a non-selectable row.
+ *
+ * @return string
+ * The highlighted item's description.
+ */
+ #[\Override]
+ protected function highlightedDescription(): string {
+ if ($this->items === []) {
+ return '';
+ }
+
+ $current = $this->items[$this->cursor];
+
+ return $current->selectable() ? $current->description : '';
+ }
+
+ /**
+ * Render one row: the marker cell and the (possibly held) item's label.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DrevOps\Tui\Model\Option $option
+ * The item row.
+ * @param bool $current
+ * Whether the row holds the cursor.
+ *
+ * @return string
+ * The rendered row.
+ */
+ public function renderOptionRow(ThemeInterface $theme, Option $option, bool $current): string {
+ return $this->marker($theme, $current) . ' ' . $this->entryLabel($theme, $option->label, $current);
+ }
+
+ /**
+ * The two-column marker cell for a row.
+ *
+ * A held item shows the up-down glyphs, the plain cursor shows the marker,
+ * and every other row is blank - all two columns wide so the labels align.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param bool $current
+ * Whether the row holds the cursor.
+ *
+ * @return string
+ * The two-column marker cell.
+ */
+ protected function marker(ThemeInterface $theme, bool $current): string {
+ // The keys that move it, so the mark says what to press rather than
+ // inventing a second vocabulary for the same two directions.
+ if ($current && $this->grabbed) {
+ return $theme->keyGlyph(Key::named(KeyName::Up)) . $theme->keyGlyph(Key::named(KeyName::Down));
+ }
+
+ return $this->elements($theme)->fieldEntrySelector($current) . ' ';
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A held item flips the labels to "reorder"/"drop" and cannot be accepted
+ * mid-move, so the accept hint is dropped until it lands; otherwise "move"
+ * and "grab" lead the base accept/cancel fragments.
+ */
+ #[\Override]
+ public function hints(): array {
+ if ($this->grabbed) {
+ return [
+ new Hint('reorder', Action::MoveUp, Action::MoveDown),
+ new Hint('drop', Action::Grab),
+ new Hint('cancel', Action::Cancel),
+ ];
+ }
+
+ return [
+ new Hint('move', Action::MoveUp, Action::MoveDown),
+ new Hint('grab', Action::Grab),
+ ...parent::hints(),
+ ];
+ }
+
+}
diff --git a/src/Field/Search.php b/src/Field/Search.php
new file mode 100644
index 00000000..73a328f9
--- /dev/null
+++ b/src/Field/Search.php
@@ -0,0 +1,151 @@
+ $options
+ * Option rows in display order - a list of options or the value => label
+ * shorthand map.
+ * @param string|list $default
+ * The initially highlighted value (single) or selected values (multiple).
+ * @param bool $multiple
+ * Whether several options are collected as a list.
+ * @param int|null $page_size
+ * The number of option rows shown at once before the list pages; NULL uses
+ * the default.
+ * @param \DrevOps\Tui\Model\SelectionBounds|null $selection_bounds
+ * The minimum/maximum selection counts enforced on accept, or NULL for no
+ * count limit.
+ */
+ public function __construct(array $options, string|array $default = '', bool $multiple = FALSE, ?int $page_size = NULL, ?SelectionBounds $selection_bounds = NULL) {
+ $this->initChoice($options, $default, $multiple);
+ $this->pageSize = $this->resolvePageSize($page_size);
+ $this->selectionBounds = $selection_bounds;
+ }
+
+ /**
+ * The field type this field binds its keys under.
+ *
+ * @return \DrevOps\Tui\Model\FieldType
+ * The search field type.
+ */
+ protected function choiceType(): FieldType {
+ return FieldType::Search;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Space is part of the query in single mode, so it cannot double as a select
+ * key there; multiple mode binds Space to toggle the highlighted option.
+ */
+ protected function handleSingleMode(Key $key): void {
+ if ($this->keys()->matches($key, Action::InsertSpace)) {
+ $this->filter .= ' ';
+ $this->resetFilterCursor();
+
+ return;
+ }
+
+ if ($this->handleFilterKey($key)) {
+ return;
+ }
+
+ $this->handleSingleChoiceKey($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query(): string {
+ return $this->filter;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function adoptQueryRows(array $rows): void {
+ $this->options = $rows;
+ $this->resetFilterCursor();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->queryLine($theme) . "\n" . ($this->queryStateLine($theme) ?? $this->renderChoiceList($theme));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * A query that has not run yet stands in for the list, and a count limit on
+ * a list nobody can see yet is noise.
+ */
+ #[\Override]
+ protected function renderConstraint(ThemeInterface $theme): string {
+ return $this->queryStateLine($theme) === NULL ? $this->selectionHint($theme) : '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function queryLine(ThemeInterface $theme): string {
+ return $this->filterLine($theme) . $this->placeholderGhost($theme, $this->filter);
+ }
+
+}
diff --git a/src/Field/Select.php b/src/Field/Select.php
new file mode 100644
index 00000000..49aeb251
--- /dev/null
+++ b/src/Field/Select.php
@@ -0,0 +1,107 @@
+ $options
+ * Option rows in display order - a list of options or the value => label
+ * shorthand map.
+ * @param string|list $default
+ * The initially highlighted value (single) or selected values (multiple).
+ * @param bool $multiple
+ * Whether several options are collected as a list.
+ * @param int|null $page_size
+ * The number of option rows shown at once before the list pages; NULL uses
+ * the default.
+ * @param \DrevOps\Tui\Model\SelectionBounds|null $selection_bounds
+ * The minimum/maximum selection counts enforced on accept, or NULL for no
+ * count limit.
+ */
+ public function __construct(array $options, string|array $default = '', bool $multiple = FALSE, ?int $page_size = NULL, ?SelectionBounds $selection_bounds = NULL) {
+ $this->initChoice($options, $default, $multiple);
+ $this->pageSize = $this->resolvePageSize($page_size);
+ $this->selectionBounds = $selection_bounds;
+ }
+
+ /**
+ * The field type this field binds its keys under.
+ *
+ * @return \DrevOps\Tui\Model\FieldType
+ * The select field type.
+ */
+ protected function choiceType(): FieldType {
+ return FieldType::Select;
+ }
+
+ /**
+ * Filter the options by case-insensitive substring over the labels.
+ *
+ * @param string $needle
+ * The query.
+ *
+ * @return list<\DrevOps\Tui\Model\Option>
+ * The matching option rows.
+ */
+ protected function filterOptions(string $needle): array {
+ $lower = Strings::lower($needle);
+
+ return array_values(array_filter($this->options, static fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(Strings::lower($option->label), $lower)));
+ }
+
+ /**
+ * The matched-character positions: a plain choice list highlights none.
+ *
+ * @param string $label
+ * The option label.
+ *
+ * @return list
+ * The matched indices (always empty).
+ */
+ protected function matchPositions(string $label): array {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->renderChoiceList($theme);
+ }
+
+}
diff --git a/src/Field/Suggest.php b/src/Field/Suggest.php
new file mode 100644
index 00000000..9bd503eb
--- /dev/null
+++ b/src/Field/Suggest.php
@@ -0,0 +1,363 @@
+
+ */
+ protected array $ranked = [];
+
+ /**
+ * Construct a suggest field.
+ *
+ * @param list $values
+ * The suggestion values.
+ * @param string $default
+ * The initial input.
+ * @param int|null $page_size
+ * The number of suggestions shown at once before the list pages; NULL uses
+ * the default.
+ * @param array $descriptions
+ * The description shown for a highlighted suggestion, keyed by value; a
+ * value with no entry shows none.
+ * @param bool $ghost
+ * Whether the leading prefix match is previewed as inline ghost-text after
+ * the caret; FALSE leaves the ranked list as the only completion.
+ */
+ public function __construct(protected array $values, string $default = '', ?int $page_size = NULL, protected array $descriptions = [], protected bool $ghost = FALSE) {
+ $this->buffer = $default;
+ $this->pageSize = $this->resolvePageSize($page_size);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Suggest);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Complete)) {
+ $this->applyCompletion();
+
+ return;
+ }
+
+ // Right accepts the ghost-text like Tab; with nothing to complete it is
+ // inert, as it is for a suggest field that never opted into ghost-text.
+ if ($keys->matches($key, Action::MoveRight) && $this->bestMatch() !== NULL) {
+ $this->applyCompletion();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveDown)) {
+ $this->cursor = min(count($this->visible()) - 1, $this->cursor + 1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveUp)) {
+ $this->cursor = max(-1, $this->cursor - 1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::DeleteBack)) {
+ $this->backspace();
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::InsertSpace)) {
+ $this->insert(' ');
+
+ return;
+ }
+
+ if ($key->isChar()) {
+ $this->insert($key->char ?? '');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buffer(): string {
+ return $this->buffer;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The buffer is append-only - the query grows at its end - so the text is
+ * added there and the suggestion highlight resets.
+ */
+ public function insert(string $text): void {
+ $this->buffer .= $text;
+ $this->resetFilterCursor();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function backspace(): void {
+ $this->buffer = Strings::substr($this->buffer, 0, -1);
+ $this->resetFilterCursor();
+ }
+
+ /**
+ * Reset the highlight and paging when the query changes.
+ */
+ protected function resetFilterCursor(): void {
+ $this->cursor = -1;
+ $this->offset = 0;
+ }
+
+ /**
+ * Whether a completion is offered in the field's current state.
+ *
+ * The buffer is append-only, so the caret is always at its end; what gates a
+ * completion here is what the rest of the editor is saying. Once a suggestion
+ * is highlighted it, not the buffer, is the live value, so previewing a
+ * completion of the buffer would contradict it. While a query is in flight
+ * the candidates still held are the previous query's, and the list they came
+ * from has already been replaced by the loading indicator - previewing one of
+ * them would put back the very answer the field is withdrawing.
+ *
+ * @return bool
+ * TRUE when the ghost-text preview applies.
+ */
+ protected function completionAvailable(): bool {
+ return $this->ghost && $this->cursor < 0 && !$this->queryLoading;
+ }
+
+ /**
+ * The candidates the buffer is completed against.
+ *
+ * Drawn from the displayed list rather than the declared order, so the
+ * previewed completion is always the leading prefix match of the very list
+ * shown beneath it - whether that order came from local ranking or from a
+ * query source.
+ *
+ * @return list
+ * The suggestion values in display order.
+ */
+ protected function completionCandidates(): array {
+ return $this->visible();
+ }
+
+ /**
+ * Land an accepted completion in the query.
+ *
+ * The completion is a new query, not a selection: the list re-filters around
+ * it and stays open, with nothing highlighted.
+ *
+ * @param string $match
+ * The candidate to complete the query to.
+ */
+ protected function completeBuffer(string $match): void {
+ $this->buffer = $match;
+ $this->resetFilterCursor();
+ }
+
+ /**
+ * The suggestions matching the current buffer, ranked by fuzzy relevance.
+ *
+ * Suggestions that came from a query source are already the answer to the
+ * buffer, so ranking them again locally would drop the ones that do not
+ * literally match it.
+ *
+ * Only the locally ranked path is memoized, and deliberately so: there the
+ * values are fixed for the field's life, so the query alone determines the
+ * ranking and one pass serves the several reads a frame makes - the list, the
+ * highlighted description, the live value and the ghost-text preview. A query
+ * source replaces the values as each query settles, which a query-keyed
+ * memo could not see, so that path reads them directly every time.
+ *
+ * @return list
+ * The matching suggestion values, most relevant first.
+ */
+ protected function visible(): array {
+ if ($this->buffer === '' || $this->queryDriven) {
+ return $this->values;
+ }
+
+ if ($this->rankedFor === $this->buffer) {
+ return $this->ranked;
+ }
+
+ $this->rankedFor = $this->buffer;
+
+ return $this->ranked = $this->matcher()->rankValues($this->values, $this->buffer);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query(): string {
+ return $this->buffer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function adoptQueryRows(array $rows): void {
+ $this->values = Option::selectableValues($rows);
+
+ $this->descriptions = [];
+ foreach ($rows as $row) {
+ if ($row->selectable()) {
+ $this->descriptions[$row->value] = $row->description;
+ }
+ }
+
+ $this->resetFilterCursor();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ if ($this->cursor >= 0) {
+ $visible = $this->visible();
+
+ return $visible[$this->cursor] ?? $this->buffer;
+ }
+
+ return $this->buffer;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $state = $this->queryStateLine($theme);
+ if ($state !== NULL) {
+ return $this->queryLine($theme) . "\n" . $state;
+ }
+
+ $visible = $this->visible();
+ $viewport = $this->pageViewport(count($visible), $this->cursor);
+
+ $rows = [];
+
+ foreach (array_slice($visible, $viewport->offset, $this->pageSize) as $slot => $value) {
+ $current = $viewport->offset + $slot === $this->cursor;
+ $rows[] = $this->elements($theme)->fieldEntrySelector($current) . ' ' . $this->renderMatchedLabel($theme, $value, $this->matchPositions($value), $current);
+ }
+
+ return implode("\n", [$this->queryLine($theme), ...$this->wrapScrolled($theme, $rows, $viewport)]);
+ }
+
+ /**
+ * The description of the highlighted suggestion, empty when none is active.
+ *
+ * @return string
+ * The highlighted suggestion's description.
+ */
+ #[\Override]
+ protected function highlightedDescription(): string {
+ if ($this->cursor < 0) {
+ return '';
+ }
+
+ $visible = $this->visible();
+
+ return $this->descriptions[$visible[$this->cursor] ?? ''] ?? '';
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The completion suffix and the placeholder share the one ghost slot after
+ * the caret: the former needs a typed query to complete, the latter an empty
+ * one, so at most one of them is ever set.
+ */
+ public function queryLine(ThemeInterface $theme): string {
+ $completion = $this->ghostSuffix();
+ $elements = $this->elements($theme);
+
+ return $elements->fieldDraft($this->buffer) . $elements->fieldCaret() . ($completion === '' ? $this->placeholderGhost($theme, $this->buffer) : $elements->fieldGhost($completion));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function matchPositions(string $label): array {
+ return $this->buffer === '' ? [] : $this->matcher()->positions($label, $this->buffer);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function hints(): array {
+ return [new Hint('move', Action::MoveUp, Action::MoveDown), ...parent::hints()];
+ }
+
+}
diff --git a/src/Field/Template.php b/src/Field/Template.php
new file mode 100644
index 00000000..f4cb4f31
--- /dev/null
+++ b/src/Field/Template.php
@@ -0,0 +1,294 @@
+
+ */
+ protected array $names;
+
+ /**
+ * The value of each slot, keyed by slot name.
+ *
+ * @var array
+ */
+ protected array $parts = [];
+
+ /**
+ * The index of the slot holding the caret.
+ */
+ protected int $active = 0;
+
+ /**
+ * Construct a template field.
+ *
+ * @param \DrevOps\Tui\Model\Template $template
+ * The shape to fill in.
+ * @param string $default
+ * The initial assembled value; a value that does not have the shape leaves
+ * every slot empty.
+ */
+ public function __construct(protected TemplateModel $template, string $default = '') {
+ $this->names = $this->template->placeholders();
+ $extracted = $this->template->extract($default);
+
+ foreach ($this->names as $name) {
+ $this->parts[$name] = $extracted[$name] ?? '';
+ }
+
+ $this->focus(0);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Template);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveDown)) {
+ $this->move(1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveUp)) {
+ $this->move(-1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::Accept)) {
+ $this->submit();
+
+ return;
+ }
+
+ $this->handleTextEditKey($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * The assembled string, with the live buffer standing in for its slot.
+ */
+ #[\Override]
+ protected function liveValue(): mixed {
+ return $this->template->assemble($this->values());
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Moving between slots is the action a reader will not guess, so it leads.
+ */
+ #[\Override]
+ public function hints(): array {
+ return [new Hint('move between parts', Action::MoveDown, Action::MoveUp), ...parent::hints()];
+ }
+
+ /**
+ * The value of every slot, with the live buffer standing in for its slot.
+ *
+ * @return array
+ * The slot values keyed by slot name, in shape order.
+ */
+ protected function values(): array {
+ $values = $this->parts;
+ $values[$this->activeName()] = $this->buffer;
+
+ return $values;
+ }
+
+ /**
+ * The name of the slot holding the caret.
+ *
+ * @return string
+ * The slot name.
+ */
+ protected function activeName(): string {
+ return $this->names[$this->active] ?? '';
+ }
+
+ /**
+ * Move the caret to another slot, wrapping around the ends.
+ *
+ * The slot being left is validated on the way out: a rejected value shows its
+ * error but does not hold the caret, so a slot filled in the wrong order can
+ * still be reached and corrected.
+ *
+ * @param int $direction
+ * The number of slots to move by: 1 forward, -1 back.
+ */
+ protected function move(int $direction): void {
+ $count = count($this->names);
+ $this->parts[$this->activeName()] = $this->buffer;
+ $this->error = $this->template->partError($this->activeName(), $this->buffer);
+
+ $this->focus((($this->active + $direction) % $count + $count) % $count);
+ }
+
+ /**
+ * Put the caret on a slot, loading its value into the edit buffer.
+ *
+ * @param int $index
+ * The slot index.
+ */
+ protected function focus(int $index): void {
+ $this->active = $index;
+ $this->initTextBuffer($this->parts[$this->activeName()] ?? '');
+ }
+
+ /**
+ * Accept the assembled value once every slot passes its own validator.
+ *
+ * A rejected slot takes the caret, so the shown error names the slot the user
+ * is looking at.
+ */
+ protected function submit(): void {
+ $values = $this->values();
+ $this->parts = $values;
+
+ foreach ($this->names as $index => $name) {
+ $error = $this->template->partError($name, $values[$name] ?? '');
+ if ($error !== NULL) {
+ $this->focus($index);
+ $this->error = $error;
+
+ return;
+ }
+ }
+
+ if (!$this->rejectAmbiguous($values)) {
+ return;
+ }
+
+ $this->accept($this->template->assemble($values));
+ }
+
+ /**
+ * Refuse a slot whose value would be misread once the shape is assembled.
+ *
+ * @param array $values
+ * The value of each slot, keyed by slot name.
+ *
+ * @return bool
+ * TRUE when every slot survives assembly; FALSE when one was rejected, the
+ * caret moved to it and the error set.
+ */
+ protected function rejectAmbiguous(array $values): bool {
+ $name = $this->template->ambiguousSlot($values);
+ if ($name === NULL) {
+ return TRUE;
+ }
+
+ $index = (int) array_search($name, $this->names, TRUE);
+ $this->focus($index);
+ $this->error = Translator::t('@label: must not contain "@text".', [
+ '@label' => $this->template->labelOf($name),
+ '@text' => $this->template->literalAt($index + 1),
+ ]);
+
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $shape = '';
+
+ foreach ($this->names as $index => $name) {
+ $shape .= $this->renderLiteral($theme, $index) . $this->renderSlot($theme, $index, $name);
+ }
+
+ $shape .= $this->renderLiteral($theme, count($this->names));
+
+ return $shape . "\n" . $this->elements($theme)->fieldState(Translator::t('filling in @label', ['@label' => $this->template->labelOf($this->activeName())]));
+ }
+
+ /**
+ * Render one chunk of the shape's fixed text, dimmed to read as context.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme supplying the dimmed styling.
+ * @param int $index
+ * The chunk position.
+ *
+ * @return string
+ * The rendered chunk; an absent chunk styles to nothing rather than to a
+ * bare pair of styling codes.
+ */
+ protected function renderLiteral(ThemeInterface $theme, int $index): string {
+ $literal = $this->template->literalAt($index);
+
+ return $literal === '' ? '' : $this->elements($theme)->fieldDescription($literal);
+ }
+
+ /**
+ * Render one slot: the live caret line, a filled value, or a dimmed hint.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme supplying the caret glyph and the dimmed styling.
+ * @param int $index
+ * The slot index.
+ * @param string $name
+ * The slot name.
+ *
+ * @return string
+ * The rendered slot.
+ */
+ protected function renderSlot(ThemeInterface $theme, int $index, string $name): string {
+ if ($index === $this->active) {
+ return $this->renderCaretLine($theme);
+ }
+
+ $value = $this->parts[$name] ?? '';
+
+ // An empty slot would collapse the shape into its fixed text alone, so it
+ // shows its label instead - dimmed, to read as a hint and not a value.
+ return $value === '' ? $this->elements($theme)->fieldDescription($this->template->labelOf($name)) : $value;
+ }
+
+}
diff --git a/src/Field/Text.php b/src/Field/Text.php
new file mode 100644
index 00000000..8cdde63d
--- /dev/null
+++ b/src/Field/Text.php
@@ -0,0 +1,118 @@
+ $completions
+ * Inline ghost-text candidates: the buffer is completed to the first
+ * candidate it is a prefix of. Empty leaves a plain text field.
+ */
+ public function __construct(string $default = '', protected array $completions = []) {
+ $this->initTextBuffer($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Text);
+ }
+
+ /**
+ * The candidates the buffer is completed against.
+ *
+ * @return list
+ * The declared candidates, in declaration order.
+ */
+ protected function completionCandidates(): array {
+ return $this->completions;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Complete)) {
+ $this->applyCompletion();
+
+ return;
+ }
+
+ // At the line's end, Right accepts the ghost-text like Tab; elsewhere it
+ // falls through to the plain caret move.
+ if ($keys->matches($key, Action::MoveRight) && $this->bestMatch() !== NULL) {
+ $this->applyCompletion();
+
+ return;
+ }
+
+ $this->handleTextEditKey($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->caretLine($theme);
+ }
+
+ /**
+ * Render the input line with the caret and any inline ghost-text.
+ *
+ * The completion suffix and the placeholder share the one ghost slot: the
+ * former needs a typed prefix to complete, the latter an empty buffer, so at
+ * most one of them is ever set.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme supplying the caret glyph and the ghost styling.
+ *
+ * @return string
+ * The input line.
+ */
+ protected function caretLine(ThemeInterface $theme): string {
+ $completion = $this->ghostSuffix();
+
+ return $this->renderInputLine($theme, $completion === '' ? $this->placeholderText($this->buffer) : $completion);
+ }
+
+}
diff --git a/src/Field/Textarea.php b/src/Field/Textarea.php
new file mode 100644
index 00000000..eb8f9333
--- /dev/null
+++ b/src/Field/Textarea.php
@@ -0,0 +1,186 @@
+initTextBuffer($default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Textarea);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($keys->matches($key, Action::ExternalEdit)) {
+ // Only act when the handoff is offered; either way the bound key is
+ // swallowed rather than inserting a raw control byte into the buffer.
+ if ($this->externalEdit) {
+ $this->externalEditRequested = TRUE;
+ }
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::NewLine)) {
+ $this->insert("\n");
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveUp)) {
+ $this->moveLine(-1);
+
+ return;
+ }
+
+ if ($keys->matches($key, Action::MoveDown)) {
+ $this->moveLine(1);
+
+ return;
+ }
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ // Accept is checked here, after the newline branch, because this scope
+ // binds it to Tab rather than Enter.
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ $this->handleTextEditKey($key);
+ }
+
+ /**
+ * Move the cursor to the adjacent line, keeping the column when possible.
+ *
+ * @param int $delta
+ * The line offset: -1 for up, 1 for down.
+ */
+ protected function moveLine(int $delta): void {
+ $lines = explode("\n", $this->buffer);
+
+ $line = 0;
+ $column = $this->cursor;
+ foreach ($lines as $index => $text) {
+ $length = Strings::length($text);
+
+ if ($column <= $length) {
+ $line = $index;
+ break;
+ }
+
+ // Skip the line and its trailing newline.
+ $column -= $length + 1;
+ }
+
+ $target = $line + $delta;
+
+ if ($target < 0 || $target >= count($lines)) {
+ return;
+ }
+
+ $offset = 0;
+ for ($index = 0; $index < $target; $index++) {
+ $offset += Strings::length($lines[$index]) + 1;
+ }
+
+ $this->cursor = $offset + min($column, Strings::length($lines[$target]));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function wantsExternalEdit(): bool {
+ return $this->externalEditRequested;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Clears the pending request. A non-NULL buffer replaces the value and is
+ * accepted, so saving and exiting the editor commits the field. A NULL buffer
+ * (the edit was aborted or unavailable) leaves the inline value untouched.
+ */
+ public function applyExternalEdit(?string $content): void {
+ $this->externalEditRequested = FALSE;
+
+ if ($content === NULL) {
+ return;
+ }
+
+ $this->buffer = $content;
+ $this->cursor = Strings::length($content);
+ $this->accept($content);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ return $this->renderCaretLine($theme) . $this->placeholderGhost($theme, $this->buffer);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function hints(): array {
+ $hints = [new Hint('insert a newline', Action::NewLine), ...parent::hints()];
+
+ if ($this->externalEdit) {
+ $hints[] = new Hint('open the editor', Action::ExternalEdit);
+ }
+
+ return $hints;
+ }
+
+}
diff --git a/src/Field/Toggle.php b/src/Field/Toggle.php
new file mode 100644
index 00000000..49160996
--- /dev/null
+++ b/src/Field/Toggle.php
@@ -0,0 +1,146 @@
+
+ */
+ protected array $values;
+
+ /**
+ * The selected option index.
+ */
+ protected int $cursor = 0;
+
+ /**
+ * Construct a toggle field.
+ *
+ * @param array $labels
+ * Options as value => label, in display order.
+ * @param string $default
+ * The initially selected value.
+ */
+ public function __construct(protected array $labels, string $default = '') {
+ $this->values = array_keys($this->labels);
+ $index = array_search($default, $this->values, TRUE);
+ $this->cursor = $index === FALSE ? 0 : $index;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Toggle);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($this->handleAccept($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Toggle)) {
+ $this->stepBy(1);
+
+ return;
+ }
+
+ if ($key->isChar()) {
+ $this->applyChar($key->char ?? '');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Each position moves to the adjacent value, wrapping at either end.
+ */
+ public function stepBy(int $delta): void {
+ $count = count($this->values);
+ if ($count < 2) {
+ return;
+ }
+
+ $this->cursor = (($this->cursor + $delta) % $count + $count) % $count;
+ }
+
+ /**
+ * Select the value whose label starts with the typed character.
+ *
+ * The first matching label wins, so labels sharing a first letter resolve to
+ * the one declared first; the other stays reachable by flipping.
+ *
+ * @param string $char
+ * The typed character.
+ */
+ protected function applyChar(string $char): void {
+ $char = Strings::lower($char);
+
+ foreach ($this->values as $index => $value) {
+ $label = $this->labels[$value] ?? $value;
+ if ($label !== '' && Strings::lower(Strings::substr($label, 0, 1)) === $char) {
+ $this->cursor = $index;
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->values[$this->cursor] ?? '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function renderBody(ThemeInterface $theme): string {
+ $parts = [];
+
+ foreach ($this->values as $index => $value) {
+ $parts[] = $this->renderExclusiveRow($theme, $this->labels[$value] ?? $value, $index === $this->cursor);
+ }
+
+ return implode(' ', $parts);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function hints(): array {
+ return [new Hint('toggle', Action::Toggle), ...parent::hints()];
+ }
+
+}
diff --git a/src/Handler/HandlerRegistry.php b/src/Handler/HandlerRegistry.php
index eb4ea6d3..0520e02b 100644
--- a/src/Handler/HandlerRegistry.php
+++ b/src/Handler/HandlerRegistry.php
@@ -11,7 +11,7 @@
* "machine_name" -> "MachineName") and looked up in the registered
* namespaces, in order. The class is the consumer's own - typically its
* processor for the field. When it declares a public static validate() or
- * transform(), the engine uses them as the field's reusable behaviour unless
+ * transform(), a collection uses them as the field's reusable behaviour unless
* the form declares its own closure, which always wins.
*
* @package DrevOps\Tui\Handler
diff --git a/src/Input/Action.php b/src/Input/Action.php
index 3d0d27cd..aa2c5c17 100644
--- a/src/Input/Action.php
+++ b/src/Input/Action.php
@@ -7,11 +7,11 @@
/**
* A semantic input action, decoupled from the physical key that triggers it.
*
- * Widgets and the panel controller ask a {@see ScopedKeyMap} whether a key
+ * Fields and the panel controller ask a {@see ScopedKeyMap} whether a key
* press means a given action ("is this Accept?"), rather than testing a raw
* {@see KeyName}. The map binds each action to one or more keys, per scope, so
* the same action can be reached by a different key in a different context (or
- * after a consumer remap). These are the fixed set of intents the widgets
+ * after a consumer remap). These are the fixed set of intents the fields
* understand; the bindings behind them are configurable, the intents are not.
*
* @package DrevOps\Tui\Input
diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php
index e2f6de15..80c5dae1 100644
--- a/src/Input/DefaultKeyMap.php
+++ b/src/Input/DefaultKeyMap.php
@@ -10,8 +10,8 @@
* The built-in key bindings: the defaults every form uses unless it opts out.
*
* A preset is a class listing its {@see Binding}s, the way a theme is a class
- * of styling methods. The base bindings are shared by every widget; the
- * navigation and per-widget-type bindings override the base only where a key
+ * of styling methods. The base bindings are shared by every field; the
+ * navigation and per-field-type bindings override the base only where a key
* means something different (Enter inserts a newline in a textarea, Space
* toggles a checkbox option, and so on). Subclass this and override
* {@see bindings()} to ship an alternate preset - {@see VimKeyMap} does exactly
@@ -101,10 +101,24 @@ public function bindings(): array {
$bindings[] = new Binding(Scope::field(FieldType::FilePicker, multiple: TRUE), Action::Reveal, KeyName::Tab);
$bindings[] = new Binding(Scope::field(FieldType::FilePicker, multiple: TRUE), Action::Toggle, KeyName::Space);
- // The reorder widget picks up and drops the highlighted item on Space;
+ // The reorder field picks up and drops the highlighted item on Space;
// Up and Down (inherited from the base) move the cursor, or the held item.
$bindings[] = new Binding(Scope::field(FieldType::Reorder), Action::Grab, KeyName::Space);
+ // A field stays reachable by its help key while it is open, wherever the
+ // key is free to mean that. Where the field takes typed characters the key
+ // is one of them, and the scope is skipped rather than fought over - which
+ // is also what {@see KeyMap::guard()} would insist on.
+ foreach (FieldType::cases() as $type) {
+ foreach ([FALSE, TRUE] as $multiple) {
+ $scope = Scope::field($type, $multiple);
+
+ if (!$scope->consumesText()) {
+ $bindings[] = new Binding($scope, Action::Help, '?');
+ }
+ }
+ }
+
return $bindings;
}
diff --git a/src/Input/Hint.php b/src/Input/Hint.php
index 748115f2..4e1630cb 100644
--- a/src/Input/Hint.php
+++ b/src/Input/Hint.php
@@ -12,7 +12,7 @@
* A hint pairs a human label ("move", "accept", "none/all") with the actions
* whose live keys illustrate it, so the glyphs are rendered from the active
* bindings - {@see \DrevOps\Tui\Theme\DefaultTheme::renderHints()} - and never
- * drift from a remap. A context - a widget or the panel hub - declares an
+ * drift from a remap. A context - a field or the panel hub - declares an
* ordered list of these; the theme turns each into a fragment and joins them.
* Listing two actions under one label groups their keys ("←/→ none/all").
*
diff --git a/src/Input/KeyMap.php b/src/Input/KeyMap.php
index 1f26798d..ae7e5d68 100644
--- a/src/Input/KeyMap.php
+++ b/src/Input/KeyMap.php
@@ -11,7 +11,7 @@
*
* Built from a flat list of {@see Binding}s - a preset's defaults followed by
* any consumer overrides - it layers them into one {@see ScopedKeyMap} per
- * scope: the base defaults, panel navigation, and each widget type that
+ * scope: the base defaults, panel navigation, and each field type that
* overrides the base. Later bindings win, so a consumer replaces a default by
* re-declaring it, and a scope override reassigns a key without disturbing the
* base for other scopes.
@@ -19,7 +19,7 @@
* Resolution validates eagerly and fails loudly, so a bad declaration is caught
* when the form is built rather than mid-session:
* - a key bound to two different actions in the same scope is a conflict;
- * - a printable character bound in the base scope, or in a scope whose widget
+ * - a printable character bound in the base scope, or in a scope whose field
* consumes typed input, would be un-typeable and is rejected;
* - a character binding that is not exactly one character is rejected.
*
@@ -28,7 +28,7 @@
final class KeyMap {
/**
- * The base scope: the defaults shared by every widget.
+ * The base scope: the defaults shared by every field.
*/
protected ScopedKeyMap $base;
@@ -82,7 +82,7 @@ public function navigation(): ScopedKeyMap {
}
/**
- * The scope for a widget type, or the base when the type has no overrides.
+ * The scope for a field type, or the base when the type has no overrides.
*
* @param \DrevOps\Tui\Model\FieldType $type
* The field type.
@@ -90,7 +90,7 @@ public function navigation(): ScopedKeyMap {
* Whether the multiple-collecting variant of the type is targeted.
*
* @return \DrevOps\Tui\Input\ScopedKeyMap
- * The bindings for that widget type.
+ * The bindings for that field type.
*/
public function forField(FieldType $type, bool $multiple = FALSE): ScopedKeyMap {
return $this->fields[Scope::field($type, $multiple)->token()] ?? $this->base;
@@ -243,7 +243,7 @@ protected function assertTypeable(array $inverted, Scope $scope): void {
throw new \InvalidArgumentException(sprintf('The %s scope consumes typed characters, so the printable character "%s" cannot be bound to an action there.', $scope->label(), $entry['key']->label()));
}
- throw new \InvalidArgumentException(sprintf('The base scope may not bind the printable character "%s"; it would be un-typeable in text widgets. Bind it in a specific non-text scope instead.', $entry['key']->label()));
+ throw new \InvalidArgumentException(sprintf('The base scope may not bind the printable character "%s"; it would be un-typeable in text fields. Bind it in a specific non-text scope instead.', $entry['key']->label()));
}
}
diff --git a/src/Input/KeyName.php b/src/Input/KeyName.php
index 17658748..f74641c0 100644
--- a/src/Input/KeyName.php
+++ b/src/Input/KeyName.php
@@ -5,7 +5,7 @@
namespace DrevOps\Tui\Input;
/**
- * Named special keys recognised by the widgets and the panel loop.
+ * Named special keys recognised by the fields and the panel loop.
*
* @package DrevOps\Tui\Input
*/
diff --git a/src/Input/Scope.php b/src/Input/Scope.php
index 4fa44efe..29b31d3e 100644
--- a/src/Input/Scope.php
+++ b/src/Input/Scope.php
@@ -7,10 +7,10 @@
use DrevOps\Tui\Model\FieldType;
/**
- * The binding context a key resolves in: base, navigation, or one widget type.
+ * The binding context a key resolves in: base, navigation, or one field type.
*
* Bindings are layered. The base scope holds the defaults shared by every
- * widget; navigation and per-field-type scopes override the base for the few
+ * field; navigation and per-field-type scopes override the base for the few
* keys that mean something different there (Enter inserts a newline in a
* textarea, Space toggles an option in a checkbox list, and so on). A scope is
* a value, not an enum, so it can wrap a {@see FieldType} without duplicating
@@ -24,7 +24,7 @@
final readonly class Scope {
/**
- * The field types whose widgets consume printable characters as input.
+ * The field types whose fields consume printable characters as input.
*
* In these scopes a letter or digit is the value the user is typing, so a
* binding may not claim a printable character for an action - that would make
@@ -73,7 +73,7 @@
* Construct a scope.
*
* @param \DrevOps\Tui\Model\FieldType|null $fieldType
- * The widget type this scope targets, or NULL for the base and navigation
+ * The field type this scope targets, or NULL for the base and navigation
* scopes.
* @param bool $navigation
* Whether this is the navigation scope.
@@ -88,7 +88,7 @@ protected function __construct(
}
/**
- * The base scope holding the defaults shared by every widget.
+ * The base scope holding the defaults shared by every field.
*
* @return self
* The base scope.
@@ -108,7 +108,7 @@ public static function navigation(): self {
}
/**
- * The scope for a single widget type.
+ * The scope for a single field type.
*
* @param \DrevOps\Tui\Model\FieldType $type
* The field type.
@@ -141,7 +141,7 @@ public function token(): string {
}
/**
- * Whether this scope's widget consumes printable characters as typed input.
+ * Whether this scope's field consumes printable characters as typed input.
*
* @return bool
* TRUE when a printable character is reserved for typing and may not be
diff --git a/src/Input/ScopedKeyMap.php b/src/Input/ScopedKeyMap.php
index b9b44ac5..2c4e8618 100644
--- a/src/Input/ScopedKeyMap.php
+++ b/src/Input/ScopedKeyMap.php
@@ -5,9 +5,9 @@
namespace DrevOps\Tui\Input;
/**
- * The resolved bindings for one scope, narrowed for a single widget.
+ * The resolved bindings for one scope, narrowed for a single field.
*
- * A widget (or the panel controller) holds one of these and asks it whether a
+ * A field (or the panel controller) holds one of these and asks it whether a
* key press means an action - {@see matches()} - instead of testing raw key
* names. It also answers the reverse question - {@see keysFor()} and
* {@see primary()} - so key hints can be rendered from the same source of
diff --git a/src/Model/Buttons.php b/src/Model/Buttons.php
index a3582a85..f835e741 100644
--- a/src/Model/Buttons.php
+++ b/src/Model/Buttons.php
@@ -7,9 +7,10 @@
/**
* The submit/cancel action pair that closes a form or a modal.
*
- * Shared chrome: a {@see FormDefinition} carries one for its root submit/cancel
- * row, and a {@see Modal} carries one for the dialog's own row. The labels are
- * configurable; a modal always shows its pair, while a form may hide it.
+ * Shared chrome: the outermost panel carries one for the row that ends the
+ * form, and a panel drawn over what is behind it carries one for the row that
+ * closes the dialog. The labels are configurable; a dialog always shows its
+ * pair, because it is the only way out of one, while a form may hide it.
*
* @package DrevOps\Tui\Model
*/
diff --git a/src/Model/DateBounds.php b/src/Model/DateBounds.php
index 54d9160e..b6083777 100644
--- a/src/Model/DateBounds.php
+++ b/src/Model/DateBounds.php
@@ -11,7 +11,7 @@
*
* Either bound may be unset (NULL) to leave that side open. The range
* arithmetic, the strict ISO parsing and the human range phrase live here once,
- * so the interactive widget, the headless engine and the answer-set validator
+ * so the interactive field, a headless collection and the answer-set validator
* all agree. The week-start day is a display concern that rides along the way
* the number field's keyboard step rides along in {@see NumberBounds}.
*
diff --git a/src/Model/Field.php b/src/Model/Field.php
deleted file mode 100644
index 70fc5677..00000000
--- a/src/Model/Field.php
+++ /dev/null
@@ -1,795 +0,0 @@
-
- */
- public array $options;
-
- /**
- * How many conditions deep the field sits, resolved by its form definition.
- *
- * Zero for a field that shows unconditionally, one for a field whose `when`
- * rule references only unconditional fields, and one more for each further
- * link in the chain. A field outside any form definition keeps the zero it
- * is constructed with.
- *
- * @see \DrevOps\Tui\Model\FormDefinition::resolveConditionalDepths()
- */
- public int $conditionalDepth = 0;
-
- /**
- * File picker only: the type, extension and size limits on a valid pick.
- */
- public readonly FilePickerConstraints $pickerConstraints;
-
- /**
- * Construct a field.
- *
- * @param string $id
- * The unique field id.
- * @param string $label
- * The human-readable label.
- * @param string $description
- * The help text.
- * @param \DrevOps\Tui\Model\FieldType $type
- * The widget type.
- * @param mixed $default
- * The declared default value, or a `fn (Context): mixed` closure computing
- * a dynamic default from the run context.
- * @param array $options
- * Option rows for choice-based fields, in display order - a list of
- * {@see Option} rows or the value => label shorthand map (normalized via
- * {@see Option::list()}).
- * @param bool $required
- * Whether a value is required.
- * @param string $requiredMessage
- * The message shown when a required field is left empty; empty derives one
- * from the label.
- * @param \DrevOps\Tui\Condition\ConditionInterface|null $when
- * The conditional-visibility rule, evaluated by the engine.
- * @param \DrevOps\Tui\Derive\Derive|null $derive
- * The derive rule, evaluated by the engine.
- * @param \DrevOps\Tui\Discovery\DiscoverInterface|\Closure|null $discover
- * The discovery rule - or a custom `fn (Context): mixed` detector -
- * evaluated by the engine in update mode.
- * @param \Closure|null $validate
- * A declared validator `fn (mixed $value): ?string` returning an error
- * message, or NULL when the value is valid.
- * @param \Closure|null $transform
- * A declared transformer `fn (mixed $value): mixed` normalizing an
- * accepted value.
- * @param bool $revealable
- * Password only: whether the editor offers a reveal/hide toggle.
- * @param bool $confirm
- * Password only: whether the editor prompts for the value twice and rejects
- * a mismatch before accepting.
- * @param bool $externalEditor
- * Whether the field may hand off to the user's $EDITOR for composing its
- * value. Honoured by the textarea widget; ignored by other types.
- * @param \DrevOps\Tui\Model\NumberBounds|null $bounds
- * Number only: optional min/max/step bounds; NULL for a plain integer
- * entry with no range or keyboard stepping.
- * @param \DrevOps\Tui\Model\FilePickerConstraints|null $picker_constraints
- * File picker only: the type, extension and size limits enforced on a pick;
- * NULL (or the default) leaves the picker unconstrained. Ignored by other
- * types.
- * @param string $pickerStart
- * File picker only: the directory the browser opens at and cannot ascend
- * above; empty falls back to the current working directory.
- * @param bool $pickerShowHidden
- * File picker only: whether dot-entries are shown when the browser opens.
- * @param int|null $pageSize
- * Choice widgets only: how many option rows show at once before the list
- * pages; NULL uses the widget default. A purely visual bound - it does not
- * constrain a headless value, so it is absent from the machine schema.
- * @param list|\Closure $completion
- * Text only: the inline ghost-text completion source - a list of candidate
- * strings, or a `fn (array $answers): list` closure
- * over the answers collected so far. Empty disables ghost-text; ignored by
- * other types.
- * @param \DrevOps\Tui\Model\DateBounds|null $dateBounds
- * Date only: the min/max range and week-start day; NULL for non-date
- * fields.
- * @param \DrevOps\Tui\Model\RenderMode $render
- * Where the field's editor is drawn: inline in the panel (the default) or
- * full-screen on its own standalone editor.
- * @param bool $multiple
- * Whether the field collects several values as a list rather than one;
- * honoured by the select, search and file picker types.
- * @param bool $bordered
- * Note only: whether the card is drawn inside a themed border with minimal
- * padding; ignored by other types.
- * @param \DrevOps\Tui\Model\SelectionBounds|null $selectionBounds
- * Multiple only: optional minimum/maximum selection counts; NULL for no
- * count limit.
- * @param \Closure|null $optionsLoader
- * An `fn(): array` loading the options on demand, or NULL
- * for static options. Resolved lazily when the panel opens (headless
- * collection resolves it up front); until then the field reads as loading.
- * @param int|null $progressSteps
- * Progress only: the number of steps for a determinate bar, or NULL for an
- * indeterminate spinner.
- * @param \Closure|null $progressWork
- * Progress only: an `fn(\DrevOps\Tui\Primitive\ProgressReporter): void` run
- * when the row is activated, driving the indicator through `advance()`.
- * @param int|null $progressCurrent
- * Progress only: the live step count while the work runs - the bar fill, or
- * the spinner tick, or NULL before it runs; mutated as the work advances.
- * @param string $progressLabel
- * Progress only: the trailing label the work sets through `advance()`,
- * shown after the bar or spinner glyph. Mutated as the work advances.
- * @param mixed $schemaDefault
- * A static value standing in for {@see $default} in machine-readable output
- * when the declared default is a closure that cannot be resolved without
- * answers; consulted only for a closure default and only when
- * {@see $hasSchemaDefault} is TRUE.
- * @param bool $hasSchemaDefault
- * Whether a {@see $schemaDefault} was declared, so a declared NULL is
- * distinguishable from an absent one.
- * @param \DrevOps\Tui\Model\TableSpec|null $table
- * Note only: a presentational table rendered beneath the card's title and
- * body; NULL when the note carries no table. Ignored by other types.
- * @param \DrevOps\Tui\Model\Template|null $template
- * Template only: the fixed shape whose `{{placeholder}}` slots the field
- * fills in. NULL for every other type.
- * @param \Closure|null $optionsSource
- * An
- * `fn(string $query, array $answers): array`
- * resolving the options for a live query, or NULL for options that do not
- * follow the query. Unlike a loader it is called again whenever the query
- * changes, so the candidates can come from a remote backend that filters
- * for itself.
- * @param int $queryMinLength
- * The number of query characters below which a query source is not called
- * at all, so a remote backend is not asked to list everything; zero calls
- * it for the empty query too.
- * @param string $hint
- * How to answer the question (e.g. "Use arrows and Space to select"), shown
- * beneath the description and styled apart from it. Empty shows no hint.
- * @param string $placeholder
- * The ghost text shown inside the editor while its buffer is empty (e.g.
- * "E.g. Golden Beetroot"). Never becomes a value: it disappears as soon as
- * anything is typed, and it is suppressed without colour, where it could
- * not be told apart from a real entry.
- * @param string $envName
- * The environment variable that answers the field, replacing the
- * mechanically prefixed one. Absolute - the form's prefix is not applied to
- * it - so an existing published name can be reproduced exactly; empty keeps
- * the mechanical name.
- * @param list $envAliases
- * Further environment variables the field also answers to, absolute and in
- * precedence order, so a naming scheme can change without a breaking
- * cut-over. Consulted only when none of the names before them is set.
- * @param bool $ghost
- * Suggest only: whether the leading prefix match among the field's options
- * is previewed as inline ghost-text after the caret, in the same slot the
- * placeholder uses while nothing is typed. A purely visual aid over the
- * same option set, so it is absent from the machine schema and leaves a
- * headless collection untouched.
- * @param array $ratingCaptions
- * Rating only: the caption of a point on the scale, keyed by the point. The
- * scale is the range in {@see $bounds}; a caption is decoration over it, so
- * points may be captioned sparsely and an uncaptioned point still answers.
- * @param \Closure|null $optionsResolver
- * An `fn(Context $context): array` resolving the options
- * from the answers collected so far, or NULL for options that do not follow
- * them. Unlike a loader it is called again whenever the answers change, so
- * one field's choices can narrow by another's answer.
- */
- public function __construct(
- public readonly string $id,
- public readonly string $label,
- public readonly string $description,
- public readonly FieldType $type,
- public readonly mixed $default,
- array $options = [],
- public readonly bool $required = FALSE,
- public readonly string $requiredMessage = '',
- public readonly ?ConditionInterface $when = NULL,
- public readonly ?Derive $derive = NULL,
- public readonly DiscoverInterface|\Closure|null $discover = NULL,
- public readonly ?\Closure $validate = NULL,
- public readonly ?\Closure $transform = NULL,
- public readonly bool $revealable = FALSE,
- public readonly bool $confirm = FALSE,
- public readonly bool $externalEditor = FALSE,
- public readonly ?NumberBounds $bounds = NULL,
- ?FilePickerConstraints $picker_constraints = NULL,
- public readonly string $pickerStart = '',
- public readonly bool $pickerShowHidden = FALSE,
- public readonly ?int $pageSize = NULL,
- public readonly array|\Closure $completion = [],
- public readonly ?DateBounds $dateBounds = NULL,
- public readonly RenderMode $render = RenderMode::Inline,
- public readonly bool $multiple = FALSE,
- public readonly bool $bordered = FALSE,
- public readonly ?SelectionBounds $selectionBounds = NULL,
- public ?\Closure $optionsLoader = NULL,
- public readonly ?int $progressSteps = NULL,
- public readonly ?\Closure $progressWork = NULL,
- public ?int $progressCurrent = NULL,
- public string $progressLabel = '',
- public readonly mixed $schemaDefault = NULL,
- public readonly bool $hasSchemaDefault = FALSE,
- public readonly ?TableSpec $table = NULL,
- public readonly ?Template $template = NULL,
- public readonly ?\Closure $optionsSource = NULL,
- public readonly int $queryMinLength = 0,
- public readonly string $hint = '',
- public readonly string $placeholder = '',
- public readonly string $envName = '',
- public readonly array $envAliases = [],
- public readonly bool $ghost = FALSE,
- public readonly array $ratingCaptions = [],
- public readonly ?\Closure $optionsResolver = NULL,
- ) {
- $this->assertEnvNames();
- $this->assertRatingCaptions();
-
- if ($this->type === FieldType::Template && !$this->template instanceof Template) {
- throw new FormException(sprintf('Field "%s" is a template field but declares no pattern; add ->pattern() with the shape to fill in.', $this->id));
- }
-
- if ($this->optionsSource instanceof \Closure) {
- if (!$this->type->supportsQuerySource()) {
- throw new FormException(sprintf('Field "%s" of type "%s" cannot source its options from a query; only search and suggest fields show one.', $this->id, $this->type->value));
- }
-
- if ($options !== [] || $optionsLoader instanceof \Closure) {
- throw new FormException(sprintf('Field "%s" declares both a query source and its own options; a query source replaces them, so declare only one.', $this->id));
- }
- }
-
- if ($this->queryMinLength > 0 && !$this->optionsSource instanceof \Closure) {
- throw new FormException(sprintf('Field "%s" declares a minimum query length but no query source to apply it to.', $this->id));
- }
-
- if (($options !== [] || $optionsLoader instanceof \Closure || $this->optionsResolver instanceof \Closure) && !$this->type->supportsOptions()) {
- throw new FormException(sprintf('Field "%s" of type "%s" shows no options; only select, search, suggest, toggle and reorder fields have a list.', $this->id, $this->type->value));
- }
-
- if ($this->optionsResolver instanceof \Closure && ($options !== [] || $optionsLoader instanceof \Closure || $this->optionsSource instanceof \Closure)) {
- throw new FormException(sprintf('Field "%s" resolves its options from the answers and declares another set of options as well; the resolved set replaces them, so declare only one.', $this->id));
- }
-
- if ($this->placeholder !== '' && !$this->type->supportsPlaceholder()) {
- throw new FormException(sprintf('Field "%s" of type "%s" shows no placeholder; only text, number, textarea, password, suggest and search fields have an input buffer to ghost.', $this->id, $this->type->value));
- }
-
- if ($this->multiple && !$this->type->supportsMultiple()) {
- throw new FormException(sprintf('Field "%s" of type "%s" does not collect several values; only select, search and file picker fields may be multiple.', $this->id, $this->type->value));
- }
-
- if ($this->selectionBounds instanceof SelectionBounds && !$this->multiple) {
- throw new FormException(sprintf('Field "%s" declares selection limits but does not collect several values.', $this->id));
- }
-
- $this->options = Option::list($options);
- $this->pickerConstraints = $picker_constraints ?? new FilePickerConstraints();
- }
-
- /**
- * Reject declared environment variable names that cannot be honoured.
- *
- * @throws \DrevOps\Tui\Model\FormException
- * When a declared name is not portable, or an alias repeats the name it
- * would never be reached behind.
- */
- protected function assertEnvNames(): void {
- if ($this->envName !== '' && preg_match(self::ENV_NAME_PATTERN, $this->envName) !== 1) {
- throw new FormException(sprintf('Field "%s" declares the environment variable name "%s", which is not a portable name; use letters, digits and underscores, starting with a letter or underscore.', $this->id, $this->envName));
- }
-
- $seen = [];
-
- foreach ($this->envAliases as $env_alias) {
- if (preg_match(self::ENV_NAME_PATTERN, $env_alias) !== 1) {
- throw new FormException(sprintf('Field "%s" declares the environment variable alias "%s", which is not a portable name; use letters, digits and underscores, starting with a letter or underscore.', $this->id, $env_alias));
- }
-
- if ($env_alias === $this->envName) {
- throw new FormException(sprintf('Field "%s" declares "%s" as both its environment variable name and an alias of it; the alias would never be reached, so declare it once.', $this->id, $env_alias));
- }
-
- if (isset($seen[$env_alias])) {
- throw new FormException(sprintf('Field "%s" declares the environment variable alias "%s" more than once; only the first would ever be reached.', $this->id, $env_alias));
- }
-
- $seen[$env_alias] = TRUE;
- }
- }
-
- /**
- * Reject captions that no point on the scale would ever show.
- *
- * @throws \DrevOps\Tui\Model\FormException
- * When captions are declared on a field that draws no scale, or a caption
- * is keyed outside the scale's range.
- */
- protected function assertRatingCaptions(): void {
- if ($this->ratingCaptions === []) {
- return;
- }
-
- if ($this->type !== FieldType::Rating) {
- throw new FormException(sprintf('Field "%s" of type "%s" draws no scale to caption; ->captions() applies to rating fields.', $this->id, $this->type->value));
- }
-
- foreach (array_keys($this->ratingCaptions) as $point) {
- if ($this->bounds instanceof NumberBounds && !$this->bounds->contains($point)) {
- throw new FormException(sprintf('Field "%s" captions the point %d, which is outside its scale of %s.', $this->id, $point, $this->bounds->describe()));
- }
- }
- }
-
- /**
- * Whether the field collects a list of values rather than a single value.
- *
- * @return bool
- * TRUE for a reorder field and for any multiple choice or file picker.
- */
- public function collectsList(): bool {
- return $this->multiple || $this->type === FieldType::Reorder;
- }
-
- /**
- * Whether the field is a multi-selection over its declared option set.
- *
- * Narrower than {@see collectsList()}: a multiple file picker collects a
- * list too, but its entries come from the filesystem, not the options.
- *
- * @return bool
- * TRUE for the option-backed multi-choice fields.
- */
- public function isMultiChoice(): bool {
- return $this->type === FieldType::Reorder || ($this->multiple && $this->type->constrainsToOptions());
- }
-
- /**
- * Whether a headless value has the shape this field collects.
- *
- * @param mixed $value
- * The candidate value.
- *
- * @return bool
- * TRUE when the value's type matches the field.
- */
- public function acceptsValue(mixed $value): bool {
- return match (TRUE) {
- $this->type === FieldType::Confirm, $this->type === FieldType::Pause => is_bool($value),
- $this->collectsList() => is_array($value),
- // A scale has no point between its points, so only a whole number names
- // one - where a number field takes any numeric entry and rounds it.
- $this->type === FieldType::Rating => is_int($value),
- $this->type->collectsInteger() => is_int($value) || is_float($value),
- // An empty string is an unset date, left to the required check; any
- // other value must be a strict `Y-m-d` calendar date.
- $this->type === FieldType::Calendar => is_string($value) && ($value === '' || DateBounds::parse($value) instanceof \DateTimeImmutable),
- default => is_string($value),
- };
- }
-
- /**
- * The human name of the value shape this field collects, translated.
- *
- * @return string
- * The value-kind fragment (e.g. "a string", "a list").
- */
- public function valueKind(): string {
- return match (TRUE) {
- $this->type === FieldType::Confirm, $this->type === FieldType::Pause => Translator::t('a boolean'),
- $this->collectsList() => Translator::t('a list'),
- $this->type === FieldType::Rating => Translator::t('a whole number'),
- $this->type->collectsInteger() => Translator::t('a number'),
- $this->type === FieldType::Calendar => Translator::t('a date (YYYY-MM-DD)'),
- default => Translator::t('a string'),
- };
- }
-
- /**
- * Get a selectable-or-disabled option by its value.
- *
- * Structural rows (separators, headings) carry no value and are never
- * returned.
- */
- public function option(string $value): ?Option {
- foreach ($this->options as $option) {
- if ($option->kind === OptionKind::Option && $option->value === $value) {
- return $option;
- }
- }
-
- return NULL;
- }
-
- /**
- * The values of the selectable options, in display order.
- *
- * @return list
- * The selectable option values (excludes separators, headings and disabled
- * options).
- */
- public function selectableValues(): array {
- return Option::selectableValues($this->options);
- }
-
- /**
- * Whether the field's option rows stand as declared.
- *
- * @return bool
- * FALSE while a loader, a resolver or a query source still owes the field
- * its rows, so there is nothing yet to count or to check a default against.
- */
- public function hasSettledOptions(): bool {
- return !$this->optionsLoader instanceof \Closure && !$this->optionsResolver instanceof \Closure && !$this->optionsSource instanceof \Closure;
- }
-
- /**
- * Whether the field's option set follows the answers rather than standing.
- *
- * @return bool
- * TRUE when the options are resolved from the collected answers or from a
- * live query, so no one list describes the field.
- */
- public function hasDynamicOptions(): bool {
- return $this->optionsResolver instanceof \Closure || $this->optionsSource instanceof \Closure;
- }
-
- /**
- * A value restated against the option set as it now stands.
- *
- * A choice value outlives the options it was picked from: a set resolved
- * from the answers narrows as they change, leaving a value that is no longer
- * offered, a ranking that no longer covers the set, or a toggle sitting on a
- * state that is gone. This drops what the set no longer holds, completes a
- * ranking back to a full permutation and returns a toggle to its first
- * state, so a value always describes the options in front of it.
- *
- * A suggest field's options are hints rather than a closed set, so its value
- * is never reconciled against them.
- *
- * @param mixed $value
- * The current value.
- *
- * @return mixed
- * The value the current options can carry.
- */
- public function reconcileValue(mixed $value): mixed {
- if (!$this->type->supportsOptions() || $this->type === FieldType::Suggest) {
- return $value;
- }
-
- $selectable = $this->selectableValues();
-
- if ($this->type === FieldType::Reorder) {
- return self::canonicalOrder($selectable, self::stringList($value));
- }
-
- if ($this->isMultiChoice()) {
- return array_values(array_filter(self::stringList($value), static fn(string $item): bool => in_array($item, $selectable, TRUE)));
- }
-
- $current = is_scalar($value) ? (string) $value : '';
-
- if (in_array($current, $selectable, TRUE)) {
- return $current;
- }
-
- // A toggle is always in one of its states, so a value the set no longer
- // offers falls back to the first option rather than to nothing.
- return $this->type === FieldType::Toggle ? ($selectable[0] ?? '') : '';
- }
-
- /**
- * The whole message for an empty value on a required field, else NULL.
- *
- * Unlike the neighbouring checks this returns a complete sentence rather than
- * a fragment: the message is the consumer's to declare, so it cannot be
- * framed by a caller.
- *
- * @param mixed $value
- * The candidate value.
- *
- * @return string|null
- * The declared message, or one derived from the label; NULL when the field
- * is optional or the value is not empty.
- */
- public function requiredViolation(mixed $value): ?string {
- // Strict comparison, so a FALSE confirm and a 0 number are values, not
- // omissions.
- if (!$this->required || !in_array($value, ['', [], NULL], TRUE)) {
- return NULL;
- }
-
- if ($this->requiredMessage !== '') {
- return Translator::t($this->requiredMessage);
- }
-
- return Translator::t('@label is required.', ['@label' => Translator::t($this->label)]);
- }
-
- /**
- * The first violated number, date or selection-count bound, as a fragment.
- *
- * @param mixed $value
- * The candidate value.
- *
- * @return string|null
- * The violation fragment (e.g. "between 1 and 10", "at least 2 items"), or
- * NULL when the value is in range or the field declares no bounds.
- */
- public function boundsViolation(mixed $value): ?string {
- return $this->bounds?->violation($value) ?? $this->dateBounds?->violation($value) ?? $this->selectionBounds?->violation($value);
- }
-
- /**
- * The file picker limit a supplied path violates, as a fragment, else NULL.
- *
- * @param mixed $value
- * The candidate value - a path, or a list of paths in multiple mode.
- *
- * @return string|null
- * The violation fragment (e.g. "an existing file"), or NULL when the path
- * meets every limit or the field declares no picker constraints.
- */
- public function pickerViolation(mixed $value): ?string {
- if ($this->type !== FieldType::FilePicker) {
- return NULL;
- }
-
- return $this->pickerConstraints->violation($value);
- }
-
- /**
- * The reason a supplied value does not fit the field's template, else NULL.
- *
- * An empty string is an unfilled template, left to the required check; any
- * other value must have the template's shape and pass every slot validator.
- *
- * @param mixed $value
- * The candidate value.
- *
- * @return string|null
- * The error message, or NULL when the value fits or the field declares no
- * template.
- */
- public function templateError(mixed $value): ?string {
- if (!$this->template instanceof Template || !is_string($value) || $value === '') {
- return NULL;
- }
-
- return $this->template->error($value);
- }
-
- /**
- * The values of the template's slots recovered from an assembled answer.
- *
- * @param mixed $value
- * The assembled answer.
- *
- * @return array
- * The value of each slot keyed by slot name; empty when the field declares
- * no template or the answer does not have its shape.
- */
- public function templateParts(mixed $value): array {
- if (!$this->template instanceof Template || !is_string($value)) {
- return [];
- }
-
- return $this->template->extract($value);
- }
-
- /**
- * Validate a supplied value against the field's selectable options.
- *
- * Handles both a single-choice scalar and a multi-choice list, returning the
- * first offending item. The message is a caller-agnostic fragment so the
- * engine and the schema validator can each frame it their own way.
- *
- * @param mixed $value
- * The candidate value - a scalar for single-choice, a list for multi.
- *
- * @return string|null
- * An error fragment when an item is not a selectable option, or NULL when
- * the field is unconstrained or every item is allowed.
- */
- public function optionError(mixed $value): ?string {
- // A field that declares no options constrains nothing - but one whose
- // options follow a query or the answers is constrained by whatever they
- // resolved to, and resolving to nothing means the value does not exist.
- if (!$this->type->constrainsToOptions() || ($this->options === [] && !$this->hasDynamicOptions())) {
- return NULL;
- }
-
- if ($this->isMultiChoice()) {
- if (!is_array($value)) {
- return Translator::t('value must be a list');
- }
-
- $items = $value;
- }
- else {
- $items = [$value];
- }
-
- foreach ($items as $item) {
- $error = $this->scalarOptionError(is_scalar($item) ? (string) $item : '');
- if ($error !== NULL) {
- return $error;
- }
- }
-
- if ($this->type === FieldType::Reorder) {
- return $this->rankingError($items);
- }
-
- return NULL;
- }
-
- /**
- * Classify a single scalar value against the selectable options.
- *
- * @param string $value
- * The candidate value.
- *
- * @return string|null
- * A fragment naming the value when it is disabled or unknown, or NULL when
- * it is a selectable option.
- */
- protected function scalarOptionError(string $value): ?string {
- if (in_array($value, $this->selectableValues(), TRUE)) {
- return NULL;
- }
-
- // Listing the allowed values is what makes the message useful, so when a
- // query source found nothing there is no list to offer and naming the value
- // is all that can honestly be said.
- if ($this->options === []) {
- return Translator::t('value "@value" was not found', ['@value' => $value]);
- }
-
- $option = $this->option($value);
- if ($option instanceof Option && $option->disabled) {
- return $option->disabledReason !== ''
- ? Translator::t('option "@value" is disabled: @reason', [
- '@value' => $value,
- '@reason' => $option->disabledReason,
- ])
- : Translator::t('option "@value" is disabled', ['@value' => $value]);
- }
-
- return Translator::t('value "@value" is not one of: @options', [
- '@value' => $value,
- '@options' => implode(', ', $this->selectableValues()),
- ]);
- }
-
- /**
- * Check that a ranking lists every selectable option exactly once.
- *
- * Membership is verified by the caller, so a value that is a full
- * permutation has the same length as the option set with no repeats.
- *
- * @param array $items
- * The supplied ranking, already confirmed to hold only selectable values.
- *
- * @return string|null
- * An error fragment when the ranking omits or repeats an option, or NULL
- * when it is a complete permutation.
- */
- protected function rankingError(array $items): ?string {
- $selectable = $this->selectableValues();
-
- $seen = [];
- foreach ($items as $item) {
- $seen[is_scalar($item) ? (string) $item : ''] = TRUE;
- }
-
- if (count($items) === count($selectable) && count($seen) === count($items)) {
- return NULL;
- }
-
- return Translator::t('must rank every option exactly once (@options)', ['@options' => implode(', ', $selectable)]);
- }
-
- /**
- * Coerce a value to a list of strings, dropping every non-string item.
- *
- * @param mixed $value
- * The value.
- *
- * @return list
- * The string items, in order; empty when the value is not an array.
- */
- public static function stringList(mixed $value): array {
- if (!is_array($value)) {
- return [];
- }
-
- $out = [];
-
- foreach ($value as $item) {
- if (is_string($item)) {
- $out[] = $item;
- }
- }
-
- return $out;
- }
-
- /**
- * Order a set of values, completing and de-duplicating a desired ordering.
- *
- * The desired values that belong to the allowed set come first - in the
- * given order, de-duplicated - then every allowed value the desired list
- * omits is appended in its declared order. The result is always a full
- * permutation of the allowed values, so a partial or dirty ordering still
- * resolves to a complete one.
- *
- * @param list $allowed
- * The full set of values, in declared order.
- * @param list $desired
- * The requested ordering; values outside the allowed set are ignored and
- * repeats collapsed.
- *
- * @return list
- * The allowed values in the resolved order.
- */
- public static function canonicalOrder(array $allowed, array $desired): array {
- $set = array_fill_keys($allowed, TRUE);
-
- $order = [];
- $seen = [];
- foreach ($desired as $value) {
- if (isset($set[$value]) && !isset($seen[$value])) {
- $order[] = $value;
- $seen[$value] = TRUE;
- }
- }
-
- foreach ($allowed as $value) {
- if (!isset($seen[$value])) {
- $order[] = $value;
- $seen[$value] = TRUE;
- }
- }
-
- return $order;
- }
-
-}
diff --git a/src/Model/FieldType.php b/src/Model/FieldType.php
index 3a26143e..ce9eb80b 100644
--- a/src/Model/FieldType.php
+++ b/src/Model/FieldType.php
@@ -7,7 +7,7 @@
use DrevOps\Tui\Translation\Translator;
/**
- * The set of supported field (widget) types.
+ * The set of supported field (field) types.
*
* @package DrevOps\Tui\Model
*/
@@ -80,9 +80,9 @@ public function isPresentational(): bool {
* Whether the field carries no answer into the payload or machine schema.
*
* Wider than {@see isPresentational()}: a note and a progress row each
- * collect no value, so the engine, the answers and the schema skip them - but
- * a progress row still takes the cursor (it runs work on activation), so it
- * is not presentational.
+ * collect no value, so a collection, the answers and the schema skip them -
+ * but a progress row still takes the cursor (it runs work on activation), so
+ * it is not presentational.
*
* @return bool
* TRUE for the display-only field types.
diff --git a/src/Model/FilePickerConstraints.php b/src/Model/FilePickerConstraints.php
index c01ee1ed..f5a3243c 100644
--- a/src/Model/FilePickerConstraints.php
+++ b/src/Model/FilePickerConstraints.php
@@ -12,7 +12,7 @@
* The one home for what counts as a valid pick: the mode (any entry, files or
* directories), the extensions selectable files are limited to, and a maximum
* file size. The predicate and the human phrase live here once, so the
- * interactive widget, the headless engine and the answer-set validator all
+ * interactive field, a headless collection and the answer-set validator all
* agree - mirroring {@see NumberBounds} and {@see SelectionBounds}, but
* constraining a filesystem path rather than a number or a count.
*
diff --git a/src/Model/FormDefinition.php b/src/Model/FormDefinition.php
deleted file mode 100644
index fb9039dd..00000000
--- a/src/Model/FormDefinition.php
+++ /dev/null
@@ -1,184 +0,0 @@
- $layout
- * The top-level panel grid: one entry per visual row naming how many
- * panels sit side by side in it, consumed in declaration order. Empty
- * renders the panels as today's row list.
- */
- public function __construct(
- public string $title,
- public string $subject,
- public array $panels = [],
- public array $fixups = [],
- public string $envPrefix = '',
- public string $banner = '',
- public Buttons $buttons = new Buttons(),
- public array $layout = [],
- ) {
- $this->flatFields = self::collectFields($this->panels);
-
- self::resolveConditionalDepths($this->flatFields);
- }
-
- /**
- * Find a field by id anywhere in the panel tree.
- *
- * @param string $id
- * The field id to find.
- */
- public function field(string $id): ?Field {
- foreach ($this->fields() as $field) {
- if ($field->id === $id) {
- return $field;
- }
- }
-
- return NULL;
- }
-
- /**
- * All fields flattened across the panel tree, in declaration order.
- *
- * @return \DrevOps\Tui\Model\Field[]
- * The fields.
- */
- public function fields(): array {
- return $this->flatFields;
- }
-
- /**
- * Recursively flatten the fields of a panel tree, in declaration order.
- *
- * @param \DrevOps\Tui\Model\Panel[] $panels
- * Panels to walk.
- *
- * @return \DrevOps\Tui\Model\Field[]
- * The flattened fields.
- */
- protected static function collectFields(array $panels): array {
- $fields = [];
-
- foreach ($panels as $panel) {
- foreach ($panel->fields as $field) {
- $fields[] = $field;
- }
-
- $fields = array_merge($fields, self::collectFields($panel->panels));
- }
-
- return $fields;
- }
-
- /**
- * Stamp every field with how many conditions deep it sits.
- *
- * A `when` rule may reference a field on any panel, so the depth is a
- * property of the whole form rather than of a panel or a field on its own.
- *
- * @param \DrevOps\Tui\Model\Field[] $fields
- * The flattened fields.
- */
- protected static function resolveConditionalDepths(array $fields): void {
- $by_id = [];
- foreach ($fields as $field) {
- $by_id[$field->id] = $field;
- }
-
- $resolved = [];
- foreach ($fields as $field) {
- $field->conditionalDepth = self::conditionalDepth($field, $by_id, $resolved, []);
- }
- }
-
- /**
- * The depth of one field: one more than the deepest field it depends on.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field to measure.
- * @param array $by_id
- * Every field in the form, keyed by id.
- * @param array $resolved
- * The depths measured so far, so a field shared by several rules is walked
- * once.
- * @param array $walking
- * The ids on the current walk, keyed by id.
- *
- * @return int
- * The depth.
- */
- protected static function conditionalDepth(Field $field, array $by_id, array &$resolved, array $walking): int {
- if (array_key_exists($field->id, $resolved)) {
- return $resolved[$field->id];
- }
-
- if (!$field->when instanceof ConditionInterface) {
- return $resolved[$field->id] = 0;
- }
-
- // A reference leading back to a field already on the walk closes a cycle.
- // Such a rule can never hold a stable depth, so the back edge contributes
- // none and the walk ends rather than deepening forever.
- if (isset($walking[$field->id])) {
- return 0;
- }
-
- $walking[$field->id] = TRUE;
-
- $deepest = 0;
- foreach ($field->when->fields() as $id) {
- if (isset($by_id[$id])) {
- $deepest = max($deepest, self::conditionalDepth($by_id[$id], $by_id, $resolved, $walking));
- }
- }
-
- return $resolved[$field->id] = $deepest + 1;
- }
-
-}
diff --git a/src/Model/Modal.php b/src/Model/Modal.php
deleted file mode 100644
index 6d1e6f30..00000000
--- a/src/Model/Modal.php
+++ /dev/null
@@ -1,39 +0,0 @@
-buttons->show) {
- throw new FormException('A modal dialog must show its buttons.');
- }
- }
-
-}
diff --git a/src/Model/NumberBounds.php b/src/Model/NumberBounds.php
index eddbfd1d..7c47b1d2 100644
--- a/src/Model/NumberBounds.php
+++ b/src/Model/NumberBounds.php
@@ -11,8 +11,8 @@
*
* Any of the three may be unset (NULL): an unset min or max leaves that side
* open, and an unset step increments by one. The range arithmetic and the
- * human range phrase live here once, so the interactive widget, the headless
- * engine and the answer-set validator all agree.
+ * human range phrase live here once, so the interactive field, the headless
+ * collection and the answer-set validator all agree.
*
* @package DrevOps\Tui\Model
*/
diff --git a/src/Model/Option.php b/src/Model/Option.php
index 489b9b7c..e57ad3ca 100644
--- a/src/Model/Option.php
+++ b/src/Model/Option.php
@@ -108,7 +108,7 @@ public static function resolved(mixed $result): array {
* The values of the selectable rows, in display order.
*
* The one filtering every collection surface shares, so the field model, the
- * choice widgets and the schema generators agree on what is selectable.
+ * choice fields and the schema generators agree on what is selectable.
*
* @param list<\DrevOps\Tui\Model\Option> $options
* The option rows.
diff --git a/src/Model/Panel.php b/src/Model/Panel.php
deleted file mode 100644
index d5998880..00000000
--- a/src/Model/Panel.php
+++ /dev/null
@@ -1,75 +0,0 @@
- $layout
- * The sub-panel grid: one entry per visual row naming how many sub-panels
- * sit side by side in it, consumed in declaration order. Empty renders the
- * sub-panels as today's row list.
- * @param \Closure|null $preload
- * An `fn(): void` run once before the panel first opens, or NULL for none.
- * Resolved in the same pass as the fields' option loaders - the panel reads
- * as loading until it returns - then cleared so it runs only once.
- */
- public function __construct(
- public readonly string $id,
- public readonly string $title,
- public readonly string $description,
- public readonly array $fields = [],
- public readonly array $panels = [],
- public readonly ?Modal $modal = NULL,
- public readonly array $layout = [],
- public ?\Closure $preload = NULL,
- ) {
- }
-
- /**
- * The number of navigable items: fields plus sub-panels.
- *
- * @return int
- * The item count.
- */
- public function itemCount(): int {
- return count($this->fields) + count($this->panels);
- }
-
- /**
- * Whether this panel opens as a modal dialog rather than a drill-in panel.
- *
- * @return bool
- * TRUE when the panel carries a modal config.
- */
- public function isModal(): bool {
- return $this->modal instanceof Modal;
- }
-
-}
diff --git a/src/Model/RenderMode.php b/src/Model/RenderMode.php
index b579d285..a80220e9 100644
--- a/src/Model/RenderMode.php
+++ b/src/Model/RenderMode.php
@@ -8,10 +8,10 @@
* Where a field's editor is drawn: inline in the panel, or on its own screen.
*
* An inline field expands its editor in place on the panel when activated - the
- * widget's own view, driven by its own keys, collapsing back to a one-line
+ * field's own view, driven by its own keys, collapsing back to a one-line
* summary on accept or cancel - so a value changes without leaving the panel.
* A standalone field opens that same editor full-screen instead, the better fit
- * for a widget that wants the whole viewport. Fields are inline by default; a
+ * for a field that wants the whole viewport. Fields are inline by default; a
* consumer opts one out with the builder's standalone() method.
*
* @package DrevOps\Tui\Model
diff --git a/src/Model/SelectionBounds.php b/src/Model/SelectionBounds.php
index dbb34875..cbf5f55a 100644
--- a/src/Model/SelectionBounds.php
+++ b/src/Model/SelectionBounds.php
@@ -10,8 +10,8 @@
* Optional minimum and maximum selection counts for a multi-value field.
*
* Either bound may be unset (NULL), leaving that side open. The count
- * arithmetic and the human phrase live here once, so the interactive widget,
- * the headless engine and the answer-set validator all agree - mirroring
+ * arithmetic and the human phrase live here once, so the interactive field,
+ * a headless collection and the answer-set validator all agree - mirroring
* {@see NumberBounds}, but constraining how many values a list holds rather
* than the magnitude of a single number.
*
diff --git a/src/Model/Weekday.php b/src/Model/Weekday.php
index 2ecab646..9dab8900 100644
--- a/src/Model/Weekday.php
+++ b/src/Model/Weekday.php
@@ -9,7 +9,7 @@
/**
* A day of the week, backed by its ISO-8601 number (Monday = 1 ... Sunday = 7).
*
- * The calendar's week-start day is one of these, and the widget builds its
+ * The calendar's week-start day is one of these, and the field builds its
* weekday header and column layout from the sequence starting at that day.
*
* @package DrevOps\Tui\Model
diff --git a/src/Primitive/Element/PrimitiveElementsInterface.php b/src/Primitive/Element/PrimitiveElementsInterface.php
new file mode 100644
index 00000000..4c2ec9fc
--- /dev/null
+++ b/src/Primitive/Element/PrimitiveElementsInterface.php
@@ -0,0 +1,158 @@
+ $body
+ * The body lines. Each is word-wrapped to the card's inner width, and an
+ * empty entry stays an empty line so a caller can space the content out.
+ * @param list $headers
+ * The header cells of an optional grid below the body.
+ * @param list> $rows
+ * The body rows of that grid; with no headers or rows there is no grid.
+ * @param bool $bordered
+ * Whether the card is boxed in the theme's border, or merely indented.
+ * @param int $reserved
+ * Columns the caller lays the card out after, kept out of the width cap so
+ * the card's right edge still lands inside the frame.
+ *
+ * @return list
+ * The card's physical lines; empty when it has no content at all.
+ */
+ public function renderCard(string $title, array $body, array $headers = [], array $rows = [], bool $bordered = TRUE, int $reserved = 0): array;
+
+ /**
+ * Draw an aligned, bordered grid from headers and rows.
+ *
+ * The columns size to their widest cell and the whole grid is capped at the
+ * frame width.
+ *
+ * @param list $headers
+ * The header cells; an empty list draws the grid with no header row.
+ * @param list> $rows
+ * The body rows, each a list of cell strings.
+ *
+ * @return list
+ * The grid's physical lines, capped at the frame width.
+ */
+ public function renderTable(array $headers, array $rows): array;
+
+ /**
+ * Draw source text as wrapped, markup-styled lines at the frame width.
+ *
+ * @param string $text
+ * The source text; its own newlines split it into physical lines first.
+ *
+ * @return list
+ * The styled lines.
+ */
+ public function renderText(string $text): array;
+
+ /**
+ * Draw a line spanning the frame, telling what is above it from what is not.
+ *
+ * @return string
+ * The styled rule.
+ */
+ public function renderRule(): string;
+
+ /**
+ * Draw a start banner: the logo above an optional version line.
+ *
+ * @param string $logo
+ * The banner logo; its newlines split it into lines.
+ * @param string $version
+ * The version shown below the logo, or an empty string for none.
+ *
+ * @return string
+ * The composed banner.
+ */
+ public function renderBanner(string $logo, string $version): string;
+
+ /**
+ * Draw a status line: the kind's glyph and the message, in its colour.
+ *
+ * @param \DrevOps\Tui\Primitive\Status $status
+ * The kind of status.
+ * @param string $text
+ * The message; its line breaks fold to spaces so the status stays one line.
+ *
+ * @return string
+ * The composed line.
+ */
+ public function renderStatus(Status $status, string $text): string;
+
+ /**
+ * Draw label/value pairs as an aligned definition list.
+ *
+ * @param array $pairs
+ * The values keyed by their label. A numeric-string label arrives as an
+ * integer key and still renders as its own text.
+ *
+ * @return list
+ * The list's physical lines; empty when there are no pairs.
+ */
+ public function renderDefinitions(array $pairs): array;
+
+ /**
+ * Draw an indeterminate spinner: an accent glyph before the caption.
+ *
+ * @param int $frame
+ * The animation frame counter; the glyph cycles through the frame set.
+ * @param string $caption
+ * The caption shown beside the spinner.
+ *
+ * @return string
+ * The composed spinner line.
+ */
+ public function renderSpinner(int $frame, string $caption): string;
+
+ /**
+ * Draw a determinate bar: a filling track, a step count and a label.
+ *
+ * @param int $current
+ * The number of completed steps.
+ * @param int $total
+ * The total number of steps; a zero total renders a full bar.
+ * @param string $caption
+ * The caption shown before the bar.
+ * @param string $label
+ * The trailing label, or an empty string for none.
+ *
+ * @return string
+ * The composed bar line.
+ */
+ public function renderProgressBar(int $current, int $total, string $caption, string $label): string;
+
+}
diff --git a/src/Primitive/Output.php b/src/Primitive/Output.php
index 8a62c3e0..40264151 100644
--- a/src/Primitive/Output.php
+++ b/src/Primitive/Output.php
@@ -4,8 +4,8 @@
namespace DrevOps\Tui\Primitive;
+use DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface;
use DrevOps\Tui\Render\Terminal;
-use DrevOps\Tui\Theme\ThemeInterface;
/**
* Static output primitives: a box, a status line, a definition list.
@@ -36,10 +36,10 @@ final class Output {
*
* @param \DrevOps\Tui\Render\Terminal $terminal
* The terminal the lines are written to.
- * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * @param \DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface $theme
* The theme that draws the box, the status glyphs and the list.
*/
- public function __construct(protected Terminal $terminal, protected ThemeInterface $theme) {
+ public function __construct(protected Terminal $terminal, protected PrimitiveElementsInterface $theme) {
}
/**
@@ -115,7 +115,7 @@ public function text(string $text): self {
* The primitive.
*/
public function rule(): self {
- return $this->writeLines([$this->theme->divider()]);
+ return $this->writeLines([$this->theme->renderRule()]);
}
/**
diff --git a/src/Primitive/Progress.php b/src/Primitive/Progress.php
index 0b33d330..5f142387 100644
--- a/src/Primitive/Progress.php
+++ b/src/Primitive/Progress.php
@@ -6,7 +6,7 @@
use DrevOps\Tui\Render\Terminal;
use DrevOps\Tui\Render\TerminalControl;
-use DrevOps\Tui\Theme\ThemeInterface;
+use DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface;
/**
* A progress primitive for slow work: a spinner, or a determinate bar.
@@ -53,7 +53,7 @@ final class Progress {
*
* @param \DrevOps\Tui\Render\Terminal $terminal
* The terminal the line is drawn on.
- * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * @param \DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface $theme
* The theme that draws the spinner glyphs and the bar.
* @param bool $active
* Whether to draw control sequences (TRUE) or stay plain (FALSE).
@@ -65,7 +65,7 @@ final class Progress {
*/
public function __construct(
protected Terminal $terminal,
- protected ThemeInterface $theme,
+ protected PrimitiveElementsInterface $theme,
protected bool $active,
?int $total,
protected string $caption,
diff --git a/src/Primitive/ProgressReporter.php b/src/Primitive/ProgressReporter.php
index c710bdee..e7e55f48 100644
--- a/src/Primitive/ProgressReporter.php
+++ b/src/Primitive/ProgressReporter.php
@@ -5,7 +5,7 @@
namespace DrevOps\Tui\Primitive;
/**
- * The handle a progress widget's work drives to advance its indicator.
+ * The handle a progress field's work drives to advance its indicator.
*
* Each `advance()` reports one step: the panel repaints the row so a
* determinate bar fills or a spinner ticks. The reporter holds no state of its
diff --git a/src/Render/HelpSection.php b/src/Render/HelpSection.php
deleted file mode 100644
index 757580eb..00000000
--- a/src/Render/HelpSection.php
+++ /dev/null
@@ -1,47 +0,0 @@
-
- */
- public array $hints;
-
- /**
- * Construct a help section.
- *
- * @param string $title
- * The section heading (e.g. "Navigation", "Select").
- * @param \DrevOps\Tui\Input\ScopedKeyMap $keys
- * The section's bindings, so the glyphs reflect the live keys.
- * @param \DrevOps\Tui\Input\Hint ...$hints
- * The hint fragments in display order.
- */
- public function __construct(
- public string $title,
- public ScopedKeyMap $keys,
- Hint ...$hints,
- ) {
- $this->hints = array_values($hints);
- }
-
-}
diff --git a/src/Render/Navigator.php b/src/Render/Navigator.php
deleted file mode 100644
index 2aaf7f3b..00000000
--- a/src/Render/Navigator.php
+++ /dev/null
@@ -1,107 +0,0 @@
-current;
- }
-
- /**
- * Drill into a sub-panel.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel to enter.
- */
- public function enter(Panel $panel): void {
- $this->parents[] = $this->current;
- $this->current = $panel;
- }
-
- /**
- * The immediate parent of the current panel, if any.
- *
- * @return \DrevOps\Tui\Model\Panel|null
- * The parent panel, or NULL when the current panel is the root.
- */
- public function parent(): ?Panel {
- return $this->parents === [] ? NULL : $this->parents[count($this->parents) - 1];
- }
-
- /**
- * Pop back to the parent panel.
- *
- * @return bool
- * TRUE when popped; FALSE when already at the root.
- */
- public function pop(): bool {
- $parent = array_pop($this->parents);
- if (!$parent instanceof Panel) {
- return FALSE;
- }
-
- $this->current = $parent;
-
- return TRUE;
- }
-
- /**
- * Whether the navigator is at the root.
- *
- * @return bool
- * TRUE at the root.
- */
- public function isRoot(): bool {
- return $this->parents === [];
- }
-
- /**
- * The breadcrumb of panel titles from the root to the current panel.
- *
- * @return list
- * The titles.
- */
- public function breadcrumb(): array {
- $titles = [];
- foreach ($this->parents as $parent) {
- $titles[] = $parent->title;
- }
-
- $titles[] = $this->current->title;
-
- return $titles;
- }
-
-}
diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php
deleted file mode 100644
index ee9caabe..00000000
--- a/src/Render/PanelController.php
+++ /dev/null
@@ -1,1513 +0,0 @@
-
- */
- protected array $modalValues = [];
-
- /**
- * The provenance snapshot taken when the current modal dialog opened.
- *
- * @var array
- */
- protected array $modalProvenance = [];
-
- /**
- * The cursor position to restore in the parent when a modal dialog closes.
- */
- protected int $modalReturnCursor = 0;
-
- /**
- * The scroll offset to restore in the parent when a modal dialog closes.
- */
- protected int $modalReturnOffset = 0;
-
- /**
- * The resolved fullscreen minimum width, measured lazily from the content.
- */
- protected ?int $minWidth = NULL;
-
- /**
- * The engine settling derives, activation and fix-ups after each edit.
- */
- protected Engine $engine;
-
- /**
- * The conditional-activation map keyed by field id.
- *
- * @var array
- */
- protected array $active = [];
-
- /**
- * The message from the last submit refused for an empty required field.
- */
- protected ?string $submitError = NULL;
-
- /**
- * Construct a controller.
- *
- * The order groups the arguments by role - the form and its theme, the
- * answer state, the collaborating services, then the display chrome - so a
- * caller reaches the common ones positionally and names the rest.
- *
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The form definition (panels, fields, titles and submit/cancel chrome).
- * @param \DrevOps\Tui\Theme\DefaultTheme $theme
- * The theme (the visual authority for rendering).
- * @param array $values
- * The initial answer values (typically the engine's resolved answers).
- * @param array $provenance
- * The initial provenance.
- * @param \DrevOps\Tui\Input\KeyMap|null $keymap
- * The resolved key bindings; NULL uses the default preset.
- * @param \DrevOps\Tui\Handler\HandlerRegistry|null $handlers
- * The registry resolving a field id to its reusable static
- * validate()/transform() behaviour; NULL leaves only the declared closures.
- * @param \DrevOps\Tui\Render\ExternalEditor|null $external_editor
- * The external-editor service (defaults to a real one); injectable for
- * tests and to gate the textarea handoff on editor availability.
- * @param bool $footer
- * Whether the contextual key-hint footer is shown.
- * @param bool $clearOnExit
- * Whether to clear the screen when the interactive loop exits.
- * @param string $banner
- * An optional start banner (logo) shown before the interactive loop.
- * @param string $version
- * An optional version string shown below the banner.
- * @param \DrevOps\Tui\Handler\Context $context
- * The run context each settling passes to the closures that read it - the
- * option sets resolved from the answers; defaults to a bare context.
- * @param \DrevOps\Tui\Engine\Engine|null $engine
- * The engine settling the form; NULL builds one over the same form and
- * handlers. Passing the one that resolved the initial state keeps a single
- * engine over the run, so what it has already resolved is not resolved
- * again on the first settling.
- */
- public function __construct(
- protected FormDefinition $form,
- protected DefaultTheme $theme,
- protected array $values = [],
- protected array $provenance = [],
- ?KeyMap $keymap = NULL,
- ?HandlerRegistry $handlers = NULL,
- ?ExternalEditor $external_editor = NULL,
- protected bool $footer = TRUE,
- protected bool $clearOnExit = TRUE,
- protected string $banner = '',
- protected string $version = '',
- protected Context $context = new Context(),
- ?Engine $engine = NULL,
- ) {
- $this->keymap = $keymap ?? KeyMapManager::create();
- $this->externalEditor = $external_editor ?? new ExternalEditor();
- $this->widgets = new WidgetFactory($this->keymap, $this->externalEditor->isAvailable(), $handlers);
- $this->nav = $this->keymap->navigation();
- $this->scroller = new Scroller();
- $this->navigator = new Navigator(new Panel('hub', $form->title, '', panels: $form->panels, layout: $form->layout));
- $this->engine = $engine ?? new Engine($form, $handlers ?? new HandlerRegistry());
-
- // Settle once at construction so the activation map exists before the
- // first frame and a seeded value set is coherent with the form logic.
- $this->resettle();
- }
-
- /**
- * Process one key press.
- *
- * @param \DrevOps\Tui\Input\Key $key
- * The key.
- */
- public function handle(Key $key): void {
- if ($this->help) {
- // Any key dismisses the help overlay.
- $this->help = FALSE;
-
- return;
- }
-
- if ($this->editor instanceof WidgetInterface) {
- $this->handleEditing($key);
-
- return;
- }
-
- $this->handleNavigation($key);
- }
-
- /**
- * Whether a field is being edited.
- *
- * @return bool
- * TRUE when editing.
- */
- public function isEditing(): bool {
- return $this->editor instanceof WidgetInterface;
- }
-
- /**
- * Whether the user has chosen to quit.
- *
- * @return bool
- * TRUE when done.
- */
- public function isDone(): bool {
- return $this->done;
- }
-
- /**
- * Whether the user cancelled.
- *
- * @return bool
- * TRUE when the user activated the cancel button.
- */
- public function isCancelled(): bool {
- return $this->cancelled;
- }
-
- /**
- * Whether the user aborted with the interrupt key (Ctrl-C).
- *
- * @return bool
- * TRUE when the loop ended on an interrupt.
- */
- public function isInterrupted(): bool {
- return $this->interrupted;
- }
-
- /**
- * Whether the help overlay is showing.
- *
- * @return bool
- * TRUE when the overlay is open.
- */
- public function isShowingHelp(): bool {
- return $this->help;
- }
-
- /**
- * Run the interactive loop against a terminal until the user quits or aborts.
- *
- * @param \DrevOps\Tui\Render\Terminal $terminal
- * The terminal.
- *
- * @return \DrevOps\Tui\Answers\Answers
- * The collected answers.
- */
- public function run(Terminal $terminal): Answers {
- $parser = new KeyParser();
- $this->terminal = $terminal;
- $terminal->setup($this->theme->background());
- $this->resolveLoaders($this->navigator->current());
-
- try {
- if ($this->banner !== '') {
- $terminal->render($this->positioned($this->theme->renderBanner($this->banner, $this->version) . "\n\n" . Translator::t('Press any key to continue...'), $terminal));
-
- // Any key dismisses the banner, but Ctrl-C here aborts like it does
- // mid-form rather than dropping the user into the questionnaire.
- foreach ($parser->parse($terminal->read()) as $key) {
- if ($this->consumeInterrupt($key)) {
- break;
- }
- }
- }
-
- while (!$this->done && !$this->interrupted) {
- $too_small = $this->tooSmall($terminal);
- $terminal->render($too_small ? $this->tooSmallFrame($terminal) : $this->positioned($this->frame($this->rows($terminal)), $terminal));
-
- $bytes = $terminal->read();
-
- // An empty read means the input is exhausted - the scripted input ran
- // out or the stream closed. Stop rather than spin re-rendering forever.
- if ($bytes === '') {
- break;
- }
-
- foreach ($parser->parse($bytes) as $key) {
- // Ctrl-C aborts from anywhere - including mid-widget - so catch it
- // above handle() and drop straight out of the read loop to the
- // teardown, leaving the collected answers as they stand.
- if ($this->consumeInterrupt($key)) {
- break 2;
- }
-
- // The too-small guard screen accepts only quit: any other key would
- // mutate state invisibly behind the notice. Quit routes through the
- // normal navigation handling so an open modal is dismissed (and its
- // snapshot restored) rather than the whole form ending over it.
- if ($too_small) {
- if ($this->nav->matches($key, Action::Quit)) {
- $this->handleNavigation($key);
- }
-
- continue;
- }
-
- $this->handle($key);
- }
-
- $this->resolveQuery();
- }
- }
- finally {
- $terminal->restore();
- // An interrupt always leaves a clean screen, even when a consumer opted
- // out of the clear-on-exit for a normal finish.
- if ($this->clearOnExit || $this->interrupted) {
- $terminal->clear();
- }
- }
-
- return $this->answers();
- }
-
- /**
- * Record an abort when the key is the interrupt (Ctrl-C).
- *
- * @param \DrevOps\Tui\Input\Key $key
- * The key to test.
- *
- * @return bool
- * TRUE when the key was the interrupt, so the caller stops reading input.
- */
- protected function consumeInterrupt(Key $key): bool {
- if (!$key->is(KeyName::Interrupt)) {
- return FALSE;
- }
-
- $this->interrupted = TRUE;
-
- return TRUE;
- }
-
- /**
- * The selection cursor.
- *
- * @return int
- * The cursor index.
- */
- public function cursor(): int {
- return $this->cursor;
- }
-
- /**
- * The current panel.
- *
- * @return \DrevOps\Tui\Model\Panel
- * The current panel.
- */
- public function currentPanel(): Panel {
- return $this->navigator->current();
- }
-
- /**
- * The current answers: the active fields' values and provenance.
- *
- * Inactive (condition-failing) fields keep their settled values internally -
- * so a later activation change surfaces them intact - but contribute no
- * answer, matching what a headless collection returns.
- *
- * @return \DrevOps\Tui\Answers\Answers
- * The self-describing answers.
- */
- public function answers(): Answers {
- $values = [];
- $provenance = [];
-
- foreach ($this->form->fields() as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
-
- if (!($this->active[$field->id] ?? TRUE)) {
- continue;
- }
-
- if (array_key_exists($field->id, $this->values)) {
- $values[$field->id] = $this->values[$field->id];
- }
-
- if (isset($this->provenance[$field->id])) {
- $provenance[$field->id] = $this->provenance[$field->id];
- }
- }
-
- return Answers::forForm($this->form, $values, $provenance);
- }
-
- /**
- * Render the current frame: the help overlay, the editor or the panel hub.
- *
- * @param int $rows
- * The screen rows the frame may fill.
- *
- * @return string
- * The frame.
- */
- public function frame(int $rows): string {
- if ($this->help) {
- return $this->theme->renderHelp($this->nav, ...$this->helpSections());
- }
-
- // A standalone field takes the whole screen; an inline field expands inside
- // the hub, which hubFrame() splices in.
- if ($this->editor instanceof WidgetInterface && $this->editing instanceof Field && $this->editing->render === RenderMode::Standalone) {
- return $this->editorFrame($this->editor, $rows);
- }
-
- if ($this->navigator->current()->isModal()) {
- return $this->modalFrame($rows);
- }
-
- return $this->hubFrame($rows);
- }
-
- /**
- * Render the editor screen for the field being edited.
- *
- * @param \DrevOps\Tui\Widget\WidgetInterface $editor
- * The active editor widget.
- * @param int $rows
- * The screen rows a fullscreen editor stretches to.
- *
- * @return string
- * The editor frame.
- */
- protected function editorFrame(WidgetInterface $editor, int $rows): string {
- $label = $this->editing instanceof Field ? Translator::t($this->editing->label) : '';
- $keys = $this->editing instanceof Field ? $this->keymap->forField($this->editing->type) : $this->nav;
- $hints = $this->footer ? $editor->hints() : [];
-
- return $this->theme->renderEditor($label, $editor->view($this->theme), $hints, $keys, $rows);
- }
-
- /**
- * Render the panel hub: the body with buttons, scrolled, framed by chrome.
- *
- * @param int $rows
- * The screen rows the frame may fill.
- *
- * @return string
- * The hub frame.
- */
- protected function hubFrame(int $rows): string {
- $panel = $this->viewPanel($this->navigator->current());
-
- // When an inline field is being edited, hand its field and rendered view to
- // the body so the theme expands the editor in place of the summary row.
- $editing = NULL;
- $view = '';
- if ($this->editor instanceof WidgetInterface && $this->editing instanceof Field) {
- $editing = $this->editing;
- $view = $this->editor->view($this->theme);
- }
-
- [$body, $cursor_line] = $this->theme->renderBody($panel, $this->answers(), $this->cursor, $editing, $view);
-
- if ($this->buttonsVisible()) {
- // The buttons follow the navigable items, which exclude presentational
- // notes - so the offset must match the cursor's item count, not the raw
- // field count, or a note would shift the button selection.
- $base = $this->itemCountFor($this->navigator->current());
- $selected = $this->cursor >= $base ? $this->cursor - $base : -1;
-
- // The action row always detaches from the items above it.
- $body[] = '';
-
- if ($this->submitError !== NULL) {
- $body[] = $this->theme->renderPanelError($this->submitError);
- }
-
- if ($this->cursor >= $base) {
- $cursor_line = count($body);
- }
-
- $body[] = $this->theme->renderButtonBar([
- Translator::t($this->form->buttons->submitLabel),
- Translator::t($this->form->buttons->cancelLabel),
- ], $selected);
- }
-
- $header = [$this->theme->renderBreadcrumbLine($this->navigator)];
- $footer = $editing instanceof Field ? $this->inlineEditFooter() : $this->hubFooter();
- $height = $this->viewportHeight($rows, count($header), count($footer));
- $viewport = $this->resolveViewport(count($body), $cursor_line, $height);
-
- return $this->theme->renderFrame($header, $body, $footer, $viewport, $height);
- }
-
- /**
- * The body viewport height that fits a frame into the screen rows.
- *
- * The theme owns the chrome accounting, so a bordered or padded frame never
- * overflows the terminal.
- *
- * @param int $rows
- * The screen rows the frame may fill.
- * @param int $header_lines
- * The header line count.
- * @param int $footer_lines
- * The footer line count.
- *
- * @return int
- * The viewport height, at least 3.
- */
- protected function viewportHeight(int $rows, int $header_lines, int $footer_lines): int {
- return max(3, $rows - $header_lines - $footer_lines - $this->theme->chromeHeight($footer_lines > 0));
- }
-
- /**
- * The screen rows a frame may fill: the terminal's, capped by the theme.
- *
- * @param \DrevOps\Tui\Render\Terminal $terminal
- * The terminal.
- *
- * @return int
- * The row budget.
- */
- protected function rows(Terminal $terminal): int {
- $max = $this->theme->maxHeight();
- $rows = $terminal->height();
-
- return $max > 0 ? min($rows, $max) : $rows;
- }
-
- /**
- * Render the current modal dialog floating over its dimmed parent.
- *
- * @param int $rows
- * The screen rows the frame may fill.
- *
- * @return string
- * The modal frame.
- */
- protected function modalFrame(int $rows): string {
- $modal = $this->viewPanel($this->navigator->current());
-
- $editing = NULL;
- $view = '';
- if ($this->editor instanceof WidgetInterface && $this->editing instanceof Field) {
- $editing = $this->editing;
- $view = $this->editor->view($this->theme);
- }
-
- // The buttons follow the navigable items, which exclude presentational
- // notes, so the offset uses the cursor's item count rather than the raw
- // field count.
- $base = $this->itemCountFor($this->navigator->current());
- $selected = $this->cursor >= $base ? $this->cursor - $base : -1;
-
- // The dialog floats over the whole backdrop frame, so the screen rows -
- // not the body viewport - bound it; the theme deducts the dialog's own
- // chrome from that budget itself.
- return $this->theme->renderModal($modal, $this->answers(), $this->cursor, $editing, $view, $selected, $this->backdrop($rows), $rows);
- }
-
- /**
- * Render the parent panel as the backdrop a modal dialog floats over.
- *
- * The parent renders un-highlighted; the theme dims it while compositing the
- * dialog on top, so what shows through the padding reads as recessed.
- *
- * @param int $rows
- * The screen rows the frame may fill.
- *
- * @return string
- * The parent frame.
- */
- protected function backdrop(int $rows): string {
- $parent = $this->navigator->parent();
-
- if (!$parent instanceof Panel) {
- // A modal is always entered from a parent, so this never happens.
- // @codeCoverageIgnoreStart
- $parent = $this->navigator->current();
- // @codeCoverageIgnoreEnd
- }
-
- [$body] = $this->theme->renderBody($this->viewPanel($parent), $this->answers(), -1);
- $header = [$this->theme->renderBreadcrumbLine($this->navigator)];
- $footer = $this->hubFooter();
- $height = $this->viewportHeight($rows, count($header), count($footer));
- $viewport = $this->scroller->viewport(0, count($body), $height);
-
- return $this->theme->renderFrame($header, $body, $footer, $viewport, $height);
- }
-
- /**
- * Position a frame within the terminal area per the layout options.
- *
- * Outside fullscreen the frame renders where the cursor homes, as always.
- * In fullscreen a frame smaller than the terminal - a capped hub, an
- * unboxed editor, the help overlay, the banner - anchors to the alignment
- * the theme options pick, padded with blank space.
- *
- * @param string $frame
- * The rendered frame.
- * @param \DrevOps\Tui\Render\Terminal $terminal
- * The terminal.
- *
- * @return string
- * The positioned frame.
- */
- protected function positioned(string $frame, Terminal $terminal): string {
- if (!$this->theme->isFullscreen()) {
- return $frame;
- }
-
- $lines = explode("\n", $frame);
- $area_width = $terminal->width();
- $area_height = $terminal->height();
- $box_width = Ansi::blockWidth($lines);
-
- if (count($lines) >= $area_height && $box_width >= $area_width) {
- return $frame;
- }
-
- [$top, $left] = Overlay::place($area_width, $area_height, $box_width, count($lines), $this->theme->halign(), $this->theme->valign());
- $backdrop = array_fill(0, $area_height, str_repeat(' ', $area_width));
-
- return implode("\n", Overlay::composite($backdrop, $lines, $box_width, $top, $left));
- }
-
- /**
- * Whether the terminal is too small for the fullscreen layout.
- *
- * @param \DrevOps\Tui\Render\Terminal $terminal
- * The terminal.
- *
- * @return bool
- * TRUE when fullscreen is on and the terminal is below the minimums.
- */
- protected function tooSmall(Terminal $terminal): bool {
- if (!$this->theme->isFullscreen()) {
- return FALSE;
- }
- if ($terminal->width() < $this->minWidth()) {
- return TRUE;
- }
- return $terminal->height() < $this->minHeight();
- }
-
- /**
- * The effective fullscreen minimum width.
- *
- * An explicit "min_width" option wins; otherwise the content is measured
- * once, at the initial answers, so the guard never flaps as values grow
- * mid-session. A "max_width" cap bounds the result: the cap is the
- * consumer's word that clipping is acceptable, and a guard demanding more
- * than the cap allows could never be satisfied by resizing.
- *
- * @return int
- * The minimum width, in columns.
- */
- protected function minWidth(): int {
- if ($this->minWidth === NULL) {
- $min = $this->theme->minWidth() > 0 ? $this->theme->minWidth() : $this->theme->measureContentWidth($this->form, $this->answers());
- $max = $this->theme->maxWidth();
- $this->minWidth = $max > 0 ? min($min, $max) : $min;
- }
-
- return $this->minWidth;
- }
-
- /**
- * The effective fullscreen minimum height, bounded like the minimum width.
- *
- * @return int
- * The minimum height, in rows.
- */
- protected function minHeight(): int {
- $max = $this->theme->maxHeight();
- $min = $this->theme->minHeight();
-
- return $max > 0 ? min($min, $max) : $min;
- }
-
- /**
- * Render the centered notice shown while the terminal is too small.
- *
- * Always centered - the alignment options position content on a screen the
- * layout fits into, which this one is not.
- *
- * @param \DrevOps\Tui\Render\Terminal $terminal
- * The terminal.
- *
- * @return string
- * The notice screen.
- */
- protected function tooSmallFrame(Terminal $terminal): string {
- $lines = [
- $this->theme->error(Translator::t('Terminal too small.')),
- Translator::t('Need at least @width x @height - have @w x @h.', [
- '@width' => (string) $this->minWidth(),
- '@height' => (string) $this->minHeight(),
- '@w' => (string) $terminal->width(),
- '@h' => (string) $terminal->height(),
- ]),
- $this->theme->renderHints($this->nav, new Hint('quit', Action::Quit)),
- ];
-
- $width = Ansi::blockWidth($lines);
-
- [$top, $left] = Overlay::center($terminal->width(), $terminal->height(), $width, count($lines));
- $backdrop = array_fill(0, max(count($lines), $terminal->height()), str_repeat(' ', max($width, $terminal->width())));
-
- return implode("\n", Overlay::composite($backdrop, $lines, $width, $top, $left));
- }
-
- /**
- * Resolve and persist the scroll viewport for the hub body.
- *
- * Follows the cursor unless wheel scrolling has detached it; the resolved
- * offset persists so the next frame scrolls from where this one settled.
- *
- * @param int $total
- * The total number of body lines.
- * @param int $cursor_line
- * The line index of the selected item.
- * @param int $height
- * The body viewport height.
- *
- * @return \DrevOps\Tui\Render\Viewport
- * The resolved viewport.
- */
- protected function resolveViewport(int $total, int $cursor_line, int $height): Viewport {
- $viewport = $this->followCursor ? $this->scroller->follow($total, $height, $cursor_line, $this->offset) : $this->scroller->viewport($this->offset, $total, $height);
- $this->offset = $viewport->offset;
-
- return $viewport;
- }
-
- /**
- * Handle a key while editing a field.
- *
- * @param \DrevOps\Tui\Input\Key $key
- * The key.
- */
- protected function handleEditing(Key $key): void {
- if (!$this->editor instanceof WidgetInterface || !$this->editing instanceof Field) {
- // @codeCoverageIgnoreStart
- return;
- // @codeCoverageIgnoreEnd
- }
-
- $this->editor->handle($key);
-
- if ($this->editor instanceof ExternalEditCapableInterface && $this->editor->wantsExternalEdit()) {
- $current = $this->editor->value();
- $captured = $this->externalEditor->edit(is_string($current) ? $current : '', $this->terminal);
- $this->editor->applyExternalEdit($captured);
- }
-
- if ($this->editor->isComplete()) {
- $this->values[$this->editing->id] = $this->editor->value();
- // Editing a derive-ruled field pins its rule, exactly as a headless
- // input to it would, so both paths badge the same situation the same.
- $this->provenance[$this->editing->id] = $this->editing->derive instanceof Derive ? Provenance::Override : Provenance::Edited;
- $this->closeEditor();
- $this->resettle();
- }
- elseif ($this->editor->isCancelled()) {
- $this->closeEditor();
- }
- }
-
- /**
- * Re-settle the form logic over the current values and clamp the cursor.
- *
- * Runs the engine's settling - option sets resolved from the answers, derive
- * rules, conditional activation and fix-ups - so an interactive change
- * propagates exactly as a headless input does, then keeps the cursor inside
- * the possibly-changed item range.
- */
- protected function resettle(): void {
- [$this->active, $this->values] = $this->engine->settle($this->values, $this->pinnedDerives(), $this->context);
-
- // The refusal describes the values as they were when submit was pressed, so
- // a change of any kind retires it rather than leaving it to contradict what
- // the panel now shows.
- $this->submitError = NULL;
-
- $count = $this->itemCountFor($this->navigator->current()) + ($this->buttonsVisible() ? self::BUTTON_COUNT : 0);
- $this->cursor = max(0, min(max(0, $count - 1), $this->cursor));
- }
-
- /**
- * The derive-ruled fields whose values are pinned against recomputation.
- *
- * Mirrors the headless pinning rule: a derive target the user supplied
- * (override) or discovery detected keeps its value, and every other derive
- * target follows its rule.
- *
- * @return array
- * The pinned map keyed by field id.
- */
- protected function pinnedDerives(): array {
- $pinned = [];
-
- foreach ($this->form->fields() as $field) {
- if ($field->derive !== NULL) {
- $provenance = $this->provenance[$field->id] ?? Provenance::Default;
- $pinned[$field->id] = $provenance === Provenance::Override || $provenance === Provenance::Detected;
- }
- }
-
- return $pinned;
- }
-
- /**
- * A panel's fields whose conditions currently hold, in declaration order.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel.
- *
- * @return list<\DrevOps\Tui\Model\Field>
- * The active fields.
- */
- protected function visibleFields(Panel $panel): array {
- return array_values(array_filter($panel->fields, fn(Field $field): bool => $this->active[$field->id] ?? TRUE));
- }
-
- /**
- * A panel's navigable fields: the active fields the cursor can land on.
- *
- * Presentational fields (notes) render but are display-only, so they are
- * excluded from navigation while staying in visibleFields() for rendering.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel.
- *
- * @return list<\DrevOps\Tui\Model\Field>
- * The navigable fields, in declaration order.
- */
- protected function navigableFields(Panel $panel): array {
- return array_values(array_filter($this->visibleFields($panel), static fn(Field $field): bool => !$field->type->isPresentational()));
- }
-
- /**
- * The number of navigable items on a panel: navigable fields plus sub-panels.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel.
- *
- * @return int
- * The item count.
- */
- protected function itemCountFor(Panel $panel): int {
- return count($this->navigableFields($panel)) + count($panel->panels);
- }
-
- /**
- * A rendering copy of a panel holding only its active fields, recursively.
- *
- * Built fresh per frame so activation changes surface immediately.
- * Navigation keeps the original panel objects - only what the theme draws is
- * filtered - and the copy carries the original Field and sub-panel content,
- * so the filtered tree stays in lock-step with the real one.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel.
- *
- * @return \DrevOps\Tui\Model\Panel
- * The filtered copy.
- */
- protected function viewPanel(Panel $panel): Panel {
- return new Panel($panel->id, $panel->title, $panel->description, $this->visibleFields($panel), array_map($this->viewPanel(...), $panel->panels), $panel->modal, $panel->layout);
- }
-
- /**
- * Handle a key while navigating a panel.
- *
- * @param \DrevOps\Tui\Input\Key $key
- * The key.
- */
- protected function handleNavigation(Key $key): void {
- if ($this->nav->matches($key, Action::Help)) {
- $this->help = TRUE;
-
- return;
- }
-
- if ($this->nav->matches($key, Action::Quit)) {
- // A modal is blocking: quit dismisses the dialog, not the whole form.
- if ($this->navigator->current()->isModal()) {
- $this->closeModal(TRUE);
- }
- else {
- $this->done = TRUE;
- }
-
- return;
- }
-
- if ($this->nav->matches($key, Action::ScrollUp)) {
- $this->offset = max(0, $this->offset - 1);
- $this->followCursor = FALSE;
-
- return;
- }
-
- if ($this->nav->matches($key, Action::ScrollDown)) {
- $this->offset++;
- $this->followCursor = FALSE;
-
- return;
- }
-
- $this->followCursor = TRUE;
-
- if ($this->nav->matches($key, Action::MoveUp) || $this->nav->matches($key, Action::MoveDown) || $this->nav->matches($key, Action::MoveLeft) || $this->nav->matches($key, Action::MoveRight)) {
- $this->moveCursor($key);
- }
- elseif ($this->nav->matches($key, Action::Back)) {
- if ($this->navigator->current()->isModal()) {
- $this->closeModal(TRUE);
- }
- elseif ($this->navigator->pop()) {
- $this->cursor = 0;
- }
- }
- elseif ($this->nav->matches($key, Action::Activate)) {
- $this->activate();
- }
- }
-
- /**
- * Move the selection cursor for a directional key.
- *
- * Fields and buttons keep the linear semantics: Up/Down step one item and
- * Left/Right move within the button pair. Inside a panel grid the arrows
- * become spatial - Left/Right walk a row's columns and Up/Down jump between
- * rows (and out to the fields above or the buttons below), landing on the
- * nearest column.
- *
- * @param \DrevOps\Tui\Input\Key $key
- * The directional key.
- */
- protected function moveCursor(Key $key): void {
- $panel = $this->navigator->current();
- $items = $this->itemCountFor($panel);
- $count = $items + ($this->buttonsVisible() ? self::BUTTON_COUNT : 0);
- $fields = count($this->navigableFields($panel));
- $up = $this->nav->matches($key, Action::MoveUp);
- $down = $this->nav->matches($key, Action::MoveDown);
-
- if ($panel->layout !== [] && $this->cursor >= $fields && $this->cursor < $items) {
- [$row, $column] = $this->gridPosition($panel->layout, $this->cursor - $fields);
-
- if ($up) {
- $this->cursor = $row > 0 ? $fields + $this->gridSlot($panel->layout, $row - 1, $column) : ($fields > 0 ? $fields - 1 : $this->cursor);
- }
- elseif ($down) {
- $this->cursor = $row < count($panel->layout) - 1 ? $fields + $this->gridSlot($panel->layout, $row + 1, $column) : ($this->buttonsVisible() ? $items : $this->cursor);
- }
- elseif ($this->nav->matches($key, Action::MoveLeft)) {
- $this->cursor = $column > 0 ? $this->cursor - 1 : $this->cursor;
- }
- else {
- $this->cursor = $column < $panel->layout[$row] - 1 ? $this->cursor + 1 : $this->cursor;
- }
-
- return;
- }
-
- if ($up) {
- $this->cursor = max(0, $this->cursor - 1);
- }
- elseif ($down) {
- $this->cursor = min(max(0, $count - 1), $this->cursor + 1);
- }
- elseif ($this->buttonsVisible() && $this->cursor >= $items) {
- // The submit/cancel buttons are inline, so Left/Right moves between them.
- $delta = $this->nav->matches($key, Action::MoveRight) ? 1 : -1;
- $this->cursor = max($items, min($count - 1, $this->cursor + $delta));
- }
- }
-
- /**
- * The grid row and column a sub-panel offset sits at for a layout.
- *
- * @param list $layout
- * The layout rows.
- * @param int $offset
- * The sub-panel offset within the panel (0-based).
- *
- * @return array{int,int}
- * The [row, column] position.
- */
- protected function gridPosition(array $layout, int $offset): array {
- $start = 0;
-
- foreach ($layout as $row => $columns) {
- if ($offset < $start + $columns) {
- return [$row, $offset - $start];
- }
-
- $start += $columns;
- }
-
- // The builder guarantees the layout covers every sub-panel.
- // @codeCoverageIgnoreStart
- return [max(0, count($layout) - 1), 0];
- // @codeCoverageIgnoreEnd
- }
-
- /**
- * The sub-panel offset of a grid position, clamped to the row's columns.
- *
- * @param list $layout
- * The layout rows.
- * @param int $row
- * The target row.
- * @param int $column
- * The desired column; a narrower target row lands on its last column.
- *
- * @return int
- * The sub-panel offset.
- */
- protected function gridSlot(array $layout, int $row, int $column): int {
- $start = 0;
-
- for ($index = 0; $index < $row; $index++) {
- $start += $layout[$index];
- }
-
- return $start + min($column, $layout[$row] - 1);
- }
-
- /**
- * Activate the selected item: edit a field or drill into a sub-panel.
- */
- protected function activate(): void {
- $panel = $this->navigator->current();
- $fields = $this->navigableFields($panel);
- $field_count = count($fields);
-
- if ($this->cursor < $field_count) {
- $field = $fields[$this->cursor];
-
- if ($field->type === FieldType::Progress) {
- $this->runProgress($field);
-
- return;
- }
-
- $this->openEditor($field);
-
- return;
- }
-
- $subpanel = $panel->panels[$this->cursor - $field_count] ?? NULL;
- if ($subpanel instanceof Panel) {
- $this->enterPanel($subpanel);
-
- return;
- }
-
- if ($this->buttonsVisible()) {
- $this->activateButton($this->cursor - $field_count - count($panel->panels));
- }
- }
-
- /**
- * Enter a sub-panel: open it as a modal dialog, or drill into it.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The panel to enter.
- */
- protected function enterPanel(Panel $panel): void {
- if ($panel->isModal()) {
- $this->openModal($panel);
-
- return;
- }
-
- $this->navigator->enter($panel);
- $this->cursor = 0;
- $this->resolveLoaders($panel);
- }
-
- /**
- * Resolve a just-entered panel's preload and option loaders, showing loading.
- *
- * Paints the panel once - the loading fields read as "Loading…" through the
- * theme - then runs the panel's preload and each field loader (blocking) and
- * settles, so drilling into a panel loads its data on entry rather than up
- * front. The preload runs first, so a field loader can read what it prepared.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The entered panel.
- */
- protected function resolveLoaders(Panel $panel): void {
- $loading = array_values(array_filter($panel->fields, static fn(Field $field): bool => $field->optionsLoader instanceof \Closure));
- $preload = $panel->preload instanceof \Closure;
-
- if ($loading === [] && !$preload) {
- return;
- }
-
- $this->repaint();
-
- if ($preload) {
- ($panel->preload)();
- $panel->preload = NULL;
- }
-
- $this->engine->loadOptions($loading);
- $this->resettle();
- }
-
- /**
- * Resolve the open editor's query against its field's source, when due.
- *
- * Runs once the whole read has been consumed rather than once per key, so a
- * burst of typing - or a paste - costs the source one call instead of one per
- * character. The loading frame is painted first, because the call blocks.
- */
- protected function resolveQuery(): void {
- $editor = $this->editor;
- $field = $this->editing;
-
- if (!$editor instanceof QueryOptionsCapableInterface || !$field instanceof Field || !$field->optionsSource instanceof \Closure) {
- return;
- }
-
- $query = $editor->pendingQuery();
- if ($query === NULL) {
- return;
- }
-
- $editor->beginQuery();
- $this->repaint();
-
- try {
- $editor->applyQuery($query, Option::resolved(($field->optionsSource)($query, $this->values)));
- }
- catch (\Throwable) {
- // Consumer code that cannot answer must not end the session over a
- // terminal still in raw mode: the field says so and stays editable, and
- // the query is remembered so the same failing call is not repeated on
- // every frame.
- $editor->failQuery($query, Translator::t('Could not load options.'));
- }
- }
-
- /**
- * Repaint the current frame in place, if a terminal is attached.
- *
- * The cooperative animation seam: a blocking loader or a progress step calls
- * this to show its latest state before it hands control back to the loop.
- */
- protected function repaint(): void {
- if ($this->terminal instanceof Terminal) {
- $this->terminal->render($this->positioned($this->frame($this->rows($this->terminal)), $this->terminal));
- }
- }
-
- /**
- * Open a modal dialog, snapshotting answers so a cancel can restore them.
- *
- * @param \DrevOps\Tui\Model\Panel $panel
- * The modal panel.
- */
- protected function openModal(Panel $panel): void {
- $this->modalValues = $this->values;
- $this->modalProvenance = $this->provenance;
- $this->modalReturnCursor = $this->cursor;
- $this->modalReturnOffset = $this->offset;
- $this->navigator->enter($panel);
- $this->cursor = 0;
- $this->offset = 0;
- $this->resolveLoaders($panel);
- }
-
- /**
- * Close the current modal dialog, restoring the parent's cursor and scroll.
- *
- * @param bool $cancel
- * TRUE to discard the dialog's edits (restoring the opening snapshot);
- * FALSE to keep them.
- */
- protected function closeModal(bool $cancel): void {
- if ($cancel) {
- $this->values = $this->modalValues;
- $this->provenance = $this->modalProvenance;
- }
-
- $this->navigator->pop();
- $this->cursor = $this->modalReturnCursor;
- $this->offset = $this->modalReturnOffset;
- $this->followCursor = TRUE;
- $this->modalValues = [];
- $this->modalProvenance = [];
- $this->modalReturnCursor = 0;
- $this->modalReturnOffset = 0;
-
- // A restored snapshot (or the dialog's kept edits) may change what is
- // active outside the dialog, so the parent re-settles before it renders.
- $this->resettle();
- }
-
- /**
- * The panel-hub footer: the contextual hint line, unless turned off.
- *
- * @return list
- * The footer lines: one when the footer is on, none when it is off.
- */
- protected function hubFooter(): array {
- return $this->footer ? [$this->theme->renderHints($this->nav, ...$this->navigationHints())] : [];
- }
-
- /**
- * The footer while a field is edited inline: the active widget's own hints.
- *
- * The keys in play are the widget's, not the hub's, so the footer switches to
- * the widget's hints against its field-scope bindings - the same line the
- * standalone editor would show.
- *
- * @return list
- * The widget's hint line, or none when the footer is turned off.
- */
- protected function inlineEditFooter(): array {
- if (!$this->footer || !$this->editor instanceof WidgetInterface || !$this->editing instanceof Field) {
- return [];
- }
-
- return [$this->theme->renderHints($this->keymap->forField($this->editing->type), ...$this->editor->hints())];
- }
-
- /**
- * The hint fragments for the panel hub, in display order.
- *
- * A panel grid navigates spatially, so its move hint carries the horizontal
- * arrows too.
- *
- * @return list<\DrevOps\Tui\Input\Hint>
- * The hub hints.
- */
- protected function navigationHints(): array {
- $move = $this->navigator->current()->layout !== [] ? new Hint('move', Action::MoveUp, Action::MoveDown, Action::MoveLeft, Action::MoveRight) : new Hint('move', Action::MoveUp, Action::MoveDown);
-
- return [
- $move,
- new Hint('select', Action::Activate),
- new Hint('back', Action::Back),
- new Hint('quit', Action::Quit),
- new Hint('help', Action::Help),
- ];
- }
-
- /**
- * The help-overlay sections: the hub, then each widget type the form uses.
- *
- * Field types are listed once, in first-seen order, so the overlay teaches
- * every widget the form can show without repeating a type.
- *
- * @return list<\DrevOps\Tui\Render\HelpSection>
- * The sections.
- */
- protected function helpSections(): array {
- $sections = [new HelpSection(Translator::t('Navigation'), $this->nav, ...$this->navigationHints())];
-
- $seen = [];
- foreach ($this->form->fields() as $field) {
- // A display-only field has no editor, so no key hints to teach.
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if (in_array($field->type, $seen, TRUE)) {
- continue;
- }
- $seen[] = $field->type;
- $widget = $this->widgets->create($field, $this->values[$field->id] ?? $field->default);
- $sections[] = new HelpSection($field->type->label(), $this->keymap->forField($field->type), ...$widget->hints());
- }
-
- return $sections;
- }
-
- /**
- * Whether the submit/cancel buttons are shown on the current panel.
- *
- * They live on the root panel only, so sub-panels are not cluttered with
- * global actions.
- *
- * @return bool
- * TRUE when buttons are enabled and the navigator is at the root panel, or
- * when the current panel is a modal (which always shows its own pair).
- */
- protected function buttonsVisible(): bool {
- if ($this->navigator->current()->isModal()) {
- return TRUE;
- }
-
- return $this->form->buttons->show && $this->navigator->isRoot();
- }
-
- /**
- * Activate a button by its index in the pair.
- *
- * In a modal the pair dismisses the dialog; otherwise it finishes the form,
- * recording whether the user cancelled.
- *
- * @param int $index
- * The button index (submit first, cancel second).
- */
- protected function activateButton(int $index): void {
- if ($this->navigator->current()->isModal()) {
- $this->closeModal($index === self::CANCEL_BUTTON);
-
- return;
- }
-
- $cancelled = $index === self::CANCEL_BUTTON;
-
- // Abandoning the form is always allowed - only completing it has to answer
- // for the required fields.
- if (!$cancelled && !$this->guardRequired()) {
- return;
- }
-
- $this->done = TRUE;
- $this->cancelled = $cancelled;
- }
-
- /**
- * Whether every active required field holds a value, else refuse the submit.
- *
- * A field left untouched never opens its editor, so the widget's own guard
- * never runs on it; this is the boundary where an empty required answer is
- * caught instead. The message names the field, which is as far as pointing
- * can go: the buttons live on the root panel and every field lives inside a
- * sub-panel, so there is never an offending row on the panel being shown.
- *
- * @return bool
- * TRUE when the form may be completed; FALSE after recording the first
- * offending field's message.
- */
- protected function guardRequired(): bool {
- foreach ($this->form->fields() as $field) {
- if ($field->type->isDisplayOnly()) {
- continue;
- }
- if (!($this->active[$field->id] ?? FALSE)) {
- continue;
- }
-
- $missing = $field->requiredViolation($this->values[$field->id] ?? NULL);
- if ($missing === NULL) {
- continue;
- }
-
- $this->submitError = $missing;
-
- return FALSE;
- }
-
- $this->submitError = NULL;
-
- return TRUE;
- }
-
- /**
- * Open the editor for a field.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The field.
- */
- protected function openEditor(Field $field): void {
- $this->editing = $field;
- $this->editor = $this->widgets->create($field, $this->values[$field->id] ?? $field->default, $this->values);
- }
-
- /**
- * Run a progress row's work, animating its indicator as it advances.
- *
- * Starts the indicator at zero, then runs the work with a reporter whose
- * every advance moves the field's live count and repaints the row - so the
- * bar fills (or the spinner ticks) in place while the blocking work proceeds.
- *
- * @param \DrevOps\Tui\Model\Field $field
- * The progress field.
- */
- protected function runProgress(Field $field): void {
- $work = $field->progressWork;
-
- if (!$work instanceof \Closure) {
- return;
- }
-
- $field->progressCurrent = 0;
- $field->progressLabel = '';
- $this->repaint();
-
- $reporter = new ProgressReporter(function (?string $label) use ($field): void {
- $next = ($field->progressCurrent ?? 0) + 1;
- $field->progressCurrent = $field->progressSteps === NULL ? $next : min($next, $field->progressSteps);
-
- if ($label !== NULL) {
- $field->progressLabel = $label;
- }
-
- $this->repaint();
- });
-
- $work($reporter);
- }
-
- /**
- * Close the editor.
- */
- protected function closeEditor(): void {
- $this->editor = NULL;
- $this->editing = NULL;
- }
-
-}
diff --git a/src/Resolver/EnvNameResolver.php b/src/Resolver/EnvNameResolver.php
index 3e1ece5f..8e8e234e 100644
--- a/src/Resolver/EnvNameResolver.php
+++ b/src/Resolver/EnvNameResolver.php
@@ -4,7 +4,7 @@
namespace DrevOps\Tui\Resolver;
-use DrevOps\Tui\Model\Field;
+use DrevOps\Tui\Block\Field;
/**
* Names the environment variables that answer a field.
@@ -36,33 +36,35 @@ public function __construct(protected string $envPrefix = '') {
/**
* The variable that answers the field when several are set.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return string
* The declared override, or the prefixed and uppercased field id.
*/
public function canonical(Field $field): string {
- return $field->envName !== '' ? $field->envName : $this->envPrefix . strtoupper($field->id);
+ $declared = $this->declaredName($field);
+
+ return $declared !== '' ? $declared : $this->envPrefix . strtoupper($field->id());
}
/**
* The additional variables the field also answers to, in declaration order.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return list
* The alias names; empty when the field declares none.
*/
public function aliases(Field $field): array {
- return $field->envAliases;
+ return $field->aliases();
}
/**
* Every variable that answers the field, in precedence order.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return list
@@ -80,14 +82,31 @@ public function all(Field $field): array {
* so it is not offered as an answer route. Declared aliases are absolute and
* carry no such risk, so they stand on their own.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return bool
* TRUE when the field declares its own name or a prefix namespaces it.
*/
public function isAdvertisable(Field $field): bool {
- return $field->envName !== '' || $this->envPrefix !== '';
+ if ($this->declaredName($field) !== '') {
+ return TRUE;
+ }
+
+ return $this->envPrefix !== '';
+ }
+
+ /**
+ * The variable name a field declares for itself.
+ *
+ * @param \DrevOps\Tui\Block\Field $field
+ * The field.
+ *
+ * @return string
+ * The name, empty when the mechanical one stands.
+ */
+ protected function declaredName(Field $field): string {
+ return $field->envName();
}
}
diff --git a/src/Resolver/InputResolver.php b/src/Resolver/InputResolver.php
index 3e08dea3..fc7b11ba 100644
--- a/src/Resolver/InputResolver.php
+++ b/src/Resolver/InputResolver.php
@@ -4,7 +4,7 @@
namespace DrevOps\Tui\Resolver;
-use DrevOps\Tui\Model\Field;
+use DrevOps\Tui\Block\Field;
use DrevOps\Tui\Model\FieldType;
use DrevOps\Tui\Translation\Translator;
@@ -14,7 +14,7 @@
* The TUI stays dependency-free: this small overlay merges the external
* layers - per-question environment variables (below) and a `--prompts`
* JSON string or file (above) - into one input map. That map is the top layer
- * the engine resolves against, so the full precedence becomes
+ * a collection resolves against, so the full precedence becomes
* `--prompts` > env > discovered > derived > default. Environment values are
* strings, so they are coerced to the field's type; `--prompts` values are
* already typed by JSON.
@@ -39,7 +39,7 @@ public function __construct(protected string $envPrefix = '') {
/**
* Build the input map for the given fields.
*
- * @param \DrevOps\Tui\Model\Field[] $fields
+ * @param list<\DrevOps\Tui\Block\Field> $fields
* The fields to resolve inputs for.
* @param string $prompts
* A `--prompts` JSON string, or a path to a JSON file, or empty.
@@ -56,7 +56,7 @@ public function resolve(array $fields, string $prompts, array $env): array {
foreach ($fields as $field) {
foreach ($names->all($field) as $name) {
if (array_key_exists($name, $env)) {
- $inputs[$field->id] = $this->coerce($env[$name], $field);
+ $inputs[$field->id()] = $this->coerce($env[$name], $field);
break;
}
@@ -75,7 +75,7 @@ public function resolve(array $fields, string $prompts, array $env): array {
*
* @param string $value
* The raw environment value.
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return mixed
@@ -86,11 +86,11 @@ protected function coerce(string $value, Field $field): mixed {
$truthy = ['1', 'true', 'yes', 'on'];
return match (TRUE) {
- $field->type === FieldType::Confirm, $field->type === FieldType::Pause => in_array(strtolower($trimmed), $truthy, TRUE),
+ $field->type() === FieldType::Confirm, $field->type() === FieldType::Pause => in_array(strtolower($trimmed), $truthy, TRUE),
$field->collectsList() => $this->splitList($value),
// Only an integral value coerces; anything else stays a string so the
- // engine's type check rejects it instead of it silently becoming 0.
- $field->type->collectsInteger() => preg_match('/^-?\d+$/', $trimmed) === 1 ? (int) $trimmed : $value,
+ // collection's type check rejects it instead of it silently becoming 0.
+ $field->type()->collectsInteger() => preg_match('/^-?\d+$/', $trimmed) === 1 ? (int) $trimmed : $value,
default => $value,
};
}
diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php
index ee1b5b9d..b39f0a4f 100644
--- a/src/Schema/AgentHelp.php
+++ b/src/Schema/AgentHelp.php
@@ -4,10 +4,11 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Block\Field;
+use DrevOps\Tui\Block\Panel;
+use DrevOps\Tui\Block\Tree;
use DrevOps\Tui\Handler\Context;
-use DrevOps\Tui\Model\Field;
use DrevOps\Tui\Model\FieldType;
-use DrevOps\Tui\Model\FormDefinition;
use DrevOps\Tui\Model\NumberBounds;
use DrevOps\Tui\Model\SelectionBounds;
use DrevOps\Tui\Model\Template;
@@ -39,8 +40,9 @@ class AgentHelp {
/**
* Construct the schema generator.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The form definition to describe.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The declared tree to describe, read from the panel every declared panel
+ * hangs from.
* @param string $envPrefix
* The prefix for per-question env variable names (e.g. "APP_"); under an
* empty prefix only a field naming its own variable carries the `env`
@@ -49,7 +51,7 @@ class AgentHelp {
* The context a closure default is evaluated against; defaults to an empty
* context carrying no prior answers.
*/
- public function __construct(protected FormDefinition $form, protected string $envPrefix = '', protected Context $context = new Context()) {
+ public function __construct(protected Panel $root, protected string $envPrefix = '', protected Context $context = new Context()) {
$this->names = new EnvNameResolver($envPrefix);
}
@@ -63,19 +65,19 @@ public function generate(): string {
$properties = [];
$required = [];
- foreach ($this->form->fields() as $field) {
+ foreach (Tree::fields($this->root) as $field) {
// A pause is a gate, and a note or a progress row is display-only: none
// is a question, so none carries an answer.
- if ($field->type === FieldType::Pause) {
+ if ($field->type() === FieldType::Pause) {
continue;
}
- if ($field->type->isDisplayOnly()) {
+ if ($field->type()->isDisplayOnly()) {
continue;
}
- $properties[$field->id] = $this->property($field);
+ $properties[$field->id()] = $this->property($field);
- if ($field->required) {
- $required[] = $field->id;
+ if ($field->isRequired()) {
+ $required[] = $field->id();
}
}
@@ -97,7 +99,7 @@ public function generate(): string {
/**
* Build the schema property for one field.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return array
@@ -105,16 +107,19 @@ public function generate(): string {
*/
protected function property(Field $field): array {
$values = $this->optionValues($field);
+ $template = $field->template();
+ $bounds = $field->numberBounds();
+ $selections = $field->selectionBounds();
$property = [];
if ($field->collectsList()) {
$property['type'] = 'array';
$property['items'] = $values === [] ? ['type' => 'string'] : ['enum' => $values];
}
- elseif ($field->type->collectsInteger()) {
+ elseif ($field->type()->collectsInteger()) {
$property['type'] = 'integer';
}
- elseif ($field->type === FieldType::Confirm) {
+ elseif ($field->type() === FieldType::Confirm) {
$property['type'] = 'boolean';
}
else {
@@ -125,56 +130,56 @@ protected function property(Field $field): array {
}
}
- if ($field->type === FieldType::Calendar) {
+ if ($field->type() === FieldType::Calendar) {
$property['format'] = 'date';
}
// A template answer is the assembled string, so its shape travels as the
// expression that string must match rather than as the pattern's own
// `{{slot}}` syntax, which no schema consumer would understand.
- if ($field->template instanceof Template) {
- $property['pattern'] = $field->template->schemaPattern();
+ if ($template instanceof Template) {
+ $property['pattern'] = $template->schemaPattern();
}
// The step is a keyboard increment, not a value constraint - the library
// accepts any in-range integer - so it never becomes a `multipleOf` that
// would reject values the collection allows.
- if ($field->bounds instanceof NumberBounds) {
- if ($field->bounds->min !== NULL) {
- $property['minimum'] = $field->bounds->min;
+ if ($bounds instanceof NumberBounds) {
+ if ($bounds->min !== NULL) {
+ $property['minimum'] = $bounds->min;
}
- if ($field->bounds->max !== NULL) {
- $property['maximum'] = $field->bounds->max;
+ if ($bounds->max !== NULL) {
+ $property['maximum'] = $bounds->max;
}
}
- if ($field->selectionBounds instanceof SelectionBounds) {
- if ($field->selectionBounds->min !== NULL) {
- $property['minItems'] = $field->selectionBounds->min;
+ if ($selections instanceof SelectionBounds) {
+ if ($selections->min !== NULL) {
+ $property['minItems'] = $selections->min;
}
- if ($field->selectionBounds->max !== NULL) {
- $property['maxItems'] = $field->selectionBounds->max;
+ if ($selections->max !== NULL) {
+ $property['maxItems'] = $selections->max;
}
}
- if ($field->label !== '') {
- $property['title'] = Translator::t($field->label);
+ if ($field->label() !== '') {
+ $property['title'] = Translator::t($field->label());
}
- if ($field->description !== '') {
- $property['description'] = Translator::t($field->description);
+ if ($field->descriptionText() !== '') {
+ $property['description'] = Translator::t($field->descriptionText());
}
// Extension keywords: JSON Schema has no slot for either, and folding them
// into `description` would merge back the three texts a form keeps apart. A
// placeholder is not `examples` either - it illustrates the shape of an
// answer without being a valid one.
- if ($field->hint !== '') {
- $property['x-hint'] = Translator::t($field->hint);
+ if ($field->helpText() !== '') {
+ $property['x-help'] = Translator::t($field->helpText());
}
- if ($field->placeholder !== '') {
- $property['x-placeholder'] = Translator::t($field->placeholder);
+ if ($field->placeholderText() !== '') {
+ $property['x-placeholder'] = Translator::t($field->placeholderText());
}
$default = DefaultResolver::resolve($field, $this->context);
@@ -200,7 +205,7 @@ protected function property(Field $field): array {
/**
* The selectable option values of an option-constrained field.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return list
@@ -208,7 +213,7 @@ protected function property(Field $field): array {
* field is not constrained to a closed set.
*/
protected function optionValues(Field $field): array {
- if (!$field->type->constrainsToOptions()) {
+ if (!$field->type()->constrainsToOptions()) {
return [];
}
diff --git a/src/Schema/DefaultResolver.php b/src/Schema/DefaultResolver.php
index 38fc4521..3b72830c 100644
--- a/src/Schema/DefaultResolver.php
+++ b/src/Schema/DefaultResolver.php
@@ -4,8 +4,8 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Block\Field;
use DrevOps\Tui\Handler\Context;
-use DrevOps\Tui\Model\Field;
/**
* Resolves a field's default for machine-readable output.
@@ -23,7 +23,7 @@ final class DefaultResolver {
/**
* Resolve the default advertised for a field.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param \DrevOps\Tui\Handler\Context $context
* The context a closure default is evaluated against; its answers are the
@@ -34,16 +34,18 @@ final class DefaultResolver {
* evaluated value, or NULL when a closure cannot be resolved.
*/
public static function resolve(Field $field, Context $context): mixed {
- if (!$field->default instanceof \Closure) {
- return $field->default;
+ $default = $field->value();
+
+ if (!$default instanceof \Closure) {
+ return $default;
}
- if ($field->hasSchemaDefault) {
- return $field->schemaDefault;
+ if ($field->hasSchemaDefault()) {
+ return $field->schemaDefaultValue();
}
try {
- return ($field->default)($context);
+ return $default($context);
}
catch (\Throwable) {
return NULL;
diff --git a/src/Schema/OptionsResolver.php b/src/Schema/OptionsResolver.php
index f9f9813b..aa29e1ec 100644
--- a/src/Schema/OptionsResolver.php
+++ b/src/Schema/OptionsResolver.php
@@ -4,9 +4,8 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Block\Field;
use DrevOps\Tui\Handler\Context;
-use DrevOps\Tui\Model\Field;
-use DrevOps\Tui\Model\Option;
/**
* Resolves a field's answer-driven options for machine-readable output.
@@ -26,25 +25,27 @@ final class OptionsResolver {
/**
* Resolve a field's options in place, if they follow the answers.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param \DrevOps\Tui\Handler\Context $context
* The context the resolver is called with.
*/
public static function resolve(Field $field, Context $context): void {
- if (!$field->optionsResolver instanceof \Closure) {
+ $resolver = $field->resolver();
+
+ if (!$resolver instanceof \Closure) {
return;
}
try {
- $field->options = Option::resolved(($field->optionsResolver)($context));
+ $field->settle($resolver($context));
}
catch (\Throwable) {
// A field's options are settled state that outlives one call, so a set
// resolved for some earlier context is still sitting there. Nothing can
// be said about this one, and saying the last one instead would be a
// description of the wrong form.
- $field->options = [];
+ $field->settle([]);
}
}
diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php
index 1c9f07db..1db427fc 100644
--- a/src/Schema/SchemaGenerator.php
+++ b/src/Schema/SchemaGenerator.php
@@ -4,16 +4,17 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Block\Field;
+use DrevOps\Tui\Block\Panel;
+use DrevOps\Tui\Block\Tree;
use DrevOps\Tui\Discovery\DiscoverInterface;
use DrevOps\Tui\Handler\Context;
-use DrevOps\Tui\Model\Field;
-use DrevOps\Tui\Model\FormDefinition;
use DrevOps\Tui\Resolver\EnvNameResolver;
/**
* Generates a machine-readable schema of every configured question.
*
- * Each prompt entry carries `{id, type, label, description, hint, placeholder,
+ * Each prompt entry carries `{id, type, label, description, help, placeholder,
* options, default, required}` plus the declared bounds, the environment
* variables that answer it and the `when`, `derive` and `discover` rules, so
* external tooling can drive or validate the form without loading the PHP
@@ -30,8 +31,9 @@ class SchemaGenerator {
/**
* Construct a generator.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The configuration to describe.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The declared tree to describe, read from the panel every declared panel
+ * hangs from.
* @param \DrevOps\Tui\Handler\Context $context
* The context a closure default is evaluated against; defaults to an empty
* context carrying no prior answers.
@@ -39,7 +41,7 @@ class SchemaGenerator {
* The prefix for per-question env variable names (e.g. "APP_"); under an
* empty prefix only a field naming its own variable advertises one.
*/
- public function __construct(protected FormDefinition $form, protected Context $context = new Context(), protected string $envPrefix = '') {
+ public function __construct(protected Panel $root, protected Context $context = new Context(), protected string $envPrefix = '') {
}
/**
@@ -52,40 +54,45 @@ public function generate(): array {
$names = new EnvNameResolver($this->envPrefix);
$prompts = [];
- foreach ($this->form->fields() as $field) {
+ foreach (Tree::fields($this->root) as $field) {
// A display-only field (a note or a progress row) collects no answer, so
// it is not a prompt external tooling drives or validates.
- if ($field->type->isDisplayOnly()) {
+ if ($field->type()->isDisplayOnly()) {
continue;
}
+ $rule = $field->rule();
+ $bounds = $field->numberBounds();
+ $dates = $field->dateBounds();
+ $discover = $field->discovery();
+
$prompts[] = [
- 'id' => $field->id,
- 'type' => $field->type->value,
- 'label' => $field->label,
- 'description' => $field->description,
- 'hint' => $field->hint,
- 'placeholder' => $field->placeholder,
+ 'id' => $field->id(),
+ 'type' => $field->type()->value,
+ 'label' => $field->label(),
+ 'description' => $field->descriptionText(),
+ 'help' => $field->helpText(),
+ 'placeholder' => $field->placeholderText(),
'options' => $this->options($field),
- 'options_dynamic' => $field->hasDynamicOptions(),
+ 'options_dynamic' => $field->hasDynamicEntries(),
'default' => DefaultResolver::resolve($field, $this->context),
- 'required' => $field->required,
+ 'required' => $field->isRequired(),
'env' => $names->isAdvertisable($field) ? $names->canonical($field) : NULL,
'env_aliases' => $names->aliases($field),
- 'min' => $field->bounds?->min,
- 'max' => $field->bounds?->max,
- 'step' => $field->bounds?->step,
- 'min_selections' => $field->selectionBounds?->min,
- 'max_selections' => $field->selectionBounds?->max,
- 'min_date' => $field->dateBounds?->min?->format('Y-m-d'),
- 'max_date' => $field->dateBounds?->max?->format('Y-m-d'),
- 'week_start' => $field->dateBounds?->weekStart->value,
- 'template' => $field->template?->pattern(),
- 'placeholders' => $field->template?->placeholders() ?? [],
- 'when' => $field->when?->toArray(),
- 'derive' => $field->derive?->toArray(),
- 'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL,
- 'depends_on' => $field->when === NULL ? [] : $field->when->fields(),
+ 'min' => $bounds?->min,
+ 'max' => $bounds?->max,
+ 'step' => $bounds?->step,
+ 'min_selections' => $field->selectionBounds()?->min,
+ 'max_selections' => $field->selectionBounds()?->max,
+ 'min_date' => $dates?->min?->format('Y-m-d'),
+ 'max_date' => $dates?->max?->format('Y-m-d'),
+ 'week_start' => $dates?->weekStart->value,
+ 'template' => $field->template()?->pattern(),
+ 'placeholders' => $field->template()?->placeholders() ?? [],
+ 'when' => $rule?->toArray(),
+ 'derive' => $field->derivation()?->toArray(),
+ 'discover' => $discover instanceof DiscoverInterface ? $discover->toArray() : NULL,
+ 'depends_on' => $rule === NULL ? [] : $rule->fields(),
];
}
@@ -95,7 +102,7 @@ public function generate(): array {
/**
* Describe a field's options.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
*
* @return array>
@@ -107,7 +114,7 @@ protected function options(Field $field): array {
$out = [];
- foreach ($field->options as $option) {
+ foreach ($field->entries() as $option) {
if (!$option->selectable()) {
continue;
}
diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php
index d68bccf2..5c6382fa 100644
--- a/src/Schema/SchemaValidator.php
+++ b/src/Schema/SchemaValidator.php
@@ -4,9 +4,10 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Block\Field;
+use DrevOps\Tui\Block\Panel;
+use DrevOps\Tui\Block\Tree;
use DrevOps\Tui\Handler\Context;
-use DrevOps\Tui\Model\Field;
-use DrevOps\Tui\Model\FormDefinition;
use DrevOps\Tui\Translation\Translator;
/**
@@ -25,13 +26,14 @@ class SchemaValidator {
/**
* Construct a validator.
*
- * @param \DrevOps\Tui\Model\FormDefinition $form
- * The configuration to validate against.
+ * @param \DrevOps\Tui\Block\Panel $root
+ * The declared tree to validate against, read from the panel every declared
+ * panel hangs from.
* @param \DrevOps\Tui\Handler\Context $context
* The run context an options resolver is evaluated against, its answers
* replaced by the set under validation; defaults to an empty context.
*/
- public function __construct(protected FormDefinition $form, protected Context $context = new Context()) {
+ public function __construct(protected Panel $root, protected Context $context = new Context()) {
}
/**
@@ -45,24 +47,26 @@ public function __construct(protected FormDefinition $form, protected Context $c
*/
public function validate(array $answers): array {
$errors = [];
- $known = [];
- foreach ($this->form->fields() as $field) {
- $known[$field->id] = TRUE;
+ // Every row the tree holds, including the ones that only show: an id that
+ // shows is still an id the form knows, so a stray value for one is ignored
+ // rather than reported as a question nobody asked.
+ $known = array_fill_keys(Tree::ids($this->root), TRUE);
+ foreach (Tree::fields($this->root) as $field) {
// A display-only field (a note or a progress row) carries no answer, so
// it is never required and any value supplied for it is ignored.
- if ($field->type->isDisplayOnly()) {
+ if ($field->type()->isDisplayOnly()) {
continue;
}
- if ($field->when !== NULL && !$field->when->matches($answers)) {
+ if (!$field->isActive($answers)) {
continue;
}
- if (!array_key_exists($field->id, $answers)) {
- if ($field->required) {
- $errors[] = Translator::t('Missing required question "@id".', ['@id' => $field->id]);
+ if (!array_key_exists($field->id(), $answers)) {
+ if ($field->isRequired()) {
+ $errors[] = Translator::t('Missing required question "@id".', ['@id' => $field->id()]);
}
continue;
@@ -74,7 +78,7 @@ public function validate(array $answers): array {
// see during collection - before membership is checked.
OptionsResolver::resolve($field, new Context($this->context->directory, $answers, $this->context->update, $this->context->version));
- $error = $this->validateValue($field, $answers[$field->id]);
+ $error = $this->validateValue($field, $answers[$field->id()]);
if ($error !== NULL) {
$errors[] = $error;
}
@@ -92,7 +96,7 @@ public function validate(array $answers): array {
/**
* Validate a single value against its field.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param mixed $value
* The value.
@@ -105,7 +109,7 @@ protected function validateValue(Field $field, mixed $value): ?string {
// letting NULL read as a type error or an empty list as a count violation.
$missing = $field->requiredViolation($value);
if ($missing !== NULL) {
- return Translator::t('Question "@id": @error', ['@id' => $field->id, '@error' => $missing]);
+ return Translator::t('Question "@id": @error', ['@id' => $field->id(), '@error' => $missing]);
}
if (!$field->acceptsValue($value)) {
@@ -128,7 +132,7 @@ protected function validateValue(Field $field, mixed $value): ?string {
/**
* Check a value against the field's declared number or date bounds.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param mixed $value
* The value.
@@ -145,7 +149,7 @@ protected function checkBounds(Field $field, mixed $value): ?string {
/**
* Check a value against the field's declared file picker constraints.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param mixed $value
* The value.
@@ -163,7 +167,7 @@ protected function checkPicker(Field $field, mixed $value): ?string {
/**
* Frame a constraint fragment as a question-scoped error message.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param string $constraint
* The constraint fragment (e.g. "a string", "between 1 and 10").
@@ -172,7 +176,7 @@ protected function checkPicker(Field $field, mixed $value): ?string {
* The framed message.
*/
protected function constraintMessage(Field $field, string $constraint): string {
- return Translator::t('Question "@id" must be @constraint.', ['@id' => $field->id, '@constraint' => $constraint]);
+ return Translator::t('Question "@id" must be @constraint.', ['@id' => $field->id(), '@constraint' => $constraint]);
}
/**
@@ -181,7 +185,7 @@ protected function constraintMessage(Field $field, string $constraint): string {
* Rejects any supplied value that is not a selectable option, telling a
* disabled option apart from an unknown one.
*
- * @param \DrevOps\Tui\Model\Field $field
+ * @param \DrevOps\Tui\Block\Field $field
* The field.
* @param mixed $value
* The value.
@@ -190,9 +194,9 @@ protected function constraintMessage(Field $field, string $constraint): string {
* An error, or NULL when valid.
*/
protected function checkOptions(Field $field, mixed $value): ?string {
- $error = $field->optionError($value);
+ $error = $field->entryError($value);
- return $error === NULL ? NULL : Translator::t('Question "@id": @error.', ['@id' => $field->id, '@error' => $error]);
+ return $error === NULL ? NULL : Translator::t('Question "@id": @error.', ['@id' => $field->id(), '@error' => $error]);
}
}
diff --git a/src/Screen/Assembler.php b/src/Screen/Assembler.php
new file mode 100644
index 00000000..b56c3569
--- /dev/null
+++ b/src/Screen/Assembler.php
@@ -0,0 +1,86 @@
+layout($arranged);
+ $names = $arranged->names();
+
+ // Furniture goes only where the named layout keeps a place for it: a
+ // layout with no header simply shows no trail, rather than being refused
+ // for not being the default.
+ if (in_array('header', $names, TRUE)) {
+ $screen->in('header')->add(new Breadcrumb($panel->title()));
+ }
+
+ $screen->in(in_array('content', $names, TRUE) ? 'content' : $names[0])->add($panel->enter());
+
+ if (in_array('footer', $names, TRUE)) {
+ $screen->in('footer')->add($this->legend($panel));
+ }
+
+ return $screen;
+ }
+
+ /**
+ * The keys a panel offers before anything in it is open.
+ *
+ * Read out of the panel's own bindings rather than written out beside them,
+ * so a retuned key changes the line that advertises it too.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel filling the content region.
+ *
+ * @return \DrevOps\Tui\Block\Legend
+ * The legend.
+ */
+ protected function legend(Panel $panel): Legend {
+ return (new Legend())->advertise($panel->bindings(), ...$panel->hints());
+ }
+
+ /**
+ * The buttons that end a form.
+ *
+ * @return \DrevOps\Tui\Block\Actions
+ * The actions.
+ */
+ public function actions(): Actions {
+ return (new Actions())->action('submit', 'Submit')->action('cancel', 'Cancel');
+ }
+
+}
diff --git a/src/Screen/Axis.php b/src/Screen/Axis.php
new file mode 100644
index 00000000..a71f90f3
--- /dev/null
+++ b/src/Screen/Axis.php
@@ -0,0 +1,24 @@
+,run:array{string,bool,string},rows:list<\DrevOps\Tui\Model\Option>}>
+ */
+ protected array $memo = [];
+
+ /**
+ * Resolves a field id to the behaviour written once for that kind of answer.
+ */
+ protected HandlerRegistry $handlers;
+
+ /**
+ * Construct a collector.
+ *
+ * @param \DrevOps\Tui\Handler\HandlerRegistry|null $handlers
+ * The registry resolving a field id to the behaviour reusable across every
+ * form that asks for that kind of answer, or NULL when nothing is reused
+ * and each field speaks only for itself.
+ * @param \DrevOps\Tui\Model\Fixup[] $fixups
+ * The rules applied once the answers have settled. They belong to the form
+ * rather than to any one block, so they arrive beside the tree.
+ */
+ public function __construct(?HandlerRegistry $handlers = NULL, protected array $fixups = []) {
+ $this->handlers = $handlers ?? new HandlerRegistry();
+ $this->deriver = new Deriver();
+ }
+
+ /**
+ * Collect a panel's answers.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to collect, and any panels beneath it.
+ * @param array $supplied
+ * Values supplied for its fields, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context|null $context
+ * The run this collection belongs to, or NULL for one that targets no
+ * directory and detects nothing.
+ *
+ * @return array
+ * The answers, keyed by field id.
+ *
+ * @throws \DrevOps\Tui\CollectException
+ * When a supplied value is refused. The answers were asked for as a whole,
+ * so one that cannot be taken fails the whole collection.
+ */
+ public function collect(Panel $panel, array $supplied = [], ?Context $context = NULL): array {
+ [$fields, $values, $sources, $active] = $this->fetched($panel, $supplied, $context ?? new Context());
+
+ $this->refuse($this->refusal($fields, $values, $sources, $active));
+
+ return $this->activeAnswers($fields, $values, $active);
+ }
+
+ /**
+ * Collect a panel's answers, each describing the question it answers.
+ *
+ * The same collection as {@see collect()}, handed back as the self-describing
+ * set: every answer carries its provenance and a snapshot of its question, so
+ * a summary needs no form configuration to print.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to collect, and any panels beneath it.
+ * @param array $supplied
+ * Values supplied for its fields, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context|null $context
+ * The run this collection belongs to, or NULL for one that targets no
+ * directory and detects nothing.
+ *
+ * @return \DrevOps\Tui\Answers\Answers
+ * The answers.
+ *
+ * @throws \DrevOps\Tui\CollectException
+ * When a supplied value is refused. The answers were asked for as a whole,
+ * so one that cannot be taken fails the whole collection.
+ */
+ public function answers(Panel $panel, array $supplied = [], ?Context $context = NULL): Answers {
+ [$fields, $values, $sources, $active] = $this->fetched($panel, $supplied, $context ?? new Context());
+
+ $this->refuse($this->refusal($fields, $values, $sources, $active));
+
+ return Answers::forTree($panel, $this->activeAnswers($fields, $values, $active), $this->provenance($fields, $sources, $active));
+ }
+
+ /**
+ * Fail the collection over a value the form refuses.
+ *
+ * @param array{string,string}|null $refusal
+ * The field's id and the reason, or NULL when nothing is refused.
+ *
+ * @throws \DrevOps\Tui\CollectException
+ * When there is a refusal to report.
+ */
+ protected function refuse(?array $refusal): void {
+ if ($refusal === NULL) {
+ return;
+ }
+
+ throw new CollectException(Translator::t('Invalid value for field "@id": @error', [
+ '@id' => $refusal[0],
+ '@error' => $refusal[1],
+ ]));
+ }
+
+ /**
+ * Resolve every value and where it came from, refusing none of them.
+ *
+ * The same resolution {@see collect()} runs, stopped where the two paths part
+ * company: a screen has somebody in front of it, so a value it cannot take is
+ * something to say on the row holding it rather than grounds for failing the
+ * call. The values arrive whole - a field a condition hides keeps the value
+ * it settled on - so a condition satisfied later surfaces a row that already
+ * knows its answer.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to resolve, and any panels beneath it.
+ * @param array $supplied
+ * Values supplied for its fields, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context|null $context
+ * The run this resolution belongs to, or NULL for one that targets no
+ * directory and detects nothing.
+ *
+ * @return array{array,array,array}
+ * The settled values, how each came to be, and which fields are there.
+ */
+ public function seed(Panel $panel, array $supplied = [], ?Context $context = NULL): array {
+ [$fields, $values, $sources, $active] = $this->settle($panel, $supplied, $context ?? new Context());
+
+ return [$values, $this->provenance($fields, $sources, $active), $active];
+ }
+
+ /**
+ * Settle a panel again over the answers it now holds.
+ *
+ * Where {@see seed()} works out what a form opens on, this works out what it
+ * stands at once somebody has answered something: the values arrive already
+ * resolved, so nothing is looked up, detected or defaulted again, and the
+ * stages that follow - the rows that follow the answers, the values computed
+ * from them, who is there at all, and the rules that write a value once the
+ * answers have settled - run exactly as they do on the way in. That is the
+ * whole of why a dependent row appears the moment its condition holds.
+ *
+ * Nothing here was supplied: an answer somebody gave is the live one, so a
+ * narrowed row set restates it rather than reporting it the way a supplied
+ * value is reported.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to settle, and any panels beneath it.
+ * @param array $values
+ * The answers as they now stand, keyed by field id.
+ * @param array $pinned
+ * The fields whose computed value must not be recomputed, keyed by field
+ * id: the ones somebody answered over the rule that computes them.
+ * @param \DrevOps\Tui\Handler\Context|null $context
+ * The run this collection belongs to, or NULL for one that targets no
+ * directory and detects nothing.
+ *
+ * @return array{array,array}
+ * The settled values, and which fields are there.
+ */
+ public function resettle(Panel $panel, array $values, array $pinned = [], ?Context $context = NULL): array {
+ $fields = Tree::fields($panel);
+
+ [$active, $values] = $this->stabilize($this->shows($panel, $fields), $fields, $values, $this->ruleMap($fields), $pinned, $context ?? new Context(), []);
+
+ return [$values, $active];
+ }
+
+ /**
+ * Fetch the rows a panel's own fields are still owed.
+ *
+ * A set that has to be fetched is fetched by whoever can wait for it. With no
+ * screen that is the collection itself, up front; with one it is whoever
+ * opens the panel, so a form does not pay on start-up for a list nobody has
+ * walked into yet.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel being opened; its own rows only, because a panel further in
+ * has not been opened.
+ * @param \Closure|null $waiting
+ * An `fn (): void` run once before the first fetch, so somebody watching is
+ * told the wait has begun rather than meeting a frozen screen.
+ *
+ * @return bool
+ * Whether anything was owed.
+ */
+ public function load(Panel $panel, ?\Closure $waiting = NULL): bool {
+ $owed = array_values(array_filter($panel->fields(), static fn(Field $field): bool => $field->loader() instanceof \Closure));
+
+ if ($owed === []) {
+ return FALSE;
+ }
+
+ if ($waiting instanceof \Closure) {
+ $waiting();
+ }
+
+ $this->loadEntries($owed);
+
+ return TRUE;
+ }
+
+ /**
+ * The behaviour written once for a field's kind of answer.
+ *
+ * @param string $id
+ * The field id.
+ *
+ * @return array{\Closure|null,\Closure|null}
+ * What refuses a value and says why, and what normalizes an accepted one;
+ * either NULL when nothing is reused for that field.
+ */
+ public function reusable(string $id): array {
+ return [$this->handlers->validator($id), $this->handlers->transformer($id)];
+ }
+
+ /**
+ * Settle every value, having first fetched the rows anyone is owed.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to collect.
+ * @param array $supplied
+ * Values supplied for its fields, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run this collection belongs to.
+ *
+ * @return array{list<\DrevOps\Tui\Block\Field>,array,array,array}
+ * The fields in declaration order, the settled values, where each value
+ * came from, and which fields are there.
+ *
+ * @throws \DrevOps\Tui\CollectException
+ * When a resolver or a source cannot answer.
+ */
+ protected function fetched(Panel $panel, array $supplied, Context $context): array {
+ // With no cursor to open a field, nothing would ever ask a loader for its
+ // rows - and a value cannot be measured against rows that never arrive.
+ $this->loadEntries(Tree::fields($panel));
+
+ [$fields, $values, $sources, $active] = $this->settle($panel, $supplied, $context);
+
+ $this->loadQueryEntries($fields, $values, $active);
+
+ return [$fields, $values, $sources, $active];
+ }
+
+ /**
+ * Resolve and settle every value, and work out who is there at all.
+ *
+ * @param \DrevOps\Tui\Block\Panel $panel
+ * The panel to collect.
+ * @param array $supplied
+ * Values supplied for its fields, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run this collection belongs to.
+ *
+ * @return array{list<\DrevOps\Tui\Block\Field>,array