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 widgetCalendar
A month calendar returning a normalized ISO YYYY-MM-DD; arrows move by day and week.
Calendar fieldCalendar
A month calendar returning a normalized ISO YYYY-MM-DD; arrows move by day and week.
Confirm widgetConfirm
Yes/No toggle; arrows or Space switch, y/n set the choice directly, Enter accepts.
Confirm fieldConfirm
Yes/No toggle; arrows or Space switch, y/n set the choice directly, Enter accepts.
File picker widgetFile picker
Browse the filesystem for a path; arrows move, enters a directory and returns to its parent. Add ->multiple() for several paths.
File picker fieldFile picker
Browse the filesystem for a path; arrows move, enters a directory and returns to its parent. Add ->multiple() for several paths.
Number widgetNumber
Integer entry (digits with an optional leading minus) accepted as an int, with optional min, max and step.
Number fieldNumber
Integer entry (digits with an optional leading minus) accepted as an int, with optional min, max and step.
Password widgetPassword
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 fieldPassword
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 widgetPause
An acknowledgment gate; Enter or Space accepts. Unattended runs auto-acknowledge it, so it never blocks automation.
Pause fieldPause
An acknowledgment gate; Enter or Space accepts. Unattended runs auto-acknowledge it, so it never blocks automation.
Progress widgetProgress
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 fieldProgress
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 widgetRating
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 fieldRating
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 widgetReorder
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 fieldReorder
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 widgetSearch
Single choice with a visible filter line; typing fuzzy-matches and ranks the labels, exact and prefix matches leading.
Search fieldSearch
Single choice with a visible filter line; typing fuzzy-matches and ranks the labels, exact and prefix matches leading.
Select widgetSelect
Single choice from a list; arrows move, Enter accepts the highlighted option, long lists page around the cursor.
Select fieldSelect
Single choice from a list; arrows move, Enter accepts the highlighted option, long lists page around the cursor.
Suggest widgetSuggest
Free text with autocomplete over a fixed option set: as you type, suggestions are fuzzy-matched and ranked by relevance.
Suggest fieldSuggest
Free text with autocomplete over a fixed option set: as you type, suggestions are fuzzy-matched and ranked by relevance.
Template widgetTemplate
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 fieldTemplate
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 widgetText
Single-line input with a movable caret; type to insert, arrows move, Backspace deletes, Enter accepts.
Text fieldText
Single-line input with a movable caret; type to insert, arrows move, Backspace deletes, Enter accepts.
Textarea widgetTextarea
Multi-line input; Enter inserts a newline, arrows move between lines, Tab accepts, with an external-editor handoff.
Textarea fieldTextarea
Multi-line input; Enter inserts a newline, arrows move between lines, Tab accepts, with an external-editor handoff.
Toggle widgetToggle
An inline switch between two labeled values; arrows or Space flip, the first letter of each label sets it directly.
Toggle fieldToggle
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 dos theme, light terminal

-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). Component architecture -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: Headless collection +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: - Interactive panel TUI + Interactive session -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 @@ -drevops/tui - component architectureBuilderModelCoreResolutionHandlersOutputInteractive TUIFormFormDefinitionPanelFieldEngineInputResolverDiscover specsDeriverDeriveTransformConditionHandlerRegistryContextAnswersSchemaGeneratorSchemaValidatorPanelControllerThemeManagerThemeKeyMapManagerKeyMapWidgetFactoryWidgetInterfaceNavigatorTerminalKeyParser \ No newline at end of file +drevops/tui - component architectureDeclaringBlock - the one treeArrangingDrivingCollectingBehaviourThemeTuiFormPanelBuilderFieldBuilderBlockInterfacePanelFieldMarkupBreadcrumbLegendActionsProgressBlock\CapabilityBlock\ElementScreenLayoutInterfaceAbstractLayoutLayoutManagerRegionScreenControllerKeyRouterScreenRendererAssemblerKeyMapTerminalCollectorInputResolverDiscoveryDeriverConditionAnswersSchemaGeneratorSchemaValidatorHandlerRegistryContextThemeInterfaceAbstractThemeDefaultThemeTheme\CapabilityThemeManagerThemeBuilder \ 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 @@ -drevops/tui - component architectureBuilderModelCoreResolutionHandlersOutputInteractive TUIFormFormDefinitionPanelFieldEngineInputResolverDiscover specsDeriverDeriveTransformConditionHandlerRegistryContextAnswersSchemaGeneratorSchemaValidatorPanelControllerThemeManagerThemeKeyMapManagerKeyMapWidgetFactoryWidgetInterfaceNavigatorTerminalKeyParser \ No newline at end of file +drevops/tui - component architectureDeclaringBlock - the one treeArrangingDrivingCollectingBehaviourThemeTuiFormPanelBuilderFieldBuilderBlockInterfacePanelFieldMarkupBreadcrumbLegendActionsProgressBlock\CapabilityBlock\ElementScreenLayoutInterfaceAbstractLayoutLayoutManagerRegionScreenControllerKeyRouterScreenRendererAssemblerKeyMapTerminalCollectorInputResolverDiscoveryDeriverConditionAnswersSchemaGeneratorSchemaValidatorHandlerRegistryContextThemeInterfaceAbstractThemeDefaultThemeTheme\CapabilityThemeManagerThemeBuilder \ 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 @@ -Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valueloop[each field whose options follow the answers]options callback(context)value => label mapresolved before conditions evaluate; a value theset no longer holds is dropped unless it was suppliedderive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file +Data flow: headless collection (Tui::collect)TuiInputResolverCollectorDeriverConditionCallerTuiInputResolverCollectorBlock treeField behaviourDiscovery specDeriverConditionAnswersCallerCallerTuiTuiInputResolverInputResolverCollectorCollectorBlock tree(Panel -> Field)Block tree(Panel -> Field)Field behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersTuiInputResolverCollectorDeriverConditioncollect(prompts, directory, update, version)root()the declared Panelresolve(fields, prompts, env)supplied valuesanswers(panel, supplied, context)fields(panel)no Screen, Layout or Region is built,and no block that only showsloop[each field whose rows are loaded]options callback()value => label rowsnothing would open a field, so a loaderis asked here or its rows never arriveloop[each field, in declaration order]alt[update mode and nothing supplied]discover(directory)detected valueadopted only when it passes the field'semptiness, type, bounds and rowssupplied > detected > declared default > static defaultloop[each field with a supplied value]transform(value)normalized valuesupplied values normalize first, so derivation,conditions and fix-ups see the final valuederive(rules, values, pinned)derived values (fixpoint)matches(answers)there / not there, per fieldrows that follow the answers re-resolve,fix-ups apply; repeat until nothing movesloop[each field that is there and was supplied]validate(value)reason or nullemptiness on a required field first,then type, bounds and rowsalt[a value was refused]CollectException naming the field and the reason[every value stands]forTree(panel, values, provenance)the self-describing setAnswersAnswers \ 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 @@ -Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valueloop[each field whose options follow the answers]options callback(context)value => label mapresolved before conditions evaluate; a value theset no longer holds is dropped unless it was suppliedderive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file +Data flow: headless collection (Tui::collect)TuiInputResolverCollectorDeriverConditionCallerTuiInputResolverCollectorBlock treeField behaviourDiscovery specDeriverConditionAnswersCallerCallerTuiTuiInputResolverInputResolverCollectorCollectorBlock tree(Panel -> Field)Block tree(Panel -> Field)Field behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersTuiInputResolverCollectorDeriverConditioncollect(prompts, directory, update, version)root()the declared Panelresolve(fields, prompts, env)supplied valuesanswers(panel, supplied, context)fields(panel)no Screen, Layout or Region is built,and no block that only showsloop[each field whose rows are loaded]options callback()value => label rowsnothing would open a field, so a loaderis asked here or its rows never arriveloop[each field, in declaration order]alt[update mode and nothing supplied]discover(directory)detected valueadopted only when it passes the field'semptiness, type, bounds and rowssupplied > detected > declared default > static defaultloop[each field with a supplied value]transform(value)normalized valuesupplied values normalize first, so derivation,conditions and fix-ups see the final valuederive(rules, values, pinned)derived values (fixpoint)matches(answers)there / not there, per fieldrows that follow the answers re-resolve,fix-ups apply; repeat until nothing movesloop[each field that is there and was supplied]validate(value)reason or nullemptiness on a required field first,then type, bounds and rowsalt[a value was refused]CollectException naming the field and the reason[every value stands]forTree(panel, values, provenance)the self-describing setAnswersAnswers \ 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 @@ -Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned, context)settled values + active map(options resolve from the answers,derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file +Data flow: interactive session (ScreenController::run)ScreenControllerScreenRendererKeyRouterUserScreenControllerAssemblerTerminalScreenRendererScreenBlockThemeKeyParserKeyRouterCollectorUserUserScreenControllerScreenControllerAssemblerAssemblerTerminalTerminalScreenRendererScreenRendererScreen-> Layout -> RegionScreen-> Layout -> RegionBlock(Panel, Field, ...)Block(Panel, Field, ...)Theme(elements)Theme(elements)KeyParserKeyParserKeyRouterKeyRouterCollectorCollectorScreenControllerScreenRendererKeyRouterassemble(panel, layout)Screen with the standard furniturea breadcrumb in 'header', the panel and itsbuttons in 'content', a legend in 'footer' -wherever the named layout keeps a placesetup() raw mode, alt screenseed(panel, supplied, context)the values the form opens on, and who is thereloop[until the form ends]alt[the terminal cannot hold the frame]render(resize notice)every key but the one that leaves is dropped[laid out]render(screen, rows, columns)arrange(available)a size for each Regionflow the Region's blocks, scroll if declaredrender(theme)elements for what it drawsstyled stringsits rowsthe framerender(frame)key pressraw bytesparse(bytes)Key listalt[the interrupt key]aborts from anywhere, including an open field[a button, work to run, or leaving]held here: each acts on something outsidethe screen, which no block knows about[anything else]handle(key)the focused block, if it binds that keyelse the panel around itmove the cursor, open a field, go into a panel,come back out, show a field's helpdoneopt[an answer was just taken]resettle(panel, values, pinned, context)settled values + who is thererows that follow the answers re-resolve,computed values recompute, conditionsshow and hide rows, fix-ups applyresolve one query, once the whole read is spentrestore(), and clear unless the session opted outalt[interrupted]InterruptException[cancelled]CancelException[finished]Answers \ 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 @@ -Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned, context)settled values + active map(options resolve from the answers,derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file +Data flow: interactive session (ScreenController::run)ScreenControllerScreenRendererKeyRouterUserScreenControllerAssemblerTerminalScreenRendererScreenBlockThemeKeyParserKeyRouterCollectorUserUserScreenControllerScreenControllerAssemblerAssemblerTerminalTerminalScreenRendererScreenRendererScreen-> Layout -> RegionScreen-> Layout -> RegionBlock(Panel, Field, ...)Block(Panel, Field, ...)Theme(elements)Theme(elements)KeyParserKeyParserKeyRouterKeyRouterCollectorCollectorScreenControllerScreenRendererKeyRouterassemble(panel, layout)Screen with the standard furniturea breadcrumb in 'header', the panel and itsbuttons in 'content', a legend in 'footer' -wherever the named layout keeps a placesetup() raw mode, alt screenseed(panel, supplied, context)the values the form opens on, and who is thereloop[until the form ends]alt[the terminal cannot hold the frame]render(resize notice)every key but the one that leaves is dropped[laid out]render(screen, rows, columns)arrange(available)a size for each Regionflow the Region's blocks, scroll if declaredrender(theme)elements for what it drawsstyled stringsits rowsthe framerender(frame)key pressraw bytesparse(bytes)Key listalt[the interrupt key]aborts from anywhere, including an open field[a button, work to run, or leaving]held here: each acts on something outsidethe screen, which no block knows about[anything else]handle(key)the focused block, if it binds that keyelse the panel around itmove the cursor, open a field, go into a panel,come back out, show a field's helpdoneopt[an answer was just taken]resettle(panel, values, pinned, context)settled values + who is thererows that follow the answers re-resolve,computed values recompute, conditionsshow and hide rows, fix-ups applyresolve one query, once the whole read is spentrestore(), and clear unless the session opted outalt[interrupted]InterruptException[cancelled]CancelException[finished]Answers \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardDeliveryBasketcontentsapple,carrotPicktheproduceforthisdelivery.Basketweight1200Weighedatthepackingbench.Harvestdate2026-07-15CourierValleyRunsOrganiconly?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-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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardDeliveryBasketcontentsapple,carrotPicktheproduceforthisdelivery.Basketweight1200Weighedatthepackingbench.Harvestdate2026-07-15CourierValleyRunsOrganiconly?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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardBasketBasketAppleCarrotTomato(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardBasketBasketAppleCarrotTomato(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdChooseafilenolargerthan64B.↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdChooseafilenolargerthan64B.↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardPricelistPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardDeliveryBasketcontentsapple,carrotPicktheproduceforthisdelivery.Basketweight1200Weighedatthepackingbench.Harvestdate2026-07-15CourierValleyRunsOrganiconly?yesNotesLeaveatthegate↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardDeliveryBasketcontentsapple,carrotPicktheproduceforthisdelivery.Basketweight1200Weighedatthepackingbench.Harvestdate2026-07-15CourierValleyRunsOrganiconly?yesNotesLeaveatthegate↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardCrateCratelabelvalley-pear-afillinginFruitIdentifiesthecrateontheloadingdock.↓/↑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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OrchardCrateCratelabelvalley-pear-afillinginFruitIdentifiesthecrateontheloadingdock.↓/↑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├──────────────────────────────────────────────────────────────────────────┤BasicsWhatthebasketholds.weekly·apple·6weekly·apple·6DeliveryHowitarrives.pickup·no[Create][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯FruitbasketBasicsBasketnameweeklyFruitappleFruitappleQuantity6BasketnameweeklyFruitappleBasicsWhatthebasketholds.weekly·apple·6weekly·apple·6DeliveryHowitarrives.pickup·noFruitbasketDeliveryMethodpickupGiftwrap?noGiftwrap?noExtrasMedium╰────────────────────────────────────────────────────────────────────────MethodpickupGiftwrap?noGiftwrap?noExtrasMediumFruitbasketDeliveryExtrasBagsizeMedium[ Create ][Cancel]BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:MediumFruitappleQuantity6╰──────────────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────↑/↓↑/↓move· \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.Howitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FruitbasketBasicsBasketnameweeklyFruitappleQuantity6BasketnameweeklyFruitappleQuantity6BasicsDeliveryFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMediumMediumMethodpickupGiftwrap?noExtrasFruitbasketDeliveryExtrasBagsizeMediumBasicsBasketname: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├──────────────────────────────────────────────────────────────────────────┤BasicsWhatthebasketholds.weekly·apple·6weekly·apple·6DeliveryHowitarrives.pickup·no[Create][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯FruitbasketBasicsBasketnameweeklyFruitappleFruitappleQuantity6BasketnameweeklyFruitappleBasicsWhatthebasketholds.weekly·apple·6weekly·apple·6DeliveryHowitarrives.pickup·noFruitbasketDeliveryMethodpickupGiftwrap?noGiftwrap?noExtrasMedium╰────────────────────────────────────────────────────────────────────────MethodpickupGiftwrap?noGiftwrap?noExtrasMediumFruitbasketDeliveryExtrasBagsizeMedium[ Create ][Cancel]BasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:MediumFruitappleQuantity6╰──────────────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────↑/↓↑/↓move· \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.Howitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FruitbasketBasicsBasketnameweeklyFruitappleQuantity6BasketnameweeklyFruitappleQuantity6BasicsDeliveryFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMediumMediumMethodpickupGiftwrap?noExtrasFruitbasketDeliveryExtrasBagsizeMediumBasicsBasketname: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 @@ -FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·no[Create][Cancel]↑/↓move·select·escback·qquit·?helpFruitbasketBasicsFruitappleQuantity6BasketnameweeklyBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·noFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMediumMethodpickupBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:MediumBasketnameweeklyFruitappleQuantity6Giftwrap?noExtrasMediumFruitbasketDeliveryExtrasBagsizeMedium[ Create ][Cancel] \ No newline at end of file +FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·toselect↑/↓tomove·toselect·ESCtogoback·QtoquitFruitbasketBasicsBasketnameweeklyFruitappleQuantity6BasketnameweeklyFruitappleQuantity6BasicsDeliveryFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMedium↑/↓tomove·toselect·ESCtogoback·↑/↓tomove·toselect·ESCtogoback·QMethodpickupGiftwrap?noExtrasFruitbasketDeliveryExtrasBagsizeMedium↑/↓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 @@ -FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·no[Create][Cancel]↑/↓move·select·escback·qquit·?helpFruitbasketBasicsFruitappleQuantity6BasketnameweeklyBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·noFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMediumMethodpickupBasicsBasketname:weeklyFruit:appleQuantity:6DeliveryMethod:pickupGiftwrap?:noExtrasBagsize:MediumBasketnameweeklyFruitappleQuantity6Giftwrap?noExtrasMediumFruitbasketDeliveryExtrasBagsizeMedium[ Create ][Cancel] \ No newline at end of file +FruitbasketBasicsWhatthebasketholds.weekly·apple·6DeliveryHowitarrives.pickup·no[ Create ][Cancel]↑/↓tomove·toselect↑/↓tomove·toselect·ESCtogoback·QtoquitFruitbasketBasicsBasketnameweeklyFruitappleQuantity6BasketnameweeklyFruitappleQuantity6BasicsDeliveryFruitbasketDeliveryMethodpickupGiftwrap?noExtrasMedium↑/↓tomove·toselect·ESCtogoback·↑/↓tomove·toselect·ESCtogoback·QMethodpickupGiftwrap?noExtrasFruitbasketDeliveryExtrasBagsizeMedium↑/↓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├──────────────────────────────────────────────────────────────────────┤PackingToggletheanswersandwatchfieldsappear.[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ConditionalfieldsPackingContentsfruitBoxsizemediumStacktheboxes?noContentsFruitVegetablesHerbs↑/↓move·accept·esccancel╰─────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────ContentsFruitVegetablesHerbsHerbs╰────────────────────────────────────────────────────────────Contentsfruit,herbs edited HerbbundlemixedContentsfruit,herbs edited BoxsizemediumBoxsizeSmallMediumLarge↑/↓move·accept·esccancelMediumLargeBoxsizelarge edited Weeklyherbdelivery?yesfruit,herbs·large·mixed·yesPackingToggletheanswersandwatchfieldsappear.fruit,herbs·large·mixed·yesPackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yesfruit·medium·nofruit·medium·no╰──────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────Med[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ConditionalfieldsPackingToggletheanswersandwatchfieldsappear.fruit·medium·no[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ConditionalfieldsPackingContentsfruitBoxsizemediumStacktheboxes?noContentsFruitVegetablesHerbsSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitVegetablesHerbsHerbsContentsfruit,herbs edited HerbbundlemixedContentsfruit,herbs edited BoxsizemediumBoxsizeSmallMediumLarge↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelarge edited Weeklyherbdelivery?yesfruit,herbs·large·mixed·yesPackingPackingContents: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├──────────────────────────────────────────────────────────────────────┤PackingToggletheanswersandwatchfieldsappear.[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ConditionalfieldsPackingContentsfruitBoxsizemediumStacktheboxes?noContentsFruitVegetablesHerbs↑/↓move·accept·esccancel╰─────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────ContentsFruitVegetablesHerbsHerbs╰────────────────────────────────────────────────────────────Contentsfruit,herbs edited HerbbundlemixedContentsfruit,herbs edited BoxsizemediumBoxsizeSmallMediumLarge↑/↓move·accept·esccancelMediumLargeBoxsizelarge edited Weeklyherbdelivery?yesfruit,herbs·large·mixed·yesPackingToggletheanswersandwatchfieldsappear.fruit,herbs·large·mixed·yesPackingContents:fruit,herbs(edited)Boxsize:large(edited)Herbbundle:mixedWeeklyherbdelivery?:yesfruit·medium·nofruit·medium·no╰──────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────Med[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ConditionalfieldsPackingToggletheanswersandwatchfieldsappear.fruit·medium·no[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ConditionalfieldsPackingContentsfruitBoxsizemediumStacktheboxes?noContentsFruitVegetablesHerbsSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitVegetablesHerbsHerbsContentsfruit,herbs edited HerbbundlemixedContentsfruit,herbs edited BoxsizemediumBoxsizeSmallMediumLarge↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelarge edited Weeklyherbdelivery?yesfruit,herbs·large·mixed·yesPackingPackingContents: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├──────────────────────────────────────────────────────────────────────┤ProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.fruit·6[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ConditionalindentationProduceorderCategoryfruitQuantity6CategoryFruitVegetableHerb↑/↓move·accept·esccancelCategoryFruitVegetableCategoryvegetable edited BasketcarrotWeeklydelivery?yesCouriernoteLeaveatthegateCategoryvegetable edited BasketcarrotBasketCarrotPotatoTomatoBasketCarrotBasket edited vegetable··6ProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.vegetable··6ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6BasketCarrot├────────────────────────────────────├───────────────────────────────────────├────────────────────────────────────────├───────────────────────────────────────────[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ConditionalindentationProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.fruit·6[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ConditionalindentationProduceorderCategoryfruitQuantity6CategoryFruitVegetableHerb↑/↓tomove·toaccept·ESCtocancelCategoryFruitVegetableCategoryvegetable edited BasketcarrotWeeklydelivery?yesCouriernoteLeaveatthegateCategoryvegetable edited BasketcarrotBasketCarrotPotatoTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketCarrotBasket edited vegetable··6ProduceorderProduceorderCategory: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├──────────────────────────────────────────────────────────────────────┤ProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.fruit·6[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ConditionalindentationProduceorderCategoryfruitQuantity6CategoryFruitVegetableHerb↑/↓move·accept·esccancelCategoryFruitVegetableCategoryvegetable edited BasketcarrotWeeklydelivery?yesCouriernoteLeaveatthegateCategoryvegetable edited BasketcarrotBasketCarrotPotatoTomatoBasketCarrotBasket edited vegetable··6ProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.vegetable··6ProduceorderCategory:vegetable(edited)Basket:(edited)Quantity:6BasketCarrot├────────────────────────────────────├───────────────────────────────────────├────────────────────────────────────────├───────────────────────────────────────────[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ConditionalindentationProduceorderPickVegetable,thenaddCarrot,tostepthechainopen.fruit·6[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ConditionalindentationProduceorderCategoryfruitQuantity6CategoryFruitVegetableHerb↑/↓tomove·toaccept·ESCtocancelCategoryFruitVegetableCategoryvegetable edited BasketcarrotWeeklydelivery?yesCouriernoteLeaveatthegateCategoryvegetable edited BasketcarrotBasketCarrotPotatoTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketCarrotBasket edited vegetable··6ProduceorderProduceorderCategory: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├──────────────────────────────────────────────────────────────────────┤NamingRedApple·red_apple·RED_APPLE·Sunny[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯DerivedvaluesNamingProducenameRedAppleSlugred_apple derived Derivedfromthename.CodeRED_APPLE derived Derivedfromtheslug.GrowerSunnyLotsunny/red_apple derived Derivedfromgrowerandslug.ProducenameRedApple├──accept·esccancelProducenameRedApplProducenameRedAppProducenameRedApProducenameRedAProducenameRedProducenameRedPProducenameRedPlProducenameRedPluProducenameRedPlumProducenameRedPlum edited Slugred_plum derived CodeRED_PLUM derived Lotsunny/red_plum derived RedPlum·red_plum·RED_PLUM·SunnyNamingRedPlum·red_plum·RED_PLUM·SunnyNamingProducename: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 +╭──────────────────────────────────────────────────────────────────────╮DerivedvaluesNamingRedApple·red_apple·RED_APPLE·Sunny[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────╯DerivedvaluesNamingProducenameRedAppleSlugred_apple derived Derivedfromthename.CodeRED_APPLE derived Derivedfromtheslug.GrowerSunnyLotsunny/red_apple derived Derivedfromgrowerandslug.ProducenameRedAppleDertoaccept·ESCtocancelProducenameRedApplProducenameRedAppProducenameRedApProducenameRedAProducenameRedProducenameRedPProducenameRedPlProducenameRedPluProducenameRedPlumProducenameRedPlum edited Slugred_plum derived CodeRED_PLUM derived Lotsunny/red_plum derived RedPlum·red_plum·RED_PLUM·SunnyNamingNamingProducename: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├──────────────────────────────────────────────────────────────────────┤NamingRedApple·red_apple·RED_APPLE·Sunny[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯DerivedvaluesNamingProducenameRedAppleSlugred_apple derived Derivedfromthename.CodeRED_APPLE derived Derivedfromtheslug.GrowerSunnyLotsunny/red_apple derived Derivedfromgrowerandslug.ProducenameRedApple├──accept·esccancelProducenameRedApplProducenameRedAppProducenameRedApProducenameRedAProducenameRedProducenameRedPProducenameRedPlProducenameRedPluProducenameRedPlumProducenameRedPlum edited Slugred_plum derived CodeRED_PLUM derived Lotsunny/red_plum derived RedPlum·red_plum·RED_PLUM·SunnyNamingRedPlum·red_plum·RED_PLUM·SunnyNamingProducename: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 +╭──────────────────────────────────────────────────────────────────────╮DerivedvaluesNamingRedApple·red_apple·RED_APPLE·Sunny[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────╯DerivedvaluesNamingProducenameRedAppleSlugred_apple derived Derivedfromthename.CodeRED_APPLE derived Derivedfromtheslug.GrowerSunnyLotsunny/red_apple derived Derivedfromgrowerandslug.ProducenameRedAppleDertoaccept·ESCtocancelProducenameRedApplProducenameRedAppProducenameRedApProducenameRedAProducenameRedProducenameRedPProducenameRedPlProducenameRedPluProducenameRedPlumProducenameRedPlum edited Slugred_plum derived CodeRED_PLUM derived Lotsunny/red_plum derived RedPlum·red_plum·RED_PLUM·SunnyNamingNamingProducename: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├──────────────────────────────────────────────────────────────────────┤StallSampleProject··1200·GoldenDelicious[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯FieldbehaviourStallStallnameSampleProjectBasketCrateweight(g)1200VarietyGoldenDelicious╰──────────────────────────────────────────────────────────────────StallnameSampleProjectaccept·esccancel╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────StallnameSampleProjecStallnameSampleProjeStallnameSampleProjStallnameSampleProStallnameSamplePrStallnameSamplePStallnameSampleStallnameSampleStallnameSamplStallnameSampStallnameSamStallnameSaStallnameSStallnameStallnameisrequired.Basket╰─────────────────────────────────╰───────────────────────────────────StallnameSeStallnameSeaStallnameSeasStallnameSeasiStallnameSeasidStallnameSeasideStallnameSeasideStallnameSeasideSStallnameSeasideStStallnameSeasideStaStallnameSeasideStalStallnameSeasideStallStallnameSeasideStall edited ╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────SeasideStall··1200·GoldenDeliciousStallSeasideStall··1200·GoldenDelicious[ Submit ][[ Submit ][Cancel[ Submit ][Cancel]Addatleastoneitemtothebasket.StallnameSeasideStall edited BasketBasketAppleCarrotTomato↑/↓move·accept·esccancel↑/↓move·accept·esccancelBasketAppleBasketapple edited SeasideStall·apple·1200·GoldenDeliciousSeasideStall·apple·1200·GoldenDeliciousStallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious╰─────────────────────────────────────────────────────────────────────AddatleastoneitemtoAddatleastoneitemtothebaAddat╰────────────────────────────────────────────────────────────────BasketApple╰──────────────────────────────────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮FieldbehaviourStallSampleProject··1200·GoldenDelicious[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldbehaviourStallStallnameSampleProjectBasketCrateweight(g)1200VarietyGoldenDeliciousStallnameSampleProjecttoaccept·ESCtocancel╰───────────────────╰──────────────────────StallnameSampleProjecStallnameSampleProjeStallnameSampleProjStallnameSampleProStallnameSamplePrStallnameSamplePStallnameSampleStallnameSampleStallnameSamplStallnameSampStallnameSamStallnameSaStallnameSStallnameStallnameisrequired.╰────────────────StallnameSeStallnameSeaStallnameSeasStallnameSeasiStallnameSeasidStallnameSeasideStallnameSeasideStallnameSeasideSStallnameSeasideStStallnameSeasideStaStallnameSeasideStalStallnameSeasideStallStallnameSeasideStall edited SeasideStall··1200·GoldenDeliciousStallAddatleastoneitemtothebasket.[ Submit ][Cancel]StallnameSeasideStall edited BasketBasketAppleCarrotTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toaccept╰────────────────────────BasketAppleBasketapple edited SeasideStall·apple·1200·GoldenDeliciousStallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDeliciousVar↑/↓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├──────────────────────────────────────────────────────────────────────┤StallSampleProject··1200·GoldenDelicious[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯FieldbehaviourStallStallnameSampleProjectBasketCrateweight(g)1200VarietyGoldenDelicious╰──────────────────────────────────────────────────────────────────StallnameSampleProjectaccept·esccancel╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────StallnameSampleProjecStallnameSampleProjeStallnameSampleProjStallnameSampleProStallnameSamplePrStallnameSamplePStallnameSampleStallnameSampleStallnameSamplStallnameSampStallnameSamStallnameSaStallnameSStallnameStallnameisrequired.Basket╰─────────────────────────────────╰───────────────────────────────────StallnameSeStallnameSeaStallnameSeasStallnameSeasiStallnameSeasidStallnameSeasideStallnameSeasideStallnameSeasideSStallnameSeasideStStallnameSeasideStaStallnameSeasideStalStallnameSeasideStallStallnameSeasideStall edited ╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────SeasideStall··1200·GoldenDeliciousStallSeasideStall··1200·GoldenDelicious[ Submit ][[ Submit ][Cancel[ Submit ][Cancel]Addatleastoneitemtothebasket.StallnameSeasideStall edited BasketBasketAppleCarrotTomato↑/↓move·accept·esccancel↑/↓move·accept·esccancelBasketAppleBasketapple edited SeasideStall·apple·1200·GoldenDeliciousSeasideStall·apple·1200·GoldenDeliciousStallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDelicious╰─────────────────────────────────────────────────────────────────────AddatleastoneitemtoAddatleastoneitemtothebaAddat╰────────────────────────────────────────────────────────────────BasketApple╰──────────────────────────────────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮FieldbehaviourStallSampleProject··1200·GoldenDelicious[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldbehaviourStallStallnameSampleProjectBasketCrateweight(g)1200VarietyGoldenDeliciousStallnameSampleProjecttoaccept·ESCtocancel╰───────────────────╰──────────────────────StallnameSampleProjecStallnameSampleProjeStallnameSampleProjStallnameSampleProStallnameSamplePrStallnameSamplePStallnameSampleStallnameSampleStallnameSamplStallnameSampStallnameSamStallnameSaStallnameSStallnameStallnameisrequired.╰────────────────StallnameSeStallnameSeaStallnameSeasStallnameSeasiStallnameSeasidStallnameSeasideStallnameSeasideStallnameSeasideSStallnameSeasideStStallnameSeasideStaStallnameSeasideStalStallnameSeasideStallStallnameSeasideStall edited SeasideStall··1200·GoldenDeliciousStallAddatleastoneitemtothebasket.[ Submit ][Cancel]StallnameSeasideStall edited BasketBasketAppleCarrotTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toaccept╰────────────────────────BasketAppleBasketapple edited SeasideStall·apple·1200·GoldenDeliciousStallStallname:SeasideStall(edited)Basket:apple(edited)Crateweight(g):1200Variety:GoldenDeliciousVar↑/↓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╰──────────────────────────────────────────────────────────────────────────╯CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526CalendarfieldCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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╰──────────────────────────────────────────────────────────────────────────╯CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526CalendarfieldCalendar2026-07-15[ Submit ][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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 @@ +╭──────────────────────────────────────────────────────────────────────────╮CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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╰──────────────────────────────────────────────────────────────────────────╯CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526CalendarfieldCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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╰──────────────────────────────────────────────────────────────────────────╯CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526CalendarfieldCalendar2026-07-15[ Submit ][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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 @@ +╭──────────────────────────────────────────────────────────────────────────╮CalendarfieldCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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╰──────────────────────────────────────────────────────────────────────────╯ConfirmfieldConfirmY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoConfirmfieldConfirmyes[Submit][Cancel]Organiconly?yesOrganiconly?YesNo \ 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╰──────────────────────────────────────────────────────────────────────────╯ConfirmfieldConfirmY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoConfirmfieldConfirmyes[ Submit ][Cancel]Organiconly?yesOrganiconly?YesNo \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ConfirmfieldConfirmOrganiconly?YesNoY/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ConfirmfieldConfirmOrganiconly?YesNoY/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╰──────────────────────────────────────────────────────────────────────────╯ConfirmfieldConfirmY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoConfirmfieldConfirmyes[Submit][Cancel]Organiconly?yesOrganiconly?YesNo \ 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╰──────────────────────────────────────────────────────────────────────────╯ConfirmfieldConfirmY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoConfirmfieldConfirmyes[ Submit ][Cancel]Organiconly?yesOrganiconly?YesNo \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ConfirmfieldConfirmOrganiconly?YesNoY/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ConfirmfieldConfirmOrganiconly?YesNoY/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓tomove·toopen·togoup·toselect·TABtoshowhiddenbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓tomove·toopen·togoup·toselect·TABtoshowhiddenbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓tomove·toopen·togoup·toselect·TABtoshowhiddenbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓tomove·toopen·togoup·toselect·TABtoshowhiddenbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistsbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistsbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSPACEtoselect·↑/↓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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.SPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.SPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistsbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.SPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerfieldFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.SPACEtoselect·↑/↓tomove·toopen·togoup·toacceptbaskets/deliveries/FilepickerfieldFilepicker[ Submit ][Cancel]Pricelistsbaskets/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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FilepickerfieldFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotefieldNote[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╰──────────────────────────────────────────────────────────────────────────╯NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotefieldNote[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotefieldNote[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╰──────────────────────────────────────────────────────────────────────────╯NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotefieldNote[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NotefieldNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(https://example.com/guide).╰─────────────────────────────────────────────────────╯MarkdownnoteNote[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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯MarkdownnoteNote[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(https://example.com/guide).╰─────────────────────────────────────────────────────╯MarkdownnoteNote[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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯MarkdownnoteNote[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯NumberfieldNumber↑/↓toadjust·toaccept·ESCtocancelBasketweight(g)4200█NumberfieldNumber1200[Submit][Cancel]Basketweight(g)1200Basketweight(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╰──────────────────────────────────────────────────────────────────────────╯NumberfieldNumber↑/↓toadjust·toaccept·ESCtocancelBasketweight(g)4200NumberfieldNumber1200[ Submit ][Cancel]Basketweight(g)1200Basketweight(g)1200Basketweight(g)120Basketweight(g)12Basketweight(g)1Basketweight(g)Basketweight(g)4Basketweight(g)42Basketweight(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NumberfieldNumberBasketweight(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NumberfieldNumberBasketweight(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╰──────────────────────────────────────────────────────────────────────────╯NumberfieldNumber↑/↓toadjust·toaccept·ESCtocancelBasketweight(g)4200█NumberfieldNumber1200[Submit][Cancel]Basketweight(g)1200Basketweight(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╰──────────────────────────────────────────────────────────────────────────╯NumberfieldNumber↑/↓toadjust·toaccept·ESCtocancelBasketweight(g)4200NumberfieldNumber1200[ Submit ][Cancel]Basketweight(g)1200Basketweight(g)1200Basketweight(g)120Basketweight(g)12Basketweight(g)1Basketweight(g)Basketweight(g)4Basketweight(g)42Basketweight(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NumberfieldNumberBasketweight(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮NumberfieldNumberBasketweight(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╰──────────────────────────────────────────────────────────────────────────╯PasswordfieldPasswordOrdercode••••••█toaccept·ESCtocancelOrdercode•••••█Ordercode••••█Ordercode•••█Ordercode••█Ordercode•█PasswordfieldPassword••••••••[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╰──────────────────────────────────────────────────────────────────────────╯PasswordfieldPasswordOrdercode••••••toaccept·ESCtocancelOrdercode•••••Ordercode••••Ordercode•••Ordercode••OrdercodePasswordfieldPassword••••••••[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercode••••••█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercode••••••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╰──────────────────────────────────────────────────────────────────────────╯PasswordfieldPasswordOrdercode••••••█toaccept·ESCtocancelOrdercode•••••█Ordercode••••█Ordercode•••█Ordercode••█Ordercode•█PasswordfieldPassword••••••••[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╰──────────────────────────────────────────────────────────────────────────╯PasswordfieldPasswordOrdercode••••••toaccept·ESCtocancelOrdercode•••••Ordercode••••Ordercode•••Ordercode••OrdercodePasswordfieldPassword••••••••[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercode••••••█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercode••••••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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercodemelon7TABtoreveal·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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PasswordfieldPasswordOrdercodemelon7TABtoreveal·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╰──────────────────────────────────────────────────────────────────────────╯PausefieldPauseReviewyourbasketyesPausefieldPauseyes[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╰──────────────────────────────────────────────────────────────────────────╯PausefieldPauseReviewyourbasketyesPausefieldPauseyes[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PausefieldPauseReviewyourbasketyes↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PausefieldPauseReviewyourbasketyes↑/↓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╰──────────────────────────────────────────────────────────────────────────╯PausefieldPauseReviewyourbasketyesPausefieldPauseyes[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╰──────────────────────────────────────────────────────────────────────────╯PausefieldPauseReviewyourbasketyesPausefieldPauseyes[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PausefieldPauseReviewyourbasketyes↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮PausefieldPauseReviewyourbasketyes↑/↓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╰──────────────────────────────────────────────────────────────────────────╯ProgressfieldProgressPackingthebox[░░░░░░░░░░]0/6Packingthebox[██████████]6/6ProgressfieldProgress[Submit][Cancel]Packingthebox[██░░░░░░░░]1/6Packingthebox[███░░░░░░░]2/6Packingthebox[█████░░░░░]3/6Packingthebox[███████░░░]4/6Packingthebox[████████░░]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╰──────────────────────────────────────────────────────────────────────────╯ProgressfieldProgressPackingthebox[░░░░░░░░░░]0/6Packingthebox[██████████]6/6ProgressfieldProgress[ Submit ][Cancel]Packingthebox[██░░░░░░░░]1/6Packingthebox[███░░░░░░░]2/6Packingthebox[█████░░░░░]3/6Packingthebox[███████░░░]4/6Packingthebox[████████░░]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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ProgressfieldProgressPackingthebox[██████████]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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ProgressfieldProgressPackingthebox[██████████]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╰──────────────────────────────────────────────────────────────────────────╯ProgressfieldProgressPackingthebox[░░░░░░░░░░]0/6Packingthebox[██████████]6/6ProgressfieldProgress[Submit][Cancel]Packingthebox[██░░░░░░░░]1/6Packingthebox[███░░░░░░░]2/6Packingthebox[█████░░░░░]3/6Packingthebox[███████░░░]4/6Packingthebox[████████░░]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╰──────────────────────────────────────────────────────────────────────────╯ProgressfieldProgressPackingthebox[░░░░░░░░░░]0/6Packingthebox[██████████]6/6ProgressfieldProgress[ Submit ][Cancel]Packingthebox[██░░░░░░░░]1/6Packingthebox[███░░░░░░░]2/6Packingthebox[█████░░░░░]3/6Packingthebox[███████░░░]4/6Packingthebox[████████░░]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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ProgressfieldProgressPackingthebox[██████████]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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ProgressfieldProgressPackingthebox[██████████]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╰──────────────────────────────────────────────────────────────────────────╯RatingfieldRatingFreshness●●●●○4/5↑/↓toadjust·toaccept·ESCtocancelFreshness●●●○○3/5FairRatingfieldRating●●●●○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╰──────────────────────────────────────────────────────────────────────────╯RatingfieldRatingFreshness●●●●4/5↑/↓toadjust·toaccept·ESCtocancelFreshness●●●○○3/5FairRatingfieldRating●●●●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 @@ +╭──────────────────────────────────────────────────────────────────────────╮RatingfieldRatingFreshness●●●○○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 @@ +╭──────────────────────────────────────────────────────────────────────────╮RatingfieldRatingFreshness●●●○○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╰──────────────────────────────────────────────────────────────────────────╯RatingfieldRatingFreshness●●●●○4/5↑/↓toadjust·toaccept·ESCtocancelFreshness●●●○○3/5FairRatingfieldRating●●●●○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╰──────────────────────────────────────────────────────────────────────────╯RatingfieldRatingFreshness●●●●4/5↑/↓toadjust·toaccept·ESCtocancelFreshness●●●○○3/5FairRatingfieldRating●●●●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 @@ +╭──────────────────────────────────────────────────────────────────────────╮RatingfieldRatingFreshness●●●○○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 @@ +╭──────────────────────────────────────────────────────────────────────────╮RatingfieldRatingFreshness●●●○○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╰──────────────────────────────────────────────────────────────────────────╯ReorderfieldReorderCarrotTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancel↑/↓toreorder·SPACEtodrop·ESCtocancelBasketCarrotAppleReorderfieldReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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╰──────────────────────────────────────────────────────────────────────────╯ReorderfieldReorderCarrotTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancel↑/↓toreorder·SPACEtodrop·ESCtocancelBasketCarrotAppleReorderfieldReorderapple,carrot,tomato[ Submit ][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ReorderfieldReorderBasketAppleCarrotTomato↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ReorderfieldReorderBasketAppleCarrotTomato↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[ Submit ][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[ Submit ][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯ReorderfieldReorderCarrotTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancel↑/↓toreorder·SPACEtodrop·ESCtocancelBasketCarrotAppleReorderfieldReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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╰──────────────────────────────────────────────────────────────────────────╯ReorderfieldReorderCarrotTomato↑/↓tomove·SPACEtograb·toaccept·ESCtocancel↑/↓toreorder·SPACEtodrop·ESCtocancelBasketCarrotAppleReorderfieldReorderapple,carrot,tomato[ Submit ][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ReorderfieldReorderBasketAppleCarrotTomato↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮ReorderfieldReorderBasketAppleCarrotTomato↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SearchfieldSearchPotato↑/↓tomove·toaccept·ESCtocancelOnionVegetableon█SearchfieldSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotOnionPepperVegetableo█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╰──────────────────────────────────────────────────────────────────────────╯SearchfieldSearch↑/↓tomove·toaccept·ESCtocancelVegetableonOnionSearchfieldSearchcarrot[ Submit ][Cancel]VegetablecarrotVegetableCarrotPotatoOnionPepperVegetableoOnionPotatoCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SearchfieldSearchVegetableCarrotPotatoOnionPepper↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SearchfieldSearchVegetableCarrotPotatoOnionPepper↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓tomove·toaccept·ESCtocancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓tomove·toaccept·ESCtocancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[ Submit ][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓tomove·toaccept·ESCtocancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓tomove·toaccept·ESCtocancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[ Submit ][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SearchfieldSearchPotato↑/↓tomove·toaccept·ESCtocancelOnionVegetableon█SearchfieldSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotOnionPepperVegetableo█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╰──────────────────────────────────────────────────────────────────────────╯SearchfieldSearch↑/↓tomove·toaccept·ESCtocancelVegetableonOnionSearchfieldSearchcarrot[ Submit ][Cancel]VegetablecarrotVegetableCarrotPotatoOnionPepperVegetableoOnionPotatoCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SearchfieldSearchVegetableCarrotPotatoOnionPepper↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SearchfieldSearchVegetableCarrotPotatoOnionPepper↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchfieldMultiSearchCarrotSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptTomatoBasketto█TomatoMultiSearchfieldMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaTomatoBaskett█ \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchfieldMultiSearchSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBaskettoTomatoMultiSearchfieldMultiSearchapple[ Submit ][Cancel]BasketappleBasketAppleBananaCarrotTomatoBaskettTomatoCarrotTomato \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSearchfieldMultiSearchBasketAppleBananaCarrotTomatoSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSearchfieldMultiSearchBasketAppleBananaCarrotTomatoSPACEtoselect·↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchfieldMultiSearchCarrotSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptTomatoBasketto█TomatoMultiSearchfieldMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaTomatoBaskett█ \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchfieldMultiSearchSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBaskettoTomatoMultiSearchfieldMultiSearchapple[ Submit ][Cancel]BasketappleBasketAppleBananaCarrotTomatoBaskettTomatoCarrotTomato \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSearchfieldMultiSearchBasketAppleBananaCarrotTomatoSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSearchfieldMultiSearchBasketAppleBananaCarrotTomatoSPACEtoselect·↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAppleBananaBoundedMultiSearchMultiSearch[ Submit ][Cancel]BasketAppleAppleBanana \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAppleBananaBoundedMultiSearchMultiSearch[ Submit ][Cancel]BasketAppleAppleBanana \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯SelectfieldSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaSelectfieldSelectapple[Submit][Cancel]FruitappleFruitAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯SelectfieldSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaSelectfieldSelectapple[ Submit ][Cancel]FruitappleFruitAppleBanana \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectfieldSelectFruitAppleBananaCherry↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectfieldSelectFruitAppleBananaCherry↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[ Submit ][Cancel]FruitappleFruitAppleBananaCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[ Submit ][Cancel]FruitappleFruitAppleBananaCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────────────────────────────────────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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────────────────────────────────────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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────────────────────────────────────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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────────────────────────────────────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╰──────────────────────────────────────────────────────────────────────────╯SelectfieldSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaSelectfieldSelectapple[Submit][Cancel]FruitappleFruitAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯SelectfieldSelectCherry↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaSelectfieldSelectapple[ Submit ][Cancel]FruitappleFruitAppleBanana \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectfieldSelectFruitAppleBananaCherry↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SelectfieldSelectFruitAppleBananaCherry↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectfieldMultiSelectTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotMultiSelectfieldMultiSelectapple[Submit][Cancel]BasketappleBasketAppleCarrotCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectfieldMultiSelectTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotMultiSelectfieldMultiSelectapple[ Submit ][Cancel]BasketappleBasketAppleCarrotCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectfieldMultiSelectBasketAppleCarrotTomatoSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectfieldMultiSelectBasketAppleCarrotTomatoSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────────────────────────────────────VegetablesCarrotTomatoLeek(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────────────────────────────────────VegetablesCarrotTomatoLeek(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────────────────────────────────────VegetablesCarrotTomatoLeek(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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────────────────────────────────────VegetablesCarrotTomatoLeek(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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectfieldMultiSelectTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotMultiSelectfieldMultiSelectapple[Submit][Cancel]BasketappleBasketAppleCarrotCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectfieldMultiSelectTomatoSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotMultiSelectfieldMultiSelectapple[ Submit ][Cancel]BasketappleBasketAppleCarrotCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectfieldMultiSelectBasketAppleCarrotTomatoSPACEtoselect·↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮MultiSelectfieldMultiSelectBasketAppleCarrotTomatoSPACEtoselect·↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotBoundedMultiSelectMultiSelect[ Submit ][Cancel]BasketBasketAppleBasketAppleCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptBasketAppleCarrotBoundedMultiSelectMultiSelect[ Submit ][Cancel]BasketBasketAppleBasketAppleCarrot \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.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 @@ +╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.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╰──────────────────────────────────────────────────────────────────────────╯SuggestfieldSuggestApricotCherry↑/↓tomove·toaccept·ESCtocancelFruitCh█CherrySuggestfieldSuggest[Submit][Cancel]FruitFruitAppleBananaMangoFruitC█ \ 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╰──────────────────────────────────────────────────────────────────────────╯SuggestfieldSuggest↑/↓tomove·toaccept·ESCtocancelFruitChCherrySuggestfieldSuggest[ Submit ][Cancel]FruitFruitAppleApricotBananaCherryMangoFruitCCherryApricotCherry \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SuggestfieldSuggestFruitAppleApricotBananaCherryMango↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SuggestfieldSuggestFruitAppleApricotBananaCherryMango↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓tomove·toaccept·ESCtocancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓tomove·toaccept·ESCtocancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[ Submit ][Cancel]FruitApple \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓tomove·toaccept·ESCtocancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓tomove·toaccept·ESCtocancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[ Submit ][Cancel]FruitApple \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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 @@ +╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggestAppleApricotBananaMango↑/↓tomove·toaccept·ESCtocancelFruitAp█GhosttextSuggest[Submit][Cancel]FruitFruitCherryFruitA█ \ 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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggest↑/↓tomove·toaccept·ESCtocancelFruitAppleAppleApricotGhosttextSuggest[ Submit ][Cancel]FruitFruitAppleApricotBananaCherryMangoFruitAppleAppleApricotBananaMango \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAp█AppleApricot↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAppleAppleApricot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggestAppleApricotBananaMango↑/↓tomove·toaccept·ESCtocancelFruitAp█GhosttextSuggest[Submit][Cancel]FruitFruitCherryFruitA█ \ 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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggest↑/↓tomove·toaccept·ESCtocancelFruitAppleAppleApricotGhosttextSuggest[ Submit ][Cancel]FruitFruitAppleApricotBananaCherryMangoFruitAppleAppleApricotBananaMango \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAp█AppleApricot↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAppleAppleApricot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SuggestfieldSuggestApricotCherry↑/↓tomove·toaccept·ESCtocancelFruitCh█CherrySuggestfieldSuggest[Submit][Cancel]FruitFruitAppleBananaMangoFruitC█ \ 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╰──────────────────────────────────────────────────────────────────────────╯SuggestfieldSuggest↑/↓tomove·toaccept·ESCtocancelFruitChCherrySuggestfieldSuggest[ Submit ][Cancel]FruitFruitAppleApricotBananaCherryMangoFruitCCherryApricotCherry \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SuggestfieldSuggestFruitAppleApricotBananaCherryMango↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮SuggestfieldSuggestFruitAppleApricotBananaCherryMango↑/↓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╰──────────────────────────────────────────────────────────────────────────╯TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablefieldStock[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╰──────────────────────────────────────────────────────────────────────────╯TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablefieldStock[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablefieldStock[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╰──────────────────────────────────────────────────────────────────────────╯TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablefieldStock[ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TablefieldStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColorInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯TemplatefieldTemplatefillinginOrchard↓/↑tomovebetweenparts·toaccept·ESCtocancelfillinginGradeCratelabelridge-pear-b█TemplatefieldTemplatevalley-pear-a[Submit][Cancel]Cratelabelvalley-pear-aCratelabelvalley█-pear-aCratelabelvalle█-pear-aCratelabelvall█-pear-aCratelabelval█-pear-aCratelabelva█-pear-aCratelabelv█-pear-aCratelabel█-pear-aCratelabelr█-pear-aCratelabelri█-pear-aCratelabelrid█-pear-aCratelabelridg█-pear-aCratelabelridge█-pear-aCratelabelridge-pear█-afillinginFruitCratelabelridge-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╰──────────────────────────────────────────────────────────────────────────╯TemplatefieldTemplatefillinginOrchard↓/↑tomovebetweenparts·toaccept·ESCtocancelfillinginGradeCratelabelridge-pear-bTemplatefieldTemplatevalley-pear-a[ Submit ][Cancel]Cratelabelvalley-pear-aCratelabelvalley-pear-aCratelabelvalle-pear-aCratelabelvall-pear-aCratelabelval-pear-aCratelabelva-pear-aCratelabelv-pear-aCratelabel-pear-aCratelabelr-pear-aCratelabelri-pear-aCratelabelrid-pear-aCratelabelridg-pear-aCratelabelridge-pear-aCratelabelridge-pear-afillinginFruitCratelabelridge-pear-aCratelabelridge-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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TemplatefieldTemplateCratelabelvalley█-pear-afillinginOrchard↓/↑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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TemplatefieldTemplateCratelabelvalley-pear-afillinginOrchard↓/↑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╰──────────────────────────────────────────────────────────────────────────╯TemplatefieldTemplatefillinginOrchard↓/↑tomovebetweenparts·toaccept·ESCtocancelfillinginGradeCratelabelridge-pear-b█TemplatefieldTemplatevalley-pear-a[Submit][Cancel]Cratelabelvalley-pear-aCratelabelvalley█-pear-aCratelabelvalle█-pear-aCratelabelvall█-pear-aCratelabelval█-pear-aCratelabelva█-pear-aCratelabelv█-pear-aCratelabel█-pear-aCratelabelr█-pear-aCratelabelri█-pear-aCratelabelrid█-pear-aCratelabelridg█-pear-aCratelabelridge█-pear-aCratelabelridge-pear█-afillinginFruitCratelabelridge-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╰──────────────────────────────────────────────────────────────────────────╯TemplatefieldTemplatefillinginOrchard↓/↑tomovebetweenparts·toaccept·ESCtocancelfillinginGradeCratelabelridge-pear-bTemplatefieldTemplatevalley-pear-a[ Submit ][Cancel]Cratelabelvalley-pear-aCratelabelvalley-pear-aCratelabelvalle-pear-aCratelabelvall-pear-aCratelabelval-pear-aCratelabelva-pear-aCratelabelv-pear-aCratelabel-pear-aCratelabelr-pear-aCratelabelri-pear-aCratelabelrid-pear-aCratelabelridg-pear-aCratelabelridge-pear-aCratelabelridge-pear-afillinginFruitCratelabelridge-pear-aCratelabelridge-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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TemplatefieldTemplateCratelabelvalley█-pear-afillinginOrchard↓/↑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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TemplatefieldTemplateCratelabelvalley-pear-afillinginOrchard↓/↑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╰──────────────────────────────────────────────────────────────────────────╯TextfieldTexttoaccept·ESCtocancelItemApple█TextfieldTextPear[Submit][Cancel]ItemPearItemPear█ItemPea█ItemPe█ItemP█ItemItemA█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╰──────────────────────────────────────────────────────────────────────────╯TextfieldTexttoaccept·ESCtocancelItemAppleTextfieldTextPear[ Submit ][Cancel]ItemPearItemPearItemPearItemPearItemPearItemItemAItemApItemAppItemAppl \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextfieldTextItemPear█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextfieldTextItemPeartoaccept·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╰──────────────────────────────────────────────────────────────────────────╯TextfieldTexttoaccept·ESCtocancelItemApple█TextfieldTextPear[Submit][Cancel]ItemPearItemPear█ItemPea█ItemPe█ItemP█ItemItemA█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╰──────────────────────────────────────────────────────────────────────────╯TextfieldTexttoaccept·ESCtocancelItemAppleTextfieldTextPear[ Submit ][Cancel]ItemPearItemPearItemPearItemPearItemPearItemItemAItemApItemAppItemAppl \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextfieldTextItemPear█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextfieldTextItemPeartoaccept·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╰──────────────────────────────────────────────────────────────────────────╯TextareafieldTextareaTastingnotesCrispandsweetHintofcitrustoinsertanewline·TABtoaccept·ESCtocancelSlightlytart█TextareafieldTextareaCrispandsweetHintofcitrus[Submit][Cancel]Hintofcitrus█S█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█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╰──────────────────────────────────────────────────────────────────────────╯TextareafieldTextareaTastingnotesCrispandsweettoinsertanewline·TABtoaccept·ESCtocancelHintofcitrusSlightlytartTextareafieldTextareaCrispandsweetHintofcitrus[ Submit ][Cancel]TastingnotesCrispandsweetHintofcitrusHintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlytSlightlytaSlightlytar \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextareafieldTextareaTastingnotesCrispandsweetHintofcitrus█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextareafieldTextareaTastingnotesCrispandsweetHintofcitrustoinsertanewline·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╰──────────────────────────────────────────────────────────────────────────╯TextareafieldTextareaTastingnotesCrispandsweetHintofcitrustoinsertanewline·TABtoaccept·ESCtocancelSlightlytart█TextareafieldTextareaCrispandsweetHintofcitrus[Submit][Cancel]Hintofcitrus█S█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█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╰──────────────────────────────────────────────────────────────────────────╯TextareafieldTextareaTastingnotesCrispandsweettoinsertanewline·TABtoaccept·ESCtocancelHintofcitrusSlightlytartTextareafieldTextareaCrispandsweetHintofcitrus[ Submit ][Cancel]TastingnotesCrispandsweetHintofcitrusHintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlytSlightlytaSlightlytar \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextareafieldTextareaTastingnotesCrispandsweetHintofcitrus█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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TextareafieldTextareaTastingnotesCrispandsweetHintofcitrustoinsertanewline·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╰──────────────────────────────────────────────────────────────────────────╯TogglefieldToggletotoggle·toaccept·ESCtocancelRipenessRipeUnripeTogglefieldToggleripe[Submit][Cancel]RipenessripeRipenessRipeUnripe \ 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╰──────────────────────────────────────────────────────────────────────────╯TogglefieldToggletotoggle·toaccept·ESCtocancelRipenessRipeUnripeTogglefieldToggleripe[ Submit ][Cancel]RipenessripeRipenessRipeUnripe \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TogglefieldToggleRipenessRipeUnripetotoggle·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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TogglefieldToggleRipenessRipeUnripetotoggle·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╰──────────────────────────────────────────────────────────────────────────╯TogglefieldToggletotoggle·toaccept·ESCtocancelRipenessRipeUnripeTogglefieldToggleripe[Submit][Cancel]RipenessripeRipenessRipeUnripe \ 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╰──────────────────────────────────────────────────────────────────────────╯TogglefieldToggletotoggle·toaccept·ESCtocancelRipenessRipeUnripeTogglefieldToggleripe[ Submit ][Cancel]RipenessripeRipenessRipeUnripe \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TogglefieldToggleRipenessRipeUnripetotoggle·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 @@ +╭──────────────────────────────────────────────────────────────────────────╮TogglefieldToggleRipenessRipeUnripetotoggle·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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FieldsFieldsPear·valley-pear-a·1200·●●●●○4/5[Submit][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldsFieldsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●○4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••SelectappleMultiSelectappleMultiSelectappleTextPear█toaccept·ESCtocancelTextPea█TextPe█TextP█TextTextA█TextAp█TextApp█TextAppl█TextApple█TextAppleeditedTextAppleeditedTemplatevalley-pear-aTemplatevalley█-pear-afillinginorchardNumber1200↓/↑tomovebetweenparts·toaccept·ESCtocancelTemplatevalle█-pear-aTemplatevall█-pear-aTemplateval█-pear-aTemplateva█-pear-aTemplatev█-pear-aTemplate█-pear-aTemplater█-pear-aTemplateri█-pear-aTemplaterid█-pear-aTemplateridg█-pear-aTemplateridge█-pear-aTemplateridge-pear█-afillinginfruitTemplateridge-pear-a█fillingingradeTemplateridge-pear-█Templateridge-pear-b█Templateridge-pear-beditedTemplateridge-pear-beditedNumber1200Number1200█Number120█Number12█Number1█NumberNumber4█Number42█Number420█Number4200█Number4200editedNumber4200editedRating●●●●○4/5↑/↓toadjust·toaccept·ESCtocancelRating●●●○○3/5FairRating●●●○○3/5FaireditedRating●●●○○3/5FaireditedCalendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Calendar2026-07-22editedCalendar2026-07-22editedTextareaCrispandsweetHintofcitrus█toinsertanewline·TABtoaccept·ESCtocancelS█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█Slightlyta█Slightlytar█Slightlytart█TextareaCrispandsweeteditedSlightlytartTextareaCrispandsweeteditedPassword••••••••Password••••••█Password•••••••█Password••••••••█Password•••••••••█Password••••••••••█Password•••••••••••█Password••••••••••••█Password••••••••editedPassword••••••••editedSelectappleSelectAppleBanana↑/↓tomove·toaccept·ESCtocancelSelectAppleBananaSelectbananaeditedAread-onlycard-thecursorskipsitanditcollectsnothing.Rating●●●○○3/5FRating●●●○○3/5FairSelectbananaeditedMultiSelectappleMultiSelectAppleSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptMultiSelectAppleMultiSelectAppleMultiSelectapple,carrotTextAppleedited▲Calendar2026-07-2Calendar2026-07-22MultiSelectapple,carroteditedReorderapple,carrot,tomatoReorderApple↑/↓tomove·SPACEtograb·toaccept·ESCtocancelReorder↑↓Apple↑/↓toreorder·SPACEtodrop·ESCtocancelReorderCarrotReorderCarrotReordercarrot,apple,tomatoTemplateridge-pear-bedited▲TextareaCrispandTextareaCrispandsweetReordercarrot,apple,tomatoeditedSuggestSuggestSuggestC█SuggestCh█SuggestCh█SuggestCherryNumber4200edited▲SlightlySlightlytartSuggestCherryeditedSearchcarrotSearchSearcho█Searchon█SearchonionRating●●●○○3/5Fairedited▲Password•••Password••••••SearchonioneditedMultiSearchappleMultiSearchMultiSearcht█MultiSearchto█MultiSearchto█MultiSearchapple,tomatoCalendar2026-07-22edited▲SelectbananSelectbananaMultiSearchapple,tomatoeditedConfirmyesConfirmYesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelConfirmYesNoConfirmnoTextareaCrispandsweetedited▲MultiSelectMultiSelectapple,carConfirmnoeditedToggleripeToggleRipeUnripetotoggle·toaccept·ESCtocancelToggleRipeUnripeToggleunripeSlightlytartReordercarrReordercarrot,apple,ToggleunripeeditedPauseyesPausePresstocontinueApple·ridge-pear-b·4200·●●●○○3/5FairFieldsFieldsText: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)BananaBananaMultiSelectappleMultiSelectAppleMultiSelectapple,carrotedited▼Reorderapple,carrot,tomatoReorderAppleReorder↑↓AppleReordercarrot,apple,tomatoedited▼SuggestSuggestSuggestC█SuggestCherryedited▼SearchcarrotSearchSearcho█Searchon█Searchonionedited▼MultiSearchappleMultiSearchMultiSearcht█MultiSearchapple,tomatoedited▼ConfirmyesConfirmYesNoConfirmYesNoConfirmnoedited▼ToggleripeToggleRipeUnripeToggleRipeUnripeToggleunripeedited▼PauseyesPausePresstocontinuetocontinue·ESCtocancelPauseyesedited \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FieldsFieldsPear·valley-pear-a·1200·●●●●4/5[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldsFieldsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••SelectappleMultiSelectapple╰───────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────TextPeartoaccept·ESCtocancelTextPeaTextPeTextPTextTextATextApTextAppTextApplTextAppleTextApple edited ╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────TextApple edited Templatevalley-pear-aTemplatevalley-pear-aTemplatevalley-pear-afillinginorchardHint↓/↑tomovebetweenparts·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────────────Templatevalle-pear-aTemplatevalle-pear-aTemplatevall-pear-aTemplatevall-pear-aTemplateval-pear-aTemplateval-pear-aTemplateva-pear-aTemplateva-pear-aTemplatev-pear-aTemplatev-pear-aTemplate-pear-aTemplate-pear-aTemplater-pear-aTemplater-pear-aTemplateri-pear-aTemplateri-pear-aTemplaterid-pear-aTemplaterid-pear-aTemplateridg-pear-aTemplateridg-pear-aTemplateridge-pear-aTemplateridge-pear-aTemplateridge-pear-aTemplateridge-pear-afillinginfruitTemplateridge-pear-aTemplateridge-pear-afillingingradeTemplateridge-pear-Templateridge-pear-Templateridge-pear-bTemplateridge-pear-bTemplateridge-pear-b edited Templateridge-pear-b edited ╰─────────────────────────────────────────────────────────────Templateridge-pear-b edited Number1200Number1200Number120Number12Number1NumberNumber4Number42Number420Number4200Number4200 edited ╰───────────────────────────────────────────────────────────Number4200 edited Rating●●●●4/5↑/↓toadjust·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────────────────────Rating●●●○○3/5FairRating●●●○○3/5Fair edited ╰────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────Rating●●●○○3/5Fair edited Calendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Calendar2026-07-22 edited ╰─────────────────────────────────────────────────────Calendar2026-07-22 edited TextareaCrispandsweetTextareaCrispandsweetHintofcitrustoinsertanewline·TABtoaccept·ESCtocancel╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────HintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlySlightlytSlightlytaSlightlytarSlightlytartTextareaCrispandsweet edited Slightlytart╰────────────────────────────────────────────────╰──────────────────────────────────────────────────TextareaCrispandsweet edited Password••••••••Password••••••╰────────────────────────────────────────────────────────────────────────Password•••••••╰───────────────────────────────────────────────────────────────────────Password••••••••Password•••••••••Password••••••••••Password•••••••••••Password••••••••••••Password•••••••• edited ╰─────────────────────────────────────────────╰───────────────────────────────────────────────Password•••••••• edited SelectappleSelectAppleBanana↑/↓tomove·toaccept·ESCtocancel╰───────────────────────────────────────────────────SelectAppleBanana╰────────────────────────────────────────────────────────────Selectbanana edited Aread-onlycard-thecursorskipsitanditcollectsnothing.Selectbanana edited MultiSelectappleSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptMultiSelectAppleMultiSelectapple,carroteditedTextAppleeditedMultiSelectapple,carrot edited Reorderapple,carrot,tomatoReorderApple↑/↓tomove·SPACEtograb·toaccept·ESCtocancelReorder↑↓Apple↑/↓toreorder·SPACEtodrop·ESCtocancelReorderCarrotReordercarrot,apple,tomatoeditedTemplateridge-pear-beditedReordercarrot,apple,tomato edited SuggestSuggestSuggestC█SuggestCh█SuggestCherryeditedNumber4200editedSuggestCherry edited SearchcarrotSearchSearcho█Searchon█SearchonioneditedRating●●●○○3/5FaireditedHintofcitrusSearchonion edited MultiSearchapple╰────────────────────────────────────────────────────╰──────────────────────────────────────────────────────MultiSearchto█MultiSearchapple,tomatoeditedCalendar2026-07-22editedMultiSearchapple,tomato edited Confirmyes╰───────────────────────────────────────────────────────ConfirmYesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────ConfirmYesNoConfirmnoeditedTextareaCrispandsweeteditedConfirmno edited ToggleripeToggleRipeUnripetotoggle·toaccept·ESCtocancelToggleRipeUnripeToggleunripeeditedSlightlytartToggleunripe edited PauseyesPauseyes edited Apple·ridge-pear-b·4200·●●●○○3/5FairFieldsFieldsText: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-aTemplatevalley-pear-aSSSlSlSliSliSligSligSlighSlighSlightSlightSlightlSlightlSlightlySlightlytSlightlytaSlightlytarSlightlytart╰──────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────MultiSelectApple╰───────────────────────────────────────────────────────────────MultiSearchMultiSearcht█PausePresstocontinuetocontinue·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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FieldsFieldsPear·valley-pear-a·1200·●●●●○4/5[Submit][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldsFieldsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●○4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••SelectappleMultiSelectappleMultiSelectappleTextPear█toaccept·ESCtocancelTextPea█TextPe█TextP█TextTextA█TextAp█TextApp█TextAppl█TextApple█TextAppleeditedTextAppleeditedTemplatevalley-pear-aTemplatevalley█-pear-afillinginorchardNumber1200↓/↑tomovebetweenparts·toaccept·ESCtocancelTemplatevalle█-pear-aTemplatevall█-pear-aTemplateval█-pear-aTemplateva█-pear-aTemplatev█-pear-aTemplate█-pear-aTemplater█-pear-aTemplateri█-pear-aTemplaterid█-pear-aTemplateridg█-pear-aTemplateridge█-pear-aTemplateridge-pear█-afillinginfruitTemplateridge-pear-a█fillingingradeTemplateridge-pear-█Templateridge-pear-b█Templateridge-pear-beditedTemplateridge-pear-beditedNumber1200Number1200█Number120█Number12█Number1█NumberNumber4█Number42█Number420█Number4200█Number4200editedNumber4200editedRating●●●●○4/5↑/↓toadjust·toaccept·ESCtocancelRating●●●○○3/5FairRating●●●○○3/5FaireditedRating●●●○○3/5FaireditedCalendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Calendar2026-07-22editedCalendar2026-07-22editedTextareaCrispandsweetHintofcitrus█toinsertanewline·TABtoaccept·ESCtocancelS█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█Slightlyta█Slightlytar█Slightlytart█TextareaCrispandsweeteditedSlightlytartTextareaCrispandsweeteditedPassword••••••••Password••••••█Password•••••••█Password••••••••█Password•••••••••█Password••••••••••█Password•••••••••••█Password••••••••••••█Password••••••••editedPassword••••••••editedSelectappleSelectAppleBanana↑/↓tomove·toaccept·ESCtocancelSelectAppleBananaSelectbananaeditedAread-onlycard-thecursorskipsitanditcollectsnothing.Rating●●●○○3/5FRating●●●○○3/5FairSelectbananaeditedMultiSelectappleMultiSelectAppleSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptMultiSelectAppleMultiSelectAppleMultiSelectapple,carrotTextAppleedited▲Calendar2026-07-2Calendar2026-07-22MultiSelectapple,carroteditedReorderapple,carrot,tomatoReorderApple↑/↓tomove·SPACEtograb·toaccept·ESCtocancelReorder↑↓Apple↑/↓toreorder·SPACEtodrop·ESCtocancelReorderCarrotReorderCarrotReordercarrot,apple,tomatoTemplateridge-pear-bedited▲TextareaCrispandTextareaCrispandsweetReordercarrot,apple,tomatoeditedSuggestSuggestSuggestC█SuggestCh█SuggestCh█SuggestCherryNumber4200edited▲SlightlySlightlytartSuggestCherryeditedSearchcarrotSearchSearcho█Searchon█SearchonionRating●●●○○3/5Fairedited▲Password•••Password••••••SearchonioneditedMultiSearchappleMultiSearchMultiSearcht█MultiSearchto█MultiSearchto█MultiSearchapple,tomatoCalendar2026-07-22edited▲SelectbananSelectbananaMultiSearchapple,tomatoeditedConfirmyesConfirmYesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelConfirmYesNoConfirmnoTextareaCrispandsweetedited▲MultiSelectMultiSelectapple,carConfirmnoeditedToggleripeToggleRipeUnripetotoggle·toaccept·ESCtocancelToggleRipeUnripeToggleunripeSlightlytartReordercarrReordercarrot,apple,ToggleunripeeditedPauseyesPausePresstocontinueApple·ridge-pear-b·4200·●●●○○3/5FairFieldsFieldsText: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)BananaBananaMultiSelectappleMultiSelectAppleMultiSelectapple,carrotedited▼Reorderapple,carrot,tomatoReorderAppleReorder↑↓AppleReordercarrot,apple,tomatoedited▼SuggestSuggestSuggestC█SuggestCherryedited▼SearchcarrotSearchSearcho█Searchon█Searchonionedited▼MultiSearchappleMultiSearchMultiSearcht█MultiSearchapple,tomatoedited▼ConfirmyesConfirmYesNoConfirmYesNoConfirmnoedited▼ToggleripeToggleRipeUnripeToggleRipeUnripeToggleunripeedited▼PauseyesPausePresstocontinuetocontinue·ESCtocancelPauseyesedited \ 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 @@ +╭──────────────────────────────────────────────────────────────────────────╮FieldsFieldsPear·valley-pear-a·1200·●●●●4/5[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯FieldsFieldsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••SelectappleMultiSelectapple╰───────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────TextPeartoaccept·ESCtocancelTextPeaTextPeTextPTextTextATextApTextAppTextApplTextAppleTextApple edited ╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────TextApple edited Templatevalley-pear-aTemplatevalley-pear-aTemplatevalley-pear-afillinginorchardHint↓/↑tomovebetweenparts·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────────────Templatevalle-pear-aTemplatevalle-pear-aTemplatevall-pear-aTemplatevall-pear-aTemplateval-pear-aTemplateval-pear-aTemplateva-pear-aTemplateva-pear-aTemplatev-pear-aTemplatev-pear-aTemplate-pear-aTemplate-pear-aTemplater-pear-aTemplater-pear-aTemplateri-pear-aTemplateri-pear-aTemplaterid-pear-aTemplaterid-pear-aTemplateridg-pear-aTemplateridg-pear-aTemplateridge-pear-aTemplateridge-pear-aTemplateridge-pear-aTemplateridge-pear-afillinginfruitTemplateridge-pear-aTemplateridge-pear-afillingingradeTemplateridge-pear-Templateridge-pear-Templateridge-pear-bTemplateridge-pear-bTemplateridge-pear-b edited Templateridge-pear-b edited ╰─────────────────────────────────────────────────────────────Templateridge-pear-b edited Number1200Number1200Number120Number12Number1NumberNumber4Number42Number420Number4200Number4200 edited ╰───────────────────────────────────────────────────────────Number4200 edited Rating●●●●4/5↑/↓toadjust·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────────────────────Rating●●●○○3/5FairRating●●●○○3/5Fair edited ╰────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────Rating●●●○○3/5Fair edited Calendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Calendar2026-07-22 edited ╰─────────────────────────────────────────────────────Calendar2026-07-22 edited TextareaCrispandsweetTextareaCrispandsweetHintofcitrustoinsertanewline·TABtoaccept·ESCtocancel╰────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────HintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlySlightlytSlightlytaSlightlytarSlightlytartTextareaCrispandsweet edited Slightlytart╰────────────────────────────────────────────────╰──────────────────────────────────────────────────TextareaCrispandsweet edited Password••••••••Password••••••╰────────────────────────────────────────────────────────────────────────Password•••••••╰───────────────────────────────────────────────────────────────────────Password••••••••Password•••••••••Password••••••••••Password•••••••••••Password••••••••••••Password•••••••• edited ╰─────────────────────────────────────────────╰───────────────────────────────────────────────Password•••••••• edited SelectappleSelectAppleBanana↑/↓tomove·toaccept·ESCtocancel╰───────────────────────────────────────────────────SelectAppleBanana╰────────────────────────────────────────────────────────────Selectbanana edited Aread-onlycard-thecursorskipsitanditcollectsnothing.Selectbanana edited MultiSelectappleSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptMultiSelectAppleMultiSelectapple,carroteditedTextAppleeditedMultiSelectapple,carrot edited Reorderapple,carrot,tomatoReorderApple↑/↓tomove·SPACEtograb·toaccept·ESCtocancelReorder↑↓Apple↑/↓toreorder·SPACEtodrop·ESCtocancelReorderCarrotReordercarrot,apple,tomatoeditedTemplateridge-pear-beditedReordercarrot,apple,tomato edited SuggestSuggestSuggestC█SuggestCh█SuggestCherryeditedNumber4200editedSuggestCherry edited SearchcarrotSearchSearcho█Searchon█SearchonioneditedRating●●●○○3/5FaireditedHintofcitrusSearchonion edited MultiSearchapple╰────────────────────────────────────────────────────╰──────────────────────────────────────────────────────MultiSearchto█MultiSearchapple,tomatoeditedCalendar2026-07-22editedMultiSearchapple,tomato edited Confirmyes╰───────────────────────────────────────────────────────ConfirmYesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancel╰─────────────────────────────────────────────────────────ConfirmYesNoConfirmnoeditedTextareaCrispandsweeteditedConfirmno edited ToggleripeToggleRipeUnripetotoggle·toaccept·ESCtocancelToggleRipeUnripeToggleunripeeditedSlightlytartToggleunripe edited PauseyesPauseyes edited Apple·ridge-pear-b·4200·●●●○○3/5FairFieldsFieldsText: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-aTemplatevalley-pear-aSSSlSlSliSliSligSligSlighSlighSlightSlightSlightlSlightlSlightlySlightlytSlightlytaSlightlytarSlightlytart╰──────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────MultiSelectApple╰───────────────────────────────────────────────────────────────MultiSearchMultiSearcht█PausePresstocontinuetocontinue·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├──────────────────────────────────────────────────────────────────────────────┤SummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeor[Placeorder][Placeorder][Cancel]↑/↓/←/→move·select·escback·qquit·?help╰────────────╰───────────────╰──────────────────────────────────────────────────────────────────────────────╯SummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeorde╰─────────────ProduceDeliveryFruitGiftwrap?noMarketstallProduceFruitVegetablesFruitappleVegetablescarrot╰────────────────────────────────╰───────────────────────────────────FruitVegetablesFruitappleVegetablescarrot[ Place order ][Cancel][ Place order ][Cancel]SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────╰──────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────────╮MarketstallSummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitFruitGiftwrap?noappleVegetablescarrot[ Place order ][Cancel]↑/↓/←/→tomove·toselect·ESCtogoback·Qtoquit╰─────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────────╯SummaryProduceDeliveryProduceDeliveryMarketstallProduceFruitVegetablesFruitappleVegetablescarrotFruitVegetablesVegetablesFruitSummaryOrdername: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├──────────────────────────────────────────────────────────────────────────────┤SummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeor[Placeorder][Placeorder][Cancel]↑/↓/←/→move·select·escback·qquit·?help╰────────────╰───────────────╰──────────────────────────────────────────────────────────────────────────────╯SummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeorde╰─────────────ProduceDeliveryFruitGiftwrap?noMarketstallProduceFruitVegetablesFruitappleVegetablescarrot╰────────────────────────────────╰───────────────────────────────────FruitVegetablesFruitappleVegetablescarrot[ Place order ][Cancel][ Place order ][Cancel]SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no╰─────────────────────╰──────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────────╮MarketstallSummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitFruitGiftwrap?noappleVegetablescarrot[ Place order ][Cancel]↑/↓/←/→tomove·toselect·ESCtogoback·Qtoquit╰─────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────────╯SummaryProduceDeliveryProduceDeliveryMarketstallProduceFruitVegetablesFruitappleVegetablescarrotFruitVegetablesVegetablesFruitSummaryOrdername: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├──────────────────────────────────────────────────────────────┤OrderoptionsPressEnteronafieldtoedititinplace;Enteracceptno·6·ripe·2026-07-15[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────╯ProduceorderOrderoptionsOrganiconly?noQuantity6RipenessRipenessripeHarvestdate2026-07-15Organiconly?YesNoRy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoOrganiconly?yes edited RipeneOrganiconly?yes edited Quantity6Quantity6↑/↓adjust·accept·esccancelQuantityQuantity1Quantity12Quantity12 edited Quantity12 edited RipeneRipenessRipeUnripeMixed↑/↓move·accept·esccancel╰──────────────────────────────────────────────────╰─────────────────────────────────────────────────────RipenessRipeUnripeRipenessunripeRipenessunripe edited HarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel13141516171819yes·12·unripe·2026-07-22Orderoptionsyes·12·unripe·2026-07-22OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)RipenessrRipenessripeRipenessunripe edited Harvestdate2026-07-151314[15]16171314[15]161718191314[15]1617181920212223242526131415161718192021[22]23242526Harvestdate2026-07-22 edited [ Submit ][[ Submit ][Cancel[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderOrderoptionsPressEnteronafieldtoedititinplace;Enteraccepts,Esccanceno·6·ripe·2026-07-15[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderoptionsOrganiconly?noQuantity6RipenessripeHarvestdate2026-07-15↑/↓Organiconly?YesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoOrganiconly?yes edited Organiconly?yes edited Quantity6Quantity6↑/↓toadjust·toaccept·ESCtocancelQuantityQuantity1Quantity12Quantity12 edited Quantity12 edited RipenessripeRipenessRipeUnripeUnripeMixed↑/↓tomove·toaccept·ESCtocancelRipenessRipeUnripeUnripeRipenessunripe edited Ripenessunripe edited Harvestdate2026-07-15HarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/←/→←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Harvestdate2026-07-22 edited yes·12·unripe·2026-07-22OrderoptionsOrderoptionsOrganiconly?: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├──────────────────────────────────────────────────────────────┤OrderoptionsPressEnteronafieldtoedititinplace;Enteracceptno·6·ripe·2026-07-15[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────╯ProduceorderOrderoptionsOrganiconly?noQuantity6RipenessRipenessripeHarvestdate2026-07-15Organiconly?YesNoRy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoOrganiconly?yes edited RipeneOrganiconly?yes edited Quantity6Quantity6↑/↓adjust·accept·esccancelQuantityQuantity1Quantity12Quantity12 edited Quantity12 edited RipeneRipenessRipeUnripeMixed↑/↓move·accept·esccancel╰──────────────────────────────────────────────────╰─────────────────────────────────────────────────────RipenessRipeUnripeRipenessunripeRipenessunripe edited HarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel13141516171819yes·12·unripe·2026-07-22Orderoptionsyes·12·unripe·2026-07-22OrderoptionsOrganiconly?:yes(edited)Quantity:12(edited)Ripeness:unripe(edited)Harvestdate:2026-07-22(edited)RipenessrRipenessripeRipenessunripe edited Harvestdate2026-07-151314[15]16171314[15]161718191314[15]1617181920212223242526131415161718192021[22]23242526Harvestdate2026-07-22 edited [ Submit ][[ Submit ][Cancel[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderOrderoptionsPressEnteronafieldtoedititinplace;Enteraccepts,Esccanceno·6·ripe·2026-07-15[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderoptionsOrganiconly?noQuantity6RipenessripeHarvestdate2026-07-15↑/↓Organiconly?YesNoY/Ntoansweryesorno·totoggle·toaccept·ESCtocancelOrganiconly?YesNoOrganiconly?yes edited Organiconly?yes edited Quantity6Quantity6↑/↓toadjust·toaccept·ESCtocancelQuantityQuantity1Quantity12Quantity12 edited Quantity12 edited RipenessripeRipenessRipeUnripeUnripeMixed↑/↓tomove·toaccept·ESCtocancelRipenessRipeUnripeUnripeRipenessunripe edited Ripenessunripe edited Harvestdate2026-07-15HarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/←/→←/→tomovebyday·↑/↓tomovebyweek·toaccept·ESCtocancel131415161718192021[22]23242526Harvestdate2026-07-22 edited yes·12·unripe·2026-07-22OrderoptionsOrderoptionsOrganiconly?: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├──────────────────────────────────────────────────────────────────────┤OrderWeekly·apple··yes[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯KeybindingsdemoOrderOrdernameWeeklyFruitappleVegetablesOrganiconly?yes╰──────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────OrdernameWeeklyFruitappleVegetablesFruitAppleBananaCherry↑/↓move·accept·esccancelFruitAppleBananaFruitbanana edited Weekly·banana··yesOrderWeekly·banana··yesOrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yesKeyboardhelpNavigation↑/↓move·select·escback·qquit·?helpTextaccept·esccancelSelect↑/↓move·accept·esccancelConfirmy/nyes/no·toggle·accept·esccancel?close╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────[ Submit ][[ Submit ][Cancel[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────╮KeybindingsdemoOrderWeekly·apple··yes[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────╯KeybindingsdemoOrderOrdernameWeeklyFruitappleVegetablesOrganiconly?yesOrganiconly?yesOrdernameWeeklyFruitappleVegetablesFruitAppleBananaCherryVegetables↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaFruitbanana edited Weekly·banana··yesOrderOrderOrdername: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├──────────────────────────────────────────────────────────────────────┤OrderWeekly·apple··yes[Subm[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯KeybindingsdemoOrderOrdernameWeeklyFruitappleVegetablesOrganiconly?yes╰──────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────OrdernameWeeklyFruitappleVegetablesFruitAppleBananaCherry↑/↓move·accept·esccancelFruitAppleBananaFruitbanana edited Weekly·banana··yesOrderWeekly·banana··yesOrderOrdername:WeeklyFruit:banana(edited)Vegetables:Organiconly?:yesKeyboardhelpNavigation↑/↓move·select·escback·qquit·?helpTextaccept·esccancelSelect↑/↓move·accept·esccancelConfirmy/nyes/no·toggle·accept·esccancel?close╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────[ Submit ][[ Submit ][Cancel[ Submit ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────╮KeybindingsdemoOrderWeekly·apple··yes[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────╯KeybindingsdemoOrderOrdernameWeeklyFruitappleVegetablesOrganiconly?yesOrganiconly?yesOrdernameWeeklyFruitappleVegetablesFruitAppleBananaCherryVegetables↑/↓tomove·toaccept·ESCtocancelFruitAppleBananaFruitbanana edited Weekly·banana··yesOrderOrderOrdername: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├──────────────────────────────────────────────────────────────────────────┤BasketYourproduceselection.Pear·6·ripePear·6·ripe[Placeorder][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceorderBasketItemPearQuantity6Quantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·EnjoytheharvestEmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ItemPearQuantity6Quantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·Enjoytheharvest╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketGiftoptions├──────────────────────────────────────────────────────────────────────────┤Item╭────────────────────────────────────────────────────────╮GiftoptionsQuant├────────────────────────────────────────────────────────┤RipenWrapthisorderasagift.GiftGiftwrap?yesWrayesGiftmessageEnjoytheharvestEmpty├────────────────────────────────────────────────────────┤Thi[Save][Discard]Thereis╰────────────────────────────────────────────────────────╯↑/↓move·select↑/↓move·select·esc↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯GiftGiftwrap?yesyesGiftmessageEnjoytheharvestThi[ Save ][Discard]EmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ProduceorderBasketEmptythebasketItemPear╭────────────────────────────────────────────────────────╮QuantQuantEmptyQuantEmptythebasket├────────────────────────────────────────────────────────┤RipenThisclearseveryitemfromyourbasket.GiftThereisnoundo.yes├────────────────────────────────────────────────────────┤[ Empty it ][Keepit]Empty╰────────────────────────────────────────────────────────╯Thisclearseveryitemfromyourbasket.Thereisnoundo.╰──────────────────╰────────────────────[Emptyit][ Keep it ]BasketYourproduceselection.Pear·6·ripeBasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest↑/↓move·select·escback·qquit↑/↓move·select·escback·qquit·?hPear·6·ripe[ Place order ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketYourproduceselection.Pear·6·ripe[ Place order ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderBasketItemPearQuantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·EnjoytheharvestEmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ItemPearQuantity6RipenessripeGiftoptions╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketGiftoptionsItemPearQuantity6Ripen╭────────────────────────────────────────────────────────╮GiftoptionsGiftoptionsGiftWrapthisorderasagift.WrayesGiftwrap?yesEmptyGiftmessageEnjoytheharvestThiThe[ Save ][Discard]╰────────────────────────────────────────────────────────╯↑/↓tomove·toselect·ESCtogoback·Qtoquit╰───────────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────╯yesGiftwrap?yesEmptyGiftmessageEnjoytheharvestGGiftoptiEmptythebasketProduceorderBasketEmptythebasketRipenessripeGift╭────────────────────────────────────Gift╭───────────────────────────────────────Gift╭────────────────────────────────────────────────────────╮WraEmptythebasketyesThisclearseveryitemfromyourbasket.Thereisnoundo.EmptyThi[ Empty it ][Keepit]The╰───────────────────────────────────────────────╰──────────────────────────────────────────────────BasketBasketItem: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├──────────────────────────────────────────────────────────────────────────┤BasketYourproduceselection.Pear·6·ripePear·6·ripe[Placeorder][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceorderBasketItemPearQuantity6Quantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·EnjoytheharvestEmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ItemPearQuantity6Quantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·Enjoytheharvest╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketGiftoptions├──────────────────────────────────────────────────────────────────────────┤Item╭────────────────────────────────────────────────────────╮GiftoptionsQuant├────────────────────────────────────────────────────────┤RipenWrapthisorderasagift.GiftGiftwrap?yesWrayesGiftmessageEnjoytheharvestEmpty├────────────────────────────────────────────────────────┤Thi[Save][Discard]Thereis╰────────────────────────────────────────────────────────╯↑/↓move·select↑/↓move·select·esc↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯GiftGiftwrap?yesyesGiftmessageEnjoytheharvestThi[ Save ][Discard]EmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ProduceorderBasketEmptythebasketItemPear╭────────────────────────────────────────────────────────╮QuantQuantEmptyQuantEmptythebasket├────────────────────────────────────────────────────────┤RipenThisclearseveryitemfromyourbasket.GiftThereisnoundo.yes├────────────────────────────────────────────────────────┤[ Empty it ][Keepit]Empty╰────────────────────────────────────────────────────────╯Thisclearseveryitemfromyourbasket.Thereisnoundo.╰──────────────────╰────────────────────[Emptyit][ Keep it ]BasketYourproduceselection.Pear·6·ripeBasketItem:PearQuantity:6Ripeness:ripeGiftoptionsGiftwrap?:yesGiftmessage:Enjoytheharvest↑/↓move·select·escback·qquit↑/↓move·select·escback·qquit·?hPear·6·ripe[ Place order ][Cancel] \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketYourproduceselection.Pear·6·ripe[ Place order ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderBasketItemPearQuantity6RipenessripeGiftoptionsWrapthisorderasagift.yes·EnjoytheharvestEmptythebasketThisclearseveryitemfromyourbasket.Thereisnoundo.ItemPearQuantity6RipenessripeGiftoptions╭──────────────────────────────────────────────────────────────────────────╮ProduceorderBasketGiftoptionsItemPearQuantity6Ripen╭────────────────────────────────────────────────────────╮GiftoptionsGiftoptionsGiftWrapthisorderasagift.WrayesGiftwrap?yesEmptyGiftmessageEnjoytheharvestThiThe[ Save ][Discard]╰────────────────────────────────────────────────────────╯↑/↓tomove·toselect·ESCtogoback·Qtoquit╰───────────────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────────────╰──────────────────────────────────────────────────────────────────────────╯yesGiftwrap?yesEmptyGiftmessageEnjoytheharvestGGiftoptiEmptythebasketProduceorderBasketEmptythebasketRipenessripeGift╭────────────────────────────────────Gift╭───────────────────────────────────────Gift╭────────────────────────────────────────────────────────╮WraEmptythebasketyesThisclearseveryitemfromyourbasket.Thereisnoundo.EmptyThi[ Empty it ][Keepit]The╰───────────────────────────────────────────────╰──────────────────────────────────────────────────BasketBasketItem: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├──────────────────────────────────────────────────────────────────────────┤OrderWhothisorderisfor.Weekly·weeklyWeekly·weeklyDeliveryHowitarrives.pickup·yes[Save][Discard]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderOrdernameWeeklyOrdernameWeeklyOrderWhothisorderisfor.Weekly·weeklyDeliveryHowitarrives.pickup·yesProduceorderDeliveryDeliverypickupGiftwrap?yesGiftwrap?yesExtrasOptionaladd-ons.╰────────────────────────────────────────────────────────────────────────DeliveryPickupLockerDoorstepDoorstep↑/↓move·accept·esccancel╰───DeliveryPickupLockerDoorstepDoorstepDeliverydoorstep edited ╰─────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────────────Deliverydoorstep edited Giftwrap?yesGiftwrap?yesExtrasOptionaladd-ons.ProduceorderDeliveryExtrasAdd-onsPackagingPackaging250gAdd-onsHerbsNutsSeedsSeedsAdd-onsHerbs╰──────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────────Add-onsHerbsNutsNutsAdd-onsherbs,nuts edited HerbnotemixedHerbnotemixedAdd-onsherbs,nuts edited HerbnotemixedHerbnotemixedPackaging250gProduceorderDeliveryExtrasPackagingBagweight250gBagweight250g250gBagweight250250gBagweight25250gBagweight2250gBagweight250g500g500g1kg250g500gBagweight1kg edited 1kgherbs,nuts·mixeddoorstep·yesdoorstep·yes[ Save ][Discard]OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)Slugweekly Slugweekly deriveSlugweekly derived Derivedfromtheordername.SlugweeklySlugweekly deriSlugweekly derived Derivedfromtheordername.╰──────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────500g1kg├──────────────────────├────────────────────────╰────────────────────────────────╰───────────────────────────────────↑/↓mo↑/↓move· \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderOrderWhothisorderisfor.Weekly·weeklyDeliveryHowitarrives.Howitarrives.pickup·yes[ Save ][Discard]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderOrdernameWeeklySlugweekly derived Derivedfromtheordername.OrdernameWeeklySlugweekly derived OrderDeliveryProduceorderDeliveryDeliverypickupGiftwrap?yesExtrasOptionaladd-ons.Optionaladd-ons.DeliveryPickupLockerDoorstepAtthestallGiftwrap?yes↑/↓tomove·toaccept·ESCtocancelDeliveryPickupLockerNearbylockerDoorstepToyourdoorDeliverydoorstep edited Deliverydoorstep edited Giftwrap?yesExtrasProduceorderDeliveryExtrasAdd-onsPackaging250gAdd-onsHerbsNutsSeeds250gSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAdd-onsHerbsAdd-onsHerbsNutsNutsAdd-onsherbs,nuts edited HerbnotemixedAdd-onsherbs,nuts edited HerbnotemixedPackagingProduceorderDeliveryExtrasPackagingBagweight250gBagweight250g250gBagweight250250gBagweight25250gBagweight2250gBagweight250g500g1kg250g500g1kgBagweight1kg edited 1kg1kgherbs,nuts·mixeddoorstep·yesOrderOrdername: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├──────────────────────────────────────────────────────────────────────────┤OrderWhothisorderisfor.Weekly·weeklyWeekly·weeklyDeliveryHowitarrives.pickup·yes[Save][Discard]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderOrdernameWeeklyOrdernameWeeklyOrderWhothisorderisfor.Weekly·weeklyDeliveryHowitarrives.pickup·yesProduceorderDeliveryDeliverypickupGiftwrap?yesGiftwrap?yesExtrasOptionaladd-ons.╰────────────────────────────────────────────────────────────────────────DeliveryPickupLockerDoorstepDoorstep↑/↓move·accept·esccancel╰───DeliveryPickupLockerDoorstepDoorstepDeliverydoorstep edited ╰─────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────────────Deliverydoorstep edited Giftwrap?yesGiftwrap?yesExtrasOptionaladd-ons.ProduceorderDeliveryExtrasAdd-onsPackagingPackaging250gAdd-onsHerbsNutsSeedsSeedsAdd-onsHerbs╰──────────────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────────────Add-onsHerbsNutsNutsAdd-onsherbs,nuts edited HerbnotemixedHerbnotemixedAdd-onsherbs,nuts edited HerbnotemixedHerbnotemixedPackaging250gProduceorderDeliveryExtrasPackagingBagweight250gBagweight250g250gBagweight250250gBagweight25250gBagweight2250gBagweight250g500g500g1kg250g500gBagweight1kg edited 1kgherbs,nuts·mixeddoorstep·yesdoorstep·yes[ Save ][Discard]OrderOrdername:WeeklySlug:weekly(derived)DeliveryDelivery:doorstep(edited)Giftwrap?:yesExtrasAdd-ons:herbs,nuts(edited)Herbnote:mixedPackagingBagweight:1kg(edited)Slugweekly Slugweekly deriveSlugweekly derived Derivedfromtheordername.SlugweeklySlugweekly deriSlugweekly derived Derivedfromtheordername.╰──────────────────────────────────────────────────────────────────────╰───────────────────────────────────────────────────────────────╰─────────────────────────────────────────────────────────────────├──────────────────────────├────────────────────────────500g1kg├──────────────────────├────────────────────────╰────────────────────────────────╰───────────────────────────────────↑/↓mo↑/↓move· \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceorderOrderWhothisorderisfor.Weekly·weeklyDeliveryHowitarrives.Howitarrives.pickup·yes[ Save ][Discard]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceorderOrderOrdernameWeeklySlugweekly derived Derivedfromtheordername.OrdernameWeeklySlugweekly derived OrderDeliveryProduceorderDeliveryDeliverypickupGiftwrap?yesExtrasOptionaladd-ons.Optionaladd-ons.DeliveryPickupLockerDoorstepAtthestallGiftwrap?yes↑/↓tomove·toaccept·ESCtocancelDeliveryPickupLockerNearbylockerDoorstepToyourdoorDeliverydoorstep edited Deliverydoorstep edited Giftwrap?yesExtrasProduceorderDeliveryExtrasAdd-onsPackaging250gAdd-onsHerbsNutsSeeds250gSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptAdd-onsHerbsAdd-onsHerbsNutsNutsAdd-onsherbs,nuts edited HerbnotemixedAdd-onsherbs,nuts edited HerbnotemixedPackagingProduceorderDeliveryExtrasPackagingBagweight250gBagweight250g250gBagweight250250gBagweight25250gBagweight2250gBagweight250g500g1kg250g500g1kgBagweight1kg edited 1kg1kgherbs,nuts·mixeddoorstep·yesOrderOrdername: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├──────────────────────────────────────────────────────────────────────────┤SummaryTheorderataglance.OrdernameWeeklyBoxOrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeorder][Cancel]├───────────────────────────────────────────────────────────────├──────────────────────────────────────────────────────────────────↑/↓/←/→move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯SummaryTheorderataglance.OrdernameWeeklyBoxOrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables├────────────────────────────────────────────────────────────────├───────────────────────────────────────────────────────────────────ProduceDeliveryFruitGiftwrap?noMarketstallProduceFruitVegetablesFruitappleVegetablescarrot[ Place order ][Cancel]SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no├──────────────────────────────────────────────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮MarketstallSummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitFruitGiftwrap?noappleVegetablescarrot[ Place order ][Cancel]↑/↓/←/→tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯SummaryProduceDeliveryProduceDeliveryMarketstallProduceFruitVegetablesFruitappleVegetablescarrotFruitFruitGiftwrap?noSummaryOrdername: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├──────────────────────────────────────────────────────────────────────────┤SummaryTheorderataglance.OrdernameWeeklyBoxOrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables[Placeorder][Cancel]├───────────────────────────────────────────────────────────────├──────────────────────────────────────────────────────────────────↑/↓/←/→move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯SummaryTheorderataglance.OrdernameWeeklyBoxOrdernameWeeklyBoxProduceDeliveryFruitGiftwrap?noVegetables├────────────────────────────────────────────────────────────────├───────────────────────────────────────────────────────────────────ProduceDeliveryFruitGiftwrap?noMarketstallProduceFruitVegetablesFruitappleVegetablescarrot[ Place order ][Cancel]SummaryOrdername:WeeklyBoxProduceFruitFruit:appleVegetablesVegetables:carrotDeliveryGiftwrap?:no├──────────────────────────────────────────────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮MarketstallSummaryTheorderataglance.OrdernameWeeklyBoxProduceDeliveryFruitFruitGiftwrap?noappleVegetablescarrot[ Place order ][Cancel]↑/↓/←/→tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯SummaryProduceDeliveryProduceDeliveryMarketstallProduceFruitVegetablesFruitappleVegetablescarrotFruitFruitGiftwrap?noSummaryOrdername: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├──────────────────────────────────────────────────────────────────────────┤BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsContents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1derivedDerivedfromtheboxname.Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1derivedDerivedfromgrowerandslug.LabelTui1derived╰───╰───────BoxnameTui1Slugtui1derivedGrowersunnyBoxcodesunny/tui1derivedLabelTui1derivedBasicsContents&optionsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeContents↑/↓move·accept·esccancel╰─────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────MediumLargeBoxsizelargeeditedBoxsizelargeeditedContentsContentsFruitVegetablesHerbsHerbsSalad╰────────────────────────────╰────────────────────────────────ContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsHerbsHerbsContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFriday█Friday↑/↓move·↑/↓move·accept·DeliverydayFrida█DeliverydayFrid█DeliverydayFri█DeliverydayFr█DeliverydayF█DeliverydayMondayWednesdaySaturdayDeliverydayM█DeliverydayMo█DeliverydayMon█DeliverydayMond█DeliverydayMonda█DeliverydayMonday█DeliverydayMondayeditedlarge·fruit,veg,herbs·mixed·yesBasicsBoxname: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?:noContents&opt├───────────────────────────├─────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceboxBasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel][Submit][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoqu↑/↓tomove·toselect·ESCtogoback·Qtoquit↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1derivedDerivedfromtheboxname.GrowersunnyBoxcodesunny/tui1Boxcodesunny/tui1derivedDerivedfromgrowerandslug.LabelTui1derivedBoxnameTui1Slugtui1derivedGrowersunnyBoxcodesunny/tui1Boxcodesunny/tui1derivedLabelTui1derivedBasicsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLarge↑/↓tomove·toaccept·ESCtocancel↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelargeeditedBoxsizelargeeditedContentsContentsFruitVegetablesHerbsSaladDeliverydayFridaDeliverydayFridaySPACEtoselect·↑/↓tomove·←/→toselectnonSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFriday█FridayDeliverydayFrida█DeliverydayFrid█DeliverydayFri█DeliverydayFr█DeliverydayF█DeliverydayMondayWednesdaySaturdayDeliverydayM█DeliverydayMo█DeliverydayMon█DeliverydayMond█DeliverydayMonda█DeliverydayMonday█DeliverydayMondayeditedlarge·fruit,veg,herbs·mixed·yesBasicsBoxname: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├──────────────────────────────────────────────────────────────────────────┤BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·noProduceboxContents&optionsBoxsizemediumContentsContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeLarge├──────────────────────────────────────────────────├─────────────────────────────────────────────────────↑/↓move·accept·esccancelMediumLargeLargeBoxsizelarge edited ╰──Boxsizelarge edited ContentsContentsSpacetotoggle,typetofilter.ContentsFruitContentsFruitVegetablesHerbsSaladContentsFruitContentsFruit├────────├────────────ContentsFruitContentsFruitVegetablesVegetables├─────├─────────VegetablesHerbsHerbsContentsfruit,veg,herbsContentsfruit,veg,herbs edited HerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbs edited Contentsfruit,veg,herbs edited HerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFridayFridayGiftwrap?noDeliverydayFridaFridayDeliverydayFridFridayDeliverydayFriFridayDeliverydayFrFridayDeliverydayFFridayDeliverydayMondayWednesdayFridayDeliverydayMMondayDeliverydayMoMondayDeliverydayMonMondayDeliverydayMondMondayDeliverydayMondaMondayDeliverydayMondayMondayDeliverydayMonday edited large·fruit,veg,herbs·mixed·yeslarge·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╰───╰──────├───────────├───────────────├──├──────FridaySaturday↑/↑/↓move \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceboxBasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.Whattheboxshipswith.medium··Friday·no[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BoxnameTui1Slugtui1 derived GrowersunnyBoxcodesunny/tui1 derived LabelTui1 derived BasicsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeSpacetotoggle,typetofilter.↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelarge edited Boxsizelarge edited ContentsContentsFruitVegetablesHerbsSaladSaladSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsContentsfruit,veg,herbs edited HerbbundlemixedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbs edited HerbbundlemixedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFridayFridayDeliverydayFridaFridayDeliverydayFridFridayDeliverydayFriFridayDeliverydayFrFridayDeliverydayFFridayDeliverydayMondayWednesdayFridaySaturdayGiftwrap?noDeliverydayMMondayDeliverydayMoMondayDeliverydayMonMondayDeliverydayMondMondayDeliverydayMondaMondayDeliverydayMondayMondayDeliverydayMonday edited large·fruit,veg,herbs·mixed·yesBasicsBoxname: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├──────────────────────────────────────────────────────────────────────────┤BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsContents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1derivedDerivedfromtheboxname.Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1derivedDerivedfromgrowerandslug.LabelTui1derived╰───╰───────BoxnameTui1Slugtui1derivedGrowersunnyBoxcodesunny/tui1derivedLabelTui1derivedBasicsContents&optionsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeContents↑/↓move·accept·esccancel╰─────────────────────────────────────────────────────────╰────────────────────────────────────────────────────────────MediumLargeBoxsizelargeeditedBoxsizelargeeditedContentsContentsFruitVegetablesHerbsHerbsSalad╰────────────────────────────╰────────────────────────────────ContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsHerbsHerbsContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFriday█Friday↑/↓move·↑/↓move·accept·DeliverydayFrida█DeliverydayFrid█DeliverydayFri█DeliverydayFr█DeliverydayF█DeliverydayMondayWednesdaySaturdayDeliverydayM█DeliverydayMo█DeliverydayMon█DeliverydayMond█DeliverydayMonda█DeliverydayMonday█DeliverydayMondayeditedlarge·fruit,veg,herbs·mixed·yesBasicsBoxname: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?:noContents&opt├───────────────────────────├─────────────────────────────── \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceboxBasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel][Submit][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoqu↑/↓tomove·toselect·ESCtogoback·Qtoquit↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1derivedDerivedfromtheboxname.GrowersunnyBoxcodesunny/tui1Boxcodesunny/tui1derivedDerivedfromgrowerandslug.LabelTui1derivedBoxnameTui1Slugtui1derivedGrowersunnyBoxcodesunny/tui1Boxcodesunny/tui1derivedLabelTui1derivedBasicsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLarge↑/↓tomove·toaccept·ESCtocancel↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelargeeditedBoxsizelargeeditedContentsContentsFruitVegetablesHerbsSaladDeliverydayFridaDeliverydayFridaySPACEtoselect·↑/↓tomove·←/→toselectnonSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·SPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbseditedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFriday█FridayDeliverydayFrida█DeliverydayFrid█DeliverydayFri█DeliverydayFr█DeliverydayF█DeliverydayMondayWednesdaySaturdayDeliverydayM█DeliverydayMo█DeliverydayMon█DeliverydayMond█DeliverydayMonda█DeliverydayMonday█DeliverydayMondayeditedlarge·fruit,veg,herbs·mixed·yesBasicsBoxname: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├──────────────────────────────────────────────────────────────────────────┤BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.medium··Friday·noProduceboxContents&optionsBoxsizemediumContentsContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeLarge├──────────────────────────────────────────────────├─────────────────────────────────────────────────────↑/↓move·accept·esccancelMediumLargeLargeBoxsizelarge edited ╰──Boxsizelarge edited ContentsContentsSpacetotoggle,typetofilter.ContentsFruitContentsFruitVegetablesHerbsSaladContentsFruitContentsFruit├────────├────────────ContentsFruitContentsFruitVegetablesVegetables├─────├─────────VegetablesHerbsHerbsContentsfruit,veg,herbsContentsfruit,veg,herbs edited HerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbs edited Contentsfruit,veg,herbs edited HerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFridayFridayGiftwrap?noDeliverydayFridaFridayDeliverydayFridFridayDeliverydayFriFridayDeliverydayFrFridayDeliverydayFFridayDeliverydayMondayWednesdayFridayDeliverydayMMondayDeliverydayMoMondayDeliverydayMonMondayDeliverydayMondMondayDeliverydayMondaMondayDeliverydayMondayMondayDeliverydayMonday edited large·fruit,veg,herbs·mixed·yeslarge·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╰───╰──────├───────────├───────────────├──├──────FridaySaturday↑/↑/↓move \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ProduceboxBasicsNamingandidentity.Tui1·tui1·sunny·sunny/tui1Contents&optionsWhattheboxshipswith.Whattheboxshipswith.medium··Friday·no[ Submit ][Cancel]↑/↓tomove·toselect·ESCtogoback·Qtoquit╰──────────────────────────────────────────────────────────────────────────╯ProduceboxBasicsBoxnameTui1Ahuman-readablename,e.g."SummerBox".Slugtui1 derived Derivedfromtheboxname.GrowersunnyBoxcodesunny/tui1 derived Derivedfromgrowerandslug.LabelTui1 derived BoxnameTui1Slugtui1 derived GrowersunnyBoxcodesunny/tui1 derived LabelTui1 derived BasicsContents&optionsProduceboxContents&optionsBoxsizemediumContentsSpacetotoggle,typetofilter.DeliverydayFridayGiftwrap?noBoxsizeSmallMediumLargeSpacetotoggle,typetofilter.↑/↓tomove·toaccept·ESCtocancelMediumLargeBoxsizelarge edited Boxsizelarge edited ContentsContentsFruitVegetablesHerbsSaladSaladSPACEtoselect·↑/↓tomove·←/→toselectnoneorall·toacceptContentsFruitContentsFruitVegetablesVegetablesVegetablesHerbsHerbsContentsfruit,veg,herbs edited HerbbundlemixedHerbbundlemixedWeeklydelivery?yesContentsfruit,veg,herbs edited HerbbundlemixedHerbbundlemixedWeeklydelivery?yesDeliverydayFridayDeliverydayFridayFridayDeliverydayFridaFridayDeliverydayFridFridayDeliverydayFriFridayDeliverydayFrFridayDeliverydayFFridayDeliverydayMondayWednesdayFridaySaturdayGiftwrap?noDeliverydayMMondayDeliverydayMoMondayDeliverydayMonMondayDeliverydayMondMondayDeliverydayMondaMondayDeliverydayMondayMondayDeliverydayMonday edited large·fruit,veg,herbs·mixed·yesBasicsBoxname: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 @@ -├──────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────╮QuickstartNeworderOrdernameFruitbananaVegetablescarrotQuantity6↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────╮QuickstartNeworderOrdernameFruitbananaVegetablescarrotQuantity6Organiconly?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 @@ -├──────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────╮QuickstartNeworderOrdernameFruitbananaVegetablescarrotQuantity6↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────╮QuickstartNeworderOrdernameFruitbananaVegetablescarrotQuantity6Organiconly?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---╭──────────────────────────────────────────────────────────────────────────╮ProduceorderNeworderWeeklyBox·banana·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +---finalframe---╭──────────────────────────────────────────────────────────────────────────╮ProduceorderNeworderWeeklyBox·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---╭──────────────────────────────────────────────────────────────────────────╮ProduceorderNeworderWeeklyBox·banana·no[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +---finalframe---╭──────────────────────────────────────────────────────────────────────────╮ProduceorderNeworderWeeklyBox·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 name Weekly 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 previewPreview 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 · ESC to go back · Q to 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 name Weekly 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 previewPreview 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 · ESC to go back · Q to 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓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 @@ -ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.GradepremiumQualitygrade.Extrasherbs,nutsAddedextras.GiftwrapyesWraptheboxasagift.↑/↓move·select·escback·qquit·?help \ No newline at end of file +ThemepreviewPreviewBoxnameWeeklyBoxShownintheheader.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»↑/↓moveselectescbackqquit?help╰──────────────────────────────────────────────────────────────────────────╯Oceanthemedemo/SeasidestallStallname:HarbourStock:fruitStock:fruitCrates:StallnameHarbouracceptesccancelStallnameHarbouStallnameHarboStallnameHarbStallnameHarStallnameHaStallnameHStallnameStallnameSStallnameSeStallnameSeaStallnameSeavStallnameSeaviStallnameSeavieStallnameSeaviewStallname:SeaviewStallname:SeaviewStock:fruitStockFruitStockFruitVegetablesHerbs↑/↓moveacceptesccancel╰─────────────────────────────────────╰───────────────────────────────────────StockFruitVegetablesStock:vegStock:vegCratesApplesPearsPlums╰────────────────────────────────────CratesApples╰─────────────────────────────────CratesApplesPearsPears»Seaview/veg/apples,pearsSeasidestall»3items»Seaview/veg/apples,pearsSeasidestallStallname:Seaview(edited)Stock:veg(edited)Crates:apples,pears(edited)~~~OCEAN~~~1.0.0Pressanykeytocontinue...Stock:fruitStockFruitStock:vegCrates:╰──────────────────────────────Crates:apples,pears« Submit »«Cancel» \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────╮OceanthemedemoSeasidestall»»Harbour/fruit/« Submit »«Cancel»↑/↓tomovetoselectESCtogobackQtoquit╰──────────────────────────────────────────────────────────────────────────╯Oceanthemedemo/SeasidestallStallname:HarbourStock:fruitCrates:Stallname:HarbourtoacceptESCtocancelStallname:HarbouStallname:HarboStallname:HarbStallname:HarStallname:HaStallname:HStallname:Stallname:SStallname:SeStallname:SeaStallname:SeavStallname:SeaviStallname:SeavieStallname:SeaviewStallname:Seaview edited Stallname:Seaview edited Stock:fruitStock:FruitVegetablesHerbs↑/↓tomovetoacceptESCtocancelStock:FruitVegetablesStock:veg edited Stock:veg edited Crates:Crates:ApplesPearsPearsPlumsSPACEtoselect↑/↓tomove←/→toselectnoneorallCrates:ApplesCrates:ApplesPearsPearsPearsPearsCrates:apples,pears edited »Seaview/veg/apples,pearsSeasidestall»SeasidestallStallname:Seaview(edited)Stock:veg(edited)Crates:apples,pears(edited)~~~OCEAN~~~1.0.0Pressanykeytocontinue... \ 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╰──────────────────────────────────────────────────────────────────────────╯CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526CalendarwidgetCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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╰──────────────────────────────────────────────────────────────────────────╯CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526CalendarwidgetCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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╰──────────────────────────────────────────────────────────────────────────╯CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526CalendarwidgetCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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╰──────────────────────────────────────────────────────────────────────────╯CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011122728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526CalendarwidgetCalendar2026-07-15[Submit][Cancel]Harvestdate2026-07-151314[15]1617181920212223242526 \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮CalendarwidgetCalendarHarvestdateJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoConfirmyesOrganiconly?yesOrganiconly?YesNo \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoConfirmyesOrganiconly?yesOrganiconly?YesNo \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmOrganiconly?YesNoy/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmOrganiconly?YesNoy/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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoConfirmyesOrganiconly?yesOrganiconly?YesNo \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmy/nyes/no·toggle·accept·esccancelOrganiconly?YesNoConfirmyesOrganiconly?yesOrganiconly?YesNo \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmOrganiconly?YesNoy/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ConfirmwidgetConfirmOrganiconly?YesNoy/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓move·open·up·select·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓move·open·up·select·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓move·open·up·select·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistsample-projectharvest.csvFilesonly.Extensions:csv.Max2MB.↑/↓move·open·up·select·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistsample-projectbaskets/deliveries/harvest.csvFilesonly.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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.json↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.json↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.json↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.json↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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╰──────────────────────────────────────────────────────────────────────────╯FilepickerwidgetFilepickerPricelistssample-projectdeliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓move·open·up·accept·tabhidden·esccancelbaskets/deliveries/FilepickerwidgetFilepicker[Submit][Cancel]Pricelistsbaskets/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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮FilepickerwidgetFilepickerPricelistssample-projectbaskets/deliveries/box.jsonharvest.csvpantry.yamlREADME.mdSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotewidgetNote[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╰──────────────────────────────────────────────────────────────────────────╯NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotewidgetNote[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotewidgetNote[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╰──────────────────────────────────────────────────────────────────────────╯NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯NotewidgetNote[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NotewidgetNoteFreshproduceorderAread-onlycard-thecursorskipsit.╭───────────────────────╮ReadytopackFramedwithaborder.╰───────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(https://example.com/guide).╰─────────────────────────────────────────────────────╯MarkdownnoteNote[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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯MarkdownnoteNote[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(https://example.com/guide).╰─────────────────────────────────────────────────────╯MarkdownnoteNote[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╰──────────────────────────────────────────────────────────────────────────╯MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯MarkdownnoteNote[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭─────────────────────────────────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MarkdownnoteNote╭──────────────────────────╮FreshproduceorderPickwhatisripetoday:crispapplessweetpearsSeetheseasonalguide.╰──────────────────────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumber↑/↓adjust·accept·esccancelBasketweight(g)4200█Number1200Basketweight(g)1200Basketweight(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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumber↑/↓adjust·accept·esccancelBasketweight(g)4200Number1200Basketweight(g)1200Basketweight(g)1200Basketweight(g)120Basketweight(g)12Basketweight(g)1Basketweight(g)Basketweight(g)4Basketweight(g)42Basketweight(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumberBasketweight(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumberBasketweight(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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumber↑/↓adjust·accept·esccancelBasketweight(g)4200█Number1200Basketweight(g)1200Basketweight(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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumber↑/↓adjust·accept·esccancelBasketweight(g)4200Number1200Basketweight(g)1200Basketweight(g)1200Basketweight(g)120Basketweight(g)12Basketweight(g)1Basketweight(g)Basketweight(g)4Basketweight(g)42Basketweight(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumberBasketweight(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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮NumberwidgetNumberBasketweight(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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••█accept·esccancelOrdercode•••••█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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••accept·esccancelOrdercode•••••Ordercode••••Ordercode•••Ordercode••OrdercodePassword••••••••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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••█accept·esccancelOrdercode•••••█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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••accept·esccancelOrdercode•••••Ordercode••••Ordercode•••Ordercode••OrdercodePassword••••••••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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PasswordwidgetPasswordOrdercode••••••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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮PasswordwidgetPasswordOrdercodemelon7tabreveal·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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮PasswordwidgetPasswordOrdercodemelon7tabreveal·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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyesPauseyes \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyesPauseyes \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyes↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyes↑/↓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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyesPauseyes \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyesPauseyes \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyes↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮PausewidgetPauseReviewyourbasketyes↑/↓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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6Packingthebox[████████████████████████]6/6ProgresswidgetProgress[Submit][Cancel]Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6Packingthebox[████████░░░░░░░░░░░░░░░░]2/6Packingthebox[████████████░░░░░░░░░░░░]3/6Packingthebox[████████████████░░░░░░░░]4/6Packingthebox[████████████████████░░░░]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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6Packingthebox[████████████████████████]6/6ProgresswidgetProgress[Submit][Cancel]Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6Packingthebox[████████░░░░░░░░░░░░░░░░]2/6Packingthebox[████████████░░░░░░░░░░░░]3/6Packingthebox[████████████████░░░░░░░░]4/6Packingthebox[████████████████████░░░░]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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[████████████████████████]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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[████████████████████████]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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6Packingthebox[████████████████████████]6/6ProgresswidgetProgress[Submit][Cancel]Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6Packingthebox[████████░░░░░░░░░░░░░░░░]2/6Packingthebox[████████████░░░░░░░░░░░░]3/6Packingthebox[████████████████░░░░░░░░]4/6Packingthebox[████████████████████░░░░]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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[░░░░░░░░░░░░░░░░░░░░░░░░]0/6Packingthebox[████████████████████████]6/6ProgresswidgetProgress[Submit][Cancel]Packingthebox[████░░░░░░░░░░░░░░░░░░░░]1/6Packingthebox[████████░░░░░░░░░░░░░░░░]2/6Packingthebox[████████████░░░░░░░░░░░░]3/6Packingthebox[████████████████░░░░░░░░]4/6Packingthebox[████████████████████░░░░]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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[████████████████████████]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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ProgresswidgetProgressPackingthebox[████████████████████████]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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●●○4/5↑/↓adjust·accept·esccancelFreshness●●●○○3/5FairRating●●●●○4/5Freshness●●○○○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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●●4/5↑/↓adjust·accept·esccancelFreshness●●●○○3/5FairRating●●●●4/5Freshness●●○○○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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●○○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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●○○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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●●○4/5↑/↓adjust·accept·esccancelFreshness●●●○○3/5FairRating●●●●○4/5Freshness●●○○○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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●●4/5↑/↓adjust·accept·esccancelFreshness●●●○○3/5FairRating●●●●4/5Freshness●●○○○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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●○○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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮RatingwidgetRatingFreshness●●●○○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╰──────────────────────────────────────────────────────────────────────────╯ReorderwidgetReorderCarrotTomato↑/↓move·spacegrab·accept·esccancel↑/↓reorder·spacedrop·esccancelBasketCarrotAppleReorderwidgetReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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╰──────────────────────────────────────────────────────────────────────────╯ReorderwidgetReorderCarrotTomato↑/↓move·spacegrab·accept·esccancel↑/↓reorder·spacedrop·esccancelBasketCarrotAppleReorderwidgetReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ReorderwidgetReorderBasketAppleCarrotTomato↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ReorderwidgetReorderBasketAppleCarrotTomato↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓move·spacegrab·accept·esccancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓move·spacegrab·accept·esccancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓move·spacegrab·accept·esccancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsReorderTomato↑/↓move·spacegrab·accept·esccancelBasketAppleCarrotStayscrispforweekswhenkeptcold.OptiondescriptionsReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleCarrotCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsReorderBasketAppleCarrotTomatoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯ReorderwidgetReorderCarrotTomato↑/↓move·spacegrab·accept·esccancel↑/↓reorder·spacedrop·esccancelBasketCarrotAppleReorderwidgetReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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╰──────────────────────────────────────────────────────────────────────────╯ReorderwidgetReorderCarrotTomato↑/↓move·spacegrab·accept·esccancel↑/↓reorder·spacedrop·esccancelBasketCarrotAppleReorderwidgetReorderapple,carrot,tomato[Submit][Cancel]Basketapple,carrot,tomatoBasketAppleBasket↑↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ReorderwidgetReorderBasketAppleCarrotTomato↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮ReorderwidgetReorderBasketAppleCarrotTomato↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SearchwidgetSearchPotato↑/↓move·accept·esccancelOnionVegetableon█SearchwidgetSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotOnionVegetableo█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╰──────────────────────────────────────────────────────────────────────────╯SearchwidgetSearch↑/↓move·accept·esccancelVegetableonOnionSearchwidgetSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotPotatoOnionVegetableoOnionPotatoCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SearchwidgetSearchVegetableCarrotPotatoOnion↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SearchwidgetSearchVegetableCarrotPotatoOnion↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓move·accept·esccancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓move·accept·esccancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓move·accept·esccancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSearchVegetableOnionPepper↑/↓move·accept·esccancelCarrotPotatoStoresbestsomewherecoolanddark.OptiondescriptionsSearchcarrot[Submit][Cancel]VegetablecarrotCarrotPotatoStayscrispforweekswhenkeptcold. \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSearchVegetableCarrotPotatoOnionPepperStayscrispforweekswhenkeptcold.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SearchwidgetSearchPotato↑/↓move·accept·esccancelOnionVegetableon█SearchwidgetSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotOnionVegetableo█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╰──────────────────────────────────────────────────────────────────────────╯SearchwidgetSearch↑/↓move·accept·esccancelVegetableonOnionSearchwidgetSearchcarrot[Submit][Cancel]VegetablecarrotVegetableCarrotPotatoOnionVegetableoOnionPotatoCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SearchwidgetSearchVegetableCarrotPotatoOnion↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SearchwidgetSearchVegetableCarrotPotatoOnion↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchwidgetMultiSearchCarrot↑/↓move·accept·esccancelTomatoBasketto█TomatoMultiSearchwidgetMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaBaskett█ \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchwidgetMultiSearch↑/↓move·accept·esccancelBaskettoTomatoMultiSearchwidgetMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaCarrotBaskettTomatoCarrotTomato \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSearchwidgetMultiSearchBasketAppleBananaCarrot↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSearchwidgetMultiSearchBasketAppleBananaCarrot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchwidgetMultiSearchCarrot↑/↓move·accept·esccancelTomatoBasketto█TomatoMultiSearchwidgetMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaBaskett█ \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSearchwidgetMultiSearch↑/↓move·accept·esccancelBaskettoTomatoMultiSearchwidgetMultiSearchapple[Submit][Cancel]BasketappleBasketAppleBananaCarrotBaskettTomatoCarrotTomato \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSearchwidgetMultiSearchBasketAppleBananaCarrot↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSearchwidgetMultiSearchBasketAppleBananaCarrot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSearchMultiSearchBasketBananaCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelAppleBananaBoundedMultiSearchMultiSearch[Submit][Cancel]BasketAppleAppleBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSearchMultiSearchBasketAppleBananaCarrotTomatoSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SelectwidgetSelectCherry↑/↓move·accept·esccancelFruitAppleBananaSelectwidgetSelectappleFruitappleFruitAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯SelectwidgetSelectCherry↑/↓move·accept·esccancelFruitAppleBananaSelectwidgetSelectappleFruitappleFruitAppleBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SelectwidgetSelectFruitAppleBananaCherry↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SelectwidgetSelectFruitAppleBananaCherry↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓move·accept·esccancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓move·accept·esccancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓move·accept·esccancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSelectCherry↑/↓move·accept·esccancelFruitAppleBananaRichinpotassium;ripensoffthetree.OptiondescriptionsSelectapple[Submit][Cancel]FruitappleFruitAppleBananaCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSelectFruitAppleBananaCherryCrispandsweet,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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮SelectwithgroupsSelectFruitFruitAppleBanana───────────────────────────────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╰──────────────────────────────────────────────────────────────────────────╯SelectwidgetSelectCherry↑/↓move·accept·esccancelFruitAppleBananaSelectwidgetSelectappleFruitappleFruitAppleBanana \ 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╰──────────────────────────────────────────────────────────────────────────╯SelectwidgetSelectCherry↑/↓move·accept·esccancelFruitAppleBananaSelectwidgetSelectappleFruitappleFruitAppleBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SelectwidgetSelectFruitAppleBananaCherry↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SelectwidgetSelectFruitAppleBananaCherry↑/↓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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectwidgetMultiSelectTomato↑/↓move·accept·esccancelBasketAppleCarrotMultiSelectwidgetMultiSelectappleBasketappleBasketAppleCarrotCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectwidgetMultiSelectTomato↑/↓move·accept·esccancelBasketAppleCarrotMultiSelectwidgetMultiSelectappleBasketappleBasketAppleCarrotCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwidgetMultiSelectBasketAppleCarrotTomato↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwidgetMultiSelectBasketAppleCarrotTomato↑/↓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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────VegetablesCarrotTomatoLeek(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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────VegetablesCarrotTomatoLeek(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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────VegetablesCarrotTomatoLeek(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 @@ -├──────────────────────────────────────────┤╭──────────────────────────────────────────╮MultiSelectwithgroupsMultiSelectBasketFruitAppleBanana──────────────────────────────VegetablesCarrotTomatoLeek(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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectwidgetMultiSelectTomato↑/↓move·accept·esccancelBasketAppleCarrotMultiSelectwidgetMultiSelectappleBasketappleBasketAppleCarrotCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯MultiSelectwidgetMultiSelectTomato↑/↓move·accept·esccancelBasketAppleCarrotMultiSelectwidgetMultiSelectappleBasketappleBasketAppleCarrotCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwidgetMultiSelectBasketAppleCarrotTomato↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮MultiSelectwidgetMultiSelectBasketAppleCarrotTomato↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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╰──────────────────────────────────────────────────────────────────────────╯BoundedMultiSelectMultiSelectCarrotTomatoSelectbetween2and3items.↑/↓move·accept·esccancelBasketAppleCarrotBoundedMultiSelectMultiSelect[Submit][Cancel]BasketBasketAppleBasketAppleCarrot \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮BoundedMultiSelectMultiSelectBasketAppleCarrotTomatoSelectbetween2and3items.↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SuggestwidgetSuggestApricot↑/↓move·accept·esccancelCherryFruitCh█CherrySuggestwidgetSuggest[Submit][Cancel]FruitFruitAppleBananaFruitC█ \ 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╰──────────────────────────────────────────────────────────────────────────╯SuggestwidgetSuggest↑/↓move·accept·esccancelFruitChCherrySuggestwidgetSuggest[Submit][Cancel]FruitFruitAppleApricotBananaFruitCCherryApricotCherry \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SuggestwidgetSuggestFruitAppleApricotBanana↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SuggestwidgetSuggestFruitAppleApricotBanana↑/↓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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓move·accept·esccancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓move·accept·esccancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓move·accept·esccancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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╰──────────────────────────────────────────────────────────────────────────╯OptiondescriptionsSuggestFruitApricotBananaCherryMango↑/↓move·accept·esccancelAppleCrispandsweet,theeverydaychoice.OptiondescriptionsSuggest[Submit][Cancel]FruitApple \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮OptiondescriptionsSuggestFruitAppleApricotBananaCherryMangoCrispandsweet,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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggestAppleApricotBanana↑/↓move·accept·esccancelFruitAp█GhosttextSuggest[Submit][Cancel]FruitFruitFruitA█ \ 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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggest↑/↓move·accept·esccancelFruitAppleAppleApricotGhosttextSuggest[Submit][Cancel]FruitFruitAppleApricotBananaFruitAppleAppleApricotBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAp█AppleApricot↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAppleAppleApricot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggestAppleApricotBanana↑/↓move·accept·esccancelFruitAp█GhosttextSuggest[Submit][Cancel]FruitFruitFruitA█ \ 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╰──────────────────────────────────────────────────────────────────────────╯GhosttextSuggest↑/↓move·accept·esccancelFruitAppleAppleApricotGhosttextSuggest[Submit][Cancel]FruitFruitAppleApricotBananaFruitAppleAppleApricotBanana \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAp█AppleApricot↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮GhosttextSuggestFruitAppleAppleApricot↑/↓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╰──────────────────────────────────────────────────────────────────────────╯SuggestwidgetSuggestApricot↑/↓move·accept·esccancelCherryFruitCh█CherrySuggestwidgetSuggest[Submit][Cancel]FruitFruitAppleBananaFruitC█ \ 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╰──────────────────────────────────────────────────────────────────────────╯SuggestwidgetSuggest↑/↓move·accept·esccancelFruitChCherrySuggestwidgetSuggest[Submit][Cancel]FruitFruitAppleApricotBananaFruitCCherryApricotCherry \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SuggestwidgetSuggestFruitAppleApricotBanana↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮SuggestwidgetSuggestFruitAppleApricotBanana↑/↓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╰──────────────────────────────────────────────────────────────────────────╯TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablewidgetStock[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╰──────────────────────────────────────────────────────────────────────────╯TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablewidgetStock[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablewidgetStock[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╰──────────────────────────────────────────────────────────────────────────╯TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯TablewidgetStock[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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TablewidgetStockBasketcontentsEverythingpickedsofar:╭───────┬────────┬──────────╮FruitColourInstock├───────┼────────┼──────────┤AppleRed12PearGreen5PlumPurple120╰───────┴────────┴──────────╯↑/↓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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplatefillinginOrchard↓/↑next/previous·accept·esccancelfillinginGradeCratelabelridge-pear-b█TemplatewidgetTemplatevalley-pear-aCratelabelvalley-pear-aCratelabelvalley█-pear-aCratelabelvalle█-pear-aCratelabelvall█-pear-aCratelabelval█-pear-aCratelabelva█-pear-aCratelabelv█-pear-aCratelabel█-pear-aCratelabelr█-pear-aCratelabelri█-pear-aCratelabelrid█-pear-aCratelabelridg█-pear-aCratelabelridge█-pear-aCratelabelridge-pear█-afillinginFruitCratelabelridge-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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplatefillinginOrchard↓/↑next/previous·accept·esccancelfillinginGradeCratelabelridge-pear-bTemplatewidgetTemplatevalley-pear-aCratelabelvalley-pear-aCratelabelvalley-pear-aCratelabelvalle-pear-aCratelabelvall-pear-aCratelabelval-pear-aCratelabelva-pear-aCratelabelv-pear-aCratelabel-pear-aCratelabelr-pear-aCratelabelri-pear-aCratelabelrid-pear-aCratelabelridg-pear-aCratelabelridge-pear-aCratelabelridge-pear-afillinginFruitCratelabelridge-pear-aCratelabelridge-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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplateCratelabelvalley█-pear-afillinginOrchard↓/↑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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplateCratelabelvalley-pear-afillinginOrchard↓/↑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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplatefillinginOrchard↓/↑next/previous·accept·esccancelfillinginGradeCratelabelridge-pear-b█TemplatewidgetTemplatevalley-pear-aCratelabelvalley-pear-aCratelabelvalley█-pear-aCratelabelvalle█-pear-aCratelabelvall█-pear-aCratelabelval█-pear-aCratelabelva█-pear-aCratelabelv█-pear-aCratelabel█-pear-aCratelabelr█-pear-aCratelabelri█-pear-aCratelabelrid█-pear-aCratelabelridg█-pear-aCratelabelridge█-pear-aCratelabelridge-pear█-afillinginFruitCratelabelridge-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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplatefillinginOrchard↓/↑next/previous·accept·esccancelfillinginGradeCratelabelridge-pear-bTemplatewidgetTemplatevalley-pear-aCratelabelvalley-pear-aCratelabelvalley-pear-aCratelabelvalle-pear-aCratelabelvall-pear-aCratelabelval-pear-aCratelabelva-pear-aCratelabelv-pear-aCratelabel-pear-aCratelabelr-pear-aCratelabelri-pear-aCratelabelrid-pear-aCratelabelridg-pear-aCratelabelridge-pear-aCratelabelridge-pear-afillinginFruitCratelabelridge-pear-aCratelabelridge-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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplateCratelabelvalley█-pear-afillinginOrchard↓/↑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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TemplatewidgetTemplateCratelabelvalley-pear-afillinginOrchard↓/↑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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextaccept·esccancelItemApple█TextPearItemPearItemPear█ItemPea█ItemPe█ItemP█ItemItemA█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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextaccept·esccancelItemAppleTextPearItemPearItemPearItemPearItemPearItemPearItemItemAItemApItemAppItemAppl \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextItemPear█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextItemPearaccept·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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextaccept·esccancelItemApple█TextPearItemPearItemPear█ItemPea█ItemPe█ItemP█ItemItemA█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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextaccept·esccancelItemAppleTextPearItemPearItemPearItemPearItemPearItemPearItemItemAItemApItemAppItemAppl \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextItemPear█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextwidgetTextItemPearaccept·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╰──────────────────────────────────────────────────────────────────────────╯TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrusnewline·tabaccept·esccancelSlightlytart█TextareawidgetTextareaCrispandsweetHintofcitrusHintofcitrus█S█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█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╰──────────────────────────────────────────────────────────────────────────╯TextareawidgetTextareaTastingnotesCrispandsweetnewline·tabaccept·esccancelHintofcitrusSlightlytartTextareawidgetTextareaCrispandsweetHintofcitrusTastingnotesCrispandsweetHintofcitrusHintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlytSlightlytaSlightlytar \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrus█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrusnewline·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╰──────────────────────────────────────────────────────────────────────────╯TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrusnewline·tabaccept·esccancelSlightlytart█TextareawidgetTextareaCrispandsweetHintofcitrusHintofcitrus█S█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█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╰──────────────────────────────────────────────────────────────────────────╯TextareawidgetTextareaTastingnotesCrispandsweetnewline·tabaccept·esccancelHintofcitrusSlightlytartTextareawidgetTextareaCrispandsweetHintofcitrusTastingnotesCrispandsweetHintofcitrusHintofcitrusSSlSliSligSlighSlightSlightlSlightlySlightlySlightlytSlightlytaSlightlytar \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrus█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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TextareawidgetTextareaTastingnotesCrispandsweetHintofcitrusnewline·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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggletoggle·accept·esccancelRipenessRipeUnripeToggleripeRipenessripeRipenessRipeUnripe \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggletoggle·accept·esccancelRipenessRipeUnripeToggleripeRipenessripeRipenessRipeUnripe \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggleRipenessRipeUnripetoggle·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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggleRipenessRipeUnripetoggle·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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggletoggle·accept·esccancelRipenessRipeUnripeToggleripeRipenessripeRipenessRipeUnripe \ 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╰──────────────────────────────────────────────────────────────────────────╯╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggletoggle·accept·esccancelRipenessRipeUnripeToggleripeRipenessripeRipenessRipeUnripe \ 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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggleRipenessRipeUnripetoggle·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 @@ -├──────────────────────────────────────────────────────────────────────────┤╭──────────────────────────────────────────────────────────────────────────╮TogglewidgetToggleRipenessRipeUnripetoggle·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├──────────────────────────────────────────────────────────────────────────┤WidgetsPear·valley-pear-a·1200·2026-07-15[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯WidgetsWidgetsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••TextPear█accept·esccancelTextPea█TextPe█TextP█TextTextA█TextAp█TextApp█TextAppl█TextApple█TextAppleeditedTextAppleeditedTemplatevalley-pear-aTemplatevalley█-pear-afillinginorchard├────↓/↑next/previous·accept·esccancelTemplatevalle█-pear-aTemplatevall█-pear-aTemplateval█-pear-aTemplateva█-pear-aTemplatev█-pear-aTemplate█-pear-aTemplater█-pear-aTemplateri█-pear-aTemplaterid█-pear-aTemplateridg█-pear-aTemplateridge█-pear-aTemplateridge-pear█-afillinginfruitTemplateridge-pear-a█fillingingradeTemplateridge-pear-█Templateridge-pear-b█Templateridge-pear-beditedTemplateridge-pear-beditedNumber1200Number1200█Number120█Number12█Number1█NumberNumber4█Number42█Number420█Number4200█Number4200editedNumber4200editedCalendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]161718192728293031←/→day·↑/↓week·accept·esccancel13141516171819Calendar2026-07-22editedCalendar2026-07-22editedTextareaCrispandsweetHintofcitrus█newline·tabaccept·esccancelS█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█Slightlyta█Slightlytar█Slightlytart█TextareaCrispandsweeteditedSlightlytartTextareaCrispandsweeteditedPassword••••••••Password••••••█Password•••••••█Password••••••••█Password•••••••••█Calendar2026-07-22Password••••••••••█accept·esccancelPassword•••••••••••█Password••••••••••••█Password••••••••editedPassword••••••••editedSelectappleSelectApple↑/↓move·accept·esccancelSelectAppleSelectbananaeditedSelectbananaeditedMultiSelectappleMultiSelectAppleMultiSelectAppleMultiSelectapple,carroteditedMultiSelectapple,carroteditedReorderapple,carrot,tomatoReorderApple↑/↓move·spacegrab·accept·esccancelReorder↑↓Apple↑/↓reorder·spacedrop·esccancelReorderCarrotReordercarrot,apple,tomatoeditedHintofcitrusReordercarrot,apple,tomatoeditedSuggestSuggestSuggestC█SuggestCh█SuggestCherryeditedSuggestCherryeditedSearchcarrotSearchSearcho█Searchon█SearchonioneditedSearchonioneditedMultiSearchappleMultiSearchMultiSearcht█MultiSearchto█MultiSearchapple,tomatoeditedMultiSearchapple,tomatoeditedConfirmyesConfirmYesNoy/nyes/no·toggle·accept·esccancelConfirmYesNoConfirmnoeditedConfirmnoeditedToggleripeToggleRipeUnripetoggle·accept·esccancelToggleRipeUnripeToggleunripeeditedToggleunripeeditedPauseyesPausePresstocontinuecontinue·esccancel1314[15]1617181920212223242526131415161718192021[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├──────────────────────────────────────────────────────────────────────────┤WidgetsPear·valley-pear-a·1200·●●●●4/5↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯WidgetsWidgetsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrus╰──────────────────────────────────────────────────────╰────────────────────────────────────────────────────────TextPearaccept·esccancel╰──────────────────────────────────────────────────────────TextPeaTextPeTextPTextTextATextApTextAppTextApplTextAppleTextApple edited ╰───────────────────────────────────────────────────╰─────────────────────────────────────────────────────TextApple edited Templatevalley-pear-aTemplatevalley-pear-afillinginorchard↓/↑next/previous·accept·esccancel╰─────────────────────────────────────────────╰────────────────────────────────────────────────Templatevalle-pear-aTemplatevall-pear-aTemplateval-pear-aTemplateva-pear-aTemplatev-pear-aTemplate-pear-aTemplater-pear-aTemplateri-pear-aTemplaterid-pear-aTemplateridg-pear-aTemplateridge-pear-aTemplateridge-pear-afillinginfruitTemplateridge-pear-afillingingradeTemplateridge-pear-Templateridge-pear-bTemplateridge-pear-b edited ╰──────────────────────────────────────────────────Templateridge-pear-b edited Number1200╰─────────────────────────────────────────────────Number1200Number120Number12Number1NumberNumber4Number42Number420Number4200Number4200 edited Number4200 edited Rating●●●●4/5╰──────────────────────────────────────────────↑/↓adjust·accept·esccancelRating●●●○○3/5FairRating●●●○○3/5Fair edited ╰───────────────────────────────────────────Rating●●●○○3/5Fair edited Calendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526Calendar2026-07-22 edited ╰────────────────────────────────────────╰──────────────────────────────────────────Calendar2026-07-22 edited TextareaCrispandsweetHintofcitrusTextareaCrispandsweetHintofcitrusnewline·tabaccept·esccancelHintofcitrus╰────────────────────────────────────────────────────TextareaCrispandsweet edited TextareaCrispandsweet edited SlightlytartPassword••••••••Password••••••╰─Password•••••••Password••••••••Password•••••••••Password••••••••••Password•••••••••••Password••••••••••••Password•••••••• edited ↑/↓move·select·escback·qquit·?helpPassword•••••••• edited SelectappleSelectApple↑/↓move·accept·esccancel↑/↓move·accept·esccancelSelectApple╰──Selectbanana edited Password•••••••• edited Selectbanana edited MultiSelectappleMultiSelectAppleMultiSelectAppleMultiSelectapple,carrot edited Selectbanana edited MultiSelectapple,carrot edited Reorderapple,carrot,tomatoReorderApple↑/↓move·spacegrab·accept·esccancel↑/↓move·spacegrab·accept·esccancelReorder↑↓Apple↑/↓reorder·spacedrop·esccancelReorderCarrot╰───Reordercarrot,apple,tomato edited Reordercarrot,apple,tomato edited Suggest╰─────SuggestSuggestCSuggestChSuggestCherry edited HintofcitrusSuggestCherry edited SearchcarrotSearchSearchoSearchonSearchonion edited Searchonion edited MultiSearchapple╰────────MultiSearchMultiSearchtMultiSearchtoMultiSearchapple,tomato edited MultiSearchapple,tomato edited ConfirmyesConfirmYesNoy/nyes/no·toggle·accept·esccancelConfirmYesNoConfirmno edited Confirmno edited ToggleripeToggleRipeUnripetoggle·accept·esccancel╰────╰───────ToggleRipeUnripeToggleunripe edited Toggleunripe edited PauseyesPausePresstocontinuecontinue·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├──────────────────────────────────────────────────────────────────────────┤WidgetsPear·valley-pear-a·1200·2026-07-15[Submit][Cancel]↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯WidgetsWidgetsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Calendar2026-07-15TextareaCrispandsweetHintofcitrusPassword••••••••TextPear█accept·esccancelTextPea█TextPe█TextP█TextTextA█TextAp█TextApp█TextAppl█TextApple█TextAppleeditedTextAppleeditedTemplatevalley-pear-aTemplatevalley█-pear-afillinginorchard├────↓/↑next/previous·accept·esccancelTemplatevalle█-pear-aTemplatevall█-pear-aTemplateval█-pear-aTemplateva█-pear-aTemplatev█-pear-aTemplate█-pear-aTemplater█-pear-aTemplateri█-pear-aTemplaterid█-pear-aTemplateridg█-pear-aTemplateridge█-pear-aTemplateridge-pear█-afillinginfruitTemplateridge-pear-a█fillingingradeTemplateridge-pear-█Templateridge-pear-b█Templateridge-pear-beditedTemplateridge-pear-beditedNumber1200Number1200█Number120█Number12█Number1█NumberNumber4█Number42█Number420█Number4200█Number4200editedNumber4200editedCalendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]161718192728293031←/→day·↑/↓week·accept·esccancel13141516171819Calendar2026-07-22editedCalendar2026-07-22editedTextareaCrispandsweetHintofcitrus█newline·tabaccept·esccancelS█Sl█Sli█Slig█Sligh█Slight█Slightl█Slightly█SlightlySlightlyt█Slightlyta█Slightlytar█Slightlytart█TextareaCrispandsweeteditedSlightlytartTextareaCrispandsweeteditedPassword••••••••Password••••••█Password•••••••█Password••••••••█Password•••••••••█Calendar2026-07-22Password••••••••••█accept·esccancelPassword•••••••••••█Password••••••••••••█Password••••••••editedPassword••••••••editedSelectappleSelectApple↑/↓move·accept·esccancelSelectAppleSelectbananaeditedSelectbananaeditedMultiSelectappleMultiSelectAppleMultiSelectAppleMultiSelectapple,carroteditedMultiSelectapple,carroteditedReorderapple,carrot,tomatoReorderApple↑/↓move·spacegrab·accept·esccancelReorder↑↓Apple↑/↓reorder·spacedrop·esccancelReorderCarrotReordercarrot,apple,tomatoeditedHintofcitrusReordercarrot,apple,tomatoeditedSuggestSuggestSuggestC█SuggestCh█SuggestCherryeditedSuggestCherryeditedSearchcarrotSearchSearcho█Searchon█SearchonioneditedSearchonioneditedMultiSearchappleMultiSearchMultiSearcht█MultiSearchto█MultiSearchapple,tomatoeditedMultiSearchapple,tomatoeditedConfirmyesConfirmYesNoy/nyes/no·toggle·accept·esccancelConfirmYesNoConfirmnoeditedConfirmnoeditedToggleripeToggleRipeUnripetoggle·accept·esccancelToggleRipeUnripeToggleunripeeditedToggleunripeeditedPauseyesPausePresstocontinuecontinue·esccancel1314[15]1617181920212223242526131415161718192021[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├──────────────────────────────────────────────────────────────────────────┤WidgetsPear·valley-pear-a·1200·●●●●4/5↑/↓move·select·escback·qquit·?help╰──────────────────────────────────────────────────────────────────────────╯WidgetsWidgetsNoteAread-onlycard-thecursorskipsitanditcollectsnothing.TextPearTemplatevalley-pear-aNumber1200Rating●●●●4/5Calendar2026-07-15TextareaCrispandsweetHintofcitrus╰──────────────────────────────────────────────────────╰────────────────────────────────────────────────────────TextPearaccept·esccancel╰──────────────────────────────────────────────────────────TextPeaTextPeTextPTextTextATextApTextAppTextApplTextAppleTextApple edited ╰───────────────────────────────────────────────────╰─────────────────────────────────────────────────────TextApple edited Templatevalley-pear-aTemplatevalley-pear-afillinginorchard↓/↑next/previous·accept·esccancel╰─────────────────────────────────────────────╰────────────────────────────────────────────────Templatevalle-pear-aTemplatevall-pear-aTemplateval-pear-aTemplateva-pear-aTemplatev-pear-aTemplate-pear-aTemplater-pear-aTemplateri-pear-aTemplaterid-pear-aTemplateridg-pear-aTemplateridge-pear-aTemplateridge-pear-afillinginfruitTemplateridge-pear-afillingingradeTemplateridge-pear-Templateridge-pear-bTemplateridge-pear-b edited ╰──────────────────────────────────────────────────Templateridge-pear-b edited Number1200╰─────────────────────────────────────────────────Number1200Number120Number12Number1NumberNumber4Number42Number420Number4200Number4200 edited Number4200 edited Rating●●●●4/5╰──────────────────────────────────────────────↑/↓adjust·accept·esccancelRating●●●○○3/5FairRating●●●○○3/5Fair edited ╰───────────────────────────────────────────Rating●●●○○3/5Fair edited Calendar2026-07-15CalendarJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031←/→day·↑/↓week·accept·esccancel131415161718192021[22]23242526Calendar2026-07-22 edited ╰────────────────────────────────────────╰──────────────────────────────────────────Calendar2026-07-22 edited TextareaCrispandsweetHintofcitrusTextareaCrispandsweetHintofcitrusnewline·tabaccept·esccancelHintofcitrus╰────────────────────────────────────────────────────TextareaCrispandsweet edited TextareaCrispandsweet edited SlightlytartPassword••••••••Password••••••╰─Password•••••••Password••••••••Password•••••••••Password••••••••••Password•••••••••••Password••••••••••••Password•••••••• edited ↑/↓move·select·escback·qquit·?helpPassword•••••••• edited SelectappleSelectApple↑/↓move·accept·esccancel↑/↓move·accept·esccancelSelectApple╰──Selectbanana edited Password•••••••• edited Selectbanana edited MultiSelectappleMultiSelectAppleMultiSelectAppleMultiSelectapple,carrot edited Selectbanana edited MultiSelectapple,carrot edited Reorderapple,carrot,tomatoReorderApple↑/↓move·spacegrab·accept·esccancel↑/↓move·spacegrab·accept·esccancelReorder↑↓Apple↑/↓reorder·spacedrop·esccancelReorderCarrot╰───Reordercarrot,apple,tomato edited Reordercarrot,apple,tomato edited Suggest╰─────SuggestSuggestCSuggestChSuggestCherry edited HintofcitrusSuggestCherry edited SearchcarrotSearchSearchoSearchonSearchonion edited Searchonion edited MultiSearchapple╰────────MultiSearchMultiSearchtMultiSearchtoMultiSearchapple,tomato edited MultiSearchapple,tomato edited ConfirmyesConfirmYesNoy/nyes/no·toggle·accept·esccancelConfirmYesNoConfirmno edited Confirmno edited ToggleripeToggleRipeUnripetoggle·accept·esccancel╰────╰───────ToggleRipeUnripeToggleunripe edited Toggleunripe edited PauseyesPausePresstocontinuecontinue·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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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']); +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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. +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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): + + + + + + + + + + + + + + + + + +
ANSINo ANSI
Unicode
ASCII
+ +A multiple `select` where the cursor and Space skip the separator and the disabled option, which can never be checked: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo ANSI
Unicode
ASCII
+ +## Option descriptions + +The highlighted item's [description](/fields/select#option-descriptions), in every display mode: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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']); +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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', + ]); +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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. +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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', + ]); +``` + +

+ +

+ + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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. + + + + + + + + + + + + + + + + + +
ANSINo ANSI
Unicode
ASCII
+ +## Option descriptions + +The highlighted suggestion's [description](/fields/select#option-descriptions), in every display mode: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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: + + + + + + + + + + + + + + + + + +
ANSINo 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 + +
+ +| | Activate | Arrange | Bind | Capture | Collect | Constrain | Depend | Descend | Flow | Focus | Nest | Occupy | Overlay | Reject | Reuse | Scroll | Show | +| ---------- | -------- | ------- | ---- | ------- | ------- | --------- | ------ | ------- | ---- | ----- | ---- | ------ | ------- | ------ | ----- | ------ | ---- | +| Screen | | | | | | | | | | | ✓ | ✓ | | | | | | +| Layout | | ✓ | | | | | | | ✓ | | ✓ | | | | ✓ | | | +| Region | | | | | | | | | ✓ | | ✓ | | | | | ✓ | | +| Panel | | | ✓ | | | | | ✓ | | ✓ | ✓ | | ✓ | | | | ✓ | +| Field | | | ✓ | ✓ | ✓ | ✓ | ✓ | | | ✓ | | | | ✓ | | | ✓ | +| Markup | | | | | | | ✓ | | | | | | | | | | ✓ | +| Breadcrumb | | | | | | | | | | | | | | | | | ✓ | +| Legend | | | | | | | | | | | | | | | | | ✓ | +| Actions | ✓ | | | | | | | | | ✓ | | | | ✓ | | | ✓ | +| Progress | ✓ | | | | | | ✓ | | | ✓ | | | | | | | ✓ | + +
+ +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:

Custom ocean theme with a 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: - -
- - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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']); -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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. -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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): - - - - - - - - - - - - - - - - - -
ANSINo ANSI
Unicode
ASCII
- -A multiple `select` where the cursor and Space skip the separator and the disabled option, which can never be checked: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo ANSI
Unicode
ASCII
- -## Option descriptions - -The highlighted item's [description](/widgets/select#option-descriptions), in every display mode: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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']); -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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', - ]); -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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. -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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', - ]); -``` - -

- -

- - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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. - - - - - - - - - - - - - - - - - -
ANSINo ANSI
Unicode
ASCII
- -## Option descriptions - -The highlighted suggestion's [description](/widgets/select#option-descriptions), in every display mode: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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: - - - - - - - - - - - - - - - - - -
ANSINo 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,array,array} + * The fields in declaration order, the settled values, where each value + * came from, and which fields are there. + */ + protected function settle(Panel $panel, array $supplied, Context $context): array { + $fields = Tree::fields($panel); + + [$values, $sources] = $this->resolveAll($fields, $supplied, $context); + $values = $this->transformSupplied($fields, $values, $sources); + [$rules, $pinned] = $this->deriveRules($fields, $sources); + [$active, $values] = $this->stabilize($this->shows($panel, $fields), $fields, $values, $rules, $pinned, $context, $this->suppliedFields($sources)); + + return [$fields, $values, $sources, $active]; + } + + /** + * Whether each row the tree holds only shows something, keyed by id. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel being collected. + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * + * @return array + * TRUE for a row that carries no value, FALSE for one that does. + */ + protected function shows(Panel $panel, array $fields): array { + $shows = array_fill_keys(Tree::ids($panel), TRUE); + + foreach ($fields as $field) { + if (!$field->type()->isDisplayOnly()) { + $shows[$field->id()] = FALSE; + } + } + + return $shows; + } + + /** + * Resolve each field's initial value and where it came from. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @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{array,array} + * The values and their sources, each keyed by field id. + */ + protected function resolveAll(array $fields, array $supplied, Context $context): array { + $values = []; + $sources = []; + + foreach ($fields as $field) { + // A field that only shows carries no answer, so it never enters the + // values: it is neither resolved nor allowed to colour what a later + // field is resolved against. + if ($field->type()->isDisplayOnly()) { + continue; + } + + $resolved = new Context($context->directory, $values, $context->update, $context->version); + [$value, $source] = $this->resolveInitial($field, $supplied, $resolved); + $sources[$field->id()] = $source; + $values[$field->id()] = $value; + } + + return [$values, $sources]; + } + + /** + * Resolve one field's initial value and where it came from. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @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{mixed,\DrevOps\Tui\Screen\Source} + * The value and its source. + */ + protected function resolveInitial(Field $field, array $supplied, Context $context): array { + if (array_key_exists($field->id(), $supplied)) { + return [$supplied[$field->id()], Source::Input]; + } + + if ($context->update) { + $detected = $this->discoverValue($field, $context); + + if ($detected !== NULL && $this->acceptsDetected($field, $detected)) { + return [$detected, Source::Detected]; + } + } + + $default = $field->value(); + + return [$default instanceof \Closure ? $default($context) : $default, Source::Default]; + } + + /** + * Detect a value that already exists outside the form. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @param \DrevOps\Tui\Handler\Context $context + * The run this collection belongs to. + * + * @return mixed + * The detected value, or NULL when nothing detects one. + */ + protected function discoverValue(Field $field, Context $context): mixed { + $discover = $field->discovery(); + + if ($discover instanceof DiscoverInterface) { + return $discover->discover($context->directory); + } + + if ($discover instanceof \Closure) { + return $discover($context); + } + + return NULL; + } + + /** + * Whether a detected value is safe to adopt. + * + * Detected values come from arbitrary files rather than from the declaration, + * so one the field would refuse falls back to the default instead of + * poisoning the answers. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @param mixed $value + * The detected value. + * + * @return bool + * TRUE when the field would take it. + */ + protected function acceptsDetected(Field $field, mixed $value): bool { + // The field's own validator is deliberately not asked: it speaks for what + // somebody offers, and nobody offered this. + return $field->acceptsValue($value) + && $field->requiredViolation($value) === NULL + && $field->boundsViolation($value) === NULL + && $field->pickerViolation($value) === NULL + && $field->templateError($value) === NULL + && $field->entryError($value) === NULL; + } + + /** + * Normalize the supplied values, so every later stage sees settled ones. + * + * Normalization happens before the set settles: conditions, computed values + * and fix-ups must read the normalized value (a trimmed string, say) rather + * than the raw one. Only supplied values are normalized - a default and a + * computed value are the form's own, and a detected one was measured when it + * was detected. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The resolved values keyed by field id. + * @param array $sources + * Where each value came from, keyed by field id. + * + * @return array + * The values, with the supplied ones normalized. + */ + protected function transformSupplied(array $fields, array $values, array $sources): array { + foreach ($fields as $field) { + if (($sources[$field->id()] ?? NULL) !== Source::Input) { + continue; + } + + $transform = $field->transformer() ?? $this->handlers->transformer($field->id()); + $values[$field->id()] = $transform instanceof \Closure ? $transform($values[$field->id()]) : $values[$field->id()]; + } + + return $values; + } + + /** + * The rules computing values, and the fields whose value is pinned. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $sources + * Where each value came from, keyed by field id. + * + * @return array{array,array} + * The 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 rules computing a value, keyed by the field each computes. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * + * @return array + * The rules. + */ + protected function ruleMap(array $fields): array { + $rules = []; + + foreach ($fields as $field) { + // A field that only shows is absent from the values, so a rule on it + // would compute against something that is not there. + if ($field->type()->isDisplayOnly()) { + continue; + } + + $derive = $field->derivation(); + + if ($derive instanceof Derive) { + $rules[$field->id()] = $derive; + } + } + + return $rules; + } + + /** + * The fields whose value was supplied, keyed by field id. + * + * @param array $sources + * Where each value came from, keyed by field id. + * + * @return array + * TRUE for each field somebody answered. + */ + protected function suppliedFields(array $sources): array { + return array_map(static fn(Source $source): bool => $source === Source::Input, $sources); + } + + /** + * Settle computed values, who is there at all, and the fix-ups. + * + * @param array $shows + * Whether each row the tree holds only shows something, keyed by id. + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The resolved values keyed by field id. + * @param array $rules + * The rules computing a value, keyed by field id. + * @param array $pinned + * The fields whose value must not be recomputed, keyed by field id. + * @param \DrevOps\Tui\Handler\Context $context + * The run this collection belongs to. + * @param array $supplied + * The fields whose value was supplied, keyed by field id. + * + * @return array{array,array} + * Which fields are there, and the settled values. + */ + protected function stabilize(array $shows, array $fields, array $values, array $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 set that never + // settles: field-count passes cover the longest chain, plus two for the + // interplay between who is there and what the fix-ups then write. + $limit = count($fields) + 2; + + for ($pass = 0; $pass <= $limit; $pass++) { + // Rows 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->resolveEntries($fields, $values, $active, $context, $supplied); + + $derived = $this->deriver->derive($rules, $values, $pinned); + + $next_active = []; + $answers = $this->activeAnswers($fields, $derived, $active); + + foreach ($fields as $field) { + $next_active[$field->id()] = $field->isActive($answers); + } + + $next_values = $this->applyFixups($shows, $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 + } + + /** + * Resolve each field's rows once, replacing the loader that owed them. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + */ + protected function loadEntries(array $fields): void { + foreach ($fields as $field) { + $loader = $field->loader(); + + if ($loader instanceof \Closure) { + $field->settle($loader()); + } + } + } + + /** + * Resolve every answer-driven row set, and restate the values against it. + * + * A resolver reads the answers, so it is asked 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 a row that is not on offer. A value somebody + * supplied is left alone for the refusal to report, rather than disappearing + * without a word. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The current values keyed by field id. + * @param array $active + * Which fields are there, keyed by field id. + * @param \DrevOps\Tui\Handler\Context $context + * The run this collection belongs to. + * @param array $supplied + * The fields whose value was supplied, keyed by field id. + * + * @return array + * The values, restated against the resolved rows. + * + * @throws \DrevOps\Tui\CollectException + * When a resolver cannot answer. + */ + protected function resolveEntries(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 one that detects what is already there - is not answered + // from the memo of the first. + $run = [$context->directory, $context->update, $context->version]; + + foreach ($fields as $field) { + $resolver = $field->resolver(); + + if (!$resolver instanceof \Closure) { + continue; + } + + $memo = $this->memo[$field->id()] ?? NULL; + + if ($memo !== NULL && $memo['answers'] === $answers && $memo['run'] === $run && $memo['rows'] === $field->entries()) { + continue; + } + + try { + $field->settle($resolver($resolved)); + } + catch (\Throwable $throwable) { + throw $this->entriesError($field, $throwable); + } + + $this->memo[$field->id()] = ['answers' => $answers, 'run' => $run, 'rows' => $field->entries()]; + + if ($supplied[$field->id()] ?? FALSE) { + continue; + } + + $values[$field->id()] = $field->reconcileValue($values[$field->id()] ?? NULL); + } + + return $values; + } + + /** + * Look each supplied value up against the query that would find it. + * + * A query source describes a candidate set too large or too remote to hold, + * so there is nothing to measure a value against until something is queried - + * and with no screen nothing is typed. The supplied value is therefore the + * query, and the field is measured against what that query answers, so a + * value no query can produce is caught rather than passed through unchecked. + * + * Only rows that close a set are worth a call: a suggest field's candidates + * are hints rather than a closed set, so its value is measured against them + * in neither path. A field with no value is left alone, and the field's + * minimum query length does not apply, because it throttles typing rather + * than a single lookup. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The settled values keyed by field id. + * @param array $active + * Which fields are there, keyed by field id. + * + * @throws \DrevOps\Tui\CollectException + * When a source cannot answer. + */ + protected function loadQueryEntries(array $fields, array $values, array $active): void { + foreach ($fields as $field) { + $source = $field->source(); + + if (!$source 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($source($query, $values)); + } + catch (\Throwable $throwable) { + // On screen a source that cannot answer degrades to a message in the + // field, but with no screen there is nobody to retype the query, so + // the collection fails instead. + throw $this->entriesError($field, $throwable); + } + + foreach ($resolved as $row) { + $rows[$row->value] = $row->label; + } + } + + $field->settle($rows); + } + } + + /** + * The queries that look a field's supplied value up, one per item. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @param mixed $value + * The settled value. + * + * @return list + * The non-empty queries; empty when there is nothing to look up. + */ + protected function queriesFor(Field $field, mixed $value): array { + $items = $field->collectsList() ? $value : [is_scalar($value) ? (string) $value : '']; + $queries = []; + + foreach (is_array($items) ? $items : [] as $item) { + if (is_string($item) && $item !== '') { + $queries[] = $item; + } + } + + return array_values(array_unique($queries)); + } + + /** + * The error for consumer row code that could not answer. + * + * @param \DrevOps\Tui\Block\Field $field + * The field whose rows were being resolved. + * @param \Throwable $throwable + * What the consumer code threw. + * + * @return \DrevOps\Tui\CollectException + * The error naming the field. + */ + protected function entriesError(Field $field, \Throwable $throwable): CollectException { + // 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 report instead of making it. + return new CollectException(Translator::t('Could not load options for field "@id": @error', [ + '@id' => $field->id(), + '@error' => $throwable->getMessage(), + ]), (int) $throwable->getCode(), $throwable); + } + + /** + * Apply the rules that write a value once the answers have settled. + * + * @param array $shows + * Whether each row the tree holds only shows something, keyed by id. + * @param array $values + * The current values keyed by field id. + * @param array $answers + * The answers the guards are measured against. + * + * @return array + * The values after the rules. + */ + protected function applyFixups(array $shows, array $values, array $answers): array { + foreach ($this->fixups as $fixup) { + if ($fixup->when instanceof ConditionInterface && !$fixup->when->matches($answers)) { + continue; + } + + // A row that only shows carries no value, so a rule 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 ($shows[$fixup->set] ?? FALSE) { + continue; + } + + if ($shows[$fixup->from] ?? FALSE) { + continue; + } + + $values[$fixup->set] = $fixup->from !== '' ? ($values[$fixup->from] ?? NULL) : $fixup->to; + } + + return $values; + } + + /** + * The first supplied value the form refuses, and why. + * + * Only supplied values are measured: a default and a computed value are the + * form's own, and a detected one was measured when it was detected. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The settled values keyed by field id. + * @param array $sources + * Where each value came from, keyed by field id. + * @param array $active + * Which fields are there, keyed by field id. + * + * @return array{string,string}|null + * The field's id and the reason, or NULL when nothing is refused. + */ + protected function refusal(array $fields, array $values, array $sources, array $active): ?array { + foreach ($fields as $field) { + if (!($active[$field->id()] ?? FALSE)) { + continue; + } + + if (($sources[$field->id()] ?? NULL) !== Source::Input) { + continue; + } + + $reason = $this->rejects($field, $values[$field->id()]); + + if ($reason !== NULL) { + return [$field->id(), $reason]; + } + } + + return NULL; + } + + /** + * The reason a field refuses a value, or NULL when it takes it. + * + * Emptiness is answered first, so a required field says so rather than + * letting NULL read as a wrong shape; the shape follows, so what is measured + * afterwards is a value of the kind the field collects. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @param mixed $value + * The value. + * + * @return string|null + * The reason, or NULL when nothing refuses it. + */ + protected function rejects(Field $field, mixed $value): ?string { + $missing = $field->requiredViolation($value); + + if ($missing !== NULL) { + return $missing; + } + + if (!$field->acceptsValue($value)) { + return Translator::t('must be @constraint.', ['@constraint' => $field->valueKind()]); + } + + return $field->refuses($value, $this->handlers->validator($field->id())); + } + + /** + * The values of the fields that are there, in declaration order. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $values + * The current values keyed by field id. + * @param array $active + * Which fields are there, keyed by field id. + * + * @return array + * The answers. + */ + 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; + } + + /** + * How each answer came to be, keyed by field id. + * + * @param list<\DrevOps\Tui\Block\Field> $fields + * The fields, in declaration order. + * @param array $sources + * Where each value came from, keyed by field id. + * @param array $active + * Which fields are there, keyed by field id. + * + * @return array + * The provenance of each answer. + */ + protected function provenance(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()]; + $derived = $field->derivation() instanceof Derive; + + $provenance[$field->id()] = match (TRUE) { + $source === Source::Detected => Provenance::Detected, + $derived && $source === Source::Input => Provenance::Override, + $derived => Provenance::Derived, + $source === Source::Input => Provenance::Edited, + default => Provenance::Default, + }; + } + + return $provenance; + } + +} diff --git a/src/Screen/Ending.php b/src/Screen/Ending.php new file mode 100644 index 00000000..a5d12f48 --- /dev/null +++ b/src/Screen/Ending.php @@ -0,0 +1,23 @@ + + */ + protected array $trail = []; + + /** + * Construct a key router. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel it moves around, which is the panel you are in. + */ + public function __construct( + protected Panel $panel, + ) { + // One declaration outlives the session driving it, so a session opens on a + // form nobody has walked into rather than wherever the last one stopped. + foreach (Tree::panels($this->panel) as $panel) { + $panel->leave(); + } + + $this->panel->enter(); + $this->settle(); + } + + /** + * Make every block in the panel answer to one set of bindings. + * + * @param \DrevOps\Tui\Input\KeyMap $keys + * The resolved bindings for the whole form. + * + * @return $this + * The router. + */ + public function bind(KeyMap $keys): self { + $this->rebind($keys, $this->panel); + + return $this; + } + + /** + * The panel you are in. + * + * @return \DrevOps\Tui\Block\Panel + * The panel, which is whichever one was gone into last. + */ + public function current(): Panel { + return $this->panel; + } + + /** + * The titles of the panels entered to get here, outermost first. + * + * @return list + * The trail. + */ + public function trail(): array { + $titles = array_map(static fn(array $step): string => $step['panel']->title(), $this->trail); + $titles[] = $this->panel->title(); + + return $titles; + } + + /** + * The block the cursor is on. + * + * @return \DrevOps\Tui\Block\Capability\FocusCapableInterface|null + * The block, or NULL when nothing in the panel takes focus. + */ + public function focused(): ?FocusCapableInterface { + return $this->focusable()[$this->cursor] ?? NULL; + } + + /** + * The innermost thing a key reaches right now. + * + * @return \DrevOps\Tui\Block\Capability\BindCapableInterface + * The open block under the cursor, else the panel around it. + */ + public function binder(): BindCapableInterface { + $focused = $this->focused(); + + return $focused instanceof Field && $focused->mode() === Mode::Edit ? $focused : $this->panel; + } + + /** + * The keys that apply right now. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The innermost binder's bindings. + */ + public function bindings(): ScopedKeyMap { + return $this->binder()->bindings(); + } + + /** + * What those keys do, as labelled fragments. + * + * @return list<\DrevOps\Tui\Input\Hint> + * The fragments. + */ + public function hints(): array { + return $this->binder()->hints(); + } + + /** + * Rewrite a legend from the keys that apply right now. + * + * @param \DrevOps\Tui\Block\Legend $legend + * The legend. + * + * @return \DrevOps\Tui\Block\Legend + * The same legend, now advertising the innermost binder's keys. + */ + public function refresh(Legend $legend): Legend { + return $legend->advertise($this->bindings(), ...$this->hints()); + } + + /** + * Whether a field's help is showing. + * + * @return bool + * TRUE when it is. + */ + public function isShowingHelp(): bool { + return $this->help instanceof Field; + } + + /** + * The field whose help is showing. + * + * @return \DrevOps\Tui\Block\Field|null + * The field, or NULL when none is showing. + */ + public function helping(): ?Field { + return $this->help; + } + + /** + * Come back out of the panel you are in, as the key that does would. + * + * @return bool + * TRUE when there was somewhere to come back to. + */ + public function leave(): bool { + return $this->ascend(); + } + + /** + * Put the cursor back on a row that is there, after some stopped being. + * + * The answers decide which rows are there at all, so a row can leave from + * under the cursor - and a cursor counting to a row that is gone is on + * nothing at all. + */ + public function reframe(): void { + $this->cursor = max(0, min($this->cursor, max(0, count($this->focusable()) - 1))); + $this->settle(); + } + + /** + * Send a key where it belongs. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + */ + public function handle(Key $key): void { + if ($this->help instanceof Field) { + // Any key dismisses help, so a reader never has to find the way out. + $this->help = NULL; + + return; + } + + $focused = $this->focused(); + + if ($focused instanceof Field && $focused->binds($key)) { + $focused->capture($key); + + return; + } + + if ($this->panel->binds($key)) { + $this->inPanel($focused, $key); + } + + // A key neither of them binds has reached the screen, which claims none of + // its own. + } + + /** + * Handle a key that travelled outward to the panel. + * + * @param \DrevOps\Tui\Block\Capability\FocusCapableInterface|null $focused + * The block with the cursor, if any has it. + * @param \DrevOps\Tui\Input\Key $key + * The key. + */ + protected function inPanel(?FocusCapableInterface $focused, Key $key): void { + $bindings = $this->panel->bindings(); + + if ($bindings->matches($key, Action::MoveDown)) { + $this->moveBy(1); + + return; + } + + if ($bindings->matches($key, Action::MoveUp)) { + $this->moveBy(-1); + + return; + } + + if ($bindings->matches($key, Action::MoveRight)) { + $this->moveAcross(1); + + return; + } + + if ($bindings->matches($key, Action::MoveLeft)) { + $this->moveAcross(-1); + + return; + } + + if ($bindings->matches($key, Action::Activate)) { + $this->activate($focused); + + return; + } + + if ($bindings->matches($key, Action::Back)) { + $this->ascend(); + + return; + } + + // Help is only offered where it leads somewhere, so a field carrying none + // leaves the key doing nothing rather than opening a blank page. + if ($bindings->matches($key, Action::Help) && $focused instanceof Field && $focused->helpText() !== '') { + $this->help = $focused; + } + } + + /** + * Do what selecting the block under the cursor does. + * + * @param \DrevOps\Tui\Block\Capability\FocusCapableInterface|null $focused + * The block with the cursor, if any has it. + */ + protected function activate(?FocusCapableInterface $focused): void { + if ($focused instanceof Field) { + $focused->open(); + + return; + } + + if ($focused instanceof Panel) { + $this->descend($focused); + } + } + + /** + * Go into a nested panel, so the screen becomes its contents. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel to go into. + */ + protected function descend(Panel $panel): void { + $panel->prepare(); + + $this->trail[] = ['panel' => $this->panel, 'cursor' => $this->cursor]; + $this->panel = $panel->enter(); + $this->cursor = 0; + $this->settle(); + } + + /** + * Come back out of a panel, restoring the screen and the row it was left on. + * + * @return bool + * TRUE when there was somewhere to come back to. + */ + protected function ascend(): bool { + // The outermost panel is not somewhere you came from, so there is nowhere + // to go back to and the key does nothing. + if ($this->trail === []) { + return FALSE; + } + + $step = array_pop($this->trail); + + $this->panel->leave(); + $this->panel = $step['panel']; + $this->cursor = $step['cursor']; + $this->settle(); + + return TRUE; + } + + /** + * Move the cursor, stopping at the ends rather than wrapping. + * + * @param int $delta + * The rows to move by. + */ + protected function moveBy(int $delta): void { + $count = count($this->focusable()); + + if ($count === 0) { + return; + } + + $this->cursor = $this->stepped($delta) ?? max(0, min($this->cursor + $delta, $count - 1)); + $this->settle(); + } + + /** + * Move the cursor along the visual row of windows it is on. + * + * Windows sit beside each other, so what is next to one is a neighbour rather + * than the next row: moving across walks the row and stops at its ends, and + * anywhere else there is nothing beside the cursor to move to. + * + * @param int $delta + * The windows to move by. + */ + protected function moveAcross(int $delta): void { + $at = $this->windowAt(); + + if ($at === NULL) { + return; + } + + [$row, $column] = $at; + $windows = $this->windowRows(); + $next = $column + $delta; + + if ($next < 0 || $next >= count($windows[$row])) { + return; + } + + $this->cursor = $windows[$row][$next]; + $this->settle(); + } + + /** + * Where the cursor lands when it steps off a row of windows, if it is on one. + * + * @param int $delta + * The visual rows to move by. + * + * @return int|null + * The block to land on, or NULL when the cursor is not on a window and the + * move is the ordinary one from a row to the row after it. + */ + protected function stepped(int $delta): ?int { + $at = $this->windowAt(); + + if ($at === NULL) { + return NULL; + } + + [$row, $column] = $at; + $windows = $this->windowRows(); + $next = $row + $delta; + + // Off the top or the bottom of the grid is out of it: the whole grid is one + // step, so what is above or below it is the row beside the grid rather than + // the window the cursor happened to be under. + if (!isset($windows[$next])) { + return $this->beside($windows, $delta); + } + + // A shorter row cannot be entered past its end, so the cursor lands on the + // window nearest the column it came from. + return $windows[$next][min($column, count($windows[$next]) - 1)]; + } + + /** + * The block on the far side of the grid, in the direction of a move. + * + * @param list> $windows + * The blocks of each visual row of windows. + * @param int $delta + * The direction: below the grid when positive, above it when negative. + * + * @return int|null + * The block to land on, or NULL when there is nothing beyond the grid. + */ + protected function beside(array $windows, int $delta): ?int { + $placed = $windows === [] ? [] : array_merge(...$windows); + + if ($placed === []) { + return NULL; + } + + $count = count($this->focusable()); + $index = $delta > 0 ? max($placed) + 1 : min($placed) - 1; + + return $index >= 0 && $index < $count ? $index : NULL; + } + + /** + * Which window of the grid the cursor is on, if it is on one. + * + * @return array{int,int}|null + * The visual row and the place in it, or NULL when the cursor is not on a + * window. + */ + protected function windowAt(): ?array { + foreach ($this->windowRows() as $row => $blocks) { + $column = array_search($this->cursor, $blocks, TRUE); + + if (is_int($column)) { + return [$row, $column]; + } + } + + return NULL; + } + + /** + * The blocks of each visual row of windows, in the order they are drawn. + * + * @return list> + * One entry per visual row, naming the focusable blocks the windows of that + * row are; empty when the panel arranges no grid. + */ + protected function windowRows(): array { + $grid = $this->panel->place()->gridRows(); + + if ($grid === []) { + return []; + } + + $windows = []; + + foreach ($this->focusable() as $index => $block) { + if ($block instanceof Panel) { + $windows[] = $index; + } + } + + $rows = []; + $taken = 0; + + foreach ($grid as $count) { + $row = array_slice($windows, $taken, $count); + + if ($row !== []) { + $rows[] = $row; + } + + $taken += $count; + } + + return $rows; + } + + /** + * Put the cursor on the block it is counting to, and take it off the rest. + */ + protected function settle(): void { + foreach ($this->focusable() as $index => $block) { + if ($index === $this->cursor) { + $block->focus(); + + continue; + } + + $block->blur(); + } + } + + /** + * Make a panel and everything in it answer to one set of bindings. + * + * @param \DrevOps\Tui\Input\KeyMap $keys + * The resolved bindings. + * @param \DrevOps\Tui\Block\Panel $panel + * The panel to spread them through. + */ + protected function rebind(KeyMap $keys, Panel $panel): void { + $panel->bind($keys); + + foreach ($panel->currentLayout()->names() as $name) { + foreach ($panel->currentLayout()->in($name)->blocks() as $block) { + if ($block instanceof Panel) { + $this->rebind($keys, $block); + + continue; + } + + if ($block instanceof BindCapableInterface) { + $block->bind($keys); + } + } + } + } + + /** + * The blocks the cursor can land on, in the order they are drawn. + * + * @return list<\DrevOps\Tui\Block\Capability\FocusCapableInterface> + * The blocks. + */ + protected function focusable(): array { + $blocks = []; + + foreach ($this->panel->currentLayout()->names() as $name) { + foreach ($this->panel->currentLayout()->in($name)->blocks() as $block) { + // A row the answers took off the screen is not somewhere the cursor can + // be, because it is not anywhere. + if ($block instanceof DependCapableInterface && $block->isHidden()) { + continue; + } + + // A field of a kind that only shows is a field by declaration and a + // passage of text by behaviour: there is nothing to open, so the cursor + // passes over it exactly as it passes over markup. + if ($block instanceof Field && $block->type()->isPresentational()) { + continue; + } + + // A block that does not claim the cursor is skipped rather than landed + // on, which is what lets markup sit between two fields. + if ($block instanceof FocusCapableInterface) { + $blocks[] = $block; + } + } + } + + return $blocks; + } + +} diff --git a/src/Screen/Layout/AbstractLayout.php b/src/Screen/Layout/AbstractLayout.php new file mode 100644 index 00000000..5a09ca3b --- /dev/null +++ b/src/Screen/Layout/AbstractLayout.php @@ -0,0 +1,161 @@ + + */ + protected array $regions = []; + + /** + * Construct a layout. + * + * @param \DrevOps\Tui\Screen\Axis $axis + * The direction its regions run. + */ + public function __construct( + protected Axis $axis, + ) { + } + + /** + * {@inheritdoc} + */ + public function axis(): Axis { + return $this->axis; + } + + /** + * {@inheritdoc} + */ + public function names(): array { + return array_keys($this->regions); + } + + /** + * {@inheritdoc} + */ + public function in(string $name): Region { + if (!isset($this->regions[$name])) { + throw new \InvalidArgumentException(sprintf('Unknown region "%s". This layout declares: %s.', $name, implode(', ', $this->names()))); + } + + return $this->regions[$name]; + } + + /** + * {@inheritdoc} + * + * Fixed regions come off the top, what remains is divided by the declared + * shares, 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. + */ + public function arrange(int $available): array { + if ($this->regions === []) { + return []; + } + + $sizes = []; + $shares = []; + $taken = 0; + + foreach ($this->regions as $name => $region) { + $fixed = $region->fixedSize(); + + if ($fixed === NULL) { + $shares[$name] = (int) $region->flexShare(); + $sizes[$name] = 0; + + continue; + } + + $sizes[$name] = $fixed; + $taken += $fixed; + } + + // A terminal too small for the fixed regions alone is a real state, not an + // error: they are trimmed in declaration order so the sizes still add up. + if ($taken > $available) { + return $this->trim($sizes, $available); + } + + if ($shares === []) { + return $sizes; + } + + $remainder = $available - $taken; + $total = array_sum($shares); + $spent = 0; + + foreach ($shares as $name => $share) { + $sizes[$name] = intdiv($remainder * $share, $total); + $spent += $sizes[$name]; + } + + $sizes[array_key_last($shares)] += $remainder - $spent; + + return $sizes; + } + + /** + * Declare a region. + * + * @param string $name + * The name a block addresses it by. + * + * @return \DrevOps\Tui\Screen\Region + * The region, for declaring its size, flow and scrolling. + */ + protected function region(string $name): Region { + if (isset($this->regions[$name])) { + throw new \InvalidArgumentException(sprintf('Region "%s" is already declared on this layout.', $name)); + } + + return $this->regions[$name] = new Region($name); + } + + /** + * Cut sizes back to what is actually available, in declaration order. + * + * @param array $sizes + * The sizes asked for. + * @param int $available + * The cells there are. + * + * @return array + * The sizes granted. + */ + protected function trim(array $sizes, int $available): array { + $left = max(0, $available); + + foreach ($sizes as $name => $size) { + $sizes[$name] = min($size, $left); + $left -= $sizes[$name]; + } + + return $sizes; + } + +} diff --git a/src/Screen/Layout/DefaultLayout.php b/src/Screen/Layout/DefaultLayout.php new file mode 100644 index 00000000..48bdd360 --- /dev/null +++ b/src/Screen/Layout/DefaultLayout.php @@ -0,0 +1,30 @@ +region('header')->fixed(1); + $this->region('content')->scrolls(); + $this->region('footer')->fixed(1); + } + +} diff --git a/src/Screen/Layout/LayoutInterface.php b/src/Screen/Layout/LayoutInterface.php new file mode 100644 index 00000000..6f409680 --- /dev/null +++ b/src/Screen/Layout/LayoutInterface.php @@ -0,0 +1,66 @@ + + * The names. + */ + public function names(): array; + + /** + * The region of a given name. + * + * @param string $name + * The region name. + * + * @return \DrevOps\Tui\Screen\Region + * The region. + */ + public function in(string $name): Region; + + /** + * Work out how much of the axis each region gets. + * + * @param int $available + * The cells to divide. + * + * @return array + * The cells each region gets, keyed by name. + */ + public function arrange(int $available): array; + +} diff --git a/src/Screen/Layout/LayoutManager.php b/src/Screen/Layout/LayoutManager.php new file mode 100644 index 00000000..75177476 --- /dev/null +++ b/src/Screen/Layout/LayoutManager.php @@ -0,0 +1,173 @@ +>|null + */ + protected static ?array $shipped = NULL; + + /** + * The layouts a consumer registered, keyed by name. + * + * @var array> + */ + protected static array $registry = []; + + /** + * Register a layout under a short name. + * + * @param string $name + * The name a form picks it by. + * @param string $class + * The layout class. + */ + public static function register(string $name, string $class): void { + self::$registry[$name] = self::vouch($class); + } + + /** + * Build a layout by name, or by class name. + * + * @param string $name + * A shipped name, a registered name, or a layout class name. + * + * @return \DrevOps\Tui\Screen\Layout\LayoutInterface + * The layout. + */ + public static function create(string $name = 'default'): LayoutInterface { + $class = self::shipped()[$name] ?? self::$registry[$name] ?? NULL; + + if ($class !== NULL) { + return new $class(); + } + + if (!is_a($name, LayoutInterface::class, TRUE)) { + throw new \InvalidArgumentException(sprintf('Unknown layout "%s". Registered: %s.', $name, implode(', ', self::names()))); + } + + $class = self::vouch($name); + + return new $class(); + } + + /** + * The names a layout can be reached by, the shipped ones first. + * + * @return list + * The names. + */ + public static function names(): array { + return array_keys(self::shipped() + self::$registry); + } + + /** + * Forget every registration a consumer made. + */ + public static function reset(): void { + self::$registry = []; + } + + /** + * The layouts that ship, keyed by the name a form picks them by. + * + * @return array> + * The classes, keyed by name. + */ + protected static function shipped(): array { + if (self::$shipped !== NULL) { + return self::$shipped; + } + + $shipped = []; + + foreach (glob(__DIR__ . '/*.php') ?: [] as $file) { + $short = basename($file, '.php'); + $class = __NAMESPACE__ . '\\' . $short; + + // The directory holds this manager and the interface too. + if (!is_a($class, LayoutInterface::class, TRUE)) { + continue; + } + + // An arrangement nobody can build is not one a form can pick. + if (!(new \ReflectionClass($class))->isInstantiable()) { + continue; + } + + $shipped[self::name($short)] = $class; + } + + return self::$shipped = $shipped; + } + + /** + * The name a shipped class is picked by. + * + * @param string $short + * The class name, without its namespace. + * + * @return string + * The name. + */ + protected static function name(string $short): string { + // The suffix is what makes the class name read as a layout; the name a form + // picks it by does not need saying twice. + if (str_ends_with($short, self::SUFFIX)) { + $short = substr($short, 0, -strlen(self::SUFFIX)); + } + + return Str2Name::pascal2kebab($short); + } + + /** + * Vouch for a layout class, or say why it is not one. + * + * @param string $class + * The class name. + * + * @return class-string<\DrevOps\Tui\Screen\Layout\LayoutInterface> + * The class. + */ + protected static function vouch(string $class): string { + if (!is_a($class, LayoutInterface::class, TRUE)) { + throw new \InvalidArgumentException(sprintf('Layout class "%s" must implement %s.', $class, LayoutInterface::class)); + } + + // An abstract layout passes the type check and then fatals on the first + // create(): refusing it here names the class rather than the call site. + if (!(new \ReflectionClass($class))->isInstantiable()) { + throw new \InvalidArgumentException(sprintf('Layout class "%s" cannot be instantiated.', $class)); + } + + return $class; + } + +} diff --git a/src/Screen/Layout/PanelLayout.php b/src/Screen/Layout/PanelLayout.php new file mode 100644 index 00000000..80880268 --- /dev/null +++ b/src/Screen/Layout/PanelLayout.php @@ -0,0 +1,29 @@ +region('content')->scrolls(); + } + +} diff --git a/src/Screen/Layout/TwoColumnLayout.php b/src/Screen/Layout/TwoColumnLayout.php new file mode 100644 index 00000000..6c2e0026 --- /dev/null +++ b/src/Screen/Layout/TwoColumnLayout.php @@ -0,0 +1,26 @@ +region('left'); + $this->region('right'); + } + +} diff --git a/src/Screen/Region.php b/src/Screen/Region.php new file mode 100644 index 00000000..360a4a0c --- /dev/null +++ b/src/Screen/Region.php @@ -0,0 +1,307 @@ + + */ + protected array $grid = []; + + /** + * Whether its contents may outrun it. + */ + protected bool $scrolls = FALSE; + + /** + * The blocks drawn in it, in the order they were added. + * + * @var list<\DrevOps\Tui\Block\BlockInterface> + */ + protected array $blocks = []; + + /** + * The first row of its contents that is visible. + */ + protected int $offset = 0; + + /** + * Construct a region. + * + * @param string $name + * The name a block addresses it by. + */ + public function __construct( + protected string $name, + ) { + } + + /** + * The name a block addresses this region by. + * + * @return string + * The name. + */ + public function name(): string { + return $this->name; + } + + /** + * Take a fixed number of cells of the axis. + * + * A header is one line whatever the terminal height, and no proportion can + * say that, which is what this is for. + * + * @param int $cells + * The cells to take. + * + * @return $this + * The region. + */ + public function fixed(int $cells): self { + if ($cells < 1) { + throw new \InvalidArgumentException('A fixed size is a count of cells, so it cannot be 0.'); + } + + $this->fixed = $cells; + $this->flex = NULL; + + return $this; + } + + /** + * Take a share of whatever the fixed regions leave behind. + * + * Shares do not sum to anything in particular, so 30, 40, 30 and 3, 4, 3 mean + * the same thing. + * + * @param int $share + * The share to take. + * + * @return $this + * The region. + */ + public function flex(int $share): self { + if ($share < 1) { + throw new \InvalidArgumentException('A flex share divides the remainder, so it cannot be 0.'); + } + + $this->flex = $share; + $this->fixed = NULL; + + return $this; + } + + /** + * The cells this region was declared to take. + * + * @return int|null + * The cells, or NULL when it takes a share instead. + */ + public function fixedSize(): ?int { + return $this->fixed; + } + + /** + * The share of the remainder this region was declared to take. + * + * @return int|null + * The share, or NULL when it takes a fixed size instead. + */ + public function flexShare(): ?int { + return $this->flex; + } + + /** + * Run the blocks inside this region along an axis. + * + * @param \DrevOps\Tui\Screen\Axis $axis + * The direction they run. + * + * @return $this + * The region. + */ + public function flow(Axis $axis): self { + $this->flow = $axis; + + return $this; + } + + /** + * The direction the blocks inside this region run. + * + * @return \DrevOps\Tui\Screen\Axis + * The direction. + */ + public function flowAxis(): Axis { + return $this->flow; + } + + /** + * Sit the panels inside this region side by side. + * + * A shape rather than a direction, which is why it is stated apart from the + * flow: the rows a region holds still run down it, and only the panels among + * them are dealt into the visual rows declared here. + * + * @param int ...$rows + * One entry per visual row, naming how many panels share it, top to + * bottom; none leaves them one under another. + * + * @return $this + * The region. + */ + public function grid(int ...$rows): self { + $this->grid = array_values($rows); + + return $this; + } + + /** + * How the panels inside this region 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; + } + + /** + * Let this region's contents outrun it. + * + * @return $this + * The region. + */ + public function scrolls(): self { + $this->scrolls = TRUE; + + return $this; + } + + /** + * Whether this region's contents may outrun it. + * + * @return bool + * TRUE when they may. + */ + public function isScrolling(): bool { + return $this->scrolls; + } + + /** + * Draw a block in this region. + * + * A region never knows which kind it was given, which is why a breadcrumb can + * go wherever a field can. + * + * @param \DrevOps\Tui\Block\BlockInterface $block + * The block. + * + * @return $this + * The region. + */ + public function add(BlockInterface $block): self { + $this->blocks[] = $block; + + return $this; + } + + /** + * Draw a block before everything already in this region. + * + * What a region holds is normally in the order it was declared in, so this is + * for the standing text a driver puts above rows that were placed before it + * knew there would be any. + * + * @param \DrevOps\Tui\Block\BlockInterface $block + * The block. + * + * @return $this + * The region. + */ + public function prepend(BlockInterface $block): self { + array_unshift($this->blocks, $block); + + return $this; + } + + /** + * The blocks drawn in this region, in the order they were added. + * + * @return list<\DrevOps\Tui\Block\BlockInterface> + * The blocks. + */ + public function blocks(): array { + return $this->blocks; + } + + /** + * Move the window onto this region's contents. + * + * @param int $row + * The first row of the contents to show. + * + * @return $this + * The region. + */ + public function scrollTo(int $row): self { + if (!$this->scrolls) { + throw new \LogicException(sprintf('Region "%s" does not scroll, so it cannot be scrolled to row %d.', $this->name, $row)); + } + + $this->offset = max(0, $row); + + return $this; + } + + /** + * The first row of this region's contents that is visible. + * + * @param int $content + * The rows its contents come to. + * @param int $visible + * The rows it was given. + * + * @return int + * The offset, never far enough to scroll the contents off their own end. + */ + public function offset(int $content, int $visible): int { + return min($this->offset, max(0, $content - $visible)); + } + +} diff --git a/src/Screen/Screen.php b/src/Screen/Screen.php new file mode 100644 index 00000000..03cd0634 --- /dev/null +++ b/src/Screen/Screen.php @@ -0,0 +1,94 @@ +layout = $layout; + + return $this; + } + + /** + * The layout arranging this screen. + * + * @return \DrevOps\Tui\Screen\Layout\LayoutInterface + * The layout. + */ + public function currentLayout(): LayoutInterface { + if (!$this->layout instanceof LayoutInterface) { + throw new \LogicException('This screen has no layout, so it has no regions to place a block in.'); + } + + return $this->layout; + } + + /** + * Take the whole terminal rather than fitting the contents. + * + * @return $this + * The screen. + */ + public function fullscreen(): self { + $this->fullscreen = TRUE; + + return $this; + } + + /** + * Whether the frame takes the whole terminal. + * + * @return bool + * TRUE when it does. + */ + public function isFullscreen(): bool { + return $this->fullscreen; + } + + /** + * 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); + } + +} diff --git a/src/Screen/ScreenController.php b/src/Screen/ScreenController.php new file mode 100644 index 00000000..7b537de0 --- /dev/null +++ b/src/Screen/ScreenController.php @@ -0,0 +1,1789 @@ + + */ + protected array $provenance = []; + + /** + * Which fields are there at all, keyed by field id. + * + * @var array + */ + protected array $active = []; + + /** + * The dialogs standing open, with the answers each was opened over. + * + * @var list,provenance:array}> + */ + protected array $dialogs = []; + + /** + * The narrowest terminal the frame can be read in, once it was measured. + */ + protected ?int $minimumWidth = NULL; + + /** + * Whether the session has ended. + */ + protected bool $done = FALSE; + + /** + * Whether the form was abandoned rather than finished. + */ + protected bool $cancelled = FALSE; + + /** + * Whether the session ended on the interrupt key. + */ + protected bool $interrupted = FALSE; + + /** + * Construct a controller. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel the screen starts in: the tree a form declares. + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme every block draws through. + * @param array $supplied + * Values supplied for its fields, keyed by field id. + * @param \DrevOps\Tui\Input\KeyMap|null $keys + * The bindings the whole screen answers to, or NULL for the default preset. + * @param \DrevOps\Tui\Screen\Collector|null $collector + * What resolves the answers the form opens on, or NULL for one that reuses + * no behaviour and applies no rules once they have settled. + * @param \DrevOps\Tui\Handler\Context $context + * The run this session belongs to. + * @param string $layout + * The layout the screen is arranged by. + * @param \DrevOps\Tui\Theme\Border $border + * The frame drawn around every region at once. + * @param bool $clearOnExit + * Whether the screen is cleared as the session ends. + * @param bool $footer + * Whether the keys that apply right now are advertised at all. + * @param string $banner + * What is shown before the form, dismissed by any key; empty opens straight + * onto the first frame. + * @param string $version + * The version shown under that banner. + * @param \DrevOps\Tui\Render\ExternalEditor|null $external_editor + * What hands a passage of text to an editor of the reader's own, or NULL + * for one that launches whatever the environment names. + */ + public function __construct( + protected Panel $panel, + protected ThemeInterface $theme, + protected array $supplied = [], + ?KeyMap $keys = NULL, + ?Collector $collector = NULL, + protected Context $context = new Context(), + protected string $layout = 'default', + protected Border $border = Border::None, + protected bool $clearOnExit = TRUE, + protected bool $footer = TRUE, + protected string $banner = '', + protected string $version = '', + ?ExternalEditor $external_editor = NULL, + ) { + $this->keys = $keys ?? KeyMapManager::create(); + $this->collector = $collector ?? new Collector(); + $this->externalEditor = $external_editor ?? new ExternalEditor(); + $this->scroller = new Scroller(); + $this->renderer = new ScreenRenderer($theme, $border); + + $assembler = new Assembler(); + $this->screen = $assembler->assemble($panel, $this->layout); + // A layout keeping no place for a piece still gets a live one: the trail + // and the keys keep tracking the session, they are just never drawn. + $this->breadcrumb = $this->furniture('header', Breadcrumb::class) ?? new Breadcrumb($panel->title()); + $this->legend = $this->furniture('footer', Legend::class) ?? (new Legend())->advertise($panel->bindings(), ...$panel->hints()); + + $this->actions = $this->buttons($assembler, $panel); + // A session opens on a form nobody has been refused yet, so whatever the + // last one left standing there is cleared rather than carried in. + $this->notice = ($this->stated($panel) ?? new Markup(self::NOTICE, ''))->body(''); + $this->help = (new Markup('screen-help', ''))->bordered(); + $this->overlay = $this->helpScreen(); + + $this->dress($assembler); + + $this->router = (new KeyRouter($panel))->bind($this->keys); + + $this->seed(); + } + + /** + * Run the session against a terminal until the form ends. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * + * @return \DrevOps\Tui\Answers\Answers + * The collected answers. + * + * @throws \DrevOps\Tui\InterruptException + * When the session was aborted with the interrupt key. + * @throws \DrevOps\Tui\CancelException + * When the form was abandoned through its cancel button. + */ + public function run(Terminal $terminal): Answers { + $parser = new KeyParser(); + $this->terminal = $terminal; + $terminal->setup($this->wash()); + + try { + $this->welcome($terminal, $parser); + + // The outermost panel is opened by the session starting rather than by a + // key, so what it owes is fetched before the first frame is read. + $this->furnish(); + + while (!$this->done && !$this->interrupted) { + $this->paint(); + $bytes = $terminal->read(); + + // An empty read means the input is exhausted - the scripted keys ran + // out, or the stream closed. Stop rather than spin on the same frame. + if ($bytes === '') { + break; + } + + // A terminal that cannot hold the frame is showing a notice instead of + // the form, so every key but the one that leaves is dropped rather than + // changing something nobody can see. + $guarded = $this->guard($terminal) !== ''; + + foreach ($parser->parse($bytes) as $key) { + // The interrupt aborts from anywhere, including from inside an open + // field, so it is answered above the routing and drops straight out + // to the teardown with the answers as they stand. + if ($key->is(KeyName::Interrupt)) { + $this->interrupted = TRUE; + + break 2; + } + + if ($guarded && !$this->quits($key)) { + continue; + } + + $this->handle($key); + } + + // Asked once the whole read is spent rather than once per key, so a + // burst of typing - or a paste - costs the source one call. + $this->query(); + } + } + finally { + $terminal->restore(); + + // An abort always leaves a clean screen, even where a consumer opted out + // of the clear for a session that ends normally. + if ($this->clearOnExit || $this->interrupted) { + $terminal->clear(); + } + } + + if ($this->interrupted) { + throw new InterruptException('The interactive session was interrupted.'); + } + + if ($this->cancelled) { + throw new CancelException('The interactive session was cancelled.'); + } + + return $this->answers(); + } + + /** + * Send one key where it belongs. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + */ + public function handle(Key $key): void { + // Any key dismisses help, so it is spent there before anything else reads + // it: a reader who asked for help never has to find the way out. + if ($this->router->isShowingHelp()) { + $this->router->handle($key); + + return; + } + + if ($this->quits($key)) { + $this->leave(); + + return; + } + + $focused = $this->router->focused(); + + if ($focused instanceof Actions && $this->pressed($focused, $key)) { + return; + } + + if ($focused instanceof Progress && $this->activates($key)) { + $this->work($focused); + + return; + } + + // Read before the key reaches it: a row that was open before it and is + // settled after it is a row whose answer has just been taken. + $open = $this->editing(); + + $this->router->handle($key); + + $this->handoff($open); + $this->stamp($open); + $this->synchronize(); + $this->furnish(); + } + + /** + * The answers as they stand: the values of the fields that are there. + * + * A field a condition hides keeps the value it settled on - so it surfaces + * intact if the condition is satisfied again - but contributes no answer, + * which is what a collection with no screen at all also hands back. + * + * @return \DrevOps\Tui\Answers\Answers + * The answers, each describing the question it answers. + */ + public function answers(): Answers { + $values = []; + $provenance = []; + + foreach (Tree::fields($this->panel) as $field) { + if ($field->type()->isDisplayOnly()) { + continue; + } + + if (!($this->active[$field->id()] ?? TRUE)) { + continue; + } + + $values[$field->id()] = $field->value(); + + if (isset($this->provenance[$field->id()])) { + $provenance[$field->id()] = $this->provenance[$field->id()]; + } + } + + return Answers::forTree($this->panel, $values, $provenance); + } + + /** + * Whether the form was abandoned rather than finished. + * + * @return bool + * TRUE when the cancel button ended it. + */ + public function isCancelled(): bool { + return $this->cancelled; + } + + /** + * Whether the session ended on the interrupt key. + * + * @return bool + * TRUE when it did. + */ + public function isInterrupted(): bool { + return $this->interrupted; + } + + /** + * What is drawn before the first frame, if anything is. + * + * @return string + * The frame, empty when the session opens straight onto the form. + */ + protected function opening(): string { + if ($this->banner === '') { + return ''; + } + + return $this->pieces()->renderBanner($this->banner, $this->version) . "\n\n" . Translator::t('Press any key to continue...'); + } + + /** + * What is drawn instead of the frame when the terminal cannot hold it. + * + * Only a frame that takes the whole terminal can be too small for it: one + * that takes what it needs is read by scrolling, however little room there + * is. The notice is centred rather than anchored where the frame would be, + * because the anchor places content on a screen the frame fits, which this + * one is not. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * + * @return string + * The notice, empty when the frame is drawn as it is. + */ + protected function guard(Terminal $terminal): string { + $occupancy = $this->occupancy(); + + if (!$occupancy instanceof OccupyCapableInterface || !$occupancy->isFullscreen()) { + return ''; + } + + $columns = $this->narrowest($occupancy); + $rows = $this->shortest($occupancy); + + if ($terminal->width() >= $columns && $terminal->height() >= $rows) { + return ''; + } + + $lines = [ + $this->pieces()->renderStatus(Status::Error, Translator::t('Terminal too small.')), + Translator::t('Need at least @width x @height - have @w x @h.', [ + '@width' => (string) $columns, + '@height' => (string) $rows, + '@w' => (string) $terminal->width(), + '@h' => (string) $terminal->height(), + ]), + (new Legend())->advertise($this->router->bindings(), new Hint('quit', Action::Quit))->render($this->theme), + ]; + + $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)); + } + + /** + * Place a drawn frame within the terminal area. + * + * A frame that takes what it needs is drawn where the cursor already is, as + * anything written to a terminal is. One that takes the whole terminal and + * then does not fill it - a capped frame, a banner, a help page - is anchored + * where the theme says, padded with blank space on the sides it leaves. + * + * @param string $frame + * The drawn frame. + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * + * @return string + * The placed frame. + */ + protected function chrome(string $frame, Terminal $terminal): string { + $occupancy = $this->occupancy(); + + if (!$occupancy instanceof OccupyCapableInterface || !$occupancy->isFullscreen()) { + return $frame; + } + + $lines = explode("\n", $frame); + $area_width = $terminal->width(); + $area_height = $terminal->height(); + $width = Ansi::blockWidth($lines); + + if (count($lines) >= $area_height && $width >= $area_width) { + return $frame; + } + + [$top, $left] = Overlay::place($area_width, $area_height, $width, count($lines), $occupancy->halign(), $occupancy->valign()); + $backdrop = array_fill(0, $area_height, str_repeat(' ', $area_width)); + + return implode("\n", Overlay::composite($backdrop, $lines, $width, $top, $left)); + } + + /** + * Show what precedes the form, and wait for the key that dismisses it. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * @param \DrevOps\Tui\Input\KeyParser $parser + * What reads keys out of the bytes the terminal delivers. + */ + protected function welcome(Terminal $terminal, KeyParser $parser): void { + $opening = $this->opening(); + + if ($opening === '') { + return; + } + + $terminal->render($this->chrome($opening, $terminal)); + + // Any key gets past it, but the interrupt aborts here as it does anywhere + // else rather than dropping a reader into a form they never asked for. + foreach ($parser->parse($terminal->read()) as $key) { + if ($key->is(KeyName::Interrupt)) { + $this->interrupted = TRUE; + + return; + } + } + } + + /** + * Draw the current frame. + */ + protected function paint(): void { + $terminal = $this->terminal; + + // One key at a time needs no terminal, so until a session is running there + // is nowhere to draw and nothing is drawn. + if (!$terminal instanceof Terminal) { + return; + } + + $notice = $this->guard($terminal); + + $terminal->render($notice === '' ? $this->chrome($this->frame($terminal), $terminal) : $notice); + } + + /** + * The frame as it stands: the furniture rewritten, then drawn outward. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal the frame is sized against. + * + * @return string + * The frame. + */ + protected function frame(Terminal $terminal): string { + $rows = $this->rows($terminal); + $columns = $this->columns($terminal); + $helping = $this->router->helping(); + + // Both are read out of the router rather than written beside it, so the + // trail and the keys on offer can never disagree with where the cursor is. + $this->breadcrumb->trail(...$this->router->trail()); + $this->refresh(); + + if ($helping instanceof Field) { + // Help can run to paragraphs, so it replaces the panel rather than + // crowding the row that offers it. + $this->help->title(Translator::t($helping->label()))->body(Translator::t($helping->helpText())); + + return $this->renderer->render($this->overlay, $rows, $columns); + } + + $this->follow($rows); + + $alone = $this->alone(); + + if ($alone instanceof Field) { + return $this->renderer->render($this->stage($alone), $rows, $columns); + } + + if ($this->router->current()->isModal()) { + return $this->overlaid($rows, $columns); + } + + return $this->renderer->render($this->screen, $rows, $columns); + } + + /** + * The columns a frame is laid out to. + * + * A row sized past the terminal hard-wraps onto the next line and corrupts + * the layout below it, so a frame is never laid out wider than there is room + * for - nor wider than the width a panel reads well at, unless it was asked + * to take the whole terminal. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * + * @return int + * The columns. + */ + protected function columns(Terminal $terminal): int { + $width = $terminal->width() > 0 ? $terminal->width() : DefaultTheme::DEFAULT_WIDTH; + $occupancy = $this->occupancy(); + + if ($occupancy instanceof OccupyCapableInterface && $occupancy->isFullscreen()) { + return $occupancy->maxWidth() > 0 ? min($width, $occupancy->maxWidth()) : $width; + } + + return min(DefaultTheme::DEFAULT_WIDTH, $width); + } + + /** + * The rows a frame is laid out to. + * + * @param \DrevOps\Tui\Render\Terminal $terminal + * The terminal. + * + * @return int + * The rows. + */ + protected function rows(Terminal $terminal): int { + $occupancy = $this->occupancy(); + $tallest = $occupancy instanceof OccupyCapableInterface ? $occupancy->maxHeight() : 0; + + return $tallest > 0 ? min($terminal->height(), $tallest) : $terminal->height(); + } + + /** + * Move each scrolling region so the row the cursor is on stays in sight. + * + * @param int $rows + * The terminal rows. + */ + protected function follow(int $rows): void { + $panel = $this->router->current(); + $focused = $this->router->focused(); + $sizes = $panel->currentLayout()->arrange($this->content($rows)); + + foreach ($panel->currentLayout()->names() as $name) { + $region = $panel->in($name); + + if (!$region->isScrolling()) { + continue; + } + + [$total, $row] = $this->renderer->extent($region, $focused instanceof BlockInterface ? $focused : NULL); + + // A region the cursor is not in stays where it was left: only the one + // holding the focused row has anything to follow. + if ($row < 0) { + continue; + } + + $height = $sizes[$name] ?? 0; + $region->scrollTo($this->scroller->follow($total, $height, $row, $region->offset($total, $height))->offset); + } + } + + /** + * The rows the panel you are in is drawn into. + * + * Every panel entered on the way in is given the whole of the region it sits + * in, so how deep the cursor has gone changes nothing about the arithmetic. + * + * @param int $rows + * The terminal rows. + * + * @return int + * The rows. + */ + protected function content(int $rows): int { + // A frame spends a rule top and bottom, so what the layout is given is the + // terminal less its chrome. + $inside = $this->border === Border::None ? $rows : max(0, $rows - self::FRAME_RULES); + + return $this->screen->currentLayout()->arrange($inside)[self::CONTENT] ?? $inside; + } + + /** + * Do what a key does on the buttons that end the form. + * + * @param \DrevOps\Tui\Block\Actions $actions + * The buttons. + * @param \DrevOps\Tui\Input\Key $key + * The key. + * + * @return bool + * TRUE when the key was spent here, so it travels no further. + */ + protected function pressed(Actions $actions, Key $key): bool { + $bindings = $this->router->current()->bindings(); + + // The buttons sit on one row, so the horizontal keys are what walks them. + if ($bindings->matches($key, Action::MoveLeft)) { + $this->step($actions, -1); + + return TRUE; + } + + if ($bindings->matches($key, Action::MoveRight)) { + $this->step($actions, 1); + + return TRUE; + } + + if (!$this->activates($key)) { + return FALSE; + } + + $this->press($actions); + + return TRUE; + } + + /** + * Whether a key selects whatever the cursor is on. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + * + * @return bool + * TRUE when it does. + */ + protected function activates(Key $key): bool { + return $this->router->current()->bindings()->matches($key, Action::Activate); + } + + /** + * Whether a key leaves where it is pressed. + * + * Asked of the keys that apply right now rather than of the whole map, which + * is what leaves the key typing itself into an open field: the letter that + * leaves a panel is a letter while something is collecting one. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + * + * @return bool + * TRUE when it does. + */ + protected function quits(Key $key): bool { + return $this->router->bindings()->matches($key, Action::Quit); + } + + /** + * Leave: close the dialog that is open, else end the session. + * + * Leaving is not abandoning. The answers stand exactly as they do on a + * finished form, because somebody who has answered a form and left it has + * still answered it. + */ + protected function leave(): void { + if ($this->router->current()->isModal()) { + $this->dismiss(TRUE); + + return; + } + + $this->done = TRUE; + } + + /** + * Move the cursor along the buttons, stopping at the ends. + * + * @param \DrevOps\Tui\Block\Actions $actions + * The buttons. + * @param int $delta + * The buttons to move by. + */ + protected function step(Actions $actions, int $delta): void { + $names = $actions->names(); + $at = array_search($actions->selected(), $names, TRUE); + $next = $names[max(0, min(count($names) - 1, (is_int($at) ? $at : 0) + $delta))] ?? NULL; + + if ($next !== NULL) { + $actions->select($next); + } + } + + /** + * End the form, close the dialog, or say why neither can happen yet. + * + * @param \DrevOps\Tui\Block\Actions $actions + * The buttons. + */ + protected function press(Actions $actions): void { + $ending = Ending::tryFrom((string) $actions->selected()); + + // Inside a dialog the pair closes the dialog: what is behind it is still + // being filled in, so neither button is about the form. + if ($this->router->current()->isModal()) { + $this->dismiss($ending === Ending::Cancel); + + return; + } + + // Abandoning the form is always allowed: only finishing it has to answer + // for the fields that are owed an answer. + $owed = $ending === Ending::Cancel ? NULL : $this->owed(); + + $actions->refuse($owed); + $this->notice->body((string) $owed); + + if (!$actions->activate()) { + return; + } + + $this->done = TRUE; + $this->cancelled = $ending === Ending::Cancel; + } + + /** + * The first field that is owed an answer and has none, and what it says. + * + * A field nobody opened never refused anything, so its own guard never ran on + * it; this is where an answer that was never given is caught instead. + * + * @return string|null + * The reason, or NULL when every field that is there is answered. + */ + protected function owed(): ?string { + foreach (Tree::fields($this->panel) as $field) { + if ($field->type()->isDisplayOnly()) { + continue; + } + + if (!($this->active[$field->id()] ?? TRUE)) { + continue; + } + + $missing = $field->requiredViolation($field->value()); + + if ($missing !== NULL) { + return $missing; + } + } + + return NULL; + } + + /** + * Run a progress block's work, drawing its indicator as it advances. + * + * @param \DrevOps\Tui\Block\Progress $progress + * The block. + */ + protected function work(Progress $progress): void { + $workload = $progress->workload(); + + if (!$workload instanceof \Closure) { + return; + } + + // The indicator starts before the work does, so the row says something is + // happening from the first blocking step rather than after the last. + $this->paint(); + + $workload(new ProgressReporter(function (?string $label) use ($progress): void { + $progress->advance(1, $label); + $this->paint(); + })); + } + + /** + * Fetch what the panel you are now in owes, before anybody reads it. + * + * A set too large or too slow to hold is fetched when the panel holding it is + * opened rather than when the form starts, so walking into one is what pays + * for it - and the row says it is still coming while the call blocks. + */ + protected function furnish(): void { + if ($this->collector->load($this->router->current(), $this->paint(...))) { + $this->resettle(); + } + } + + /** + * Ask an open row's source for the rows the query it holds names. + * + * Unlike a set fetched once, a query source is asked again as what is typed + * changes - so the call belongs where the typing is read rather than where + * the panel is opened. + */ + protected function query(): void { + $open = $this->editing(); + $editor = $open?->editor(); + $source = $open?->source(); + + if (!$editor instanceof QueryOptionsCapableInterface || !$source instanceof \Closure) { + return; + } + + $query = $editor->pendingQuery(); + + if ($query === NULL) { + return; + } + + $editor->beginQuery(); + $this->paint(); + + try { + $rows = Option::resolved($source($query, $this->values())); + $editor->applyQuery($query, $rows); + $open->settle($this->offered($open, $rows)); + } + catch (\Throwable) { + // Consumer code that cannot answer must not end a session over a terminal + // still in raw mode: the row says so and stays open, and the query is + // remembered so the same failing call is not made again on every frame. + $editor->failQuery($query, Translator::t('Could not load options.')); + } + } + + /** + * Everything a row has been offered so far, the latest query included. + * + * A query names a slice of a set rather than the set, so what it answers adds + * to what earlier ones answered: a choice made under one query is still the + * reader's when the next no longer offers it, and the row it stands for is + * still there to measure it against. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * @param list<\DrevOps\Tui\Model\Option> $rows + * What the latest query answered. + * + * @return array + * The label of every row offered so far, keyed by its value. + */ + protected function offered(Field $field, array $rows): array { + $offered = []; + + foreach ([...$field->entries(), ...$rows] as $row) { + if ($row->kind === OptionKind::Option) { + $offered[$row->value] = $row->label; + } + } + + return $offered; + } + + /** + * Hand what an open field holds to an editor of the reader's own. + * + * The field asks and the session answers: launching a program means leaving + * the terminal to it and taking it back afterwards, which is the session's to + * do and nothing a block could reach. + * + * @param \DrevOps\Tui\Block\Field|null $open + * The field the key reached, if it reached one that was open. + */ + protected function handoff(?Field $open): void { + if (!$open instanceof Field) { + return; + } + + $editor = $open->editor(); + + if (!$editor instanceof ExternalEditCapableInterface || !$editor->wantsExternalEdit()) { + return; + } + + $held = $editor->value(); + $editor->applyExternalEdit($this->externalEditor->edit(is_string($held) ? $held : '', $this->terminal)); + + // What came back is what is being typed, not what was accepted: the field + // still has to be accepted before it becomes the answer. + $open->draft($editor->value()); + } + + /** + * Record how an answer came to be, once somebody has taken it. + * + * Taking an answer is what stamps it, whether or not the answer changed: a + * reader who opened a row and accepted what was there has answered it. + * + * @param \DrevOps\Tui\Block\Field|null $open + * The field the key reached, if it reached one that was open. + */ + protected function stamp(?Field $open): void { + if (!$open instanceof Field || !$open->hasAccepted()) { + return; + } + + // Changing a field that computes its answer pins the rule against being + // recomputed, exactly as supplying a value to it does. + $this->provenance[$open->id()] = $open->derivation() instanceof Derive ? Provenance::Override : Provenance::Edited; + + // A refusal describes the answers as they stood when the button was + // pressed, so a change of any kind retires it rather than leaving it to + // contradict what the screen now shows. + $this->actions->refuse(NULL); + $this->notice->body(''); + + $this->resettle(); + } + + /** + * Keep track of the dialogs that opened and closed while a key was handled. + * + * A dialog is entered and left through the same keys everything else is, so + * what tells one from the other is where the cursor ended up. Watching that + * rather than intercepting the keys is what keeps the router's one rule - + * inward to whatever binds it - true of a dialog too. + */ + protected function synchronize(): void { + $current = $this->router->current(); + + // Going into a dialog is where the answers behind it are remembered, so + // that whatever it does to them can be put back. + if ($current->isModal() && $this->standing() !== $current) { + $this->dialogs[] = ['panel' => $current, 'values' => $this->values(), 'provenance' => $this->provenance]; + $this->reset($current); + + return; + } + + // A dialog left any other way than through its own buttons is abandoned, + // so the answers behind it stand as they did when it opened. + while ($this->dialogs !== [] && $this->standing() !== $current) { + $this->discard(); + } + } + + /** + * Close the dialog that is open, keeping or discarding what it collected. + * + * @param bool $discard + * Whether the answers go back to what they were when it opened. + */ + protected function dismiss(bool $discard): void { + if ($discard) { + $this->discard(); + } + else { + array_pop($this->dialogs); + $this->resettle(); + } + + $this->router->leave(); + } + + /** + * Put the answers back as they stood when the open dialog was opened. + */ + protected function discard(): void { + $dialog = array_pop($this->dialogs); + + if ($dialog === NULL) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + + foreach (Tree::fields($this->panel) as $field) { + if (array_key_exists($field->id(), $dialog['values'])) { + $field->default($dialog['values'][$field->id()]); + } + } + + $this->provenance = $dialog['provenance']; + + $this->resettle(); + } + + /** + * The dialog standing open, if one is. + * + * @return \DrevOps\Tui\Block\Panel|null + * The panel it was opened from, or NULL when no dialog is open. + */ + protected function standing(): ?Panel { + return $this->dialogs === [] ? NULL : $this->dialogs[count($this->dialogs) - 1]['panel']; + } + + /** + * Rest a panel's own buttons back on the first of them. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel. + */ + protected function reset(Panel $panel): void { + foreach ($panel->place()->blocks() as $block) { + if ($block instanceof Actions) { + $block->select(Ending::Submit->value); + + return; + } + } + } + + /** + * Resolve every answer the form opens on. + * + * The values a collection with no screen at all would arrive at, put onto the + * blocks that hold them: one declaration reaches both paths, so a form opens + * showing exactly what it would have answered headlessly. + */ + protected function seed(): void { + [$values, $provenance, $active] = $this->collector->seed($this->panel, $this->supplied, $this->context); + + foreach (Tree::fields($this->panel) as $field) { + if (array_key_exists($field->id(), $values)) { + $field->default($values[$field->id()]); + } + } + + $this->provenance = $provenance; + $this->active = $active; + + $this->settled(); + } + + /** + * Settle the form again over the answers it now holds. + * + * The same stages the opening answers went through, so a row that follows the + * answers narrows, a computed value recomputes, a condition shows or hides a + * row and a rule that writes a value re-applies - the moment the answer they + * read is taken rather than at the end of the form. + */ + protected function resettle(): void { + [$values, $active] = $this->collector->resettle($this->panel, $this->values(), $this->pinned(), $this->context); + + foreach (Tree::fields($this->panel) as $field) { + if (array_key_exists($field->id(), $values)) { + $field->default($values[$field->id()]); + } + } + + $this->active = $active; + + $this->settled(); + $this->router->reframe(); + } + + /** + * The answers as the blocks hold them, keyed by field id. + * + * @return array + * The values, a row that only shows carrying none. + */ + protected function values(): array { + $values = []; + + foreach (Tree::fields($this->panel) as $field) { + if ($field->type()->isDisplayOnly()) { + continue; + } + + $values[$field->id()] = $field->value(); + } + + return $values; + } + + /** + * The answers of the rows that are there, keyed by field id. + * + * What a condition is measured against: a row that is not there answers + * nothing, so it cannot decide whether another row is there either. + * + * @return array + * The answers. + */ + protected function answered(): array { + $answers = []; + + foreach (Tree::fields($this->panel) as $field) { + if ($field->type()->isDisplayOnly()) { + continue; + } + + if ($this->active[$field->id()] ?? FALSE) { + $answers[$field->id()] = $field->value(); + } + } + + return $answers; + } + + /** + * The computed answers that must not be recomputed, keyed by field id. + * + * A rule computes an answer until somebody answers over it, and detecting one + * outside the form counts as answering it - so both pin the rule, and every + * other computed answer follows it. + * + * @return array + * The pinned map. + */ + protected function pinned(): array { + $pinned = []; + + foreach (Tree::fields($this->panel) as $field) { + if (!$field->derivation() instanceof Derive) { + continue; + } + + $provenance = $this->provenance[$field->id()] ?? Provenance::Default; + $pinned[$field->id()] = $provenance === Provenance::Override || $provenance === Provenance::Detected; + } + + return $pinned; + } + + /** + * Bring the screen into line with the answers that have just settled. + * + * Which rows are there at all and how each answer came to be are both facts + * about the answers rather than about any block, so a block is told them + * whenever they are worked out again. + */ + protected function settled(): void { + $answers = $this->answered(); + + foreach (Tree::panels($this->panel) as $panel) { + foreach ($panel->blocks() as $block) { + if ($block instanceof DependCapableInterface) { + $block->isActive($answers) ? $block->reveal() : $block->hide(); + } + } + } + + foreach (Tree::fields($this->panel) as $field) { + $provenance = $this->provenance[$field->id()] ?? Provenance::Default; + + // How an answer starts out is not news, so saying it of every untouched + // row would badge the whole form and tell a reader nothing. + $field->badge($provenance === Provenance::Default ? '' : $provenance->label()); + } + } + + /** + * Advertise the keys that apply right now, unless the form says not to. + */ + protected function refresh(): void { + if (!$this->footer) { + $this->legend->clear(); + + return; + } + + $this->legend->advertise($this->router->bindings(), ...$this->hints()); + } + + /** + * What the keys on offer do, in the order they are advertised. + * + * Two of them are the session's rather than any block's, which is why they + * are added here: leaving is about the session, and help is a fact about the + * question rather than about whatever is collecting the answer. + * + * @return list<\DrevOps\Tui\Input\Hint> + * The fragments. + */ + protected function hints(): array { + $open = $this->editing(); + $hints = $this->router->hints(); + + // Never beside an open row's keys, where the same letter is something + // being typed rather than a way out. + if (!$open instanceof Field) { + $hints[] = new Hint('quit', Action::Quit); + } + + $asking = $open instanceof Field ? $open : $this->router->focused(); + + if ($asking instanceof Field && $asking->helpText() !== '') { + $hints[] = new Hint('show help', Action::Help); + } + + return $hints; + } + + /** + * The field that is open, if one is. + * + * @return \DrevOps\Tui\Block\Field|null + * The field, or NULL when every row is settled. + */ + protected function editing(): ?Field { + $focused = $this->router->focused(); + + return $focused instanceof Field && $focused->mode() === Mode::Edit ? $focused : NULL; + } + + /** + * The field that has the whole frame to itself, if one has. + * + * @return \DrevOps\Tui\Block\Field|null + * The field, or NULL when the panel is what is drawn. + */ + protected function alone(): ?Field { + $focused = $this->editing(); + + if (!$focused instanceof Field) { + return NULL; + } + + return $focused->renderMode() === RenderMode::Standalone ? $focused : NULL; + } + + /** + * The screen a field that takes the whole frame is drawn on. + * + * The trail and the keys on offer are the blocks the panel's own screen + * draws, so a field with the frame to itself is the same session with one row + * in front of the reader instead of a list. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * + * @return \DrevOps\Tui\Screen\Screen + * The screen. + */ + protected function stage(Field $field): Screen { + // A field with the frame to itself always reads as trail, editor, keys - + // whatever arrangement the session behind it uses. + $screen = (new Screen())->layout(new DefaultLayout()); + + $screen->in('header')->add($this->breadcrumb); + $screen->in(self::CONTENT)->add($field); + $screen->in('footer')->add($this->legend); + + return $screen; + } + + /** + * Draw the open dialog over the screen it was opened from. + * + * @param int $rows + * The terminal rows. + * @param int $columns + * The columns the frame is laid out to. + * + * @return string + * The frame. + */ + protected function overlaid(int $rows, int $columns): string { + $modal = $this->router->current(); + + // What is behind the dialog is the screen as it was before it opened, the + // row the dialog was opened from included. + $modal->leave(); + $behind = explode("\n", $this->renderer->render($this->screen, $rows, $columns)); + $modal->enter(); + + $inset = max(2, intdiv($columns, self::DIALOG_INSET)); + $width = max(1, $columns - 2 * $inset); + $box = explode("\n", $this->dialog($modal, $rows, $width)); + + // Only plain text can be sliced by column, and what shows through beside + // the dialog is sliced on every row it covers. + $backdrop = array_map(static fn(string $line): string => Box::fit(Ansi::strip($line), $columns), $behind); + + [$top, $left] = Overlay::center($columns, count($backdrop), $width, count($box)); + + return implode("\n", Overlay::composite($backdrop, $box, $width, $top, $left, $this->recede(...))); + } + + /** + * Draw the dialog itself: its title over what it holds, inside a border. + * + * @param \DrevOps\Tui\Block\Panel $modal + * The panel the dialog is. + * @param int $rows + * The terminal rows. + * @param int $columns + * The columns the dialog is drawn in. + * + * @return string + * The dialog. + */ + protected function dialog(Panel $modal, int $rows, int $columns): string { + // A dialog is one thing to read under its title, whatever arrangement the + // session behind it uses. + $screen = (new Screen())->layout(new DefaultLayout()); + + $screen->in('header')->add(new Breadcrumb($modal->title())); + $screen->in(self::CONTENT)->add($modal); + + // A dialog is as tall as what it holds - it is one thing to read rather + // than a list to scroll - up to as much of it as the terminal can show. + $height = min($rows, $this->depth($modal) + self::FRAME_RULES + self::DIALOG_RULES); + + // A dialog with no edge is a dialog nobody can see the edge of, so a form + // that draws no frame still draws one around what floats over it. + $renderer = new ScreenRenderer($this->theme, $this->border === Border::None ? Border::Line : $this->border); + + return $renderer->render($screen, $height, $columns); + } + + /** + * The rows a panel's own blocks come to. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel. + * + * @return int + * The rows. + */ + protected function depth(Panel $panel): int { + $rows = 0; + + foreach ($panel->currentLayout()->names() as $name) { + [$total] = $this->renderer->extent($panel->in($name)); + $rows += $total; + } + + return $rows; + } + + /** + * Push back a run of what shows from behind whatever is drawn over it. + * + * @param string $segment + * The run. + * + * @return string + * The receded run. + */ + protected function recede(string $segment): string { + return $this->theme instanceof DimCapableInterface ? $this->theme->dim($segment) : $segment; + } + + /** + * The wash the whole terminal is filled with, behind everything drawn. + * + * @return string|null + * The background, or NULL to keep the terminal's own. + */ + protected function wash(): ?string { + $occupancy = $this->occupancy(); + + return $occupancy instanceof OccupyCapableInterface ? $occupancy->background() : NULL; + } + + /** + * The theme, when it says how much of the terminal it takes. + * + * @return \DrevOps\Tui\Theme\Capability\OccupyCapableInterface|null + * The theme, or NULL when it says nothing about the terminal at all. + */ + protected function occupancy(): ?OccupyCapableInterface { + return $this->theme instanceof OccupyCapableInterface ? $this->theme : NULL; + } + + /** + * The theme, narrowed to the finished pieces the session draws around a form. + * + * @return \DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface + * The theme, able to draw them. + * + * @throws \InvalidArgumentException + * When the theme does not implement the elements. + */ + protected function pieces(): PrimitiveElementsInterface { + if (!$this->theme instanceof PrimitiveElementsInterface) { + $elements = PrimitiveElementsInterface::class; + + throw new \InvalidArgumentException(sprintf('%s cannot draw the session chrome: it does not implement %s.', $this->theme::class, $elements)); + } + + return $this->theme; + } + + /** + * The narrowest terminal the frame can be read in. + * + * 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. + * + * @param \DrevOps\Tui\Theme\Capability\OccupyCapableInterface $occupancy + * What the theme says about the terminal it takes. + * + * @return int + * The columns. + */ + protected function narrowest(OccupyCapableInterface $occupancy): int { + if ($this->minimumWidth !== NULL) { + return $this->minimumWidth; + } + + $stated = $occupancy->minWidth(); + $needed = $stated > 0 ? $stated : $this->widest() + ($this->border === Border::None ? 0 : 2 * self::FRAME_RULES); + $widest = $occupancy->maxWidth(); + + // A cap is a consumer's word that a narrower frame reads well enough, and + // a guard asking for more than the cap allows could never be satisfied. + return $this->minimumWidth = $widest > 0 ? min($needed, $widest) : $needed; + } + + /** + * The shortest terminal the frame can be read in. + * + * @param \DrevOps\Tui\Theme\Capability\OccupyCapableInterface $occupancy + * What the theme says about the terminal it takes. + * + * @return int + * The rows. + */ + protected function shortest(OccupyCapableInterface $occupancy): int { + $tallest = $occupancy->maxHeight(); + + return $tallest > 0 ? min($occupancy->minHeight(), $tallest) : $occupancy->minHeight(); + } + + /** + * The widest row the form draws, whatever room it is given to draw it in. + * + * @return int + * The columns. + */ + protected function widest(): int { + $width = 0; + + foreach (Tree::panels($this->panel) as $panel) { + foreach ($panel->blocks() as $block) { + // A panel is measured by what it holds rather than by the row it draws, + // and an entered one draws no row at all. + if ($block instanceof Panel) { + continue; + } + + if ($block instanceof DependCapableInterface && $block->isHidden()) { + continue; + } + + $width = max($width, Ansi::blockWidth(explode("\n", $block->render($this->theme)))); + } + } + + return $width; + } + + /** + * Put the way out into each panel that has one of its own. + * + * @param \DrevOps\Tui\Screen\Assembler $assembler + * The assembler that builds the standard pair. + */ + protected function dress(Assembler $assembler): void { + // The buttons end the form rather than the panel the cursor happens to be + // in, so they go among the outermost panel's own rows: going into a nested + // one leaves them behind exactly as it leaves that panel's siblings behind. + if ($this->panel->currentButtons()->show && !$this->carries($this->panel)) { + $this->panel->place()->add($this->notice)->add($this->actions); + } + + foreach (Tree::panels($this->panel) as $panel) { + // The outermost panel is not somewhere you opened, so it has nothing of + // its own to close: the buttons in it are the form's. + if ($panel === $this->panel) { + continue; + } + if (!$panel->isModal()) { + continue; + } + if ($this->carries($panel)) { + continue; + } + + $region = $panel->place(); + + // A dialog that only says something says it here: its standing text is + // its whole content, where a panel you walk into has rows instead. + if ($panel->descriptionText() !== '') { + $region->prepend(new Markup($panel->id() . '-description', $panel->descriptionText())); + } + + $region->add($this->buttons($assembler, $panel)); + } + + foreach (Tree::fields($this->panel) as $field) { + $field->handoff($this->externalEditor->isAvailable()); + $field->reuse(...$this->collector->reusable($field->id())); + } + } + + /** + * The buttons that close a panel, labelled as it declares them. + * + * @param \DrevOps\Tui\Screen\Assembler $assembler + * The assembler that builds the standard pair. + * @param \DrevOps\Tui\Block\Panel $panel + * The panel they close. + * + * @return \DrevOps\Tui\Block\Actions + * The buttons. + */ + protected function buttons(Assembler $assembler, Panel $panel): Actions { + $declared = $panel->currentButtons(); + + // The assembler says which buttons a form has; the panel says what they + // read, so a form that renamed its submit gets the name it chose. + return ($this->placed($panel) ?? $assembler->actions()) + ->action(Ending::Submit->value, Translator::t($declared->submitLabel)) + ->action(Ending::Cancel->value, Translator::t($declared->cancelLabel)); + } + + /** + * The way out a panel is already carrying, if it has been given one. + * + * One declaration outlives the session driving it, so a second run over the + * same panels takes on the buttons that are there rather than putting a + * second pair beside them. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel. + * + * @return \DrevOps\Tui\Block\Actions|null + * The buttons, or NULL when the panel carries none. + */ + protected function placed(Panel $panel): ?Actions { + foreach ($panel->place()->blocks() as $block) { + if ($block instanceof Actions) { + return $block; + } + } + + return NULL; + } + + /** + * The row a panel is already carrying for what withholds its end. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel. + * + * @return \DrevOps\Tui\Block\Markup|null + * The row, or NULL when the panel carries none. + */ + protected function stated(Panel $panel): ?Markup { + foreach ($panel->place()->blocks() as $block) { + if ($block instanceof Markup && $block->id() === self::NOTICE) { + return $block; + } + } + + return NULL; + } + + /** + * Whether a panel already carries the way out of it. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel. + * + * @return bool + * TRUE when it does. + */ + protected function carries(Panel $panel): bool { + return $this->placed($panel) instanceof Actions; + } + + /** + * The screen a field's help is drawn on. + * + * The trail and the keys on offer are the same blocks the panel's own screen + * draws, so asking for help changes what is being read and nothing else. + * + * @return \DrevOps\Tui\Screen\Screen + * The screen. + */ + protected function helpScreen(): Screen { + // Help always reads as trail, card, keys - whatever arrangement the + // session behind it uses. + $screen = (new Screen())->layout(new DefaultLayout()); + + $screen->in('header')->add($this->breadcrumb); + $screen->in(self::CONTENT)->add($this->help); + $screen->in('footer')->add($this->legend); + + return $screen; + } + + /** + * A piece of standard furniture the assembler put in a region. + * + * @param string $name + * The region name. + * @param class-string $kind + * The kind of block it is. + * + * @return T + * The block. + * + * @throws \LogicException + * When the region holds no block of that kind. + * + * @template T of \DrevOps\Tui\Block\BlockInterface + */ + protected function furniture(string $name, string $kind): ?object { + if (!in_array($name, $this->screen->currentLayout()->names(), TRUE)) { + return NULL; + } + + foreach ($this->screen->in($name)->blocks() as $block) { + if ($block instanceof $kind) { + return $block; + } + } + + // The assembler puts one of each into every region it furnishes, so a + // furnished region missing its piece never reaches a frame. + // @codeCoverageIgnoreStart + throw new \LogicException(sprintf('The assembled screen holds no %s in its "%s" region.', $kind, $name)); + // @codeCoverageIgnoreEnd + } + +} diff --git a/src/Screen/ScreenRenderer.php b/src/Screen/ScreenRenderer.php new file mode 100644 index 00000000..e43dc053 --- /dev/null +++ b/src/Screen/ScreenRenderer.php @@ -0,0 +1,702 @@ +border === Border::None) { + return implode("\n", $this->lay($screen->currentLayout(), $rows, $columns)); + } + + // A frame spends a rule top and bottom, and a border column plus a gutter + // each side, so what the layout is given is the terminal less its chrome. + $inside = $this->lay($screen->currentLayout(), max(0, $rows - 2), max(1, $columns - 4)); + + return implode("\n", $this->framed($inside, $columns)); + } + + /** + * The rows a region's blocks come to, and which one a block starts on. + * + * The same walk a frame makes, counted rather than drawn: a region that + * scrolls is moved against these numbers, so what a driver measures and what + * a reader sees are worked out by one rule rather than by two free to drift + * apart. It measures a region's own blocks, which is what the panel you are + * in holds - going into a panel is where a layout starts rather than where a + * row is counted. + * + * @param \DrevOps\Tui\Screen\Region $region + * The region. + * @param \DrevOps\Tui\Block\BlockInterface|null $of + * The block to locate, if one is being looked for. + * + * @return array{int,int} + * The rows its blocks come to, and the first row of the given block - or + * -1 when it holds no such block. + */ + public function extent(Region $region, ?BlockInterface $of = NULL): array { + $total = 0; + $row = -1; + $spaced = $this->spaced(); + + foreach ($this->pieces($region) as $piece) { + // Every piece that draws at all costs a row, so anything past zero is a + // piece with air owed above it. + if ($spaced && $total > 0) { + $total++; + } + + $at = array_search($of, $piece['blocks'], TRUE); + + if ($of instanceof BlockInterface && is_int($at)) { + $row = $total + $piece['offsets'][$at]; + } + + $total += $piece['height']; + } + + return [$total, $row]; + } + + /** + * What a region stacks, piece by piece. + * + * A grid stacks as one piece however many windows it deals into it, which is + * exactly how it draws: windows sitting beside each other share the rows they + * are drawn on, so counting them one under another would say the region is + * several times as deep as anyone can see. + * + * @param \DrevOps\Tui\Screen\Region $region + * The region. + * + * @return list,offsets:list}> + * Each piece: the rows it comes to, the blocks drawn in it, and the row + * each of those starts on within it. + */ + protected function pieces(Region $region): array { + $blocks = $region->blocks(); + + if ($region->gridRows() === []) { + return $this->stacked($blocks); + } + + [$panels, $above, $below] = $this->dealt($blocks); + $windows = $this->windowPiece($panels, $region->gridRows()); + + return [ + ...$this->stacked($above), + ...($windows['height'] > 0 ? [$windows] : []), + ...$this->stacked($below), + ]; + } + + /** + * One piece per block, in the order they are drawn. + * + * @param list<\DrevOps\Tui\Block\BlockInterface> $blocks + * The blocks. + * + * @return list,offsets:list}> + * The pieces. + */ + protected function stacked(array $blocks): array { + $pieces = []; + + foreach ($this->rendered($blocks) as $index => $rendered) { + $pieces[] = [ + 'height' => substr_count($rendered, "\n") + 1, + 'blocks' => [$blocks[$index]], + 'offsets' => [0], + ]; + } + + return $pieces; + } + + /** + * The one piece a grid of windows stacks as. + * + * @param list<\DrevOps\Tui\Block\Panel> $panels + * The panels, in the order they were placed. + * @param list $grid + * How many of them share each visual row. + * + * @return array{height:int,blocks:list<\DrevOps\Tui\Block\BlockInterface>,offsets:list} + * The rows the grid comes to, its windows, and the row each starts on. + */ + protected function windowPiece(array $panels, array $grid): array { + $blocks = []; + $offsets = []; + $height = 0; + $taken = 0; + + foreach ($grid as $count) { + // Every visual row after the first is told apart from the one above it, + // exactly as it is drawn. + if ($height > 0) { + $height++; + } + + $tallest = 0; + + foreach (array_slice($panels, $taken, $count) as $panel) { + $blocks[] = $panel; + $offsets[] = $height; + $tallest = max($tallest, substr_count($panel->preview($this->theme), "\n") + 1); + $taken += 1; + } + + $height += $tallest; + } + + return ['height' => $height, 'blocks' => $blocks, 'offsets' => $offsets]; + } + + /** + * Deal a region's blocks into its windows and the rows around them. + * + * @param list<\DrevOps\Tui\Block\BlockInterface> $blocks + * The blocks. + * + * @return array{list<\DrevOps\Tui\Block\Panel>,list<\DrevOps\Tui\Block\BlockInterface>,list<\DrevOps\Tui\Block\BlockInterface>} + * The panels the grid deals, the rows above it, and the rows below it. A + * row placed before the first window stays above the grid and one placed + * after the last stays below it, so nothing moves past a row it was + * written above. + */ + protected function dealt(array $blocks): array { + $panels = []; + $above = []; + $below = []; + + foreach ($blocks as $block) { + if ($block instanceof Panel) { + $panels[] = $block; + + continue; + } + + if ($panels === []) { + $above[] = $block; + + continue; + } + + $below[] = $block; + } + + return [$panels, $above, $below]; + } + + /** + * Wrap laid-out rows in the frame that surrounds every region at once. + * + * @param list $lines + * The rows as the layout arranged them. + * @param int $columns + * The columns the frame spans, including its own. + * + * @return list + * The framed rows. + */ + protected function framed(array $lines, int $columns): array { + $chrome = $this->chrome(); + $chars = Box::chars($this->border, $this->unicode()); + $inner = max(1, $columns - 4); + $bar = $chrome->chromeBorder($chars['v']); + + $out = [$chrome->chromeBorder(Box::rule($chars['tl'], $chars['tr'], $chars['h'], $columns))]; + + foreach ($lines as $line) { + $out[] = $bar . ' ' . Box::fit($line, $inner) . ' ' . $bar; + } + + $out[] = $chrome->chromeBorder(Box::rule($chars['bl'], $chars['br'], $chars['h'], $columns)); + + return $out; + } + + /** + * The theme, narrowed to the elements the window chrome composes. + * + * @return \DrevOps\Tui\Block\Element\ChromeElementsInterface + * The theme, able to draw the chrome. + * + * @throws \InvalidArgumentException + * When the theme does not implement the elements. + */ + protected function chrome(): ChromeElementsInterface { + if (!$this->theme instanceof ChromeElementsInterface) { + $elements = ChromeElementsInterface::class; + + throw new \InvalidArgumentException(sprintf('%s cannot draw the window chrome: it does not implement %s.', $this->theme::class, $elements)); + } + + return $this->theme; + } + + /** + * Whether the frame is drawn with glyphs rather than their stand-ins. + * + * @return bool + * TRUE when the theme declared it handles them. + */ + protected function unicode(): bool { + return $this->theme instanceof UnicodeCapableInterface && $this->theme->hasUnicode(); + } + + /** + * Draw a layout into a space. + * + * @param \DrevOps\Tui\Screen\Layout\LayoutInterface $layout + * The layout. + * @param int $rows + * The rows it may fill. + * @param int $columns + * The columns it may fill. + * + * @return list + * The rows. + */ + protected function lay(LayoutInterface $layout, int $rows, int $columns): array { + $names = $layout->names(); + + if ($names === []) { + return []; + } + + $down = $layout->axis() === Axis::Rows; + $sizes = $layout->arrange($down ? $rows : $columns); + + // A rows layout stacks its regions; a columns layout draws each into its + // own width and then pastes them side by side onto shared rows. + if ($down) { + $out = []; + + foreach ($sizes as $name => $size) { + foreach ($this->fill($layout->in($name), $size, $columns) as $line) { + $out[] = $line; + } + } + + return $out; + } + + $columns_out = []; + + foreach ($sizes as $name => $size) { + $columns_out[] = $this->fill($layout->in($name), $rows, $size); + } + + return $this->paste($columns_out, $sizes, $rows); + } + + /** + * Draw a region's blocks into the space it was given. + * + * @param \DrevOps\Tui\Screen\Region $region + * The region. + * @param int $rows + * The rows it was given. + * @param int $columns + * The columns it was given. + * + * @return list + * Exactly $rows rows, padded or clipped to fit. + */ + protected function fill(Region $region, int $rows, int $columns): array { + $lines = $this->arrange($region, $rows, $columns); + + // Its contents are its own problem once it has a size: it scrolls if it was + // declared to, and clips if it was not. Either way it hands back the rows + // it was given, so the frame stays the shape the layout worked out. + $content = count($lines); + $from = $region->isScrolling() ? $region->offset($content, $rows) : 0; + $lines = array_slice($lines, $from, max(0, $rows)); + + while (count($lines) < $rows) { + $lines[] = ''; + } + + $lines = array_map(static fn(string $line): string => Box::fit($line, $columns), $lines); + + // Only a region you can move through says there is more: one that clips + // says nothing, because there is no way to reach what it dropped. + if (!$region->isScrolling()) { + return $lines; + } + + return $this->marked($lines, $columns, $from > 0, $from + $rows < $content); + } + + /** + * Run a region's blocks the way it was declared they run. + * + * An entered panel is where the next layout starts, which is where depth + * comes from rather than a fifth level. Only the renderer knows the box it + * has to fit into, so the recursion happens here. + * + * Going into a panel replaces the screen with its contents, so the panel + * takes the whole region and the rows placed beside it are the ones you left + * behind - the sibling rows of the panel you came from, and the buttons that + * end the form, which sit among the outermost panel's own rows. Coming back + * out draws every one of them again. + * + * @param \DrevOps\Tui\Screen\Region $region + * The region. + * @param int $rows + * The rows it was given. + * @param int $columns + * The columns it was given. + * + * @return list + * The rows, before the region is sized to the space it has. + */ + protected function arrange(Region $region, int $rows, int $columns): array { + $blocks = $region->blocks(); + + foreach ($blocks as $block) { + if ($block instanceof Panel && $block->isEntered()) { + return $this->lay($block->currentLayout(), $rows, $columns); + } + } + + if ($region->gridRows() !== []) { + return $this->gridded($blocks, $region->gridRows(), $columns); + } + + $drawn = array_values($this->rendered($blocks)); + + return $region->flowAxis() === Axis::Columns ? $this->across($drawn) : $this->down($drawn); + } + + /** + * Stack a region's own rows, then deal its panels into visual rows. + * + * @param list<\DrevOps\Tui\Block\BlockInterface> $blocks + * The blocks. + * @param list $grid + * How many panels share each visual row, top to bottom. + * @param int $columns + * The columns the region was given. + * + * @return list + * The rows. + */ + protected function gridded(array $blocks, array $grid, int $columns): array { + [$panels, $above, $below] = $this->dealt($blocks); + $windows = implode("\n", $this->windows($panels, $grid, $columns)); + + // The whole grid stacks as one more block, so what shows between it and + // the rows around it is the same air that shows between any two of them. + return $this->down([ + ...array_values($this->rendered($above)), + ...($windows === '' ? [] : [$windows]), + ...array_values($this->rendered($below)), + ]); + } + + /** + * Paste each visual row's panels side by side at one column width. + * + * @param list<\DrevOps\Tui\Block\Panel> $panels + * The panels, in the order they were placed. + * @param list $grid + * How many of them share each visual row. + * @param int $columns + * The columns the region was given. + * + * @return list + * The rows. + */ + protected function windows(array $panels, array $grid, int $columns): array { + $lines = []; + $taken = 0; + + foreach ($grid as $count) { + // Every visual row after the first is told apart from the one above it, + // whatever the theme says about the air between one block and the next. + if ($lines !== []) { + $lines[] = ''; + } + + $width = max(1, intdiv($columns - ($count - 1) * self::GUTTER, max(1, $count))); + $windows = []; + $height = 0; + + foreach (array_slice($panels, $taken, $count) as $panel) { + $window = explode("\n", $panel->preview($this->theme)); + $height = max($height, count($window)); + $windows[] = $window; + $taken += 1; + } + + for ($row = 0; $row < $height; $row++) { + $cells = []; + + foreach ($windows as $window) { + $cells[] = Box::fit($window[$row] ?? '', $width); + } + + // The gutters can outgrow a tiny frame even at one-column cells, so the + // assembled row is clamped to the region as a whole. + $lines[] = rtrim(Box::fit(implode(str_repeat(' ', self::GUTTER), $cells), $columns)); + } + } + + return $lines; + } + + /** + * Mark a region's edges where its contents run past them. + * + * @param list $lines + * The rows the region hands back. + * @param int $columns + * The columns it was given. + * @param bool $above + * Whether there is content above the first row. + * @param bool $below + * Whether there is content below the last row. + * + * @return list + * The rows, marked at whichever edges they outran. + */ + protected function marked(array $lines, int $columns, bool $above, bool $below): array { + if ($lines === []) { + return $lines; + } + + if ($above) { + $lines[0] = $this->mark($lines[0], $columns, TRUE); + } + + if ($below) { + $last = count($lines) - 1; + $lines[$last] = $this->mark($lines[$last], $columns, FALSE); + } + + return $lines; + } + + /** + * Put an overflow mark at the far edge of one row. + * + * @param string $line + * The row, already fitted to the columns it was given. + * @param int $columns + * The columns it was given. + * @param bool $above + * Whether the content it points at is above rather than below. + * + * @return string + * The row, ending in the mark. + */ + protected function mark(string $line, int $columns, bool $above): string { + $marker = $this->chrome()->chromeOverflowMarker($above); + $width = Ansi::width($marker); + + // The mark sits at the region's own edge rather than on a row of its own, + // so saying there is more never costs a row of what there is. + return $width >= $columns ? $line : Box::fit($line, $columns - $width) . $marker; + } + + /** + * Stack rendered blocks down a region, spaced as the theme asks. + * + * What shows between the rows a region holds is the theme's to say and the + * flow's to do: a block draws itself and never learns what sits above or + * below it, so the air between two of them can only be put in here. + * + * @param list $drawn + * The rendered blocks. + * + * @return list + * The rows. + */ + protected function down(array $drawn): array { + $lines = []; + $spaced = $this->spaced(); + + foreach ($drawn as $block) { + if ($spaced && $lines !== []) { + $lines[] = ''; + } + + foreach (explode("\n", $block) as $line) { + $lines[] = $line; + } + } + + return $lines; + } + + /** + * Whether a blank row shows between one block in a region and the next. + * + * @return bool + * TRUE when the theme asks for the air; FALSE when the rows stack against + * each other, and when the theme says nothing about spacing at all. + */ + protected function spaced(): bool { + return $this->theme instanceof OccupyCapableInterface && $this->theme->spacing() === Spacing::Padded; + } + + /** + * What each block in a region drew, keyed by where it sits in it. + * + * @param list<\DrevOps\Tui\Block\BlockInterface> $blocks + * The blocks. + * + * @return array + * What each block that drew anything drew. + */ + protected function rendered(array $blocks): array { + $drawn = []; + + foreach ($blocks as $index => $block) { + // A block the answers took off the screen is not there at all: it costs + // no row, rather than costing a blank one. + if ($block instanceof DependCapableInterface && $block->isHidden()) { + continue; + } + + $rendered = $block->render($this->theme); + + // Nor is a block with nothing to say: what shows between one row and the + // next is the flow's to decide, so a block that drew nothing must not + // leave a blank row behind for the spacing to be added around. + if ($rendered === '') { + continue; + } + + $drawn[$index] = $rendered; + } + + return $drawn; + } + + /** + * Run rendered blocks across a region. + * + * @param list $drawn + * The rendered blocks. + * + * @return list + * The rows. + */ + protected function across(array $drawn): array { + $blocks = array_map(static fn(string $block): array => explode("\n", $block), $drawn); + $widths = array_map(static fn(array $lines): int => max(array_map(Ansi::width(...), $lines)), $blocks); + $height = $blocks === [] ? 0 : max(array_map(count(...), $blocks)); + $lines = []; + + for ($row = 0; $row < $height; $row++) { + $parts = []; + + foreach ($blocks as $index => $block) { + $parts[] = Box::fit($block[$row] ?? '', $widths[$index]); + } + + $lines[] = rtrim(implode(' ', $parts)); + } + + return $lines; + } + + /** + * Paste columns side by side onto shared rows. + * + * @param list> $columns + * Each column's rows. + * @param array $widths + * Each column's width, in declaration order. + * @param int $rows + * The rows to produce. + * + * @return list + * The rows. + */ + protected function paste(array $columns, array $widths, int $rows): array { + $widths = array_values($widths); + $out = []; + + for ($row = 0; $row < $rows; $row++) { + $parts = []; + + foreach ($columns as $index => $column) { + $parts[] = Box::fit($column[$row] ?? '', $widths[$index]); + } + + $out[] = implode('', $parts); + } + + return $out; + } + +} diff --git a/src/Screen/Source.php b/src/Screen/Source.php new file mode 100644 index 00000000..ff917c43 --- /dev/null +++ b/src/Screen/Source.php @@ -0,0 +1,18 @@ +read()) instanceof Key) { + $field->handle($key); + + if ($field->isComplete() || $field->isCancelled()) { + break; + } + } + + return $field->isCancelled() ? NULL : $field->value(); + } + +} diff --git a/src/Testing/KeyStreamInterface.php b/src/Testing/KeyStreamInterface.php index e962901c..641e3cdb 100644 --- a/src/Testing/KeyStreamInterface.php +++ b/src/Testing/KeyStreamInterface.php @@ -7,7 +7,7 @@ use DrevOps\Tui\Input\Key; /** - * A source of key presses consumed by the widgets. + * A source of key presses consumed by the fields. * * @package DrevOps\Tui\Testing */ diff --git a/src/Testing/ScreenTester.php b/src/Testing/ScreenTester.php new file mode 100644 index 00000000..d5c59bb5 --- /dev/null +++ b/src/Testing/ScreenTester.php @@ -0,0 +1,548 @@ +root()); + * $answers = $tester->run(Key::named(KeyName::Enter), 'Ada', Key::named(KeyName::Enter)); + * $this->assertSame('Ada', $answers->value('courier')); + * @endcode + * + * @package DrevOps\Tui\Testing + */ +final class ScreenTester { + + /** + * The message thrown when a result is read before run() was called. + */ + protected const string NOT_RUN = 'Call run() before reading the results.'; + + /** + * The theme display options merged over the deterministic defaults. + * + * @var array + */ + protected array $options = ['color' => FALSE, 'unicode' => TRUE, 'mode' => Mode::Dark]; + + /** + * The theme the blocks draw through, once one is given. + */ + protected ?ThemeInterface $theme = NULL; + + /** + * The bindings the screen answers to, once some are given. + */ + protected ?KeyMap $keys = NULL; + + /** + * What resolves the answers the form opens on, once one is given. + */ + protected ?Collector $collector = NULL; + + /** + * The run the session belongs to. + */ + protected Context $context; + + /** + * Values supplied for the fields, keyed by field id. + * + * @var array + */ + protected array $supplied = []; + + /** + * The layout the screen is arranged by. + */ + protected string $layout = 'default'; + + /** + * The frame drawn around every region at once. + */ + protected Border $border = Border::None; + + /** + * Whether the screen is cleared as the session ends. + */ + protected bool $clearOnExit = TRUE; + + /** + * Whether the keys that apply right now are advertised at all. + */ + protected bool $footer = TRUE; + + /** + * What is shown before the form, dismissed by any key. + */ + protected string $banner = ''; + + /** + * The version shown under that banner. + */ + protected string $version = ''; + + /** + * What hands a passage of text to an editor of the reader's own. + */ + protected ?ExternalEditor $externalEditor = NULL; + + /** + * The reported terminal height. + */ + protected int $rows = 24; + + /** + * The reported terminal width. + */ + protected int $cols = 80; + + /** + * The terminal the last run() drove, or NULL before the first run. + */ + protected ?BufferedTerminal $terminal = NULL; + + /** + * The answers the last run() collected, or NULL when it ended another way. + */ + protected ?Answers $answers = NULL; + + /** + * Construct a tester for a panel. + * + * @param \DrevOps\Tui\Block\Panel $panel + * The panel the screen starts in: the tree a form declares. + */ + public function __construct(protected Panel $panel) { + $this->context = new Context(); + } + + /** + * Set the theme the blocks draw through. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return $this + * The tester. + */ + public function theme(ThemeInterface $theme): self { + $this->theme = $theme; + + return $this; + } + + /** + * Merge theme display options over the deterministic defaults. + * + * @param array $options + * The options (e.g. "color", "unicode", "mode"). + * + * @return $this + * The tester. + */ + public function options(array $options): self { + $this->options = $options + $this->options; + + return $this; + } + + /** + * Set the bindings the screen answers to. + * + * @param \DrevOps\Tui\Input\KeyMap $keys + * The bindings. + * + * @return $this + * The tester. + */ + public function keys(KeyMap $keys): self { + $this->keys = $keys; + + return $this; + } + + /** + * Set what resolves the answers the form opens on. + * + * @param \DrevOps\Tui\Screen\Collector $collector + * The collector, carrying whatever behaviour is reused across forms and + * whatever rules apply once the answers have settled. + * + * @return $this + * The tester. + */ + public function collector(Collector $collector): self { + $this->collector = $collector; + + return $this; + } + + /** + * Set the run the session belongs to. + * + * @param \DrevOps\Tui\Handler\Context $context + * The context. + * + * @return $this + * The tester. + */ + public function context(Context $context): self { + $this->context = $context; + + return $this; + } + + /** + * Supply values for the fields, as a caller of the form would. + * + * @param array $supplied + * The values, keyed by field id. + * + * @return $this + * The tester. + */ + public function supplied(array $supplied): self { + $this->supplied = $supplied; + + return $this; + } + + /** + * Set the layout the screen is arranged by. + * + * @param string $layout + * The layout name or class. + * + * @return $this + * The tester. + */ + public function layout(string $layout): self { + $this->layout = $layout; + + return $this; + } + + /** + * Set the frame drawn around every region at once. + * + * @param \DrevOps\Tui\Theme\Border $border + * The border. + * + * @return $this + * The tester. + */ + public function border(Border $border): self { + $this->border = $border; + + return $this; + } + + /** + * Set whether the screen is cleared as the session ends. + * + * @param bool $clear + * Whether it is cleared. + * + * @return $this + * The tester. + */ + public function clearOnExit(bool $clear): self { + $this->clearOnExit = $clear; + + return $this; + } + + /** + * Set whether the keys that apply right now are advertised at all. + * + * @param bool $show + * Whether they are. + * + * @return $this + * The tester. + */ + public function footer(bool $show): self { + $this->footer = $show; + + return $this; + } + + /** + * Set what is shown before the form, dismissed by any key. + * + * @param string $banner + * The banner. + * @param string $version + * The version shown under it. + * + * @return $this + * The tester. + */ + public function banner(string $banner, string $version = ''): self { + $this->banner = $banner; + $this->version = $version; + + return $this; + } + + /** + * Set what hands a passage of text to an editor of the reader's own. + * + * @param \DrevOps\Tui\Render\ExternalEditor $editor + * The editor service, so a test can answer for one without launching a + * program or depending on the environment naming one. + * + * @return $this + * The tester. + */ + public function externalEditor(ExternalEditor $editor): self { + $this->externalEditor = $editor; + + return $this; + } + + /** + * Set the reported terminal height. + * + * @param int $rows + * The number of rows. + * + * @return $this + * The tester. + */ + public function rows(int $rows): self { + $this->rows = $rows; + + return $this; + } + + /** + * Set the reported terminal width. + * + * @param int $cols + * The number of columns, at least 1. + * + * @return $this + * The tester. + * + * @throws \InvalidArgumentException + * When the width is below one column, which no frame can be laid out to. + */ + public function cols(int $cols): self { + if ($cols < 1) { + throw new \InvalidArgumentException('The terminal width must be at least 1 column.'); + } + + $this->cols = $cols; + + return $this; + } + + /** + * Run the session, feeding it the given scripted keystrokes. + * + * @param string|\DrevOps\Tui\Input\Key ...$items + * The scripted input: each item is either raw keystroke bytes (a string, + * e.g. "\n" or "Ada") or a Key, encoded to its canonical bytes. + * + * @return \DrevOps\Tui\Answers\Answers + * The collected answers. + * + * @throws \DrevOps\Tui\InterruptException + * When the scripted keys aborted the session. + * @throws \DrevOps\Tui\CancelException + * When the scripted keys abandoned the form. + */ + public function run(string|Key ...$items): Answers { + $keystrokes = []; + + foreach ($items as $item) { + $keystrokes[] = $item instanceof Key ? KeyEncoder::encode($item) : $item; + } + + $this->answers = NULL; + $this->terminal = new BufferedTerminal($keystrokes, $this->rows, $this->cols); + + return $this->answers = $this->controller()->run($this->terminal); + } + + /** + * The answers the last run() collected. + * + * @return \DrevOps\Tui\Answers\Answers + * The answers. + * + * @throws \LogicException + * When run() has not been called, or ended without collecting any. + */ + public function answers(): Answers { + return $this->answers ?? throw new \LogicException(self::NOT_RUN); + } + + /** + * Everything the last run() wrote to the terminal. + * + * Readable after a session that ended by being abandoned or aborted, so what + * was on screen at the time can still be asserted on. + * + * @return string + * The captured output, including ANSI escape sequences. + * + * @throws \LogicException + * When run() has not been called yet. + */ + public function output(): string { + return $this->terminal instanceof BufferedTerminal ? $this->terminal->output() : throw new \LogicException(self::NOT_RUN); + } + + /** + * The captured output with its ANSI escape sequences stripped. + * + * @return string + * The stripped output, convenient for substring assertions. + * + * @throws \LogicException + * When run() has not been called yet. + */ + public function display(): string { + return Ansi::strip($this->output()); + } + + /** + * The frames the last run() drew, in the order they were drawn. + * + * @return list + * The frames, each as it reached the terminal. + * + * @throws \LogicException + * When run() has not been called yet. + */ + public function frames(): array { + $frames = []; + /** @var non-empty-string $clear */ + $clear = TerminalControl::clear(); + + // Every frame is written behind a screen clear, which is what tells one + // from the next; the clear that ends the session writes no frame at all. + foreach (explode($clear, $this->output()) as $frame) { + if ($frame !== '') { + $frames[] = $frame; + } + } + + return $frames; + } + + /** + * One frame the last run() drew. + * + * @param int $index + * The frame, counted from the first; a negative index counts back from the + * last, so -1 is the frame the session ended on. + * + * @return string + * The frame, with its ANSI escape sequences stripped. + * + * @throws \LogicException + * When run() has not been called yet. + * @throws \OutOfBoundsException + * When no frame was drawn at that index. + */ + public function frame(int $index = -1): string { + $frames = $this->frames(); + $at = $index < 0 ? count($frames) + $index : $index; + + if (!isset($frames[$at])) { + throw new \OutOfBoundsException(sprintf('The session drew %d frames, so there is none at index %d.', count($frames), $index)); + } + + return Ansi::strip($frames[$at]); + } + + /** + * The controller the run drives. + * + * @return \DrevOps\Tui\Screen\ScreenController + * The controller. + */ + protected function controller(): ScreenController { + return new ScreenController( + $this->panel, + $this->theme ?? new DefaultTheme($this->width(), $this->themeOptions()), + $this->supplied, + $this->keys, + $this->collector, + $this->context, + $this->layout, + $this->border, + $this->clearOnExit, + $this->footer, + $this->banner, + $this->version, + $this->externalEditor, + ); + } + + /** + * The display options the theme is built from. + * + * The frame's border is stated on the tester rather than among these, and a + * theme that believes it is drawing a different one lays its rows out to a + * width the frame does not have - so a row aligned against the theme's width + * would stop short of the frame's edge, or run past it. Stating it here keeps + * the two in step, and an option a consumer sets by hand still wins. + * + * @return array + * The options. + */ + protected function themeOptions(): array { + return $this->options + ['border' => $this->border->value]; + } + + /** + * The width the theme lays a frame out to. + * + * @return int + * The columns: the terminal's when the frame takes the whole of it, else + * the width a panel reads at, clamped to the room there is. + */ + protected function width(): int { + if (($this->options['fullscreen'] ?? FALSE) === TRUE) { + return $this->cols; + } + + return min(DefaultTheme::DEFAULT_WIDTH, $this->cols); + } + +} diff --git a/src/Testing/TuiTester.php b/src/Testing/TuiTester.php index e0ba7ad7..0b7b6328 100644 --- a/src/Testing/TuiTester.php +++ b/src/Testing/TuiTester.php @@ -6,8 +6,9 @@ use DrevOps\Tui\Answers\Answers; use DrevOps\Tui\Builder\Form; +use DrevOps\Tui\CancelException; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Model\FormDefinition; +use DrevOps\Tui\InterruptException; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Theme\Mode; use DrevOps\Tui\Tui; @@ -15,7 +16,7 @@ /** * Drives a form's interactive panel TUI from scripted keystrokes. * - * The form-level companion to {@see \DrevOps\Tui\Testing\WidgetRunner}: it + * The form-level companion to {@see \DrevOps\Tui\Testing\FieldRunner}: it * feeds keystrokes through a scripted terminal's read() and runs the real * panel loop, so a consumer can assert on the collected answers and on what * was rendered - without a real TTY. Keystrokes are supplied as raw byte @@ -101,14 +102,14 @@ final class TuiTester { /** * Construct a tester for a form. * - * @param \DrevOps\Tui\Model\FormDefinition|\DrevOps\Tui\Builder\Form $form - * The form under test: a Form builder or its built definition. + * @param \DrevOps\Tui\Builder\Form $form + * The form under test. * @param string[] $handler_namespaces * Namespaces searched for per-field consumer classes. * @param string $env_prefix * The env-variable prefix for per-question overrides. */ - public function __construct(FormDefinition|Form $form, array $handler_namespaces = [], string $env_prefix = '') { + public function __construct(Form $form, array $handler_namespaces = [], string $env_prefix = '') { $this->tui = new Tui($form, $handler_namespaces, $env_prefix); } @@ -127,6 +128,21 @@ public function theme(string $theme): self { return $this; } + /** + * Set the layout the screen is arranged by. + * + * @param string $layout + * The layout name or class. + * + * @return $this + * The tester. + */ + public function layout(string $layout): self { + $this->tui->layout($layout); + + return $this; + } + /** * Merge theme display options over the deterministic defaults. * @@ -249,10 +265,27 @@ public function run(string|Key ...$items): Answers { $controller = $this->tui->controller($this->options, $this->theme, '', $this->version, $this->directory, $width, $this->update); - $this->answers = $controller->run($terminal); - $this->output = $terminal->output(); - $this->cancelled = $controller->isCancelled(); - $this->interrupted = $controller->isInterrupted(); + $this->cancelled = FALSE; + $this->interrupted = FALSE; + + // A session that ends without a submit raises rather than returning, and a + // test asserting on how a run ended is asking a question about it rather + // than being surprised by it - so the ending is recorded and the answers as + // they stood are handed back either way. + try { + $this->answers = $controller->run($terminal); + } + catch (CancelException) { + $this->cancelled = TRUE; + $this->answers = $controller->answers(); + } + catch (InterruptException) { + $this->interrupted = TRUE; + $this->answers = $controller->answers(); + } + finally { + $this->output = $terminal->output(); + } return $this->answers; } diff --git a/src/Testing/WidgetRunner.php b/src/Testing/WidgetRunner.php deleted file mode 100644 index 1704df5f..00000000 --- a/src/Testing/WidgetRunner.php +++ /dev/null @@ -1,40 +0,0 @@ -read()) instanceof Key) { - $widget->handle($key); - - if ($widget->isComplete() || $widget->isCancelled()) { - break; - } - } - - return $widget->isCancelled() ? NULL : $widget->value(); - } - -} diff --git a/src/Theme/AbstractTheme.php b/src/Theme/AbstractTheme.php new file mode 100644 index 00000000..0299a439 --- /dev/null +++ b/src/Theme/AbstractTheme.php @@ -0,0 +1,457 @@ +width; + } + + /** + * {@inheritdoc} + */ + public function keyGlyph(Key $key): string { + // Its name, because a name is what the floor has: an arrow is outside ASCII + // and a shorthand is a vocabulary a theme with a terminal behind it can + // afford to teach. + return $key->label(); + } + + /** + * {@inheritdoc} + */ + public function chromeBorder(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function chromeOverflowMarker(bool $above): string { + return $above ? '^' : 'v'; + } + + /** + * {@inheritdoc} + */ + public function breadcrumbLabel(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function breadcrumbSeparator(): string { + return '>'; + } + + /** + * {@inheritdoc} + */ + public function legendKey(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function legendDescription(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function legendSeparator(): string { + return '*'; + } + + /** + * {@inheritdoc} + */ + public function fieldSelector(bool $selected): string { + return $selected ? '>' : ' '; + } + + /** + * {@inheritdoc} + */ + public function fieldIndent(int $depth): string { + return ''; + } + + /** + * {@inheritdoc} + */ + public function fieldLabel(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldHelpMarker(): string { + return '[?]'; + } + + /** + * {@inheritdoc} + */ + public function fieldValue(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldValueSeparator(): string { + return ', '; + } + + /** + * {@inheritdoc} + */ + public function fieldMask(): string { + return '*'; + } + + /** + * {@inheritdoc} + */ + public function fieldBadge(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldDescription(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldEntry(string $text, bool $chosen, bool $focused = FALSE): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldEntryMatch(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldEntrySelector(bool $selected): string { + return $selected ? '>' : ' '; + } + + /** + * {@inheritdoc} + */ + public function fieldEntryMarker(bool $chosen, bool $exclusive = FALSE): string { + if ($exclusive) { + return $chosen ? '(*)' : '( )'; + } + + return $chosen ? '[x]' : '[ ]'; + } + + /** + * {@inheritdoc} + */ + public function fieldEntryNote(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldEntryDescription(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldEntrySeparator(): string { + return str_repeat('-', max(1, $this->contentWidth())); + } + + /** + * {@inheritdoc} + */ + public function fieldConstraint(string $text): string { + // The one line that cannot be told apart by colour or slant here, so it + // opens with a mark instead: nothing can strip a character. + return '> ' . $text; + } + + /** + * {@inheritdoc} + */ + public function fieldError(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldCaret(): string { + return '|'; + } + + /** + * {@inheritdoc} + */ + public function fieldDraft(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldGhost(string $text): string { + // Text nobody typed reads as text somebody did unless something sets it + // apart, and the floor has nothing to set it apart with. + return ''; + } + + /** + * {@inheritdoc} + */ + public function fieldInput(string $before, string $after, string $ghost = ''): string { + return $this->fieldDraft($before) . $this->fieldCaret() . $this->fieldDraft($after) . $this->fieldGhost($ghost); + } + + /** + * {@inheritdoc} + */ + public function fieldScale(int $current, int $min, int $max, string $caption): string { + // Clamped onto the scale, because a point outside it would otherwise ask + // for a run of negative length. + $points = max(1, $max - $min + 1); + $filled = max(1, min($points, $current - $min + 1)); + + $line = str_repeat('*', $filled) . str_repeat('-', $points - $filled) . ' ' . $current . '/' . $max; + + return $caption === '' ? $line : $line . ' ' . $caption; + } + + /** + * {@inheritdoc} + */ + public function fieldLoading(): string { + return '...'; + } + + /** + * {@inheritdoc} + */ + public function fieldState(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function fieldCaption(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function panelSelector(bool $selected): string { + return $selected ? '>' : ' '; + } + + /** + * {@inheritdoc} + */ + public function panelTitle(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function panelDescend(): string { + return '>'; + } + + /** + * {@inheritdoc} + */ + public function panelDescription(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function panelSummary(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function panelSummarySeparator(): string { + return '-'; + } + + /** + * {@inheritdoc} + */ + public function markupTitle(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function markupLine(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function markupStrong(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function markupEmphasis(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function markupCode(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function markupLink(string $text, string $url): string { + // A target nothing can follow is still address, so it is written out beside + // the label rather than dropped with the styling that would have hidden it. + return $text . ' (' . $url . ')'; + } + + /** + * {@inheritdoc} + */ + public function markupBullet(): string { + return '-'; + } + + /** + * {@inheritdoc} + */ + public function actionButton(string $label): string { + return '[ ' . $label . ' ]'; + } + + /** + * {@inheritdoc} + */ + public function actionSelected(string $label): string { + // The frame is the same one every button carries: which of them has focus + // is carried by paint, and the floor has none to spend on it. + return $this->actionButton($label); + } + + /** + * {@inheritdoc} + */ + public function actionSeparator(): string { + return ' '; + } + + /** + * {@inheritdoc} + */ + public function progressCaption(string $text): string { + return $text; + } + + /** + * {@inheritdoc} + */ + public function progressSpinner(int $frame): string { + return self::SPINNER_ASCII[abs($frame) % count(self::SPINNER_ASCII)]; + } + + /** + * {@inheritdoc} + */ + public function progressTrack(int $filled, int $width): string { + $filled = max(0, min($width, $filled)); + + return '[' . str_repeat('#', $filled) . str_repeat('-', $width - $filled) . ']'; + } + + /** + * {@inheritdoc} + */ + public function progressCount(int $current, int $total): string { + return $current . '/' . $total; + } + +} diff --git a/src/Theme/Capability/ColorSchemeCapableInterface.php b/src/Theme/Capability/ColorSchemeCapableInterface.php new file mode 100644 index 00000000..431955f1 --- /dev/null +++ b/src/Theme/Capability/ColorSchemeCapableInterface.php @@ -0,0 +1,38 @@ +color; + } + + /** + * {@inheritdoc} + */ + public function isDark(): bool { + return $this->isDark; + } + + /** + * Wrap text in an SGR code, honouring colour-off. + * + * The single low-level helper every styler builds on. + * + * @param string $sgr + * The SGR parameters (e.g. "1;36"); empty leaves the text unstyled. + * @param string $text + * The text. + * + * @return string + * The styled text (unchanged when colour is off). + */ + protected function paint(string $sgr, string $text): string { + return Ansi::style($text, $this->color ? $sgr : ''); + } + + /** + * Add bold to an SGR code when an item is selected. + * + * @param string $sgr + * The base SGR code. + * @param bool $selected + * Whether the item is the selected (cursor) one. + * + * @return string + * The code, made bold when selected. + */ + protected function emphasize(string $sgr, bool $selected): string { + if (!$selected) { + return $sgr; + } + + $drop = ['', Sgr::Bold->value, Sgr::Dim->value]; + $parts = array_values(array_filter(explode(';', $sgr), static fn(string $part): bool => !in_array($part, $drop, TRUE))); + array_unshift($parts, Sgr::Bold->value); + + return implode(';', $parts); + } + +} diff --git a/src/Theme/Capability/DimCapableInterface.php b/src/Theme/Capability/DimCapableInterface.php new file mode 100644 index 00000000..9e7a279c --- /dev/null +++ b/src/Theme/Capability/DimCapableInterface.php @@ -0,0 +1,30 @@ +unicode; + } + + /** + * Pick between a glyph and the stand-in that reads where it cannot be drawn. + * + * Both forms are stated together so neither display mode can be set and the + * other silently left broken. + * + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + * + * @return string + * Whichever the terminal draws. + */ + protected function glyph(string $glyph, string $ascii): string { + return $this->unicode ? $glyph : $ascii; + } + +} diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php index 14bcefda..1a43b851 100644 --- a/src/Theme/DefaultTheme.php +++ b/src/Theme/DefaultTheme.php @@ -4,61 +4,57 @@ namespace DrevOps\Tui\Theme; -use DrevOps\Tui\Answers\Answers; -use DrevOps\Tui\Answers\Provenance; -use DrevOps\Tui\Answers\ValueFormatter; -use DrevOps\Tui\Input\Action; -use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Input\ScopedKeyMap; -use DrevOps\Tui\Model\Field; -use DrevOps\Tui\Model\FieldType; -use DrevOps\Tui\Model\FormDefinition; -use DrevOps\Tui\Model\Modal; -use DrevOps\Tui\Model\Panel; -use DrevOps\Tui\Model\TableSpec; +use DrevOps\Tui\Primitive\Element\PrimitiveElementsInterface; use DrevOps\Tui\Primitive\Status; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Render\Box; -use DrevOps\Tui\Render\HelpSection; use DrevOps\Tui\Render\Markup; use DrevOps\Tui\Render\MarkupKind; use DrevOps\Tui\Render\MarkupSegment; -use DrevOps\Tui\Render\Navigator; -use DrevOps\Tui\Render\Overlay; -use DrevOps\Tui\Render\Scroller; use DrevOps\Tui\Render\Table; -use DrevOps\Tui\Render\Viewport; +use DrevOps\Tui\Theme\Capability\ColorSchemeCapableInterface; +use DrevOps\Tui\Theme\Capability\ColorSchemeCapableTrait; +use DrevOps\Tui\Theme\Capability\DimCapableInterface; +use DrevOps\Tui\Theme\Capability\MarkdownCapableInterface; +use DrevOps\Tui\Theme\Capability\OccupyCapableInterface; +use DrevOps\Tui\Theme\Capability\UnicodeCapableInterface; +use DrevOps\Tui\Theme\Capability\UnicodeCapableTrait; +use DrevOps\Tui\Theme\Override\Glyph; +use DrevOps\Tui\Theme\Override\Overrides; +use DrevOps\Tui\Theme\Override\ThemeElement; use DrevOps\Tui\Translation\Translator; use DrevOps\Tui\Utils\Strings; /** - * The default theme: the appearance atoms plus the assembly that arranges them. + * The theme that ships: every element painted, and the pieces they assemble. * - * Two layers, one class. The **atoms** are one method per colour and glyph - * (title(), value(), marker(), border(), caret()…) - each takes text or a flag - * and returns it styled for the theme's mode; these are what a consumer theme - * overrides. The **render*()** methods are the assembly: they arrange those - * atoms into field rows, the scrolled frame and the editor. Pure box geometry - * (character sets, width fitting) lives in {@see Box}; everything visual routes - * through the atoms. + * It raises {@see AbstractTheme}'s floor by declaring colour, a scheme, + * Unicode, markdown, dimming and occupancy, so each element it answers for + * carries a palette and a glyph instead of the plain string the floor hands + * back. Alongside the elements it draws the pieces a primitive asks for - + * cards, grids, status lines - which take plain strings and arrays and nothing + * a form is holding, so the same piece serves standalone and in-form callers. * - * A consumer theme extends this and overrides just what it wants - usually an - * atom, occasionally a render method for a layout tweak: + * Where two elements must agree on one colour, they draw it from a small + * protected palette rather than restating it. The palette is not API: it is the + * one place a hue is written down, so a theme extending this repaints a family + * in a line rather than element by element. * * @code * class OceanTheme extends DefaultTheme { - * public function title(string $text): string { return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text); } - * public function renderPanelLine(Panel $panel, bool $selected): string { - * return $this->marker($selected) . ' ' . $this->label($panel->title); - * } + * protected function accent(): string { return Sgr::of(Sgr::Bold, Sgr::BrightCyan); } + * public function fieldConstraint(string $text): string { return $this->paint(Sgr::of(Sgr::Cyan), $text); } * } * @endcode * * @package DrevOps\Tui\Theme */ -class DefaultTheme implements ThemeInterface { +class DefaultTheme extends AbstractTheme implements PrimitiveElementsInterface, ColorSchemeCapableInterface, DimCapableInterface, MarkdownCapableInterface, OccupyCapableInterface, UnicodeCapableInterface { + + use ColorSchemeCapableTrait; + use UnicodeCapableTrait; /** * The default frame width, used when a caller does not specify one. @@ -83,24 +79,11 @@ class DefaultTheme implements ThemeInterface { */ protected const int MIN_HEIGHT = 10; - /** - * The rows reserved for the two scroll indicators (▲/▼). - * - * The scrolled body window carries its indicators outside the viewport - * height, so the frame budget reserves a row for each. - */ - protected const int INDICATOR_LINES = 2; - /** * The Unicode spinner animation frames, one glyph per tick. */ protected const array SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - /** - * The ASCII spinner animation frames used when Unicode is off. - */ - protected const array SPINNER_ASCII = ['|', '/', '-', '\\']; - /** * The determinate progress bar's width in cells. */ @@ -134,16 +117,6 @@ class DefaultTheme implements ThemeInterface { */ protected const int CARD_INDENT = 2; - /** - * Whether colour (ANSI) is enabled, resolved from the "color" option. - */ - protected bool $color; - - /** - * Whether Unicode glyphs are used, resolved from the "unicode" option. - */ - protected bool $unicode; - /** * Whether markdown in descriptions and notes is rendered, from "markdown". */ @@ -155,14 +128,9 @@ class DefaultTheme implements ThemeInterface { protected bool $indentConditional; /** - * Whether the dark palette is used, resolved from the "mode" option. - */ - protected bool $isDark; - - /** - * The outer frame width, including the border when one is drawn. + * What a consumer states differently, consulted before every element. */ - protected int $outerWidth; + protected Overrides $overrides; /** * Construct a theme. @@ -175,9 +143,11 @@ class DefaultTheme implements ThemeInterface { * on), "spacing" (a SPACING_* value), "border" (a BORDER_* value), plus any * option a concrete theme declares. */ - public function __construct(protected int $width = self::DEFAULT_WIDTH, protected array $options = []) { + public function __construct(int $width = self::DEFAULT_WIDTH, protected array $options = []) { + $this->width = $width; $this->validateOptions(); + $this->overrides = new Overrides(); $this->color = is_bool($this->options['color'] ?? NULL) ? $this->options['color'] : TRUE; $this->unicode = is_bool($this->options['unicode'] ?? NULL) ? $this->options['unicode'] : TRUE; $this->markdown = is_bool($this->options['markdown'] ?? NULL) && $this->options['markdown']; @@ -190,12 +160,10 @@ public function __construct(protected int $width = self::DEFAULT_WIDTH, protecte $this->width = min($this->width, $this->maxWidth()); } - $this->outerWidth = $this->width; - // A border consumes two frame columns plus a one-column gutter each side. // Lay rows out that much narrower to keep right-aligned badges inside it. if ($this->borderStyle() !== Border::None) { - $this->width = max(1, $this->width - 4); + $this->width = max(1, $this->width - self::BOX_CHROME); } } @@ -379,30 +347,13 @@ protected function mode(): Mode { } /** - * The frame width the renderer lays out to. - * - * @return int - * The width. - */ - protected function width(): int { - return $this->width; - } - - /** - * {@inheritdoc} - */ - public function contentWidth(): int { - return $this->width; - } - - /** - * The vertical spacing option. + * Whether the markdown subset is drawn, from "markdown". * - * @return \DrevOps\Tui\Theme\Spacing - * The spacing; padded when unset. + * @return bool + * TRUE when it is drawn rather than left as literal text. */ - protected function spacing(): Spacing { - return $this->enumOption('spacing', Spacing::class, Spacing::Padded); + public function hasMarkdown(): bool { + return $this->markdown; } /** @@ -412,7 +363,7 @@ protected function spacing(): Spacing { * The border style; a rounded box when unset - a form is framed unless * it explicitly asks for no border. */ - protected function borderStyle(): Border { + public function borderStyle(): Border { return $this->enumOption('border', Border::class, Border::Rounded); } @@ -427,549 +378,632 @@ protected function field(): FieldStyle { } /** - * The leading blank gutter a field's rows are laid out after. - * - * A field shown behind a `when` rule steps in from the fields that decide - * it, one step per condition in the chain, so the panel reads as a hierarchy - * rather than a flat list. The single source of the indent: every row a - * field contributes - its label row, its value continuation lines, its - * description, its note card - is laid out after this same gutter, and the - * width measurement adds it back. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return string - * The gutter, or an empty string when the option is off or the field shows - * unconditionally. - */ - protected function fieldIndent(Field $field): string { - if (!$this->indentConditional) { - return ''; - } - - return str_repeat(' ', self::CONDITIONAL_INDENT * $field->conditionalDepth); - } - - /** - * Whether the frame expands to the whole terminal screen. - * - * @return bool - * TRUE when the "fullscreen" option is on. + * {@inheritdoc} */ public function isFullscreen(): bool { return ($this->options['fullscreen'] ?? FALSE) === TRUE; } /** - * The horizontal alignment of content within the available width. - * - * @return \DrevOps\Tui\Theme\HAlign - * The alignment; left when unset. + * {@inheritdoc} */ public function halign(): HAlign { return $this->enumOption('halign', HAlign::class, HAlign::Left); } /** - * The vertical alignment of content within the available height. - * - * @return \DrevOps\Tui\Theme\VAlign - * The alignment; top when unset. + * {@inheritdoc} */ public function valign(): VAlign { return $this->enumOption('valign', VAlign::class, VAlign::Top); } /** - * The minimum terminal width fullscreen mode needs, in columns. - * - * @return int - * The explicit "min_width" option, or 0 when the minimum should be - * measured from the form's content instead. + * {@inheritdoc} */ public function minWidth(): int { return $this->intOption('min_width', 0); } /** - * The minimum terminal height fullscreen mode needs, in rows. - * - * @return int - * The minimum height. + * {@inheritdoc} */ public function minHeight(): int { return $this->intOption('min_height', self::MIN_HEIGHT); } /** - * The widest frame fullscreen mode may stretch to, in columns. - * - * @return int - * The cap, or 0 for uncapped. + * {@inheritdoc} */ public function maxWidth(): int { return $this->intOption('max_width', 0); } /** - * The tallest frame fullscreen mode may stretch to, in rows. - * - * @return int - * The cap, or 0 for uncapped. + * {@inheritdoc} */ public function maxHeight(): int { return $this->intOption('max_height', 0); } /** - * The outer frame width, including the border when one is drawn. - * - * @return int - * The width. + * {@inheritdoc} */ - public function outerWidth(): int { - return $this->outerWidth; + public function spacing(): Spacing { + return $this->enumOption('spacing', Spacing::class, Spacing::Padded); } /** - * The background the theme washes the screen with, or NULL for none. + * {@inheritdoc} * * A styled span closes with a full reset, so a background opened once would - * not survive it. The render layer instead re-opens this background on every - * line and after every reset and erases each line to its end, so the whole - * screen - the gaps between spans and the padding past the content included - - * fills with it. A theme declares its background here the same way it - * declares a title colour. - * - * @return string|null - * The background SGR parameters (e.g. "44" for blue), or NULL to keep the - * terminal's own background. + * not survive it. The driver instead re-opens this background on every line + * and after every reset and erases each line to its end, so the whole screen + * fills with it. */ public function background(): ?string { return NULL; } /** - * Whether colour is enabled. - * - * @return bool - * TRUE when colour is enabled. + * {@inheritdoc} */ - public function hasColor(): bool { - return $this->color; + public function dim(string $text): string { + return $this->paint(Sgr::of(Sgr::Dim), $text); } /** - * Whether Unicode glyphs are enabled. + * Take the elements a consumer states differently. * - * @return bool - * TRUE when Unicode glyphs are used, FALSE for the ASCII fallback. + * @param \DrevOps\Tui\Theme\Override\Overrides $overrides + * The patch; anything it does not name keeps the theme's own answer. + * + * @return static + * The theme. */ - public function hasUnicode(): bool { - return $this->unicode; + public function overrides(Overrides $overrides): static { + $this->overrides = $overrides; + + return $this; } /** - * Wrap text in an SGR code, honouring colour-off. + * The glyph a consumer stated for an element, resolved for the display mode. * - * The single low-level helper every styler builds on. + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. * - * @param string $sgr - * The SGR parameters (e.g. "1;36"); empty leaves the text unstyled. - * @param string $text - * The text. + * @return string|null + * The glyph, or NULL when nobody stated one. + */ + protected function overriddenGlyph(ThemeElement $element): ?string { + $override = $this->overrides->glyph($element); + + return $override instanceof Glyph ? $this->glyph($override->glyph, $override->ascii) : NULL; + } + + /** + * The hue that says "here", "now" or "picked". + * + * The one colour a theme is recognised by, so it is written once: the cursor, + * the caret, an exclusive mark and every indicator of work in progress all + * carry it, and a theme repaints the family by repainting this. * * @return string - * The styled text (unchanged when colour is off). + * The SGR parameters. */ - protected function paint(string $sgr, string $text): string { - return Ansi::style($text, $this->color ? $sgr : ''); + protected function accent(): string { + return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue); } /** - * Add bold to an SGR code when an item is selected. + * Draw text in the accent hue. * - * @param string $sgr - * The base SGR code. - * @param bool $selected - * Whether the item is the selected (cursor) one. + * @param string $text + * The text. * * @return string - * The code, made bold when selected. + * The painted text. */ - protected function emphasize(string $sgr, bool $selected): string { - if (!$selected) { - return $sgr; - } - - $drop = ['', Sgr::Bold->value, Sgr::Dim->value]; - $parts = array_values(array_filter(explode(';', $sgr), static fn(string $part): bool => !in_array($part, $drop, TRUE))); - array_unshift($parts, Sgr::Bold->value); - - return implode(';', $parts); + protected function highlight(string $text): string { + return $this->paint($this->accent(), $text); } /** - * {@inheritdoc} + * The hue the guidance voice speaks in. + * + * A step along the grey ramp rather than a hue of its own: a coloured + * guidance line reads as output the field produced rather than as chrome + * telling you what it expects. + * + * @return string + * The SGR parameters. */ - public function title(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $this->linkify($text)); + protected function guidance(): string { + return Sgr::of(Sgr::Italic, Sgr::Pewter); } /** - * {@inheritdoc} + * Draw a heading: a name for what follows it. + * + * @param string $text + * The text. + * + * @return string + * The painted text. */ - public function label(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize('', $selected), $this->linkify($text)); + protected function title(string $text): string { + return $this->paint($this->accent(), $this->linkify($text)); } /** - * {@inheritdoc} + * Draw a name: what something is called, rather than what it holds. + * + * @param string $text + * The text. + * @param bool $emphatic + * Whether it is weighted above the names around it. + * + * @return string + * The painted text. */ - public function value(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize(Sgr::of(Sgr::Green), $selected), $text); + protected function label(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize('', $emphatic), $this->linkify($text)); } /** - * {@inheritdoc} + * Draw an answer: what something holds, rather than what it is called. + * + * @param string $text + * The text. + * @param bool $emphatic + * Whether it is weighted above the answers around it. + * + * @return string + * The painted text. */ - public function description(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize(Sgr::of(Sgr::Grey), $selected), $this->linkify($text)); + protected function value(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize(Sgr::of(Sgr::Green), $emphatic), $text); } /** - * {@inheritdoc} + * Draw prose: what explains something, rather than what it says. + * + * @param string $text + * The text. + * + * @return string + * The painted text. */ - public function hint(string $text, bool $selected = FALSE): string { - // The description's grey, italicized: guidance on how to answer is never - // louder than the question itself, but still reads as its own voice. - return $this->paint($this->emphasize(Sgr::of(Sgr::Italic, Sgr::Grey), $selected), $this->linkify($text)); + protected function description(string $text): string { + // Secondary text stays secondary wherever it appears: weighting it puts an + // explanation at the same weight as the thing it explains. + return $this->paint(Sgr::of(Sgr::Grey), $this->linkify($text)); } /** - * {@inheritdoc} + * Draw an aside: quieter than prose, and never the point of the line. + * + * @param string $text + * The text. + * + * @return string + * The painted text. */ - public function badge(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize(Sgr::of(Sgr::Reverse), $selected), $text); + protected function footer(string $text): string { + return $this->paint(Sgr::of(Sgr::Grey), $text); } /** - * {@inheritdoc} + * Draw a heading over a run of rows. + * + * @param string $text + * The text. + * + * @return string + * The painted text. */ - public function cursor(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold, Sgr::Reverse), $text); + protected function heading(string $text): string { + return $this->paint(Sgr::of(Sgr::Bold, Sgr::Grey), $this->linkify($text)); } /** - * {@inheritdoc} + * Draw box-drawing characters. + * + * @param string $text + * The run of glyphs. + * + * @return string + * The painted run. */ - public function footer(string $text): string { - return $this->paint(Sgr::of(Sgr::Grey), $text); + protected function border(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Cyan) : Sgr::of(Sgr::Blue), $text); } /** - * {@inheritdoc} + * Draw a mark that wants attention without claiming something failed. + * + * @param string $text + * The text. + * + * @return string + * The painted text. */ - public function breadcrumb(string $text): string { - return $this->paint(Sgr::of(Sgr::Grey), $text); + protected function indicator(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Yellow) : Sgr::of(Sgr::Magenta), $text); } /** - * Recede text into the background, so a modal reads as floating above it. + * Draw a statement that something failed. * * @param string $text * The text. * * @return string - * The dimmed text (unchanged when colour is off). + * The painted text. */ - public function dim(string $text): string { - return $this->paint(Sgr::of(Sgr::Dim), $text); + protected function error(string $text): string { + return $this->paint(Sgr::of(Sgr::Red), $text); } /** - * {@inheritdoc} + * The cursor mark, or the gap standing in its place. + * + * @param bool $selected + * Whether the cursor rests here. + * + * @return string + * The mark. */ - public function indicator(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Yellow) : Sgr::of(Sgr::Magenta), $text); + protected function marker(bool $selected): string { + return $selected ? $this->highlight($this->glyph('❯', '>')) : ' '; } /** - * {@inheritdoc} + * Resolve any `[text](url)` links in a single line of chrome text. + * + * @param string $text + * The text. + * + * @return string + * The text with links resolved to the terminal's capability. */ - public function highlight(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $text); + protected function linkify(string $text): string { + return Markup::links($text, $this->color); } /** * {@inheritdoc} */ - public function highlightMatch(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Yellow) : Sgr::of(Sgr::Bold, Sgr::Magenta), $text); + #[\Override] + public function keyGlyph(Key $key): string { + $name = $key->name; + + if (!$name instanceof KeyName) { + return $key->label(); + } + + return match ($name) { + KeyName::Up, KeyName::MouseWheelUp => $this->glyph('↑', '^'), + KeyName::Down, KeyName::MouseWheelDown => $this->glyph('↓', 'v'), + KeyName::Left => $this->glyph('←', '<'), + KeyName::Right => $this->glyph('→', '>'), + KeyName::Enter => $this->glyph('↵', '<'), + KeyName::Escape => Translator::t('esc'), + KeyName::Interrupt => Translator::t('ctrl-c'), + KeyName::Tab => Translator::t('tab'), + KeyName::Space => Translator::t('space'), + KeyName::Backspace => $this->glyph('⌫', Translator::t('bksp')), + KeyName::Delete => Translator::t('del'), + KeyName::Home => Translator::t('home'), + KeyName::End => Translator::t('end'), + KeyName::PageUp => Translator::t('pgup'), + KeyName::PageDown => Translator::t('pgdn'), + }; } /** * {@inheritdoc} */ - public function heading(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold, Sgr::Grey), $this->linkify($text)); + #[\Override] + public function chromeBorder(string $text): string { + return $this->border($text); } /** * {@inheritdoc} */ - public function strong(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold), $text); + #[\Override] + public function chromeOverflowMarker(bool $above): string { + return $this->indicator($above ? $this->glyph('▲', '^') : $this->glyph('▼', 'v')); } /** * {@inheritdoc} */ - public function emphasis(string $text): string { - return $this->paint(Sgr::of(Sgr::Italic), $text); + #[\Override] + public function breadcrumbLabel(string $text): string { + return $this->paint(Sgr::of(Sgr::Grey), $text); } /** * {@inheritdoc} */ - public function code(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::BrightYellow) : Sgr::of(Sgr::Magenta), $text); + #[\Override] + public function breadcrumbSeparator(): string { + return $this->overriddenGlyph(ThemeElement::BreadcrumbSeparator) ?? $this->glyph('›', '>'); } /** * {@inheritdoc} */ - public function link(string $text, string $url): string { - return Markup::hyperlink($text, $url, $this->color); + #[\Override] + public function legendKey(string $text): string { + // Case is what sets a key apart from what it does, so the legend needs no + // weight to carry it. Uppercased here rather than at each source, so a + // translated key name is uppercased with the rest. + return $this->paint($this->overrides->style(ThemeElement::LegendKey) ?? Sgr::of(Sgr::Grey), Strings::upper($text)); } /** * {@inheritdoc} */ - public function bullet(): string { - return $this->unicode ? '•' : '-'; + #[\Override] + public function legendDescription(string $text): string { + return $this->paint(Sgr::of(Sgr::Grey), $text); } /** - * Resolve any `[text](url)` links in a single line of chrome text. - * - * @param string $text - * The text. - * - * @return string - * The text with links resolved to the terminal's capability. + * {@inheritdoc} */ - protected function linkify(string $text): string { - return Markup::links($text, $this->color); + #[\Override] + public function legendSeparator(): string { + return $this->paint(Sgr::of(Sgr::Ash), $this->overriddenGlyph(ThemeElement::LegendSeparator) ?? $this->glyph('·', '*')); } /** - * Render description or note-body text as themed physical lines. - * - * Links resolve on every terminal; the rest of the markdown subset - bold, - * emphasis, inline code and bullet lists - is rendered only when the - * "markdown" option is on, and otherwise left as literal text. Each span is - * styled by its own atom, so a custom theme restyles markup by overriding - * those atoms. - * - * @param string $source - * The source text; newlines separate physical lines. - * @param bool $selected - * Whether the owning row is selected. - * - * @return list - * The rendered lines. + * {@inheritdoc} */ - protected function markupBody(string $source, bool $selected): array { - $lines = []; - - foreach (Markup::parse($source, $this->markdown) as $line) { - $rendered = $line->bullet ? $this->description($this->bullet() . ' ', $selected) : ''; - - foreach ($line->segments as $segment) { - $rendered .= $this->markupSegment($segment, $selected); - } + #[\Override] + public function fieldSelector(bool $selected): string { + $glyph = $this->overriddenGlyph(ThemeElement::FieldSelector); - $lines[] = $rendered; + // An override names the mark, not the palette: the cursor and everything + // else the theme accents stay one signal, whichever glyph carries it. + if ($glyph === NULL || !$selected) { + return $this->marker($selected); } - return $lines; + return $this->highlight($glyph); } /** - * Style one parsed markup span with its atom. - * - * Plain text is the description atom, so a theme that restyles description - * text restyles the body of a description and a note with it - not only the - * one-line rows that call the atom directly. - * - * @param \DrevOps\Tui\Render\MarkupSegment $segment - * The span. - * @param bool $selected - * Whether the owning row is selected. - * - * @return string - * The styled span. + * {@inheritdoc} */ - protected function markupSegment(MarkupSegment $segment, bool $selected): string { - return match ($segment->kind) { - MarkupKind::Bold => $this->strong($segment->text), - MarkupKind::Emphasis => $this->emphasis($segment->text), - MarkupKind::Code => $this->code($segment->text), - MarkupKind::Link => $this->link($segment->text, $segment->url), - // The parser has already split every link into its own span, so the - // atom's own link resolution finds nothing left to do here. - MarkupKind::Text => $this->description($segment->text, $selected), - }; + #[\Override] + public function fieldIndent(int $depth): string { + if (!$this->indentConditional) { + return ''; + } + + return str_repeat(' ', self::CONDITIONAL_INDENT * max(0, $depth)); } /** * {@inheritdoc} */ - public function divider(): string { - return $this->footer(str_repeat($this->unicode ? '─' : '-', max(1, $this->width))); + #[\Override] + public function fieldLabel(string $text): string { + return $this->label($text); } /** * {@inheritdoc} */ - public function disabled(string $text): string { - return $this->paint(Sgr::of(Sgr::Grey), $text); + #[\Override] + public function fieldHelpMarker(): string { + // The label's own colour and never weighted: the mark belongs to the label + // it follows, and weighting it would have it competing with the label + // instead of hanging off it. + // + // A superscript rather than an enclosed glyph. An enclosed question mark + // either has no glyph in a monospace font at all (⍰ renders as a + // missing-character box) or is naturally wider than one cell and gets + // squeezed out of shape by a surface that pins each cell to a fixed + // advance (ⓘ, ℹ). A superscript is narrow by design, so it survives both. + return $this->overriddenGlyph(ThemeElement::FieldHelpMarker) ?? $this->paint('', $this->glyph('ⁱ', '[?]')); } /** * {@inheritdoc} */ - public function error(string $text): string { - return $this->paint(Sgr::of(Sgr::Red), $text); + #[\Override] + public function fieldValue(string $text): string { + return $this->value($text); } /** * {@inheritdoc} */ - public function rule(string $text): string { - return $this->paint(Sgr::of(Sgr::Grey), $text); + #[\Override] + public function fieldValueSeparator(): string { + return $this->overrides->text(ThemeElement::FieldValueSeparator) ?? ', '; } /** * {@inheritdoc} */ - public function border(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Cyan) : Sgr::of(Sgr::Blue), $text); + #[\Override] + public function fieldMask(): string { + return $this->glyph('•', '*'); } /** * {@inheritdoc} */ - public function marker(bool $selected): string { - return $selected ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $this->unicode ? '❯' : '>') : ' '; + #[\Override] + public function fieldBadge(string $text): string { + return $this->paint(Sgr::of(Sgr::Reverse), $text); } /** * {@inheritdoc} */ - public function arrow(): string { - return $this->unicode ? '›' : '>'; + #[\Override] + public function fieldDescription(string $text): string { + return $this->description($text); } /** * {@inheritdoc} */ - public function separator(): string { - return $this->unicode ? '›' : '>'; + #[\Override] + public function fieldEntry(string $text, bool $chosen, bool $focused = FALSE): string { + // Where the cursor rests is the louder of the two facts, and the only one + // that moves, so it takes the accent and picking takes weight. + if ($focused) { + return $this->highlight($text); + } + + return $this->label($text, $chosen); } /** * {@inheritdoc} */ - public function arrowUp(): string { - return $this->unicode ? '↑' : '^'; + #[\Override] + public function fieldEntryMatch(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Yellow) : Sgr::of(Sgr::Bold, Sgr::Magenta), $text); } /** * {@inheritdoc} */ - public function arrowDown(): string { - return $this->unicode ? '↓' : 'v'; + #[\Override] + public function fieldEntrySelector(bool $selected): string { + $glyph = $this->overriddenGlyph(ThemeElement::FieldEntrySelector); + + if ($glyph === NULL || !$selected) { + return $this->marker($selected); + } + + return $this->highlight($glyph); } /** * {@inheritdoc} */ - public function arrowLeft(): string { - return $this->unicode ? '←' : '<'; + #[\Override] + public function fieldEntryMarker(bool $chosen, bool $exclusive = FALSE): string { + $glyph = $this->overriddenGlyph(ThemeElement::FieldEntryMarker); + + // Only the picked state is stated, so an entry nobody picked keeps the + // mark the theme draws for it and the patch stays a patch. + if ($glyph !== NULL && $chosen) { + return $exclusive ? $this->paint($this->accent(), $glyph) : $this->value($glyph); + } + + // A round mark for a question that takes one answer and a square one for a + // question that takes several, so the shape says how many before anything + // has been picked at all. + if ($exclusive) { + return $chosen ? $this->paint($this->accent(), $this->glyph('●', '(*)')) : $this->glyph('○', '( )'); + } + + return $chosen ? $this->value($this->glyph('◼', '[x]')) : $this->glyph('◻', '[ ]'); } /** * {@inheritdoc} */ - public function arrowRight(): string { - return $this->unicode ? '→' : '>'; + #[\Override] + public function fieldEntryNote(string $text): string { + return $this->paint(Sgr::of(Sgr::Grey), $text); } /** * {@inheritdoc} */ - public function enter(): string { - return $this->unicode ? '↵' : '<'; + #[\Override] + public function fieldEntryDescription(string $text): string { + // Slanted against the description's grey: it says the same kind of thing + // about a smaller subject, and the slant marks it as belonging to the entry + // above rather than to the field. + return $this->paint(Sgr::of(Sgr::Italic, Sgr::Grey), $this->linkify($text)); } /** * {@inheritdoc} */ - public function dot(): string { - return $this->unicode ? '·' : '*'; + #[\Override] + public function fieldEntrySeparator(): string { + return $this->renderRule(); } /** * {@inheritdoc} */ - public function indicatorUp(): string { - return $this->unicode ? '▲' : '^'; + #[\Override] + public function fieldConstraint(string $text): string { + // Guidance on how to answer is never louder than the question itself, but + // still reads as its own voice - and it sits directly beneath an entry's + // own description, so the two must not be mistaken for each other on any + // surface. Slant reinforces the hue where the surface honours it, and where + // neither survives the voice falls back to a mark, which nothing can strip. + $marked = $this->hasColor() ? $text : $this->glyph('› ', '> ') . $text; + + return $this->paint($this->guidance(), $this->linkify($marked)); } /** * {@inheritdoc} */ - public function indicatorDown(): string { - return $this->unicode ? '▼' : 'v'; + #[\Override] + public function fieldError(string $text): string { + return $this->error($text); } /** * {@inheritdoc} */ - public function radio(bool $on): string { - return $on ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); + #[\Override] + public function fieldCaret(): string { + $glyph = $this->overriddenGlyph(ThemeElement::FieldCaret); + + return $this->highlight($glyph ?? $this->glyph('█', '|')); } /** * {@inheritdoc} */ - public function check(bool $on): string { - return $on ? $this->value($this->unicode ? '◼' : '[x]') : ($this->unicode ? '◻' : '[ ]'); + #[\Override] + public function fieldDraft(string $text): string { + // What is being typed takes no colour of its own: the caret is what says + // where you are in it, and painting it would read as an accepted answer. + return $text; } /** * {@inheritdoc} */ - public function caret(): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Cyan) : Sgr::of(Sgr::Bold, Sgr::Blue), $this->unicode ? '█' : '|'); - } - - /** - * {@inheritdoc} - */ - public function ghost(string $text): string { + #[\Override] + public function fieldGhost(string $text): string { return $this->color ? $this->paint(Sgr::of(Sgr::Grey), $text) : ''; } /** * {@inheritdoc} + * + * The flat style is the draft with a plain caret. The boxed and underline + * styles wrap it in a fixed-width filled or underlined field, so the entry + * area reads as an input the way an MS-DOS form marked its fields: the fill + * runs behind the value text, and a reverse-video caret sits over the + * character it is on, so the letter still shows. */ - public function renderInput(string $before, string $after, string $ghost = ''): string { + #[\Override] + public function fieldInput(string $before, string $after, string $ghost = ''): string { if (!$this->color || $this->field() === FieldStyle::Flat) { - return $before . $this->caret() . $after . ($ghost === '' ? '' : $this->ghost($ghost)); + return $this->fieldDraft($before) . $this->fieldCaret() . $this->fieldDraft($after) . ($ghost === '' ? '' : $this->fieldGhost($ghost)); } // The caret reverses the character it sits on (a space at the line end), so @@ -998,79 +1032,265 @@ public function renderInput(string $before, string $after, string $ghost = ''): /** * {@inheritdoc} */ - public function mask(): string { - return $this->unicode ? '•' : '*'; + #[\Override] + public function fieldScale(int $current, int $min, int $max, string $caption): string { + // The filled run carries the theme's accent; the empty remainder and the + // readout stay plain, so the scale reads with colour off and in ASCII + // alike. + [$on, $off] = $this->unicode ? ['●', '○'] : ['*', '-']; + $caption = $this->oneLine($caption); + + // Clamp onto the scale: a point outside the range must not ask for a run + // of negative length. The lowest point still fills one, because it is a + // point like any other. The frame bounds the run because nothing wider can + // be drawn anyway, so an absurd range costs a truncated line rather than + // the whole heap. + $points = max(1, min($this->width, $max - $min + 1)); + $filled = max(1, min($points, $current - $min + 1)); + + $line = $this->highlight(str_repeat($on, $filled)) . str_repeat($off, $points - $filled) . ' ' . $current . '/' . $max; + + return $caption === '' ? $line : $line . ' ' . $caption; } /** * {@inheritdoc} */ - public function renderSpinner(int $frame, string $caption): string { - // The glyph carries the theme's accent through highlight(), so every theme - // spins in its own palette with no per-theme override. This method is - // public, so a direct call may pass a negative frame; fold it into range. - $frames = $this->unicode ? self::SPINNER_FRAMES : self::SPINNER_ASCII; - $glyph = $this->highlight($frames[abs($frame) % count($frames)]); - $caption = $this->oneLine($caption); + #[\Override] + public function fieldLoading(): string { + return $this->highlight($this->glyph('…', '...')); + } - return $caption === '' ? $glyph : $glyph . ' ' . $caption; + /** + * {@inheritdoc} + */ + #[\Override] + public function fieldState(string $text): string { + return $this->footer($text); } /** * {@inheritdoc} */ - public function renderProgressBar(int $current, int $total, string $caption, string $label): string { - // The filled run carries the theme's accent through highlight(); the empty - // track and the count stay plain, so the bar reads with colour off and in - // ASCII alike. - [$fill, $track] = $this->unicode ? ['█', '░'] : ['#', '-']; - $caption = $this->oneLine($caption); - $label = $this->oneLine($label); + #[\Override] + public function fieldCaption(string $text): string { + // The guidance hue at a different weight: a caption and a constraint are + // both the field speaking about the list rather than listing it, so they + // read as a pair - but weight against slant keeps them apart, and keeps the + // caption from being read as the panel's own trail. + return $this->paint(Sgr::of(Sgr::Bold, Sgr::Steel), $text); + } - // Clamp to the bar width: this method is public, so a direct call with - // current past total must not hand str_repeat() a negative track length. - $ratio = $total > 0 ? $current / $total : 1.0; - $filled = max(0, min(self::PROGRESS_WIDTH, (int) round($ratio * self::PROGRESS_WIDTH))); + /** + * {@inheritdoc} + */ + #[\Override] + public function panelSelector(bool $selected): string { + return $this->marker($selected); + } - $bar = ($filled > 0 ? $this->highlight(str_repeat($fill, $filled)) : '') . str_repeat($track, self::PROGRESS_WIDTH - $filled); - $line = ($caption === '' ? '' : $caption . ' ') . '[' . $bar . '] ' . $current . '/' . $total; + /** + * {@inheritdoc} + */ + #[\Override] + public function panelTitle(string $text): string { + return $this->title($text); + } - return $label === '' ? $line : $line . ' ' . $label; + /** + * {@inheritdoc} + */ + #[\Override] + public function panelDescend(): string { + return $this->description($this->glyph('›', '>')); } /** * {@inheritdoc} */ - public function renderScale(int $current, int $min, int $max, string $caption): string { - // The filled run carries the theme's accent through highlight(); the empty - // remainder and the readout stay plain, so the scale reads with colour off - // and in ASCII alike. - [$on, $off] = $this->unicode ? ['●', '○'] : ['*', '-']; - $caption = $this->oneLine($caption); + #[\Override] + public function panelDescription(string $text): string { + return $this->description($text); + } - // Clamp onto the scale: this method is public, so a direct call with a - // point outside the range must not hand str_repeat() a negative count. The - // lowest point still fills one, because it is a point like any other. The - // frame bounds the run because nothing wider than it can be drawn anyway, - // so an absurd range costs a truncated line rather than the whole heap. - $points = max(1, min($this->width, $max - $min + 1)); - $filled = max(1, min($points, $current - $min + 1)); + /** + * {@inheritdoc} + */ + #[\Override] + public function panelSummary(string $text): string { + return $this->value($text); + } - $line = $this->highlight(str_repeat($on, $filled)) . str_repeat($off, $points - $filled) . ' ' . $current . '/' . $max; + /** + * {@inheritdoc} + */ + #[\Override] + public function panelSummarySeparator(): string { + return $this->description($this->glyph('·', '*')); + } - return $caption === '' ? $line : $line . ' ' . $caption; + /** + * {@inheritdoc} + */ + #[\Override] + public function markupTitle(string $text): string { + return $this->title($text); } /** * {@inheritdoc} */ - public function renderLoading(string $caption): string { - // The ellipsis carries the theme's accent through highlight(), matching the - // spinner and bar; the caption stays plain. - $dots = $this->highlight($this->unicode ? '…' : '...'); - $caption = $this->oneLine($caption); + #[\Override] + public function markupLine(string $text): string { + return $this->description($text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function markupStrong(string $text): string { + return $this->paint(Sgr::of(Sgr::Bold), $text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function markupEmphasis(string $text): string { + return $this->paint(Sgr::of(Sgr::Italic), $text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function markupCode(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::BrightYellow) : Sgr::of(Sgr::Magenta), $text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function markupLink(string $text, string $url): string { + return Markup::hyperlink($text, $url, $this->color); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function markupBullet(): string { + return $this->glyph('•', '-'); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function actionButton(string $label): string { + return $this->value($this->frameAction($label)); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function actionSelected(string $label): string { + return $this->paint(Sgr::of(Sgr::Bold, Sgr::Reverse), $this->frameAction($label)); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function actionSeparator(): string { + return ' '; + } + + /** + * Frame an action's label. + * + * The framing is the theme's rather than the block's, so a theme that draws a + * button differently changes this alone and the block goes on knowing only + * that it has labels. + * + * @param string $label + * The label. + * + * @return string + * The framed label. + */ + protected function frameAction(string $label): string { + return '[ ' . $label . ' ]'; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function progressCaption(string $text): string { + return $this->oneLine($text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function progressSpinner(int $frame): string { + $frames = $this->unicode ? self::SPINNER_FRAMES : self::SPINNER_ASCII; + + return $this->highlight($frames[abs($frame) % count($frames)]); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function progressTrack(int $filled, int $width): string { + [$fill, $track] = $this->unicode ? ['█', '░'] : ['#', '-']; + $filled = max(0, min($width, $filled)); + + return '[' . ($filled > 0 ? $this->highlight(str_repeat($fill, $filled)) : '') . str_repeat($track, $width - $filled) . ']'; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function progressCount(int $current, int $total): string { + return $current . '/' . $total; + } + + /** + * {@inheritdoc} + */ + public function renderSpinner(int $frame, string $caption): string { + // Composed from the same elements the in-form indicator draws, so a theme + // that restyles the glyph restyles it everywhere it turns up. + $glyph = $this->progressSpinner($frame); + $caption = $this->progressCaption($caption); - return $caption === '' ? $dots : $caption . ' ' . $dots; + return $caption === '' ? $glyph : $glyph . ' ' . $caption; + } + + /** + * {@inheritdoc} + */ + public function renderProgressBar(int $current, int $total, string $caption, string $label): string { + // Composed from the same elements the in-form bar draws, so a theme that + // restyles the track or the count restyles both places it appears. + $caption = $this->progressCaption($caption); + $label = $this->oneLine($label); + + // A total of zero has no ratio to take, and the bar reads as finished + // rather than as empty: there was nothing left to do. + $ratio = $total > 0 ? $current / $total : 1.0; + $bar = $this->progressTrack((int) round($ratio * self::PROGRESS_WIDTH), self::PROGRESS_WIDTH); + $line = ($caption === '' ? '' : $caption . ' ') . $bar . ' ' . $this->progressCount($current, $total); + + return $label === '' ? $line : $line . ' ' . $label; } /** @@ -1128,89 +1348,28 @@ public function renderTable(array $headers, array $rows): array { } /** - * A card's grid, set off from any title and body above it by a blank line. - * - * @param list $headers - * The header cells. - * @param list> $rows - * The body rows. - * @param int $width - * The width available inside the card's own chrome. - * @param bool $spaced - * Whether a blank line precedes the grid; FALSE when the grid opens the - * card and so has nothing above to be set off from. - * - * @return list - * The grid's lines, empty when there is no grid. + * {@inheritdoc} */ - protected function cardTable(array $headers, array $rows, int $width, bool $spaced): array { - if ($headers === [] && $rows === []) { - return []; - } - - $table = $this->tableLines($headers, $rows, $width); - - if ($table === [] || !$spaced) { - return $table; - } - - return array_merge([''], $table); + public function renderRule(): string { + return $this->footer(str_repeat($this->glyph('─', '-'), max(1, $this->width))); } /** - * Wrap source text to a width and style each physical line as markup. - * - * @param string $text - * The source text; its own newlines split it into physical lines first. - * @param int $width - * The width to wrap to. - * - * @return list - * The styled lines, each fitting the width. + * {@inheritdoc} */ - protected function wrapMarkup(string $text, int $width): array { + public function renderBanner(string $logo, string $version): string { $lines = []; - foreach ($this->wrapLines($text, $width) as $chunk) { - if ($chunk === '') { - $lines[] = ''; - - continue; - } - - foreach ($this->markupBody($chunk, FALSE) as $rendered) { - $lines[] = $rendered; - } + foreach (explode("\n", $logo) as $line) { + $lines[] = $this->title($line); } - return $lines; - } - - /** - * Split source text into physical lines and word-wrap each to a width. - * - * @param string $text - * The source text; its own line endings split it first. - * @param int $width - * The width to wrap to. - * - * @return list - * The wrapped lines, unstyled. - */ - protected function wrapLines(string $text, int $width): array { - $lines = []; - - foreach (explode("\n", $this->normalizeLines($text)) as $physical) { - $wrapped = Strings::wrap($physical, $width); - - // A line with no visible characters wraps to nothing; keeping it blank - // lets a caller space the content out. - foreach ($wrapped === [] ? [''] : $wrapped as $line) { - $lines[] = $line; - } + if ($version !== '') { + $lines[] = ''; + $lines[] = $this->footer(Translator::t('Version: @version', ['@version' => $version])); } - return $lines; + return implode("\n", $lines); } /** @@ -1230,27 +1389,6 @@ public function renderStatus(Status $status, string $text): string { }; } - /** - * The glyph that leads a status line. - * - * @param \DrevOps\Tui\Primitive\Status $status - * The kind of status. - * - * @return string - * The glyph, respecting the theme's Unicode mode. - */ - protected function statusSymbol(Status $status): string { - // Every glyph is one column wide in any terminal - none has an emoji - // presentation or an East Asian width - so a run of status lines aligns. - return match ($status) { - Status::Note => $this->unicode ? '•' : '-', - Status::Info => $this->unicode ? '›' : '>', - Status::Success => $this->unicode ? '✓' : '+', - Status::Warning => '!', - Status::Error => $this->unicode ? '✗' : 'x', - }; - } - /** * {@inheritdoc} */ @@ -1290,9 +1428,30 @@ public function renderDefinitions(array $pairs): array { } /** - * Render one definition: its label, its value, and any wrapped continuation. + * The glyph that leads a status line. * - * @param string $label + * @param \DrevOps\Tui\Primitive\Status $status + * The kind of status. + * + * @return string + * The glyph, respecting the theme's Unicode mode. + */ + protected function statusSymbol(Status $status): string { + // Every glyph is one column wide in any terminal - none has an emoji + // presentation or an East Asian width - so a run of status lines aligns. + return match ($status) { + Status::Note => $this->glyph('•', '-'), + Status::Info => $this->glyph('›', '>'), + Status::Success => $this->glyph('✓', '+'), + Status::Warning => '!', + Status::Error => $this->glyph('✗', 'x'), + }; + } + + /** + * Render one definition: its label, its value, and any wrapped continuation. + * + * @param string $label * The label. * @param string $value * The value. @@ -1327,1513 +1486,249 @@ protected function definitionLines(string $label, string $value, int $column, in } /** - * Fold a caption or label to a single physical line for the indicators. - * - * The spinner and bar redraw in place with carriage returns, so a CR or LF - * would reposition the cursor or leave a stale row behind; newlines collapse - * to a space. - * - * @param string $text - * The text. - * - * @return string - * The text with its line breaks folded to spaces. - */ - protected function oneLine(string $text): string { - return str_replace(["\r\n", "\r", "\n"], ' ', $text); - } - - /** - * Build the body lines and the line index of the selected item. + * A card's grid, set off from any title and body above it by a blank line. * - * @param \DrevOps\Tui\Model\Panel $panel - * The panel. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * @param int $cursor - * The selected item index. - * @param \DrevOps\Tui\Model\Field|null $editing - * The field whose editor is expanded inline in the panel, or NULL when no - * field is being edited inline. - * @param string $editorView - * The inline editor's rendered view, spliced in at the editing field's row - * in place of its summary. + * @param list $headers + * The header cells. + * @param list> $rows + * The body rows. + * @param int $width + * The width available inside the card's own chrome. + * @param bool $spaced + * Whether a blank line precedes the grid; FALSE when the grid opens the + * card and so has nothing above to be set off from. * - * @return array{list,int} - * The body lines and the selected item's first line index. + * @return list + * The grid's lines, empty when there is no grid. */ - public function renderBody(Panel $panel, Answers $answers, int $cursor, ?Field $editing = NULL, string $editorView = ''): array { - $lines = []; - $cursor_line = 0; - $index = 0; - $rendered = 0; - - $spacing = $this->spacing(); - $gap = $spacing === Spacing::Padded ? 1 : 0; - $verbose = $spacing !== Spacing::Compact; - - foreach ($panel->fields as $field) { - // A presentational field renders as a card but is not navigable: it - // takes no cursor slot, and a leading gap only when it has output. - if ($field->type->isPresentational()) { - $note = $this->renderNoteLines($field, $answers); - - if ($note === []) { - continue; - } - - if ($rendered > 0 && $gap > 0) { - $lines[] = ''; - } - - foreach ($note as $line) { - $lines[] = $line; - } - - $rendered++; - - continue; - } - - if ($rendered > 0 && $gap > 0) { - $lines[] = ''; - } - - if ($index === $cursor) { - $cursor_line = count($lines); - } - - // The row methods lay a field's own rows out after its gutter; the - // shared description block knows no field, so it is stepped in here. - $indent = $this->fieldIndent($field); - - if ($editing instanceof Field && $field->id === $editing->id) { - foreach ($this->renderInlineEditor($field, $editorView, $index === $cursor) as $line) { - $lines[] = $line; - } - - if ($verbose) { - foreach ($this->renderFieldGuidance($field, $index === $cursor) as $guidance_line) { - $lines[] = $indent . $guidance_line; - } - } - - $index++; - $rendered++; - - continue; - } - - foreach ($this->renderFieldLine($field, $answers, $index === $cursor) as $line) { - $lines[] = $line; - } - - if ($verbose) { - foreach ($this->renderFieldGuidance($field, $index === $cursor) as $guidance_line) { - $lines[] = $indent . $guidance_line; - } - } - - $index++; - $rendered++; - } - - if ($panel->layout !== []) { - if ($rendered > 0) { - $lines[] = ''; - } - - [$grid, $selected_line] = $this->renderPanelGrid($panel, $answers, $cursor - $index); - - if ($selected_line >= 0) { - $cursor_line = count($lines) + $selected_line; - } - - return [array_merge($lines, $grid), $cursor_line]; + protected function cardTable(array $headers, array $rows, int $width, bool $spaced): array { + if ($headers === [] && $rows === []) { + return []; } - foreach ($panel->panels as $subpanel) { - if ($rendered > 0 && $gap > 0) { - $lines[] = ''; - } - - if ($index === $cursor) { - $cursor_line = count($lines); - } - - $lines[] = $this->renderPanelLine($subpanel, $index === $cursor); - - if ($verbose && $subpanel->description !== '') { - foreach ($this->renderDescriptionBlock(Translator::t($subpanel->description), $index === $cursor) as $description_line) { - $lines[] = $description_line; - } - } - - $summary = $verbose ? $this->summarizePanel($subpanel, $answers) : ''; - if ($summary !== '') { - $lines[] = $this->renderSummaryLine($summary, $index === $cursor); - } + $table = $this->tableLines($headers, $rows, $width); - $index++; - $rendered++; + if ($table === [] || !$spaced) { + return $table; } - return [$lines, $cursor_line]; + return array_merge([''], $table); } /** - * Build the grid of side-by-side sub-panel columns a layout declares. - * - * Each layout row takes its share of sub-panels in declaration order and - * zips their preview blocks side by side at an equal column width; a blank - * line separates the rows. Selection is by whole column, so the selected - * block's first line is the row it starts on. + * Render a table at an explicit width cap, styled with the theme's palette. * - * @param \DrevOps\Tui\Model\Panel $panel - * The panel whose layout and sub-panels are rendered. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * @param int $selected - * The selected sub-panel offset, or negative for none. + * @param list $headers + * The header cells. + * @param list> $rows + * The body rows. + * @param int $max_width + * The widest the table may be, in columns. * - * @return array{list,int} - * The grid lines and the selected block's first line index (-1 when no - * sub-panel is selected). + * @return list + * The styled table lines. */ - protected function renderPanelGrid(Panel $panel, Answers $answers, int $selected): array { - $lines = []; - $selected_line = -1; - $offset = 0; - - foreach ($panel->layout as $row => $columns) { - if ($row > 0) { - $lines[] = ''; - } - - $column_width = max(1, intdiv($this->width - ($columns - 1) * 2, $columns)); - $blocks = []; - $height = 0; - - foreach (array_slice($panel->panels, $offset, $columns) as $subpanel) { - if ($offset === $selected) { - $selected_line = count($lines); - } - - $block = $this->renderColumnBlock($subpanel, $answers, $offset === $selected); - $height = max($height, count($block)); - $blocks[] = $block; - $offset++; - } - - for ($line = 0; $line < $height; $line++) { - $cells = []; - - foreach ($blocks as $block) { - $cells[] = Box::fit($block[$line] ?? '', $column_width); - } - - // The gutters can outgrow a tiny frame even at one-column cells, so - // the assembled row is clamped to the frame width as a whole. - $lines[] = rtrim(Box::fit(implode(' ', $cells), $this->width)); - } - } + protected function tableLines(array $headers, array $rows, int $max_width): array { + // An explicit table always draws its grid, so a None frame falls back to + // the single-line box, exactly as a bordered note does. + $style = $this->borderStyle() === Border::None ? Border::Line : $this->borderStyle(); - return [$lines, $selected_line]; + return Table::render( + $headers, + $rows, + $style, + $this->unicode, + $max_width, + fn(string $cell): string => $this->heading($cell), + fn(string $cell): string => $this->value($cell), + fn(string $glyphs): string => $this->border($glyphs), + ); } /** - * Render one sub-panel's preview block for a grid column. + * Wrap a note's content lines in the theme's border box. * - * The block carries what the row list spreads over its rows - the title, - * the description and, instead of the one-line summary, one row per field - * value - plus a drill-in row per nested sub-panel, so a column reads as a - * window into the panel. + * The box is sized to its widest content line and capped at the frame width; + * an explicit note border shows even when the frame itself is borderless, so + * a None frame style falls back to the single-line box. * - * @param \DrevOps\Tui\Model\Panel $panel - * The sub-panel. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * @param bool $selected - * Whether the panel holds the cursor. + * @param list $content + * The styled content lines (title and body). + * @param int $reserved + * Columns the caller lays the box out after, kept out of the width cap so + * the box's right edge still lands inside the frame. * * @return list - * The block lines; the grid clips them to the column width. + * The boxed lines. */ - protected function renderColumnBlock(Panel $panel, Answers $answers, bool $selected): array { - $lines = [$this->renderPanelLine($panel, $selected)]; - $verbose = $this->spacing() !== Spacing::Compact; - - if ($verbose && $panel->description !== '') { - $lines[] = $this->renderDescriptionLine(Translator::t($panel->description), $selected); + protected function boxedNote(array $content, int $reserved = 0): array { + $style = $this->borderStyle(); + if ($style === Border::None) { + $style = Border::Line; } - foreach ($panel->fields as $field) { - $indent = $this->fieldIndent($field); + $chars = Box::chars($style, $this->unicode); - // A presentational field carries no value; it previews as its title. - if ($field->type->isPresentational()) { - $title = $this->noteTitleFirstLine($field, $answers); - if ($title !== '') { - $lines[] = $indent . ' ' . $this->heading($title); - } + $inner = 0; + foreach ($content as $line) { + $inner = max($inner, Ansi::width($line)); + } - continue; - } + // boxLine adds a one-column gutter and a border column each side, so the + // outer width is the content width plus four columns of chrome. + $outer = min(max(1, $this->width - $reserved), $inner + self::BOX_CHROME); - $value = $this->columnValuePreview($field, $answers); - $lines[] = $indent . ' ' . $this->description(Translator::t($field->label), $selected) . ' ' . $this->value($value, $selected); - } + $lines = [$this->border(Box::rule($chars['tl'], $chars['tr'], $chars['h'], $outer))]; - foreach ($panel->panels as $subpanel) { - $lines[] = ' ' . $this->description(Translator::t($subpanel->title) . ' ' . $this->arrow(), $selected); + foreach ($content as $line) { + $lines[] = $this->boxLine($line, $chars['v'], $outer); } + $lines[] = $this->border(Box::rule($chars['bl'], $chars['br'], $chars['h'], $outer)); + return $lines; } /** - * A field's value as one grid cell: first line, marked when there is more. - * - * A grid cell is one physical row, so a multi-line value previews as its - * first line - an embedded newline would desync the column zip - followed by - * a marker so the cell does not read as the whole value. Rendering and - * measuring both route through here, so a column can never be sized without - * the room its marker needs. + * Wrap a content line in vertical borders with a one-column gutter each side. * - * @param \DrevOps\Tui\Model\Field $field - * The field to preview. - * @param \DrevOps\Tui\Answers\Answers $answers - * The collected answers. + * @param string $content + * The content (may carry ANSI codes and be shorter than the inner width). + * @param string $vertical + * The vertical border glyph. + * @param int $outer_width + * The outer width the line is padded to, including the border columns. * * @return string - * The previewed value. + * The boxed line, padded to the outer width. */ - protected function columnValuePreview(Field $field, Answers $answers): string { - $value_lines = explode("\n", $this->normalizeLines($this->renderFieldValue($field, $answers->value($field->id)))); - $more = $this->unicode ? '…' : '...'; + protected function boxLine(string $content, string $vertical, int $outer_width): string { + $bar = $this->border($vertical); - return $value_lines[0] . (count($value_lines) > 1 ? $more : ''); + return $bar . ' ' . Box::fit($content, max(1, $outer_width - self::BOX_CHROME)) . ' ' . $bar; } /** - * Render a field row, one entry per physical line. - * - * A single-line value is one row: the label, then the value. A multi-line - * value (a textarea) spans one row per line - the first rides the label row, - * the rest align under the value column - so no row ever carries an embedded - * newline that would desync the box border and scroll maths. Each line is - * styled on its own, so no colour span crosses a row boundary. The rows sit - * after the field's own gutter, so the value column follows the indent - * rather than the frame edge. + * Wrap source text to a width and style each physical line as markup. * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * @param bool $selected - * Whether the row is selected. + * @param string $text + * The source text; its own newlines split it into physical lines first. + * @param int $width + * The width to wrap to. * * @return list - * The field's rows: the label row carrying the value's first line, then any - * further value lines indented to the value column. + * The styled lines, each fitting the width. */ - public function renderFieldLine(Field $field, Answers $answers, bool $selected): array { - $prefix = $this->fieldIndent($field) . $this->marker($selected) . ' ' . $this->label(Translator::t($field->label), $selected) . ' '; - $indent = str_repeat(' ', Ansi::width($prefix)); - + protected function wrapMarkup(string $text, int $width): array { $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, $selected); - } + foreach ($this->wrapLines($text, $width) as $chunk) { + if ($chunk === '') { + $lines[] = ''; - $provenance = $answers->provenanceOf($field->id); + continue; + } - if ($provenance !== Provenance::Default) { - $lines[0] = Ansi::alignRight($lines[0], $this->badge(' ' . $provenance->label() . ' ', $selected), $this->width); + foreach ($this->markupBody($chunk) as $rendered) { + $lines[] = $rendered; + } } return $lines; } /** - * Render a field's editor in place of its value: the label, then the view. - * - * The field keeps its label and marker; the widget's own rendered view takes - * the place of the summary value, on the label row and, when it spans - * several lines, aligning the rest under that value column - so the field - * reads as its editor opened in place, the rest of the panel still around it. + * Split source text into physical lines and word-wrap each to a width. * - * @param \DrevOps\Tui\Model\Field $field - * The field being edited. - * @param string $view - * The widget's rendered view. - * @param bool $selected - * Whether the field's row holds the cursor (it does while editing). + * @param string $text + * The source text; its own line endings split it first. + * @param int $width + * The width to wrap to. * * @return list - * The label row carrying the view's first line, then any further view lines - * indented to the value column. + * The wrapped lines, unstyled. */ - public function renderInlineEditor(Field $field, string $view, bool $selected): array { - $prefix = $this->fieldIndent($field) . $this->marker($selected) . ' ' . $this->label(Translator::t($field->label), $selected) . ' '; - $indent = str_repeat(' ', Ansi::width($prefix)); - + protected function wrapLines(string $text, int $width): array { $lines = []; - foreach (explode("\n", $view) as $index => $line) { - $lines[] = ($index === 0 ? $prefix : $indent) . $line; + foreach (explode("\n", $this->normalizeLines($text)) as $physical) { + $wrapped = Strings::wrap($physical, $width); + + // A line with no visible characters wraps to nothing; keeping it blank + // lets a caller space the content out. + foreach ($wrapped === [] ? [''] : $wrapped as $line) { + $lines[] = $line; + } } return $lines; } /** - * Render a note card: its interpolated title and body, boxed when bordered. + * Render passage text as themed physical lines. * - * The title and body carry the same `{{field}}` templating derived values - * use, interpolated here against the current answers so a note reflects prior - * answers. A plain card is a heading title over grey body lines; a bordered - * note wraps them in the theme's box with a one-column gutter each side. The - * card sits after the field's own gutter, and a boxed one narrows by that - * much so its right edge still lands inside the frame. + * Links resolve on every terminal; the rest of the markdown subset - bold, + * emphasis, inline code and bullet lists - is rendered only when the + * "markdown" option is on, and otherwise left as literal text. * - * @param \DrevOps\Tui\Model\Field $field - * The note field. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers, interpolated into the title and body. + * @param string $source + * The source text; newlines separate physical lines. * * @return list - * The card's physical lines; empty when it has neither title nor body. + * The rendered lines. */ - public function renderNoteLines(Field $field, Answers $answers): array { - $indent = $this->fieldIndent($field); - $body = $this->noteText($field->description, $answers); + protected function markupBody(string $source): array { + $lines = []; - [$headers, $rows] = $this->noteTable($field, $answers); + foreach (Markup::parse($source, $this->markdown) as $line) { + $rendered = $line->bullet ? $this->markupLine($this->markupBullet() . ' ') : ''; - $lines = $this->renderCard( - $this->noteText($field->label, $answers), - $body === '' ? [] : [$body], - $headers, - $rows, - $field->bordered, - Ansi::width($indent), - ); + foreach ($line->segments as $segment) { + $rendered .= $this->markupSegment($segment); + } + + $lines[] = $rendered; + } - return array_map(static fn(string $line): string => $indent . $line, $lines); + return $lines; } /** - * A note's table cells, interpolated against the current answers. + * Style one parsed markup span with its element. * - * @param \DrevOps\Tui\Model\Field $field - * The note field. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers, interpolated into each cell. + * @param \DrevOps\Tui\Render\MarkupSegment $segment + * The span. * - * @return array{list,list>} - * The headers and rows, both empty when the note has no table. + * @return string + * The styled span. */ - protected function noteTable(Field $field, Answers $answers): array { - $spec = $field->table; - - if (!$spec instanceof TableSpec) { - return [[], []]; - } - - $interpolate = fn(string $cell): string => $this->noteText($cell, $answers); - - $rows = []; - foreach ($spec->rows as $row) { - $rows[] = array_map($interpolate, $row); - } - - return [array_map($interpolate, $spec->headers), $rows]; + protected function markupSegment(MarkupSegment $segment): string { + return match ($segment->kind) { + MarkupKind::Bold => $this->markupStrong($segment->text), + MarkupKind::Emphasis => $this->markupEmphasis($segment->text), + MarkupKind::Code => $this->markupCode($segment->text), + MarkupKind::Link => $this->markupLink($segment->text, $segment->url), + // The parser has already split every link into its own span, so the + // element's own link resolution finds nothing left to do here. + MarkupKind::Text => $this->markupLine($segment->text), + }; } /** - * Wrap a note's content lines in the theme's border box. + * Fold a caption or label to a single physical line for the indicators. * - * The box is sized to its widest content line and capped at the frame width; - * an explicit note border shows even when the frame itself is borderless, so - * a None frame style falls back to the single-line box. + * The spinner and bar redraw in place with carriage returns, so a CR or LF + * would reposition the cursor or leave a stale row behind; newlines collapse + * to a space. * - * @param list $content - * The styled content lines (title and body). - * @param int $reserved - * Columns the caller lays the box out after, kept out of the width cap so - * the box's right edge still lands inside the frame. - * - * @return list - * The boxed lines. - */ - protected function boxedNote(array $content, int $reserved = 0): array { - $style = $this->borderStyle(); - if ($style === Border::None) { - $style = Border::Line; - } - - $chars = Box::chars($style, $this->unicode); - - $inner = 0; - foreach ($content as $line) { - $inner = max($inner, Ansi::width($line)); - } - - // boxLine adds a one-column gutter and a border column each side, so the - // outer width is the content width plus four columns of chrome. - $outer = min(max(1, $this->width - $reserved), $inner + 4); - - $lines = [$this->borderRule($chars['tl'], $chars['tr'], $chars['h'], $outer)]; - - foreach ($content as $line) { - $lines[] = $this->boxLine($line, $chars['v'], $outer); - } - - $lines[] = $this->borderRule($chars['bl'], $chars['br'], $chars['h'], $outer); - - return $lines; - } - - /** - * Render a table at an explicit width cap, styled with the theme's atoms. - * - * @param list $headers - * The header cells. - * @param list> $rows - * The body rows. - * @param int $max_width - * The widest the table may be, in columns. - * - * @return list - * The styled table lines. - */ - protected function tableLines(array $headers, array $rows, int $max_width): array { - // An explicit table always draws its grid, so a None frame falls back to - // the single-line box, exactly as a bordered note does. - $style = $this->borderStyle() === Border::None ? Border::Line : $this->borderStyle(); - - return Table::render( - $headers, - $rows, - $style, - $this->unicode, - $max_width, - fn(string $cell): string => $this->heading($cell), - fn(string $cell): string => $this->value($cell), - fn(string $glyphs): string => $this->border($glyphs), - ); - } - - /** - * Interpolate a translated note source string against the current answers. - * - * @param string $source - * The note's title or body source text. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers, interpolated into its `{{field}}` tokens. - * - * @return string - * The translated, interpolated text. - */ - protected function noteText(string $source, Answers $answers): string { - return Strings::interpolate(Translator::t($source), $answers->values); - } - - /** - * The first physical line of a note's interpolated title. - * - * A grid cell is one row, so a multi-line title collapses to its first line. - * - * @param \DrevOps\Tui\Model\Field $field - * The note field. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * - * @return string - * The interpolated first line, empty when the title is empty. - */ - protected function noteTitleFirstLine(Field $field, Answers $answers): string { - $title = $this->noteText($field->label, $answers); - - return $title === '' ? '' : explode("\n", $this->normalizeLines($title))[0]; - } - - /** - * Render a sub-panel row. - * - * @param \DrevOps\Tui\Model\Panel $panel - * The sub-panel. - * @param bool $selected - * Whether the row is selected. - * - * @return string - * The row. - */ - public function renderPanelLine(Panel $panel, bool $selected): string { - return $this->marker($selected) . ' ' . $this->label(Translator::t($panel->title), $selected) . ' ' . $this->description($this->arrow(), $selected); - } - - /** - * Render a description row. - * - * @param string $description - * The description. - * @param bool $selected - * Whether the row's item is selected. - * - * @return string - * The row. - */ - public function renderDescriptionLine(string $description, bool $selected): string { - return ' ' . $this->description($description, $selected); - } - - /** - * Render a description as indented, markup-rendered physical lines. - * - * Unlike {@see renderDescriptionLine()}, this expands the markdown subset - - * so a description carries bold, emphasis, inline code, links and bullet - * lists - and returns one entry per physical line rather than a single row. - * - * @param string $description - * The description source. - * @param bool $selected - * Whether the owning row is selected. - * - * @return list - * The indented description lines. - */ - public function renderDescriptionBlock(string $description, bool $selected): array { - return array_map(static fn(string $line): string => ' ' . $line, $this->markupBody($description, $selected)); - } - - /** - * The guidance beneath a field's row: its description, then its hint. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param bool $selected - * Whether the field's row is selected. - * - * @return list - * The rendered lines; empty when the field declares neither text. - */ - protected function renderFieldGuidance(Field $field, bool $selected): array { - $lines = []; - - if ($field->description !== '') { - foreach ($this->renderDescriptionBlock(Translator::t($field->description), $selected) as $line) { - $lines[] = $line; - } - } - - if ($field->hint !== '') { - foreach ($this->renderFieldHint(Translator::t($field->hint), $selected) as $line) { - $lines[] = $line; - } - } - - return $lines; - } - - /** - * Render a field's hint as indented lines, in the hint style. - * - * Plain text rather than the description's markup subset: a hint is one short - * instruction, so it carries no formatting of its own and reads uniformly - * against the description above it. - * - * @param string $hint - * The hint source; newlines separate physical lines. - * @param bool $selected - * Whether the owning row is selected. - * - * @return list - * The indented hint lines. - */ - public function renderFieldHint(string $hint, bool $selected): array { - // Fold CRLF and lone-CR endings the way the markup parser does for a - // description: a surviving carriage return would send the cursor back to - // column 0 mid-frame and overwrite the row it sits on. - return array_map(fn(string $line): string => ' ' . $this->hint($line, $selected), explode("\n", $this->normalizeLines($hint))); - } - - /** - * Summarize a sub-panel's active field values into one line, for the hub. - * - * @param \DrevOps\Tui\Model\Panel $panel - * The sub-panel. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * - * @return string - * The summary, or an empty string when the panel has no active fields. - */ - public function summarizePanel(Panel $panel, Answers $answers): string { - $parts = []; - - foreach ($panel->fields as $field) { - if (!$answers->has($field->id)) { - continue; - } - - $value = $answers->value($field->id); - $rendered = is_array($value) && count($value) > 3 - ? Translator::formatPlural(count($value), '1 item selected', '@count items selected') - : $this->renderFieldValue($field, $value); - - // A summary is one line, so a multi-line value (a textarea) folds to a - // single row rather than breaking the row it sits on. - $parts[] = str_replace("\n", ' ', $this->normalizeLines($rendered)); - - if (count($parts) >= 4) { - break; - } - } - - return implode(' ' . $this->dot() . ' ', $parts); - } - - /** - * Render a sub-panel value-summary row. - * - * @param string $summary - * The summary text. - * @param bool $selected - * Whether the row's item is selected. - * - * @return string - * The row. - */ - public function renderSummaryLine(string $summary, bool $selected): string { - $max = max(1, $this->width - 4); - - if (Strings::length($summary) > $max) { - // Only the Unicode marker fits the budget in one column; ASCII clips to - // the full width instead, as a table cell does. - $clipped = $this->unicode ? Strings::substr($summary, 0, $max - 1) . '…' : Strings::substr($summary, 0, $max); - } - else { - $clipped = $summary; - } - - return ' ' . $this->value($clipped, $selected); - } - - /** - * Render a breadcrumb line for the navigator. - * - * @param \DrevOps\Tui\Render\Navigator $navigator - * The navigator. - * - * @return string - * The breadcrumb line. - */ - public function renderBreadcrumbLine(Navigator $navigator): string { - return $this->breadcrumb(implode(' ' . $this->separator() . ' ', array_map(Translator::t(...), $navigator->breadcrumb()))); - } - - /** - * Measure the natural width of the widest content row across a form. - * - * Walks every panel - nested ones included - at its unpadded row widths - * (marker, label, value, badge, description and summary columns) plus the - * button bar when the form shows one, and adds the border chrome: the - * narrowest frame that shows the initial content unclipped. Editors adapt - * to the frame width, so they do not join the measurement. - * - * @param \DrevOps\Tui\Model\FormDefinition $form - * The form. - * @param \DrevOps\Tui\Answers\Answers $answers - * The initial answers. - * - * @return int - * The natural outer width, in columns. - */ - public function measureContentWidth(FormDefinition $form, Answers $answers): int { - $width = $form->buttons->show ? Ansi::width($this->renderButtonBar([ - Translator::t($form->buttons->submitLabel), - Translator::t($form->buttons->cancelLabel), - ], -1)) : 0; - - $stack = [new Panel('hub', $form->title, '', [], $form->panels, NULL, $form->layout)]; - - while ($stack !== []) { - $panel = array_shift($stack); - $width = max($width, $this->measureBody($panel, $answers)); - $stack = array_merge($stack, $panel->panels); - } - - return $width + ($this->borderStyle() === Border::None ? 0 : 4); - } - - /** - * Measure the natural width of a panel body's widest row. - * - * Mirrors renderBody()'s row anatomy without its width-dependent padding: - * a field row is the marker, label and value columns plus the provenance - * badge; description, sub-panel and summary rows carry their own indents. - * - * @param \DrevOps\Tui\Model\Panel $panel - * The panel. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * - * @return int - * The widest row's visible width, in columns. - */ - protected function measureBody(Panel $panel, Answers $answers): int { - $width = 0; - $verbose = $this->spacing() !== Spacing::Compact; - - foreach ($panel->fields as $field) { - // A note renders as a card, not a label/value row; measure its actual - // lines so the frame fits its title and body (and box, when bordered). - if ($field->type->isPresentational()) { - foreach ($this->renderNoteLines($field, $answers) as $line) { - $width = max($width, Ansi::width($line)); - } - - continue; - } - - $indent = Ansi::width($this->fieldIndent($field)); - - // A multi-line value renders one physical row per line, all under the - // value column, so the widest single line is what the row needs. - $row = $indent + 4 + Markup::width(Translator::t($field->label), FALSE, $this->color) + $this->measureValueWidth($field, $answers); - - $provenance = $answers->provenanceOf($field->id); - if ($provenance !== Provenance::Default) { - $row += 3 + Strings::length($provenance->label()); - } - - $width = max($width, $row); - - if (!$verbose) { - continue; - } - - if ($field->description !== '') { - $width = max($width, $indent + 4 + Markup::width(Translator::t($field->description), $this->markdown, $this->color)); - } - - if ($field->hint !== '') { - $width = max($width, $indent + 4 + Markup::width(Translator::t($field->hint), FALSE, $this->color)); - } - } - - if ($panel->layout !== []) { - // Grid rows lay their columns out at one shared width, so a row needs - // its widest block times its column count, plus the gutters. - $offset = 0; - - foreach ($panel->layout as $columns) { - $widest = 0; - - foreach (array_slice($panel->panels, $offset, $columns) as $subpanel) { - $widest = max($widest, $this->measureColumnBlock($subpanel, $answers)); - } - - $width = max($width, $columns * $widest + 2 * ($columns - 1)); - $offset += $columns; - } - - return $width; - } - - foreach ($panel->panels as $subpanel) { - $width = max($width, 4 + Markup::width(Translator::t($subpanel->title), FALSE, $this->color)); - - if (!$verbose) { - continue; - } - - if ($subpanel->description !== '') { - $width = max($width, 4 + Markup::width(Translator::t($subpanel->description), $this->markdown, $this->color)); - } - - $summary = $this->summarizePanel($subpanel, $answers); - if ($summary !== '') { - $width = max($width, 4 + Ansi::width($summary)); - } - } - - return $width; - } - - /** - * Measure the natural width of a sub-panel's grid preview block. - * - * Mirrors renderColumnBlock()'s row anatomy at unpadded widths: the title - * and drill-in rows with their marker and arrow gutters, the description - * indent, and the label/value field rows. - * - * @param \DrevOps\Tui\Model\Panel $panel - * The sub-panel. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * - * @return int - * The widest block row's visible width, in columns. - */ - protected function measureColumnBlock(Panel $panel, Answers $answers): int { - $width = 4 + Markup::width(Translator::t($panel->title), FALSE, $this->color); - - if ($this->spacing() !== Spacing::Compact && $panel->description !== '') { - $width = max($width, 4 + Markup::width(Translator::t($panel->description), $this->markdown, $this->color)); - } - - foreach ($panel->fields as $field) { - $indent = Ansi::width($this->fieldIndent($field)); - - if ($field->type->isPresentational()) { - $title = $this->noteTitleFirstLine($field, $answers); - if ($title !== '') { - $width = max($width, $indent + 2 + Markup::width($title, FALSE, $this->color)); - } - - continue; - } - - $width = max($width, $indent + 4 + Markup::width(Translator::t($field->label), FALSE, $this->color) + Ansi::width($this->columnValuePreview($field, $answers))); - } - - foreach ($panel->panels as $subpanel) { - $width = max($width, 4 + Markup::width(Translator::t($subpanel->title), FALSE, $this->color)); - } - - return $width; - } - - /** - * Measure a field value's widest physical line. - * - * A multi-line value never renders as one long row - the row list stacks - * its lines under the value column and a grid cell previews only the first - * - so measuring the whole string would overstate the width it needs. - * - * @param \DrevOps\Tui\Model\Field $field - * The field the value belongs to. - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * - * @return int - * The widest line's visible width, in columns. - */ - protected function measureValueWidth(Field $field, Answers $answers): int { - $width = 0; - - foreach (explode("\n", $this->normalizeLines($this->renderFieldValue($field, $answers->value($field->id)))) as $line) { - $width = max($width, Ansi::width($line)); - } - - return $width; - } - - /** - * The chrome rows a frame adds around the scrolled body window. - * - * Everything renderFrame() emits that is neither a header/footer line nor a - * body-window line: border rules and spacing pads for a boxed frame, the - * footer gap for a borderless one - plus the reserved scroll-indicator rows. - * The single home of the frame-height budget, so a caller sizing the body - * viewport to the terminal never overflows it. - * - * @param bool $has_footer - * Whether the frame draws footer lines (a boxed frame separates them with - * an extra rule). - * - * @return int - * The chrome row count. - */ - public function chromeHeight(bool $has_footer): int { - if ($this->borderStyle() === Border::None) { - return ($this->spacing() === Spacing::Compact ? 0 : 1) + self::INDICATOR_LINES; - } - - $pad = $this->spacing() === Spacing::Padded ? 2 : 0; - - return 3 + ($has_footer ? 1 : 0) + $pad + self::INDICATOR_LINES; - } - - /** - * Compose a frame: pinned header, scrolled body with indicators, footer. - * - * In fullscreen the body window stretches to its full budget - the block - * aligns per the halign/valign options and the frame fills the terminal - * exactly; otherwise the frame stays as tall as its content. - * - * @param list $header - * The pinned header lines. - * @param list $body - * The full body lines. - * @param list $footer - * The pinned footer lines. - * @param \DrevOps\Tui\Render\Viewport $viewport - * The computed viewport. - * @param int $height - * The body viewport height. - * - * @return string - * The composed frame. - */ - public function renderFrame(array $header, array $body, array $footer, Viewport $viewport, int $height): string { - return $this->renderBoxed($header, $body, $footer, $viewport, $height, $this->outerWidth, $this->borderStyle(), $this->isFullscreen()); - } - - /** - * Compose a frame at an explicit width and border, else the same as a frame. - * - * The width/border are parameters so a modal can reuse the theme's boxing in - * a narrower box; the standard frame passes its own outer width and border. - * - * @param list $header - * The pinned header lines. - * @param list $body - * The full body lines. - * @param list $footer - * The pinned footer lines. - * @param \DrevOps\Tui\Render\Viewport $viewport - * The computed viewport. - * @param int $height - * The body viewport height. - * @param int $outer_width - * The outer width, including the border columns. - * @param \DrevOps\Tui\Theme\Border $border - * The border style to draw. - * @param bool $stretch - * Whether the body window stretches to its full budget with the block - * aligned inside it (the fullscreen frame), rather than hugging the - * content (a modal dialog's box). - * - * @return string - * The composed frame. - */ - protected function renderBoxed(array $header, array $body, array $footer, Viewport $viewport, int $height, int $outer_width, Border $border, bool $stretch = FALSE): string { - if ($border === Border::None) { - return $this->renderBorderless($header, $body, $footer, $viewport, $height, $stretch); - } - - $chars = Box::chars($border, $this->unicode); - $middle = $this->scrolledBody($body, $viewport, $height); - $pad = $this->spacing() === Spacing::Padded; - - if ($stretch) { - $middle = $this->alignBlock($middle, max(1, $outer_width - 4), $height + self::INDICATOR_LINES); - } - - $out = [$this->borderRule($chars['tl'], $chars['tr'], $chars['h'], $outer_width)]; - - foreach ($header as $line) { - $out[] = $this->boxLine($line, $chars['v'], $outer_width); - } - - $out[] = $this->borderRule($chars['ml'], $chars['mr'], $chars['h'], $outer_width); - - if ($pad) { - $out[] = $this->boxLine('', $chars['v'], $outer_width); - } - - foreach ($middle as $line) { - $out[] = $this->boxLine($line, $chars['v'], $outer_width); - } - - if ($pad) { - $out[] = $this->boxLine('', $chars['v'], $outer_width); - } - - if ($footer !== []) { - $out[] = $this->borderRule($chars['ml'], $chars['mr'], $chars['h'], $outer_width); - - foreach ($footer as $line) { - $out[] = $this->boxLine($line, $chars['v'], $outer_width); - } - } - - $out[] = $this->borderRule($chars['bl'], $chars['br'], $chars['h'], $outer_width); - - return implode("\n", $out); - } - - /** - * Compose a borderless frame, detaching the status line by spacing. - * - * @param list $header - * The header lines. - * @param list $body - * The body lines. - * @param list $footer - * The footer lines. - * @param \DrevOps\Tui\Render\Viewport $viewport - * The viewport. - * @param int $height - * The body viewport height. - * @param bool $stretch - * Whether the body window stretches to its full budget with the block - * aligned inside it. - * - * @return string - * The composed frame. - */ - protected function renderBorderless(array $header, array $body, array $footer, Viewport $viewport, int $height, bool $stretch = FALSE): string { - $middle = $this->scrolledBody($body, $viewport, $height); - - if ($stretch) { - $middle = $this->alignBlock($middle, $this->width, $height + self::INDICATOR_LINES); - } - - $lines = array_merge($header, $middle); - - if ($this->spacing() !== Spacing::Compact) { - $lines[] = ''; - } - - return implode("\n", array_merge($lines, $footer)); - } - - /** - * Align a block of lines within an area, padding it to the area's size. - * - * The lines move as one unit - their left edges stay mutually aligned - to - * the anchor the halign/valign options pick: blank rows pad the block to the - * target height and a uniform indent shifts it across the width. - * - * @param list $lines - * The block lines (may carry ANSI codes). - * @param int $inner_width - * The width of the area the block aligns within. - * @param int $target_height - * The height the block pads to. - * - * @return list - * The aligned lines, exactly the target height when the block fits it. - */ - protected function alignBlock(array $lines, int $inner_width, int $target_height): array { - $block_width = Ansi::blockWidth($lines); - - [$top, $left] = Overlay::place($inner_width, $target_height, $block_width, count($lines), $this->halign(), $this->valign()); - - $indent = str_repeat(' ', $left); - $out = array_fill(0, $top, ''); - - foreach ($lines as $line) { - $out[] = $line === '' ? '' : $indent . $line; - } - - while (count($out) < $target_height) { - $out[] = ''; - } - - return $out; - } - - /** - * The visible body window, wrapped with the scroll indicators. - * - * @param list $body - * The full body lines. - * @param \DrevOps\Tui\Render\Viewport $viewport - * The computed viewport. - * @param int $height - * The body viewport height. - * - * @return list - * The visible lines, with an indicator line for each hidden side. - */ - protected function scrolledBody(array $body, Viewport $viewport, int $height): array { - $lines = []; - - if ($viewport->hasAbove) { - $lines[] = $this->indicator(' ' . $this->indicatorUp()); - } - - $lines = array_merge($lines, (new Scroller())->slice($body, $viewport->offset, $height)); - - if ($viewport->hasBelow) { - $lines[] = $this->indicator(' ' . $this->indicatorDown()); - } - - return $lines; - } - - /** - * Build a horizontal border rule, coloured with the border atom. - * - * @param string $left - * The left corner or junction glyph. - * @param string $right - * The right corner or junction glyph. - * @param string $fill - * The horizontal fill glyph. - * @param int $outer_width - * The total width the rule spans. - * - * @return string - * The styled rule. - */ - protected function borderRule(string $left, string $right, string $fill, int $outer_width): string { - return $this->border(Box::rule($left, $right, $fill, $outer_width)); - } - - /** - * Wrap a content line in vertical borders with a one-column gutter each side. - * - * @param string $content - * The content (may carry ANSI codes and be shorter than the inner width). - * @param string $vertical - * The vertical border glyph. - * @param int $outer_width - * The outer width the line is padded to, including the border columns. - * - * @return string - * The boxed line, padded to the outer width. - */ - protected function boxLine(string $content, string $vertical, int $outer_width): string { - $bar = $this->border($vertical); - - return $bar . ' ' . Box::fit($content, max(1, $outer_width - 4)) . ' ' . $bar; - } - - /** - * {@inheritdoc} - */ - public function renderBanner(string $logo, string $version): string { - $lines = []; - - foreach (explode("\n", $logo) as $line) { - $lines[] = $this->title($line); - } - - if ($version !== '') { - $lines[] = ''; - $lines[] = $this->footer(Translator::t('Version: @version', ['@version' => $version])); - } - - return implode("\n", $lines); - } - - /** - * {@inheritdoc} - */ - public function keyHint(Key $key): string { - $name = $key->name; - - if (!$name instanceof KeyName) { - return $key->label(); - } - - return match ($name) { - KeyName::Up, KeyName::MouseWheelUp => $this->arrowUp(), - KeyName::Down, KeyName::MouseWheelDown => $this->arrowDown(), - KeyName::Left => $this->arrowLeft(), - KeyName::Right => $this->arrowRight(), - KeyName::Enter => $this->enter(), - KeyName::Escape => Translator::t('esc'), - KeyName::Interrupt => Translator::t('ctrl-c'), - KeyName::Tab => Translator::t('tab'), - KeyName::Space => Translator::t('space'), - KeyName::Backspace => $this->unicode ? '⌫' : Translator::t('bksp'), - KeyName::Delete => Translator::t('del'), - KeyName::Home => Translator::t('home'), - KeyName::End => Translator::t('end'), - KeyName::PageUp => Translator::t('pgup'), - KeyName::PageDown => Translator::t('pgdn'), - }; - } - - /** - * {@inheritdoc} - */ - public function keysHint(ScopedKeyMap $keys, string $label, Action ...$actions): string { - $glyphs = []; - - foreach ($actions as $action) { - $key = $keys->primary($action); - - if ($key instanceof Key) { - $glyphs[] = $this->keyHint($key); - } - } - - return $glyphs === [] ? '' : implode('/', $glyphs) . ' ' . $label; - } - - /** - * Render a context's hint fragments as one dot-joined footer line. - * - * Each {@see Hint} becomes a labelled fragment drawn from the live bindings, - * so the line never contradicts a remapped key. Fragments whose actions are - * all unbound drop out, and an entirely unbound context yields an empty line. - * - * @param \DrevOps\Tui\Input\ScopedKeyMap $keys - * The active scope's bindings. - * @param \DrevOps\Tui\Input\Hint ...$hints - * The hint fragments, in display order. - * - * @return string - * The themed hint line, or an empty string when nothing is bound. - */ - public function renderHints(ScopedKeyMap $keys, Hint ...$hints): string { - $fragments = []; - - foreach ($hints as $hint) { - $fragment = $this->keysHint($keys, $hint->label, ...$hint->actions); - - if ($fragment !== '') { - $fragments[] = $fragment; - } - } - - return $fragments === [] ? '' : $this->renderHintLine(...$fragments); - } - - /** - * Render a dimmed line of key hints, joined with the dot glyph. - * - * @param string ...$hints - * The hint fragments (e.g. "enter accept", "esc cancel"). Empty fragments - - * an unbound action - are dropped so the line has no dangling separators. - * - * @return string - * The themed hint line. - */ - public function renderHintLine(string ...$hints): string { - return $this->footer(implode(' ' . $this->dot() . ' ', array_filter($hints))); - } - - /** - * Render the header shown above a field's editor: its label, underlined. - * - * @param string $label - * The field label. - * - * @return string - * The two-line themed header. - */ - public function renderEditorHeader(string $label): string { - $underline = str_repeat($this->unicode ? '─' : '-', max(1, Markup::width($label, FALSE, $this->color))); - - return $this->title($label) . "\n" . $this->rule($underline); - } - - /** - * Compose a field's editor screen: the label, the widget view and its hints. - * - * @param string $label - * The field label. - * @param string $view - * The widget's rendered view. - * @param list<\DrevOps\Tui\Input\Hint> $hints - * The widget's hint fragments; an empty list draws no hint line, so the - * footer can be turned off form-wide. - * @param \DrevOps\Tui\Input\ScopedKeyMap|null $keys - * The editor's scope bindings, so the hint glyphs reflect the active keys. - * @param int $rows - * The terminal rows a fullscreen editor stretches its frame to; 0 keeps - * the screen as tall as its content. - * - * @return string - * The editor screen - boxed when the theme has a border, stretched to the - * given rows in fullscreen, else plain. - */ - public function renderEditor(string $label, string $view, array $hints = [], ?ScopedKeyMap $keys = NULL, int $rows = 0): string { - $hint = $keys instanceof ScopedKeyMap ? $this->renderHints($keys, ...$hints) : ''; - $footer = $hint === '' ? [] : [$hint]; - $stretch = $this->isFullscreen() && $rows > 0; - - if ($this->borderStyle() !== Border::None || $stretch) { - $body = explode("\n", $view); - $height = count($body); - - // A borderless editor keeps its label-over-rule header inside the frame. - $header = $this->borderStyle() === Border::None ? explode("\n", $this->renderEditorHeader($label)) : [$this->title($label)]; - - // A fullscreen editor stretches its frame like the hub does - the hint - // footer pins to the bottom row. A view taller than the budget keeps - // its full height - widgets page inside themselves, so slicing here - // would hide rows they expect to show. - if ($stretch) { - $height = max($height, $rows - count($header) - count($footer) - $this->chromeHeight($footer !== [])); - } - - return $this->renderFrame($header, $body, $footer, new Viewport(0, FALSE, FALSE), $height); - } - - $screen = $this->renderEditorHeader($label) . "\n" . $view; - - return $hint === '' ? $screen : $screen . "\n\n" . $hint; - } - - /** - * Compose the full-screen key-binding help overlay. - * - * @param \DrevOps\Tui\Input\ScopedKeyMap $nav - * The navigation bindings, for the close hint. - * @param \DrevOps\Tui\Render\HelpSection ...$sections - * The contexts to list, each a heading with its bindings and hints. - * - * @return string - * The rendered overlay. - */ - public function renderHelp(ScopedKeyMap $nav, HelpSection ...$sections): string { - $lines = [$this->title(Translator::t('Keyboard help')), '']; - - foreach ($sections as $section) { - $lines[] = $this->label($section->title); - $hint = $this->renderHints($section->keys, ...$section->hints); - - if ($hint !== '') { - $lines[] = $hint; - } - - $lines[] = ''; - } - - $lines[] = $this->renderHints($nav, new Hint('close', Action::Help)); - - return implode("\n", $lines); - } - - /** - * Compose a modal dialog: a centered box floating over the dimmed backdrop. - * - * The dialog's description text, its fields and its own submit/cancel buttons - * are boxed in a narrower frame, then spliced centered over the backdrop so - * the dimmed parent shows through the padding on every side. - * - * @param \DrevOps\Tui\Model\Panel $modal - * The modal panel (carrying its {@see \DrevOps\Tui\Model\Modal} config). - * @param \DrevOps\Tui\Answers\Answers $answers - * The current answers. - * @param int $cursor - * The selected item index within the dialog. - * @param \DrevOps\Tui\Model\Field|null $editing - * The field whose editor is expanded inline in the dialog, or NULL. - * @param string $editorView - * The inline editor's rendered view. - * @param int $selectedButton - * The index of the selected dialog button, or -1 when none is selected. - * @param string $backdrop - * The rendered parent frame to dim and overlay the dialog on. - * @param int $height - * The screen height, bounding the dialog so its footer never clips. - * - * @return string - * The composited screen. - */ - public function renderModal(Panel $modal, Answers $answers, int $cursor, ?Field $editing, string $editorView, int $selectedButton, string $backdrop, int $height): string { - $config = $modal->modal; - - if (!$config instanceof Modal) { - // @codeCoverageIgnoreStart - return $backdrop; - // @codeCoverageIgnoreEnd - } - - [$fields, $field_cursor] = $this->renderBody($modal, $answers, $cursor, $editing, $editorView); - - $lead = []; - if ($modal->description !== '') { - foreach (explode("\n", Translator::t($modal->description)) as $line) { - $lead[] = $this->label($line); - } - - if ($fields !== []) { - $lead[] = ''; - } - } - - $body = array_merge($lead, $fields); - - // The buttons pin to a footer so a dialog taller than the terminal never - // clips its only way out; the body scrolls under them to keep the cursor - // in view. - $footer = [ - $this->renderButtonBar([ - Translator::t($config->buttons->submitLabel), - Translator::t($config->buttons->cancelLabel), - ], $selectedButton), - ]; - - $inset = max(2, intdiv($this->outerWidth, 8)); - $modal_width = max(1, $this->outerWidth - 2 * $inset); - $border = $this->borderStyle() === Border::None ? Border::Line : $this->borderStyle(); - - // Fit the dialog within the screen height so the pinned button footer is - // never clipped, reserving the box chrome (four rules, the title, the - // footer and any spacing pad). Only the body scrolls; the footer stays put. - $pad = $this->spacing() === Spacing::Padded ? 1 : 0; - $room = max(0, $height - 6 - 2 * $pad); - - if (count($body) > $room && $room >= 3) { - // The body overflows and there is room to scroll it under the footer. - $cursor_line = $selectedButton >= 0 ? max(0, count($body) - 1) : count($lead) + $field_cursor; - $body_height = $room - 2; - $viewport = (new Scroller())->follow(count($body), $body_height, $cursor_line, 0); - } - else { - // The body fits, or there is too little room to scroll: show what fits. - $body = array_slice($body, 0, $room); - $viewport = new Viewport(0, FALSE, FALSE); - $body_height = count($body); - } - - $box = explode("\n", $this->renderBoxed([$this->title(Translator::t($modal->title))], $body, $footer, $viewport, $body_height, $modal_width, $border)); - - // Pad the backdrop so a short parent frame still gives the dialog room to - // sit over, rather than shrinking it. - $backdrop_lines = array_map(fn(string $line): string => Box::fit(Ansi::strip($line), $this->outerWidth), explode("\n", $backdrop)); - $area_height = max(count($backdrop_lines), count($box)); - - while (count($backdrop_lines) < $area_height) { - $backdrop_lines[] = str_repeat(' ', $this->outerWidth); - } - - [$top, $left] = Overlay::center($this->outerWidth, $area_height, $modal_width, count($box)); - - return implode("\n", Overlay::composite($backdrop_lines, $box, $modal_width, $top, $left, fn(string $segment): string => $this->dim($segment))); - } - - /** - * Render a panel-level error row, aligned with the rows above it. - * - * The message is a declared string, so it may carry line breaks; they fold to - * spaces because the caller counts this as one body row and a second physical - * line would push the frame past the height it laid out for. - * - * @param string $message - * The message. - * - * @return string - * The themed error row. - */ - public function renderPanelError(string $message): string { - return ' ' . $this->error($this->oneLine($message)); - } - - /** - * Render a row of inline submit/cancel buttons. - * - * @param list $labels - * The button labels, in order. - * @param int $selected - * The index of the selected button, or -1 for none. + * @param string $text + * The text. * * @return string - * The themed button row with the buttons side by side. + * The text with its line breaks folded to spaces. */ - public function renderButtonBar(array $labels, int $selected): string { - $parts = []; - - foreach ($labels as $index => $label) { - $text = '[ ' . $label . ' ]'; - $parts[] = $index === $selected ? $this->cursor($text) : $this->value($text); - } - - return ' ' . implode(' ', $parts); + protected function oneLine(string $text): string { + return str_replace(["\r\n", "\r", "\n"], ' ', $text); } /** @@ -2855,82 +1750,4 @@ protected function normalizeLines(string $value): string { return str_replace(["\r\n", "\r"], "\n", $value); } - /** - * Render a field's value readably, masking secret values. - * - * @param \DrevOps\Tui\Model\Field $field - * The field the value belongs to. - * @param mixed $value - * The value. - * - * @return string - * The rendered value. - */ - protected function renderFieldValue(Field $field, mixed $value): string { - // A field whose options have not yet loaded reads as loading, not empty. - if ($field->optionsLoader instanceof \Closure) { - return $this->renderLoading(''); - } - - if ($field->type === FieldType::Progress) { - return $this->renderProgress($field); - } - - if ($field->type === FieldType::Password) { - return is_string($value) && $value !== '' ? ValueFormatter::mask($this->mask()) : ''; - } - - if ($field->type === FieldType::Rating) { - return $this->renderRating($field, $value); - } - - return ValueFormatter::format($value); - } - - /** - * Render a rating row's scale from the field's declared points. - * - * A collapsed row shows the scale rather than the bare number, so the grade - * reads the same whether or not the editor is open. - * - * @param \DrevOps\Tui\Model\Field $field - * The rating field. - * @param mixed $value - * The chosen point. - * - * @return string - * The rendered scale. - */ - protected function renderRating(Field $field, mixed $value): string { - // The builder always closes a rating's scale, so the fallbacks only catch a - // hand-built field: a row degrades to a single point rather than crashing - // the frame it is drawn in. - $min = $field->bounds->min ?? 0; - $point = is_int($value) || is_float($value) ? (int) $value : $min; - $caption = $field->ratingCaptions[$point] ?? ''; - - return $this->renderScale($point, $min, $field->bounds->max ?? 0, $caption === '' ? '' : Translator::t($caption)); - } - - /** - * Render a progress row's indicator from its live state. - * - * A determinate row draws a bar that reads empty before the work runs and - * fills as it advances; an indeterminate row draws a spinner that sits on its - * first frame until the work ticks it. - * - * @param \DrevOps\Tui\Model\Field $field - * The progress field. - * - * @return string - * The rendered indicator. - */ - protected function renderProgress(Field $field): string { - if ($field->progressSteps === NULL) { - return $this->renderSpinner($field->progressCurrent ?? 0, $field->progressLabel); - } - - return $this->renderProgressBar($field->progressCurrent ?? 0, $field->progressSteps, '', $field->progressLabel); - } - } diff --git a/src/Theme/DosTheme.php b/src/Theme/DosTheme.php index 96d84744..a0324599 100644 --- a/src/Theme/DosTheme.php +++ b/src/Theme/DosTheme.php @@ -10,9 +10,8 @@ * The look of EDIT.COM, QBasic and Norton Commander - bright white headings, * cyan values and yellow highlights inside a double-line box on the classic DOS * blue, in the period-correct 16-colour SGR set rather than 256-colour. It - * declares its colours by overriding the appearance atoms directly, defaults to - * a double-line border and washes the screen blue, and inherits the default - * theme's layout and glyphs. + * states its colours, defaults to a double-line border and washes the screen + * blue, and inherits every element from the default theme. * * @package DrevOps\Tui\Theme */ @@ -22,23 +21,23 @@ class DosTheme extends DefaultTheme { * {@inheritdoc} */ #[\Override] - public function title(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightWhite), $text); + protected function accent(): string { + return Sgr::of(Sgr::Bold, Sgr::BrightWhite); } /** * {@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 indicator(string $text): string { + protected function indicator(string $text): string { return $this->paint(Sgr::of(Sgr::BrightYellow), $text); } @@ -46,52 +45,28 @@ public function indicator(string $text): string { * {@inheritdoc} */ #[\Override] - public function highlight(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightWhite), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function highlightMatch(string $text): string { - return $this->paint(Sgr::of(Sgr::BrightYellow), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function description(string $text, bool $selected = FALSE): string { + protected function description(string $text): string { // The inherited dim grey is too dark to read on the blue wash; the CGA // light grey (colour 7) is the period-correct body text and clears it. - return $this->paint($this->emphasize(Sgr::of(Sgr::White), $selected), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function hint(string $text, bool $selected = FALSE): string { - // CGA had no italic, so the inherited style would leave the hint identical - // to the body text; bright cyan is the period-correct help colour and lifts - // off the blue wash. - return $this->paint($this->emphasize(Sgr::of(Sgr::BrightCyan), $selected), $text); + return $this->paint(Sgr::of(Sgr::White), $text); } /** * {@inheritdoc} */ #[\Override] - public function footer(string $text): string { - return $this->paint(Sgr::of(Sgr::White), $text); + protected function guidance(): string { + // CGA had no italic, so the inherited style would leave the guidance voice + // identical to the body text; bright cyan is the period-correct help colour + // and lifts off the blue wash. + return Sgr::of(Sgr::BrightCyan); } /** * {@inheritdoc} */ #[\Override] - public function breadcrumb(string $text): string { + protected function footer(string $text): string { return $this->paint(Sgr::of(Sgr::White), $text); } @@ -99,7 +74,7 @@ public function breadcrumb(string $text): string { * {@inheritdoc} */ #[\Override] - public function heading(string $text): string { + protected function heading(string $text): string { return $this->paint(Sgr::of(Sgr::Bold, Sgr::White), $text); } @@ -107,7 +82,7 @@ public function heading(string $text): string { * {@inheritdoc} */ #[\Override] - public function border(string $text): string { + protected function border(string $text): string { return $this->paint(Sgr::of(Sgr::BrightWhite), $text); } @@ -115,31 +90,23 @@ public function border(string $text): string { * {@inheritdoc} */ #[\Override] - public function marker(bool $selected): string { - return $selected ? $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightWhite), $this->unicode ? '❯' : '>') : ' '; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function radio(bool $on): string { - return $on ? $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightWhite), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); + public function fieldEntryMatch(string $text): string { + return $this->paint(Sgr::of(Sgr::BrightYellow), $text); } /** * {@inheritdoc} */ #[\Override] - public function caret(): string { - return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightWhite), $this->unicode ? '█' : '|'); + public function breadcrumbLabel(string $text): string { + return $this->paint(Sgr::of(Sgr::White), $text); } /** * {@inheritdoc} */ #[\Override] - protected function borderStyle(): Border { + public function borderStyle(): Border { // The MS-DOS look is a bordered window (EDIT.COM / Norton Commander), so // default to a double-line box when the form declares no border of its own. if (!isset($this->options['border'])) { diff --git a/src/Theme/EmberTheme.php b/src/Theme/EmberTheme.php index 454e27f7..8aac5a36 100644 --- a/src/Theme/EmberTheme.php +++ b/src/Theme/EmberTheme.php @@ -7,10 +7,9 @@ /** * A warm, retro theme: burnt-orange accents, olive values, gold highlights. * - * A curated 256-colour palette selectable by name ("ember"). It declares its - * colours by overriding the appearance atoms directly and inherits the default - * theme's layout, glyphs and dark/light mode, so it renders across every widget - * and degrades to plain text when colour is off. + * A curated 256-colour palette selectable by name ("ember"). It states its + * colours and inherits every element from the default theme, so it renders + * across every field and degrades to plain text when colour is off. * * @package DrevOps\Tui\Theme */ @@ -20,23 +19,23 @@ class EmberTheme extends DefaultTheme { * {@inheritdoc} */ #[\Override] - public function title(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust), $text); + protected function accent(): string { + return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust); } /** * {@inheritdoc} */ #[\Override] - public function value(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Olive) : Sgr::of(Sgr::Khaki), $selected), $text); + protected function value(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Olive) : Sgr::of(Sgr::Khaki), $emphatic), $text); } /** * {@inheritdoc} */ #[\Override] - public function indicator(string $text): string { + protected function indicator(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Gold) : Sgr::of(Sgr::Bronze), $text); } @@ -44,23 +43,7 @@ public function indicator(string $text): string { * {@inheritdoc} */ #[\Override] - public function highlight(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function highlightMatch(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Gold) : Sgr::of(Sgr::Bronze), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function border(string $text): string { + protected function border(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Brown) : Sgr::of(Sgr::Umber), $text); } @@ -68,24 +51,8 @@ public function border(string $text): string { * {@inheritdoc} */ #[\Override] - public function marker(bool $selected): string { - return $selected ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust), $this->unicode ? '❯' : '>') : ' '; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function radio(bool $on): string { - return $on ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function caret(): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Orange) : Sgr::of(Sgr::Bold, Sgr::Rust), $this->unicode ? '█' : '|'); + public function fieldEntryMatch(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Gold) : Sgr::of(Sgr::Bronze), $text); } } diff --git a/src/Theme/FrostTheme.php b/src/Theme/FrostTheme.php index 86f22c1e..9bea82af 100644 --- a/src/Theme/FrostTheme.php +++ b/src/Theme/FrostTheme.php @@ -7,10 +7,9 @@ /** * A calm, arctic theme: frost-blue accents, sage values, sand highlights. * - * A curated 256-colour palette selectable by name ("frost"). It declares its - * colours by overriding the appearance atoms directly and inherits the default - * theme's layout, glyphs and dark/light mode, so it renders across every widget - * and degrades to plain text when colour is off. + * A curated 256-colour palette selectable by name ("frost"). It states its + * colours and inherits every element from the default theme, so it renders + * across every field and degrades to plain text when colour is off. * * @package DrevOps\Tui\Theme */ @@ -20,23 +19,23 @@ class FrostTheme extends DefaultTheme { * {@inheritdoc} */ #[\Override] - public function title(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt), $text); + protected function accent(): string { + return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt); } /** * {@inheritdoc} */ #[\Override] - public function value(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Sage) : Sgr::of(Sgr::Moss), $selected), $text); + protected function value(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Sage) : Sgr::of(Sgr::Moss), $emphatic), $text); } /** * {@inheritdoc} */ #[\Override] - public function indicator(string $text): string { + protected function indicator(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Sand) : Sgr::of(Sgr::Ochre), $text); } @@ -44,23 +43,7 @@ public function indicator(string $text): string { * {@inheritdoc} */ #[\Override] - public function highlight(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function highlightMatch(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Sand) : Sgr::of(Sgr::Ochre), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function border(string $text): string { + protected function border(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Steel) : Sgr::of(Sgr::Teal), $text); } @@ -68,24 +51,8 @@ public function border(string $text): string { * {@inheritdoc} */ #[\Override] - public function marker(bool $selected): string { - return $selected ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt), $this->unicode ? '❯' : '>') : ' '; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function radio(bool $on): string { - return $on ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function caret(): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Sky) : Sgr::of(Sgr::Bold, Sgr::Cobalt), $this->unicode ? '█' : '|'); + public function fieldEntryMatch(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Sand) : Sgr::of(Sgr::Ochre), $text); } } diff --git a/src/Theme/MidnightTheme.php b/src/Theme/MidnightTheme.php index 7b703888..4c52ff69 100644 --- a/src/Theme/MidnightTheme.php +++ b/src/Theme/MidnightTheme.php @@ -7,10 +7,9 @@ /** * A cool, vivid theme: violet accents, green values, pink highlights. * - * A curated 256-colour palette selectable by name ("midnight"). It declares its - * colours by overriding the appearance atoms directly and inherits the default - * theme's layout, glyphs and dark/light mode, so it renders across every widget - * and degrades to plain text when colour is off. + * A curated 256-colour palette selectable by name ("midnight"). It states its + * colours and inherits every element from the default theme, so it renders + * across every field and degrades to plain text when colour is off. * * @package DrevOps\Tui\Theme */ @@ -20,23 +19,23 @@ class MidnightTheme extends DefaultTheme { * {@inheritdoc} */ #[\Override] - public function title(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo), $text); + protected function accent(): string { + return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo); } /** * {@inheritdoc} */ #[\Override] - public function value(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Jade) : Sgr::of(Sgr::Forest), $selected), $text); + protected function value(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Jade) : Sgr::of(Sgr::Forest), $emphatic), $text); } /** * {@inheritdoc} */ #[\Override] - public function indicator(string $text): string { + protected function indicator(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Pink) : Sgr::of(Sgr::Fuchsia), $text); } @@ -44,23 +43,7 @@ public function indicator(string $text): string { * {@inheritdoc} */ #[\Override] - public function highlight(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function highlightMatch(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Pink) : Sgr::of(Sgr::Fuchsia), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function border(string $text): string { + protected function border(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Purple) : Sgr::of(Sgr::Slate), $text); } @@ -68,24 +51,8 @@ public function border(string $text): string { * {@inheritdoc} */ #[\Override] - public function marker(bool $selected): string { - return $selected ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo), $this->unicode ? '❯' : '>') : ' '; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function radio(bool $on): string { - return $on ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function caret(): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::Violet) : Sgr::of(Sgr::Bold, Sgr::Indigo), $this->unicode ? '█' : '|'); + public function fieldEntryMatch(string $text): string { + return $this->paint($this->isDark ? Sgr::of(Sgr::Pink) : Sgr::of(Sgr::Fuchsia), $text); } } diff --git a/src/Theme/MonoTheme.php b/src/Theme/MonoTheme.php index 8771d86f..6209ce93 100644 --- a/src/Theme/MonoTheme.php +++ b/src/Theme/MonoTheme.php @@ -10,9 +10,8 @@ * A monochrome palette selectable by name ("mono"). Accents are bold, matches * invert, and values and the border sit on the 256-colour grey ramp - so the * chrome reads on any background without relying on colour perception. The - * semantic red error is inherited on purpose. It declares its colours by - * overriding the appearance atoms directly and inherits the default theme's - * layout, glyphs and dark/light mode. + * semantic red error is inherited on purpose. It states its colours and + * inherits every element from the default theme, including its dark/light mode. * * @package DrevOps\Tui\Theme */ @@ -22,47 +21,42 @@ class MonoTheme extends DefaultTheme { * {@inheritdoc} */ #[\Override] - public function title(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black), $text); + protected function accent(): string { + return $this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black); } /** * {@inheritdoc} */ #[\Override] - public function value(string $text, bool $selected = FALSE): string { - return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Silver) : Sgr::of(Sgr::Ash), $selected), $text); + protected function value(string $text, bool $emphatic = FALSE): string { + return $this->paint($this->emphasize($this->isDark ? Sgr::of(Sgr::Silver) : Sgr::of(Sgr::Ash), $emphatic), $text); } /** * {@inheritdoc} */ #[\Override] - public function indicator(string $text): string { - return $this->paint(Sgr::of(Sgr::Bold), $text); + protected function guidance(): string { + // The inherited guidance hue would be the one colour in a hue-free theme, + // so the voice moves along the grey ramp instead: a step away from the + // description it must not be mistaken for. + return $this->isDark ? Sgr::of(Sgr::Italic, Sgr::Pewter) : Sgr::of(Sgr::Italic, Sgr::Ash); } /** * {@inheritdoc} */ #[\Override] - public function highlight(string $text): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black), $text); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function highlightMatch(string $text): string { - return $this->paint(Sgr::of(Sgr::Reverse), $text); + protected function indicator(string $text): string { + return $this->paint(Sgr::of(Sgr::Bold), $text); } /** * {@inheritdoc} */ #[\Override] - public function border(string $text): string { + protected function border(string $text): string { return $this->paint($this->isDark ? Sgr::of(Sgr::Gunmetal) : Sgr::of(Sgr::Pewter), $text); } @@ -70,24 +64,8 @@ public function border(string $text): string { * {@inheritdoc} */ #[\Override] - public function marker(bool $selected): string { - return $selected ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black), $this->unicode ? '❯' : '>') : ' '; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function radio(bool $on): string { - return $on ? $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black), $this->unicode ? '●' : '(*)') : ($this->unicode ? '○' : '( )'); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function caret(): string { - return $this->paint($this->isDark ? Sgr::of(Sgr::Bold, Sgr::BrightWhite) : Sgr::of(Sgr::Bold, Sgr::Black), $this->unicode ? '█' : '|'); + public function fieldEntryMatch(string $text): string { + return $this->paint(Sgr::of(Sgr::Reverse), $text); } } diff --git a/src/Theme/Override/BreadcrumbOverrides.php b/src/Theme/Override/BreadcrumbOverrides.php new file mode 100644 index 00000000..d30de1f4 --- /dev/null +++ b/src/Theme/Override/BreadcrumbOverrides.php @@ -0,0 +1,45 @@ +overrides->setGlyph(ThemeElement::BreadcrumbSeparator, $glyph, $ascii); + + return $this; + } + +} diff --git a/src/Theme/Override/FieldOverrides.php b/src/Theme/Override/FieldOverrides.php new file mode 100644 index 00000000..8d2d2d88 --- /dev/null +++ b/src/Theme/Override/FieldOverrides.php @@ -0,0 +1,135 @@ +overrides->setGlyph(ThemeElement::FieldSelector, $glyph, $ascii); + + return $this; + } + + /** + * Draw the mark saying a field has help with this glyph. + * + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + * + * @return $this + * The group. + */ + public function helpMarker(string $glyph, string $ascii): self { + $this->overrides->setGlyph(ThemeElement::FieldHelpMarker, $glyph, $ascii); + + return $this; + } + + /** + * Stand this text between the parts of an answer that has more than one. + * + * One argument rather than two: what joins the parts of an answer is a + * phrase the reader parses, not a glyph a terminal may fail to draw. + * + * @param string $text + * The separator. + * + * @return $this + * The group. + */ + public function valueSeparator(string $text): self { + $this->overrides->setText(ThemeElement::FieldValueSeparator, $text); + + return $this; + } + + /** + * Draw the mark saying which entry has focus with this glyph. + * + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + * + * @return $this + * The group. + */ + public function entrySelector(string $glyph, string $ascii): self { + $this->overrides->setGlyph(ThemeElement::FieldEntrySelector, $glyph, $ascii); + + return $this; + } + + /** + * Draw the mark recording a picked entry with this glyph. + * + * The mark an entry carries once it is picked; an entry nobody picked keeps + * whatever the theme draws for it, so a patch stays a patch. + * + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + * + * @return $this + * The group. + */ + public function entryMarker(string $glyph, string $ascii): self { + $this->overrides->setGlyph(ThemeElement::FieldEntryMarker, $glyph, $ascii); + + return $this; + } + + /** + * Draw the mark showing where the next keystroke lands with this glyph. + * + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + * + * @return $this + * The group. + */ + public function caret(string $glyph, string $ascii): self { + $this->overrides->setGlyph(ThemeElement::FieldCaret, $glyph, $ascii); + + return $this; + } + +} diff --git a/src/Theme/Override/Glyph.php b/src/Theme/Override/Glyph.php new file mode 100644 index 00000000..137030cc --- /dev/null +++ b/src/Theme/Override/Glyph.php @@ -0,0 +1,31 @@ +overrides->setGlyph(ThemeElement::LegendSeparator, $glyph, $ascii); + + return $this; + } + + /** + * Paint a key in these colours. + * + * @param \DrevOps\Tui\Theme\Sgr ...$parts + * The palette parts, in order (e.g. Sgr::Bold, Sgr::Cyan). + * + * @return $this + * The group. + */ + public function key(Sgr ...$parts): self { + $this->overrides->setStyle(ThemeElement::LegendKey, Sgr::of(...$parts)); + + return $this; + } + +} diff --git a/src/Theme/Override/Overrides.php b/src/Theme/Override/Overrides.php new file mode 100644 index 00000000..f49a5cd6 --- /dev/null +++ b/src/Theme/Override/Overrides.php @@ -0,0 +1,116 @@ + + */ + protected array $glyphs = []; + + /** + * The plain strings, keyed by the element they replace. + * + * @var array + */ + protected array $texts = []; + + /** + * The SGR parameters, keyed by the element they repaint. + * + * @var array + */ + protected array $styles = []; + + /** + * State the glyph an element draws. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * @param string $glyph + * The glyph. + * @param string $ascii + * Its ASCII stand-in. + */ + public function setGlyph(ThemeElement $element, string $glyph, string $ascii): void { + $this->glyphs[$element->value] = new Glyph($glyph, $ascii); + } + + /** + * State the text an element draws. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * @param string $text + * The text. + */ + public function setText(ThemeElement $element, string $text): void { + $this->texts[$element->value] = $text; + } + + /** + * State the colour an element is painted in. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * @param string $sgr + * The SGR parameters. + */ + public function setStyle(ThemeElement $element, string $sgr): void { + $this->styles[$element->value] = $sgr; + } + + /** + * The glyph stated for an element. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * + * @return \DrevOps\Tui\Theme\Override\Glyph|null + * The pair, or NULL when nobody stated one. + */ + public function glyph(ThemeElement $element): ?Glyph { + return $this->glyphs[$element->value] ?? NULL; + } + + /** + * The text stated for an element. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * + * @return string|null + * The text, or NULL when nobody stated one. + */ + public function text(ThemeElement $element): ?string { + return $this->texts[$element->value] ?? NULL; + } + + /** + * The colour stated for an element. + * + * @param \DrevOps\Tui\Theme\Override\ThemeElement $element + * The element. + * + * @return string|null + * The SGR parameters, or NULL when nobody stated any. + */ + public function style(ThemeElement $element): ?string { + return $this->styles[$element->value] ?? NULL; + } + +} diff --git a/src/Theme/Override/ThemeElement.php b/src/Theme/Override/ThemeElement.php new file mode 100644 index 00000000..15b1024d --- /dev/null +++ b/src/Theme/Override/ThemeElement.php @@ -0,0 +1,35 @@ +breadcrumb(fn(BreadcrumbOverrides $b) => $b + * ->separator('›', '>')) + * ->legend(fn(LegendOverrides $l) => $l + * ->separator('·', '|') + * ->key(Sgr::Bold)) + * ->field(fn(FieldOverrides $f) => $f + * ->selector('❯', '>') + * ->helpMarker('ⁱ', '[?]') + * ->valueSeparator(', ') + * ->entryMarker('◼', '[x]') + * ->caret('█', '|')) + * ->overrides(); + * @endcode + * + * Reach for a subclass when you are changing a palette; reach for this when you + * are changing a handful of glyphs. + * + * @package DrevOps\Tui\Theme + */ +final class ThemeBuilder { + + /** + * The patch every group writes into. + */ + protected Overrides $overrides; + + /** + * Construct a builder. + */ + public function __construct() { + $this->overrides = new Overrides(); + } + + /** + * Patch the breadcrumb's elements. + * + * @param \Closure $group + * Given the breadcrumb's group, states what it draws differently. + * + * @return $this + * The builder. + */ + public function breadcrumb(\Closure $group): self { + $group(new BreadcrumbOverrides($this->overrides)); + + return $this; + } + + /** + * Patch the legend's elements. + * + * @param \Closure $group + * Given the legend's group, states what it draws differently. + * + * @return $this + * The builder. + */ + public function legend(\Closure $group): self { + $group(new LegendOverrides($this->overrides)); + + return $this; + } + + /** + * Patch the field's elements. + * + * @param \Closure $group + * Given the field's group, states what it draws differently. + * + * @return $this + * The builder. + */ + public function field(\Closure $group): self { + $group(new FieldOverrides($this->overrides)); + + return $this; + } + + /** + * The patch collected so far. + * + * @return \DrevOps\Tui\Theme\Override\Overrides + * The overrides, holding only the elements that were stated. + */ + public function overrides(): Overrides { + return $this->overrides; + } + +} diff --git a/src/Theme/ThemeInterface.php b/src/Theme/ThemeInterface.php index bb2efe03..7fd724ec 100644 --- a/src/Theme/ThemeInterface.php +++ b/src/Theme/ThemeInterface.php @@ -4,464 +4,69 @@ namespace DrevOps\Tui\Theme; -use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\ScopedKeyMap; -use DrevOps\Tui\Primitive\Status; /** - * A theme's look: one method per themeable element. + * A theme, in the two things that belong to no one part of what it draws. * - * Every method here is a single knob. The stylers take text and return it in - * that element's colour (already resolved for the theme's dark/light mode); the - * symbol methods return a glyph for the theme's Unicode mode. To restyle an - * element, a theme overrides just that one method - see {@see DefaultTheme} for - * the dark/light palette that ships with the library. + * Everything a theme styles is an element, and every element belongs to the + * block that declares it - so a theme is written as one + * `*ElementsInterface` implementation per block and nothing here. What is left + * is what no block could own: the width they all lay out against, and how this + * theme writes a key. + * + * A theme says what it can do rather than being asked: declaring + * {@see \DrevOps\Tui\Theme\Capability\ColorSchemeCapableInterface}, + * {@see \DrevOps\Tui\Theme\Capability\UnicodeCapableInterface} or any other + * capability is what grants the facility that goes with it. A theme that + * declares none still draws - it hands back the strings it was given - which is + * why a form renders in a terminal that supports nothing. * * @code - * class OceanTheme extends DefaultTheme { - * public function title(string $text): string { return $this->paint(Sgr::of(Sgr::Bold, Sgr::BrightCyan), $text); } - * public function marker(bool $selected): string { return $selected ? '~ ' : ' '; } + * final class OrchardTheme extends AbstractTheme implements ColorSchemeCapableInterface { + * use ColorSchemeCapableTrait; + * + * public function fieldLabel(string $text): string { return $this->paint(Sgr::of(Sgr::Bold, Sgr::Green), $text); } * } * @endcode * * The {@see Mode}, {@see Spacing} and {@see Border} enums carry the display - * options a consumer passes in the theme options array (as enum cases or - * their string values). How the styled pieces are arranged into rows and - * frames is the render*() layer on {@see DefaultTheme}. + * options a consumer passes in the theme options array (as enum cases or their + * string values). * * @package DrevOps\Tui\Theme */ interface ThemeInterface { /** - * A heading or an editor label. - */ - public function title(string $text): string; - - /** - * A field label; bold when its row is selected. - */ - public function label(string $text, bool $selected = FALSE): string; - - /** - * A field value; bold when its row is selected. - */ - public function value(string $text, bool $selected = FALSE): string; - - /** - * A help/description line; bold when its row is selected. - */ - public function description(string $text, bool $selected = FALSE): string; - - /** - * A field's hint - how to answer it; bold when its row is selected. + * The width, in columns, available for the content a theme lays out. * - * The guidance text a field declares, styled apart from its description so - * the two read as different things. Unrelated to {@see keyHint()} and - * {@see keysHint()}, which draw the bound keys. - */ - public function hint(string $text, bool $selected = FALSE): string; - - /** - * A provenance badge (e.g. "edited"); bold when its row is selected. - */ - public function badge(string $text, bool $selected = FALSE): string; - - /** - * The active (focused) button. - */ - public function cursor(string $text): string; - - /** - * A footer: the status and hint lines. - */ - public function footer(string $text): string; - - /** - * The width, in columns, available for a widget's rendered content. + * The frame's inner width, already less any border and gutter. It belongs to + * no block because every one of them measures against it and none may ask + * where its own space ends: a card wraps to it, a badge column ends at it, + * and a field drops a line it has no room for rather than wrapping it into + * fragments. Asking the theme is what keeps one width across the whole frame. * - * The frame's inner width, already less any border and gutter. A widget wraps - * or omits its own secondary lines against this so they fit the panel. + * @return int + * The width. */ public function contentWidth(): int; /** - * The navigator breadcrumb. - */ - public function breadcrumb(string $text): string; - - /** - * A scroll indicator (the up/down arrows). - */ - public function indicator(string $text): string; - - /** - * The highlighted (cursor) row in a list widget. - */ - public function highlight(string $text): string; - - /** - * A run of query-matched characters within an option label. - */ - public function highlightMatch(string $text): string; - - /** - * A non-selectable group heading in an option list. - */ - public function heading(string $text): string; - - /** - * Bold markup (`**text**`) inside a description or note. - */ - public function strong(string $text): string; - - /** - * Emphasis markup (`*text*`) inside a description or note. - */ - public function emphasis(string $text): string; - - /** - * Inline-code markup (`` `text` ``) inside a description or note. - */ - public function code(string $text): string; - - /** - * A hyperlink: a clickable label on capable terminals, else `text (url)`. - * - * @param string $text - * The visible link label. - * @param string $url - * The link target. - * - * @return string - * The rendered link. - */ - public function link(string $text, string $url): string; - - /** - * The bullet glyph that leads an unordered-list item in markup. - */ - public function bullet(): string; - - /** - * A non-selectable separator line between options in an option list. - */ - public function divider(): string; - - /** - * A disabled (non-selectable) option's label and reason, dimmed. - */ - public function disabled(string $text): string; - - /** - * A validation error message. - */ - public function error(string $text): string; - - /** - * The editor-header underline. - */ - public function rule(string $text): string; - - /** - * The frame box, when a border is on. - */ - public function border(string $text): string; - - /** - * The selection cursor for a row: the marker glyph when selected, else a gap. - */ - public function marker(bool $selected): string; - - /** - * The drill-in / breadcrumb arrow symbol. - */ - public function arrow(): string; - - /** - * The breadcrumb separator symbol. - */ - public function separator(): string; - - /** - * The "move up" key hint symbol. - */ - public function arrowUp(): string; - - /** - * The "move down" key hint symbol. - */ - public function arrowDown(): string; - - /** - * The "move left" key hint symbol. - */ - public function arrowLeft(): string; - - /** - * The "move right" key hint symbol. - */ - public function arrowRight(): string; - - /** - * The enter/accept key hint symbol. - */ - public function enter(): string; - - /** - * The dot that joins hint and summary fragments. - */ - public function dot(): string; - - /** - * The "more above" scroll-indicator symbol. - */ - public function indicatorUp(): string; - - /** - * The "more below" scroll-indicator symbol. - */ - public function indicatorDown(): string; - - /** - * A radio symbol: filled in the cursor colour when on, empty when off. - */ - public function radio(bool $on): string; - - /** - * A checkbox symbol: filled in the value colour when checked, empty when off. - */ - public function check(bool $on): string; - - /** - * The text-input caret, in the cursor colour. - */ - public function caret(): string; - - /** - * Inline ghost-text: a dimmed completion suffix, empty without colour. - * - * @param string $text - * The completion suffix shown after the caret. - * - * @return string - * The dimmed suffix, or an empty string in no-colour mode - without ANSI it - * cannot be told apart from typed text, so it is suppressed. - */ - public function ghost(string $text): string; - - /** - * Render an editor input line, styled per the "field" theme option. - * - * The flat style returns the value with a plain caret. The boxed and - * underline styles wrap it in a fixed-width filled or underlined field, so - * the entry area reads as an input the way an MS-DOS form marked its fields: - * the fill runs behind the value text, and a reverse-video caret sits over - * the character it is on, so the letter still shows. - * - * @param string $before - * The buffer text before the caret. - * @param string $after - * The buffer text after the caret. - * @param string $ghost - * The inline ghost-text completion suffix, or an empty string. - * - * @return string - * The composed input line. - */ - public function renderInput(string $before, string $after, string $ghost = ''): string; - - /** - * Render 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; - - /** - * Render a determinate progress bar: a filling bar, 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; - - /** - * Render a rating scale: a run of points filled up to the chosen one. - * - * The one scale renderer behind both a rating's editor and its collapsed - * panel row, so overriding it restyles the two together. - * - * @param int $current - * The chosen point. - * @param int $min - * The lowest point of the scale. - * @param int $max - * The highest point of the scale. - * @param string $caption - * The chosen point's caption, or an empty string when it has none; its line - * breaks fold to spaces so the scale stays one line. - * - * @return string - * The composed scale line. - */ - public function renderScale(int $current, int $min, int $max, string $caption): string; - - /** - * Render a static loading indicator: a caption and a themed ellipsis. - * - * The resting state of a progressable load - shown while a callable resolves - * but before (or without) any advance. + * How this theme writes one key. * - * @param string $caption - * The caption shown before the ellipsis. - * - * @return string - * The composed loading line. - */ - public function renderLoading(string $caption): string; - - /** - * Render a card: a heading, a body and an optional grid, boxed or indented. - * - * The one card renderer behind both a standalone box and a note field's card, - * so overriding it restyles the two together. - * - * @param string $title - * The heading shown as the card's first line; empty for a bare card. - * @param list $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; - - /** - * Render 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; - - /** - * Render an aligned, bordered table from headers and rows. - * - * Lays the data out as a grid coloured with the theme's atoms and drawn in - * its border style, honouring the Unicode and colour switches. 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 table's physical lines, capped at the frame width. - */ - public function renderTable(array $headers, array $rows): array; - - /** - * Render 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; - - /** - * Render 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; - - /** - * Render 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; - - /** - * The masked-character symbol for secret values. - */ - public function mask(): string; - - /** - * Render a single key as its hint glyph (an arrow, a word or the character). + * Vocabulary rather than styling, which is why it is here and not on a block: + * the legend that lists the live bindings, the field that names a key in a + * prompt and the notice that says how to quit all have to spell the same key + * the same way. It is handed a key and nothing else, so it never reaches for + * anything a form is holding. * * @param \DrevOps\Tui\Input\Key $key - * The key to render. - * - * @return string - * The glyph, respecting the theme's Unicode mode. - */ - public function keyHint(Key $key): string; - - /** - * Render a hint fragment: the primary keys of one or more actions, labelled. - * - * The glyphs are drawn from the live bindings, so a hint never contradicts a - * remapped key. An action with no bound key contributes nothing, and when no - * action is bound the fragment is empty. - * - * @param \DrevOps\Tui\Input\ScopedKeyMap $keys - * The scope's bindings. - * @param string $label - * The label describing what the keys do (e.g. "move", "accept"). - * @param \DrevOps\Tui\Input\Action ...$actions - * The actions whose primary keys lead the fragment. + * The key. * * @return string - * The fragment (e.g. "↑/↓ move"), or an empty string when nothing is bound. + * The key as this theme writes it. */ - public function keysHint(ScopedKeyMap $keys, string $label, Action ...$actions): string; + public function keyGlyph(Key $key): string; } diff --git a/src/Tui.php b/src/Tui.php index f443d9a1..3f163042 100644 --- a/src/Tui.php +++ b/src/Tui.php @@ -5,35 +5,39 @@ namespace DrevOps\Tui; use DrevOps\Tui\Answers\Answers; +use DrevOps\Tui\Block\Panel; +use DrevOps\Tui\Block\Tree; use DrevOps\Tui\Builder\Form; -use DrevOps\Tui\Engine\Engine; use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Handler\HandlerRegistry; use DrevOps\Tui\Input\KeyMap; use DrevOps\Tui\Input\KeyMapManager; -use DrevOps\Tui\Model\FormDefinition; use DrevOps\Tui\Primitive\Output; use DrevOps\Tui\Primitive\Progress; -use DrevOps\Tui\Render\PanelController; use DrevOps\Tui\Render\Terminal; use DrevOps\Tui\Resolver\InputResolver; use DrevOps\Tui\Schema\AgentHelp; use DrevOps\Tui\Schema\SchemaGenerator; use DrevOps\Tui\Schema\SchemaValidator; +use DrevOps\Tui\Screen\Collector; +use DrevOps\Tui\Screen\Layout\LayoutManager; +use DrevOps\Tui\Screen\ScreenController; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Theme\Mode; +use DrevOps\Tui\Theme\Override\Overrides; +use DrevOps\Tui\Theme\ThemeBuilder; use DrevOps\Tui\Theme\ThemeManager; use DrevOps\Tui\Translation\Translator; /** * The one-class entry point for collecting a form's answers. * - * Wraps the engine, input resolver, schema tools and panel TUI so a consumer + * Wraps the collector, input resolver, schema tools and panel TUI so a consumer * can collect answers - headlessly or interactively - in a single call. It also * owns the global TUI runtime shared by every form: the theme, key bindings, * colour and glyph forcing, the key-hint footer, screen clearing and the active * language, each set through a fluent setter. Those internals stay reachable - * via form(), engine() and registry() when a consumer wants finer control. + * via root() and registry() when a consumer wants finer control. * * @package DrevOps\Tui */ @@ -44,20 +48,15 @@ final class Tui { */ protected HandlerRegistry $registry; - /** - * The engine. - */ - protected Engine $engine; - /** * The effective env-variable prefix for per-question overrides. */ protected string $envPrefix; /** - * The form definition. + * What collects the answers with no screen at all, once one is asked for. */ - protected FormDefinition $form; + protected ?Collector $collector = NULL; /** * The theme name or class (empty for the default). @@ -71,6 +70,16 @@ final class Tui { */ protected array $themeOptions = []; + /** + * What the consumer states differently, on whatever theme is selected. + */ + protected ?Overrides $themeOverrides = NULL; + + /** + * The layout the screen is arranged by. + */ + protected string $layout = 'default'; + /** * The resolved key bindings; NULL uses the default preset. */ @@ -114,8 +123,8 @@ final class Tui { /** * Construct a TUI. * - * @param \DrevOps\Tui\Model\FormDefinition|\DrevOps\Tui\Builder\Form $form - * The form: a Form builder (built internally) or its built definition. + * @param \DrevOps\Tui\Builder\Form $form + * The form declaring the panels and fields to collect. * @param string[] $handler_namespaces * Namespaces searched, in order, for per-field consumer classes offering * reusable static validate()/transform() behaviour. @@ -123,34 +132,98 @@ final class Tui { * The env-variable prefix for per-question overrides; wins over the * form-declared prefix, which wins over the "TUI_" default. */ - public function __construct(FormDefinition|Form $form, array $handler_namespaces = [], string $env_prefix = '') { - $this->form = $form instanceof Form ? $form->build() : $form; - $this->envPrefix = $env_prefix !== '' ? $env_prefix : ($this->form->envPrefix !== '' ? $this->form->envPrefix : 'TUI_'); + public function __construct(protected Form $form, array $handler_namespaces = [], string $env_prefix = '') { + $declared = $form->currentEnvPrefix(); + $this->envPrefix = $env_prefix !== '' ? $env_prefix : ($declared !== '' ? $declared : 'TUI_'); $this->registry = new HandlerRegistry($handler_namespaces); - $this->engine = new Engine($this->form, $this->registry); } /** - * Set the interactive theme name and its display options. + * Select the theme, or state what it draws differently. * - * @param string $theme - * The theme name or class. Empty (or "auto") auto-detects light/dark from - * the terminal background. + * Two things a consumer wants at two different sizes, so one call answers + * both. A name picks the theme and its display options. A closure is handed a + * {@see \DrevOps\Tui\Theme\ThemeBuilder} and states the elements whatever + * theme is selected should draw differently - anything it does not name keeps + * the theme's own answer, which is what makes it a patch rather than a + * replacement. Reach for a subclass when changing a palette; reach for the + * closure when changing a handful of glyphs. + * + * @code + * $tui->theme('mono') + * ->theme(fn(ThemeBuilder $t) => $t + * ->breadcrumb(fn(BreadcrumbOverrides $b) => $b->separator('›', '>')) + * ->field(fn(FieldOverrides $f) => $f->selector('❯', '>'))); + * @endcode + * + * @param string|\Closure $theme + * The theme name or class - empty (or "auto") auto-detects light/dark from + * the terminal background - or an `fn (ThemeBuilder $t): void` stating what + * the selected theme draws differently. * @param array $options * Display options for the theme, keyed by name - e.g. * `['spacing' => Spacing::Padded, 'border' => Border::Rounded]` - plus any - * a custom theme reads. + * a custom theme reads. Ignored when a closure is given, which patches the + * theme rather than choosing one. * * @return $this * The facade. */ - public function theme(string $theme, array $options = []): self { + public function theme(string|\Closure $theme, array $options = []): self { + if ($theme instanceof \Closure) { + $builder = new ThemeBuilder(); + $theme($builder); + $this->themeOverrides = $builder->overrides(); + + return $this; + } + $this->theme = $theme; $this->themeOptions = $options; return $this; } + /** + * Build the selected theme, carrying anything stated differently. + * + * @param string $name + * The theme name or class; empty falls back to the facade's theme. + * @param int $width + * The frame width. + * @param array $options + * The resolved display options. + * + * @return \DrevOps\Tui\Theme\DefaultTheme + * The theme. + */ + protected function buildTheme(string $name, int $width, array $options): DefaultTheme { + $theme = ThemeManager::create($this->resolveTheme($name), $width, $options); + + return $this->themeOverrides instanceof Overrides ? $theme->overrides($this->themeOverrides) : $theme; + } + + /** + * Set the layout the interactive screen is arranged by. + * + * The name resolves through the layout manager - a shipped layout, one + * registered by the consumer, or a class - and an unknown name throws here + * rather than mid-session. Headless collection is unaffected, since a layout + * exists only to arrange drawing. + * + * @param string $layout + * The layout name or class. Empty selects the default layout. + * + * @return $this + * The facade. + */ + public function layout(string $layout): self { + $this->layout = $layout === '' ? 'default' : $layout; + LayoutManager::create($this->layout); + + return $this; + } + /** * Set the key-binding preset and optional overrides. * @@ -319,8 +392,8 @@ public function translator(Translator $translator): self { * @return \DrevOps\Tui\Answers\Answers * The collected answers. * - * @throws \DrevOps\Tui\Engine\EngineException - * When the engine cannot process the configuration or answers. + * @throws \DrevOps\Tui\CollectException + * When the answers cannot be taken as they were given. * @throws \DrevOps\Tui\InterruptException * When the user aborts the interactive session with the interrupt key. * @throws \DrevOps\Tui\CancelException @@ -351,9 +424,12 @@ public function collect(string $prompts = '', string $directory = '', bool $upda // Restore this facade's language at the operation boundary: another facade // constructed or configured meanwhile may have replaced the shared one. Translator::setShared($this->translator); - $inputs = (new InputResolver($this->envPrefix))->resolve($this->form->fields(), $prompts, getenv()); + $root = $this->root(); + $inputs = (new InputResolver($this->envPrefix))->resolve(Tree::fields($root), $prompts, getenv()); - return $this->engine->collect($inputs, $this->context($directory, $update, $version)); + $this->collector ??= new Collector($this->registry, $this->form->currentFixups()); + + return $this->collector->answers($root, $inputs, $this->context($directory, $update, $version)); } /** @@ -390,7 +466,7 @@ public function progress(?int $total, string $caption, callable $work, ?Terminal $terminal ??= self::primitiveTerminal(); - $theme = ThemeManager::create($this->resolveTheme(''), DefaultTheme::DEFAULT_WIDTH, $this->primitiveThemeOptions()); + $theme = $this->buildTheme('', DefaultTheme::DEFAULT_WIDTH, $this->primitiveThemeOptions()); return (new Progress($terminal, $theme, $terminal->isOutputTty(), $total, $caption))->run($work); } @@ -418,7 +494,7 @@ public function output(?Terminal $terminal = NULL): Output { $terminal ??= self::primitiveTerminal(); $options = $this->primitiveThemeOptions($terminal->isOutputTty()); - $theme = ThemeManager::create($this->resolveTheme(''), self::frameWidth($options, $terminal->width()), $options); + $theme = $this->buildTheme('', self::frameWidth($options, $terminal->width()), $options); return new Output($terminal, $theme); } @@ -445,8 +521,8 @@ public function output(?Terminal $terminal = NULL): Output { * @return \DrevOps\Tui\Answers\Answers * The collected answers. * - * @throws \DrevOps\Tui\Engine\EngineException - * When the engine cannot process the configuration or answers. + * @throws \DrevOps\Tui\CollectException + * When the answers cannot be taken as they were given. * @throws \DrevOps\Tui\InterruptException * When the user aborts the interactive session with the interrupt key. * @throws \DrevOps\Tui\CancelException @@ -463,32 +539,16 @@ public function interact(string $theme = '', string $banner = '', string $versio // when set, otherwise they are auto-detected from the terminal. $options = $this->resolveThemeOptions($terminal); - $controller = $this->controller($options, $theme, $banner, $version, $directory, self::frameWidth($options, $terminal->width()), $update); - - $answers = $controller->run($terminal); - - // An interrupt is an abort, not a submit: surface it so the partial answers - // collected before the abort are never mistaken for a completed form. - if ($controller->isInterrupted()) { - throw new InterruptException('The interactive session was interrupted.'); - } - - // The cancel button is the same abort expressed as a click: without this a - // cancelled session would return its answers exactly like a submitted one. - if ($controller->isCancelled()) { - throw new CancelException('The interactive session was cancelled.'); - } - - return $answers; + return $this->controller($options, $theme, $banner, $version, $directory, self::frameWidth($options, $terminal->width()), $update)->run($terminal); } /** - * Build the interactive panel controller for the resolved display options. + * Build the session that drives the form for the resolved display options. * - * Shared by interact() and the test harness: it resolves and settles every - * field's state through the engine, resolves the theme and banner, and wires - * the controller - so a caller that supplies its own terminal (a real one, - * or a scripted one for tests) can run the interactive loop against it. + * Shared by interact() and the test harness: it builds the tree the form + * declares, resolves the theme and banner and wires the session - so a caller + * that supplies its own terminal (a real one, or a scripted one for tests) + * can run it against that. * * @param array $options * The resolved theme display options (colour, Unicode, mode). @@ -506,39 +566,35 @@ public function interact(string $theme = '', string $banner = '', string $versio * @param bool $update * Whether discovery pre-fills the initial state from an existing project. * - * @return \DrevOps\Tui\Render\PanelController - * The controller, ready to run against a terminal. + * @return \DrevOps\Tui\Screen\ScreenController + * The session, ready to run against a terminal. * * @internal * Public for the {@see \DrevOps\Tui\Testing\TuiTester} harness; consumers * collect through run(), collect() or interact(). */ - public function controller(array $options, string $theme = '', string $banner = '', string $version = '', string $directory = '', int $width = DefaultTheme::DEFAULT_WIDTH, bool $update = FALSE): PanelController { + public function controller(array $options, string $theme = '', string $banner = '', string $version = '', string $directory = '', int $width = DefaultTheme::DEFAULT_WIDTH, bool $update = FALSE): ScreenController { // Restore this facade's language before rendering (see collect()). Translator::setShared($this->translator); - $context = $this->context($directory, $update, $version); - - // The full state, not collect()'s active-only answers: an inactive field - // keeps its settled value, so a condition satisfied mid-session surfaces - // the field with its default rather than an empty value. - [$values, $provenance] = $this->engine->resolveState([], $context); + $drawn = $this->buildTheme($theme, $width, $options); - $banner_text = $banner !== '' ? $banner : $this->form->banner; - - return new PanelController( - $this->form, - ThemeManager::create($this->resolveTheme($theme), $width, $options), - $values, - $provenance, + return new ScreenController( + $this->root(), + $drawn, + [], $this->keymap ?? KeyMapManager::create(), - $this->registry, - footer: $this->footer, + new Collector($this->registry, $this->form->currentFixups()), + $this->context($directory, $update, $version), + // The frame the theme was told to lay its rows out to is the frame that + // has to be drawn around them, so the border is read back off it rather + // than resolved a second time here. + layout: $this->layout, + border: $drawn->borderStyle(), clearOnExit: $this->clearOnExit, - banner: $banner_text, + footer: $this->footer, + banner: $banner !== '' ? $banner : $this->form->currentBanner(), version: $version, - context: $context, - engine: $this->engine, ); } @@ -578,7 +634,7 @@ public static function frameWidth(array $options, int $terminal_width): int { * The schema. */ public function schema(?Context $context = NULL): array { - return (new SchemaGenerator($this->form, $context ?? new Context(), $this->envPrefix))->generate(); + return (new SchemaGenerator($this->root(), $context ?? new Context(), $this->envPrefix))->generate(); } /** @@ -592,7 +648,7 @@ public function schema(?Context $context = NULL): array { * The help text. */ public function agentHelp(?Context $context = NULL): string { - return (new AgentHelp($this->form, $this->envPrefix, $context ?? new Context()))->generate(); + return (new AgentHelp($this->root(), $this->envPrefix, $context ?? new Context()))->generate(); } /** @@ -608,37 +664,32 @@ public function agentHelp(?Context $context = NULL): string { * The validation errors (empty when valid). */ public function validate(array $answers, ?Context $context = NULL): array { - return (new SchemaValidator($this->form, $context ?? new Context()))->validate($answers); + return (new SchemaValidator($this->root(), $context ?? new Context()))->validate($answers); } /** - * The form definition. + * The handler registry. * - * @return \DrevOps\Tui\Model\FormDefinition - * The form definition. + * @return \DrevOps\Tui\Handler\HandlerRegistry + * The handler registry. */ - public function form(): FormDefinition { - return $this->form; + public function registry(): HandlerRegistry { + return $this->registry; } /** - * The engine. + * The declared block tree: the panel every declared panel hangs from. * - * @return \DrevOps\Tui\Engine\Engine - * The engine. - */ - public function engine(): Engine { - return $this->engine; - } - - /** - * The handler registry. + * The rows a form asks about are settled state - a set of entries that + * arrives from somewhere else settles onto the block holding it - so one tree + * carries the declaration and what has become of it, and every operation on + * this facade reads that one. * - * @return \DrevOps\Tui\Handler\HandlerRegistry - * The handler registry. + * @return \DrevOps\Tui\Block\Panel + * The root panel. */ - public function registry(): HandlerRegistry { - return $this->registry; + public function root(): Panel { + return $this->form->root(); } /** diff --git a/src/Utils/Strings.php b/src/Utils/Strings.php index ea244a52..e2a86b82 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -107,6 +107,19 @@ public static function lower(string $text): string { return self::mbstring() ? mb_strtolower($text, 'UTF-8') : strtolower($text); } + /** + * Uppercase text. + * + * @param string $text + * The text. + * + * @return string + * The uppercased text; the fallback uppercases ASCII letters only. + */ + public static function upper(string $text): string { + return self::mbstring() ? mb_strtoupper($text, 'UTF-8') : strtoupper($text); + } + /** * Replace `{{token}}` placeholders in a template with values. * diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php deleted file mode 100644 index 3b287b8c..00000000 --- a/src/Widget/AbstractWidget.php +++ /dev/null @@ -1,406 +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. - * - * Widgets 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 widget's binding scope. - */ - protected function keyScope(): Scope { - return Scope::base(); - } - - /** - * The resolved bindings for this widget, defaulting to the built-in preset. - * - * @return \DrevOps\Tui\Input\ScopedKeyMap - * The scoped bindings. - */ - protected function keys(): ScopedKeyMap { - return $this->scoped ??= KeyMapManager::create()->scope($this->keyScope()); - } - - /** - * Cancel the widget 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 widget. - */ - 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; - } - - /** - * Style an option label, highlighted when its row holds the cursor. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * @param string $label - * The option label. - * @param bool $current - * Whether the option's row holds the cursor. - * - * @return string - * The label, highlight-styled when current. - */ - protected function highlightLabel(ThemeInterface $theme, string $label, bool $current): string { - return $current ? $theme->highlight($label) : $label; - } - - /** - * Render a radio option row: the radio glyph plus the highlighted label. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * @param string $label - * The option label. - * @param bool $current - * Whether the option's row holds the cursor. - * - * @return string - * The rendered row. - */ - protected function renderRadioRow(ThemeInterface $theme, string $label, bool $current): string { - return $theme->radio($current) . ' ' . $this->highlightLabel($theme, $label, $current); - } - - /** - * The shared fuzzy matcher. - * - * @return \DrevOps\Tui\Widget\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 on the cursor row the rest keeps the highlight colour. - * With no matched positions this is exactly {@see highlightLabel()}. - * - * @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. - * - * @return string - * The styled label. - */ - protected function renderMatchedLabel(ThemeInterface $theme, string $label, array $positions, bool $current): string { - if ($positions === []) { - return $this->highlightLabel($theme, $label, $current); - } - - $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); - $run = ''; - } - - $run .= $char; - $run_matched = $is_matched; - } - - return $out . $this->styleRun($theme, $run, $run_matched, $current); - } - - /** - * 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. - * - * @return string - * The styled run. - */ - protected function styleRun(ThemeInterface $theme, string $run, bool $matched, bool $current): string { - if ($matched) { - return $theme->highlightMatch($run); - } - - return $current ? $theme->highlight($run) : $run; - } - - /** - * {@inheritdoc} - * - * The frame every widget shares: the widget's own body, then the highlighted - * option's description beneath it (choice widgets only), then the validation - * error line. A widget renders only its body via {@see renderBody()}. - */ - public function view(ThemeInterface $theme): string { - $lines = [$this->renderBody($theme)]; - - $description = $this->renderOptionDescription($theme, $this->highlightedDescription()); - if ($description !== '') { - $lines[] = $description; - } - - if ($this->error !== NULL) { - $lines[] = $theme->error($this->error); - } - - return implode("\n", $lines); - } - - /** - * The widget'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 widgets without one. - * - * The choice widgets override this (directly or via a capability trait) to - * surface the highlighted option's description; every other widget 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 { - $width = $theme->contentWidth(); - - if ($description === '' || $width < self::MIN_DESCRIPTION_WIDTH) { - return ''; - } - - return implode("\n", array_map(static fn(string $line): string => $theme->description($line), Strings::wrap($description, $width))); - } - - /** - * Validate and, when valid, transform a value and complete the widget. - * - * @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/Widget/CalendarWidget.php b/src/Widget/CalendarWidget.php deleted file mode 100644 index 45cdddcc..00000000 --- a/src/Widget/CalendarWidget.php +++ /dev/null @@ -1,271 +0,0 @@ -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 widget'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('day', Action::MoveLeft, Action::MoveRight), - new Hint('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) . $theme->title($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 $theme->footer(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 $theme->highlight(sprintf('[%2d]', $day)); - } - - $cell = sprintf(' %2d ', $day); - - return $this->bounds->contains($date) ? $cell : $theme->description($cell); - } - -} diff --git a/src/Widget/Capability/ExternalEditCapableInterface.php b/src/Widget/Capability/ExternalEditCapableInterface.php deleted file mode 100644 index 1b8b6a97..00000000 --- a/src/Widget/Capability/ExternalEditCapableInterface.php +++ /dev/null @@ -1,33 +0,0 @@ -pageSize; - } - - /** - * Resolve the effective page size, rejecting a non-positive declared value. - * - * The builder rejects a non-positive page size, but a widget 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[] = $theme->indicator(' ' . $theme->indicatorUp()); - } - - $lines = array_merge($lines, $rows); - - if ($viewport->hasBelow) { - $lines[] = $theme->indicator(' ' . $theme->indicatorDown()); - } - - return $lines; - } - -} diff --git a/src/Widget/Capability/RevealCapableInterface.php b/src/Widget/Capability/RevealCapableInterface.php deleted file mode 100644 index 33738553..00000000 --- a/src/Widget/Capability/RevealCapableInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -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 widget 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 dim 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 ''; - } - - return $theme->description(Translator::t('Select @constraint.', ['@constraint' => $this->selectionBounds->describe()])); - } - - /** - * Append the selection-count hint line beneath a view, when it is shown. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * @param string $view - * The rendered view. - * - * @return string - * The view, with the hint line below it when bounds are present. - */ - protected function withSelectionHint(ThemeInterface $theme, string $view): string { - $hint = $this->selectionHint($theme); - - return $hint === '' ? $view : $view . "\n" . $hint; - } - -} diff --git a/src/Widget/Capability/SelectionCapableInterface.php b/src/Widget/Capability/SelectionCapableInterface.php deleted file mode 100644 index 4e8842dc..00000000 --- a/src/Widget/Capability/SelectionCapableInterface.php +++ /dev/null @@ -1,24 +0,0 @@ -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 { - $yes_label = $this->highlightLabel($theme, Translator::t('Yes'), $this->current); - $no_label = $this->highlightLabel($theme, Translator::t('No'), !$this->current); - - return $theme->radio($this->current) . ' ' . $yes_label . ' ' . $theme->radio(!$this->current) . ' ' . $no_label; - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function hints(): array { - return [new Hint('yes/no', Action::Yes, Action::No), new Hint('toggle', Action::Toggle), ...parent::hints()]; - } - -} diff --git a/src/Widget/FilePickerWidget.php b/src/Widget/FilePickerWidget.php deleted file mode 100644 index e8188d81..00000000 --- a/src/Widget/FilePickerWidget.php +++ /dev/null @@ -1,736 +0,0 @@ - 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 widget. - * - * @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 { - $lines = [$theme->breadcrumb($this->crumb())]; - - if ($this->filter !== '') { - $lines[] = $this->filter . $theme->caret(); - } - - $entries = $this->entries(); - - if ($entries === []) { - $lines[] = $theme->description(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); - } - - $body = implode("\n", array_merge($lines, $this->wrapScrolled($theme, $rows, $viewport))); - - return $this->withSelectionHint($theme, $this->withConstraintHint($theme, $body)); - } - - /** - * 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 dim 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 ''; - } - - return $theme->description($describe); - } - - /** - * Append the constraint hint line beneath a view, when it is shown. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * @param string $view - * The rendered view. - * - * @return string - * The view, with the hint line below it when constraints are present. - */ - protected function withConstraintHint(ThemeInterface $theme, string $view): string { - $hint = $this->constraintHint($theme); - - return $hint === '' ? $view : $view . "\n" . $hint; - } - - /** - * {@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('up', Action::MoveLeft), - new Hint($this->multiple ? 'accept' : 'select', Action::Accept), - new Hint('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; - $row = $theme->marker($current) . ' '; - - if ($this->multiple) { - $box = $this->isSelectable($name) ? $theme->check(isset($this->selected[$this->join($name)])) : $this->blankBox($theme); - $row .= $box . ' '; - } - - return $row . $this->highlightLabel($theme, $label, $current); - } - - /** - * 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($theme->check(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/NumberWidget.php b/src/Widget/NumberWidget.php deleted file mode 100644 index d9b4ceca..00000000 --- a/src/Widget/NumberWidget.php +++ /dev/null @@ -1,167 +0,0 @@ -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/Widget/PasswordWidget.php b/src/Widget/PasswordWidget.php deleted file mode 100644 index f78be192..00000000 --- a/src/Widget/PasswordWidget.php +++ /dev/null @@ -1,198 +0,0 @@ -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 widget. - 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[] = $theme->footer(Translator::t('re-enter to confirm')); - } - - return implode("\n", $rows); - } - - /** - * {@inheritdoc} - * - * The reveal toggle is the non-obvious action, so it leads when the widget - * 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); - - return match ($this->display) { - PasswordDisplay::Hidden => $theme->renderInput('', '', $placeholder), - PasswordDisplay::Masked => $theme->renderInput(str_repeat($theme->mask(), $this->cursor), str_repeat($theme->mask(), Strings::length($this->buffer) - $this->cursor), $placeholder), - PasswordDisplay::Plaintext => $this->renderInputLine($theme, $placeholder), - }; - } - -} diff --git a/src/Widget/PauseWidget.php b/src/Widget/PauseWidget.php deleted file mode 100644 index 6c64ab35..00000000 --- a/src/Widget/PauseWidget.php +++ /dev/null @@ -1,70 +0,0 @@ -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); - $glyph = $key instanceof Key ? $theme->keyHint($key) : $theme->enter(); - - return Translator::t('Press @key to continue', ['@key' => $theme->highlight($glyph)]); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function hints(): array { - return [new Hint('continue', Action::Accept), new Hint('cancel', Action::Cancel)]; - } - -} diff --git a/src/Widget/RatingWidget.php b/src/Widget/RatingWidget.php deleted file mode 100644 index ac58d6ea..00000000 --- a/src/Widget/RatingWidget.php +++ /dev/null @@ -1,158 +0,0 @@ - $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 $theme->renderScale($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/Widget/ReorderWidget.php b/src/Widget/ReorderWidget.php deleted file mode 100644 index cdb7c823..00000000 --- a/src/Widget/ReorderWidget.php +++ /dev/null @@ -1,258 +0,0 @@ - - */ - 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 widget. - * - * @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->highlightLabel($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 { - if ($current && $this->grabbed) { - return $theme->arrowUp() . $theme->arrowDown(); - } - - return $theme->marker($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/Widget/SearchWidget.php b/src/Widget/SearchWidget.php deleted file mode 100644 index 17c3b56f..00000000 --- a/src/Widget/SearchWidget.php +++ /dev/null @@ -1,140 +0,0 @@ - $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 widget 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->withSelectionHint($theme, $this->renderChoiceList($theme))); - } - - /** - * {@inheritdoc} - */ - public function queryLine(ThemeInterface $theme): string { - return $this->filterLine($theme) . $this->placeholderGhost($theme, $this->filter); - } - -} diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php deleted file mode 100644 index 676f8d5f..00000000 --- a/src/Widget/SelectWidget.php +++ /dev/null @@ -1,107 +0,0 @@ - $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 widget 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->withSelectionHint($theme, $this->renderChoiceList($theme)); - } - -} diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php deleted file mode 100644 index 211666be..00000000 --- a/src/Widget/SuggestWidget.php +++ /dev/null @@ -1,362 +0,0 @@ - - */ - protected array $ranked = []; - - /** - * Construct a suggest widget. - * - * @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 widget'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 widget 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 widget'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[] = $theme->marker($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(); - - return $this->buffer . $theme->caret() . ($completion === '' ? $this->placeholderGhost($theme, $this->buffer) : $theme->ghost($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/Widget/TemplateWidget.php b/src/Widget/TemplateWidget.php deleted file mode 100644 index c0f29c76..00000000 --- a/src/Widget/TemplateWidget.php +++ /dev/null @@ -1,294 +0,0 @@ - - */ - 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 widget. - * - * @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 Template $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('next/previous', 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" . $theme->footer(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 === '' ? '' : $theme->description($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 === '' ? $theme->description($this->template->labelOf($name)) : $value; - } - -} diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php deleted file mode 100644 index f9a033a4..00000000 --- a/src/Widget/TextWidget.php +++ /dev/null @@ -1,118 +0,0 @@ - $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/Widget/TextareaWidget.php b/src/Widget/TextareaWidget.php deleted file mode 100644 index de9fae5c..00000000 --- a/src/Widget/TextareaWidget.php +++ /dev/null @@ -1,186 +0,0 @@ -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('newline', Action::NewLine), ...parent::hints()]; - - if ($this->externalEdit) { - $hints[] = new Hint('editor', Action::ExternalEdit); - } - - return $hints; - } - -} diff --git a/src/Widget/ToggleWidget.php b/src/Widget/ToggleWidget.php deleted file mode 100644 index 334612f1..00000000 --- a/src/Widget/ToggleWidget.php +++ /dev/null @@ -1,146 +0,0 @@ - - */ - protected array $values; - - /** - * The selected option index. - */ - protected int $cursor = 0; - - /** - * Construct a toggle widget. - * - * @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->renderRadioRow($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/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php deleted file mode 100644 index 28e4fd40..00000000 --- a/src/Widget/WidgetFactory.php +++ /dev/null @@ -1,321 +0,0 @@ -keymap = $keymap ?? KeyMapManager::create(); - } - - /** - * Create a widget for a field, wired with its scope's key bindings. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param mixed $current - * The current value to seed the widget with. - * @param array $answers - * The answers collected so far, passed to a text completion closure. - * - * @return \DrevOps\Tui\Widget\WidgetInterface - * The widget. - */ - public function create(Field $field, mixed $current, array $answers = []): WidgetInterface { - $widget = match ($field->type) { - FieldType::Confirm => new ConfirmWidget((bool) $current), - FieldType::Toggle => new ToggleWidget($this->labels($field), $this->text($current)), - FieldType::Select => new SelectWidget($this->options($field), $this->seed($field, $current), $field->multiple, $field->pageSize, $field->selectionBounds), - FieldType::Reorder => new ReorderWidget($this->options($field), Field::stringList($current), $field->pageSize), - FieldType::Suggest => new SuggestWidget($field->selectableValues(), $this->text($current), $field->pageSize, $this->suggestDescriptions($field), $field->ghost), - FieldType::Search => new SearchWidget($this->options($field), $this->seed($field, $current), $field->multiple, $field->pageSize, $field->selectionBounds), - FieldType::FilePicker => new FilePickerWidget($field->pickerStart, $this->seed($field, $current), $field->pickerConstraints, $field->pickerShowHidden, $field->multiple, $field->pageSize, $field->selectionBounds), - FieldType::Number => new NumberWidget($this->number($current), $field->bounds), - FieldType::Rating => $this->rating($field, $current), - FieldType::Calendar => new CalendarWidget($this->text($current), $field->dateBounds), - FieldType::Textarea => new TextareaWidget($this->text($current), $field->externalEditor && $this->externalEditorAvailable), - FieldType::Password => new PasswordWidget($this->text($current), $field->revealable, $field->confirm), - FieldType::Pause => new PauseWidget(), - FieldType::Text => new TextWidget($this->text($current), $this->completionsFor($field, $answers)), - FieldType::Template => new TemplateWidget($this->template($field), $this->text($current)), - // A note is presentational: the theme renders it and the cursor skips it, - // so it is never edited and needs no widget. - FieldType::Note => throw new \LogicException('Note fields are presentational and have no editor widget.'), - // A progress row runs its work on activation and draws itself in its - // panel row; it has no editor to open. - FieldType::Progress => throw new \LogicException('A progress row is not edited.'), - }; - - if ($widget instanceof QueryOptionsCapableInterface && $field->optionsSource instanceof \Closure) { - $widget->driveByQuery($field->queryMinLength); - } - - // The field declaration always wins over the registry's convention-resolved - // behaviour, mirroring the engine's headless resolution. - $widget->setHandlers($this->guarded($field, $field->validate ?? $this->handlers?->validator($field->id)), $field->transform ?? $this->handlers?->transformer($field->id)); - - if ($widget instanceof PlaceholderCapableInterface) { - $widget->setPlaceholder($field->placeholder); - } - - return $widget->setKeys($this->keymap->forField($field->type, $field->multiple)); - } - - /** - * A validator that rejects an empty value before the field's own runs. - * - * Composed here rather than inside the widgets so every type enforces - * emptiness identically, in the same order the engine validates a supplied - * input. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param \Closure|null $validator - * The field's resolved validator, or NULL when it declares none. - * - * @return \Closure|null - * The guarded validator, the resolved one unchanged for an optional field, - * or NULL when there is nothing to enforce. - */ - protected function guarded(Field $field, ?\Closure $validator): ?\Closure { - if (!$field->required) { - return $validator; - } - - return static fn(mixed $value): ?string => $field->requiredViolation($value) ?? ($validator instanceof \Closure ? $validator($value) : NULL); - } - - /** - * Coerce a current value to the string a text-seeded widget 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 widget 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 : ''; - } - - /** - * Build a rating widget over the field's scale, seeded with its value. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param mixed $current - * The current value to seed the widget with. - * - * @return \DrevOps\Tui\Widget\RatingWidget - * The widget. - */ - protected function rating(Field $field, mixed $current): RatingWidget { - $scale = $field->bounds; - - if (!$scale instanceof NumberBounds || $scale->min === NULL || $scale->max === NULL) { - // @codeCoverageIgnoreStart - throw new \LogicException(sprintf('Field "%s" is a rating field carrying no closed scale.', $field->id)); - // @codeCoverageIgnoreEnd - } - - return new RatingWidget(is_int($current) || is_float($current) ? (int) $current : $scale->min, $scale->min, $scale->max, $this->captions($field)); - } - - /** - * 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 widget shows is the caption the panel row shows. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return array - * The localized caption of each point, keyed by the point. - */ - protected function captions(Field $field): array { - return array_map(static fn(string $caption): string => $caption === '' ? '' : Translator::t($caption), $field->ratingCaptions); - } - - /** - * The widget seed value for a field: a scalar, or a list when multiple. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param mixed $current - * The current value to seed the widget 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 $field, mixed $current): string|array { - return $field->multiple ? Field::stringList($current) : $this->text($current); - } - - /** - * Resolve a text field's completion source to a concrete candidate list. - * - * A closure source is called with the answers collected so far; the result is - * coerced to a list of strings, so a mistyped source degrades to no - * completion rather than erroring. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * @param array $answers - * The answers collected so far. - * - * @return list - * The candidate strings; empty when the field declares no completion. - */ - protected function completionsFor(Field $field, array $answers): array { - $source = $field->completion instanceof \Closure ? ($field->completion)($answers) : $field->completion; - - return Field::stringList($source); - } - - /** - * The shape a template field fills in. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return \DrevOps\Tui\Model\Template - * The template. - */ - protected function template(Field $field): Template { - if (!$field->template instanceof Template) { - // @codeCoverageIgnoreStart - throw new \LogicException(sprintf('Field "%s" is a template field carrying no template.', $field->id)); - // @codeCoverageIgnoreEnd - } - - return $field->template; - } - - /** - * The selectable value => label map for a field's options. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return array - * The localized labels keyed by value, for widgets that take a flat option - * map. - */ - protected function labels(Field $field): array { - $out = []; - - foreach ($this->options($field) as $option) { - if ($option->selectable()) { - $out[$option->value] = $option->label; - } - } - - return $out; - } - - /** - * A field's options with their labels and disabled reasons translated. - * - * Translating once here, rather than at each widget draw, keeps the list a - * widget searches identical to the list it shows, so a match runs against the - * same text the user reads. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return list<\DrevOps\Tui\Model\Option> - * The options in display order, localized to the active language. - */ - protected function options(Field $field): 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) : '', - ), $field->options); - } - - /** - * The description shown for each selectable option value, keyed by value. - * - * For the value-based suggest widget, which carries no option rows: the - * localized per-option description keyed by its value. - * - * @param \DrevOps\Tui\Model\Field $field - * The field. - * - * @return array - * The description for each selectable option value. - */ - protected function suggestDescriptions(Field $field): array { - $out = []; - - foreach ($this->options($field) as $option) { - if (!$option->selectable()) { - continue; - } - - $out[$option->value] = $option->description; - } - - return $out; - } - -} diff --git a/src/Widget/WidgetInterface.php b/src/Widget/WidgetInterface.php deleted file mode 100644 index 3abccc42..00000000 --- a/src/Widget/WidgetInterface.php +++ /dev/null @@ -1,101 +0,0 @@ - - * The ordered hint fragments. - */ - public function hints(): array; - -} diff --git a/tests/phpunit/Fixtures/Form/AllFieldsForm.php b/tests/phpunit/Fixtures/Form/AllFieldsForm.php new file mode 100644 index 00000000..3a333329 --- /dev/null +++ b/tests/phpunit/Fixtures/Form/AllFieldsForm.php @@ -0,0 +1,62 @@ +panel('fields', 'Fields', function (PanelBuilder $p) use ($picker_start): void { + $p->note('note', 'Note')->description('A read-only note field.'); + $p->text('text', 'Text')->default('txt'); + $p->template('template', 'Template')->pattern('{{head}}-{{tail}}')->default('a-b'); + $p->number('number', 'Number')->default(7); + $p->rating('rating', 'Rating')->captions([1 => 'Poor', 5 => 'Excellent'])->default(4); + $p->calendar('date', 'Calendar')->default('2026-07-15'); + $p->textarea('textarea', 'Textarea')->default('note'); + $p->password('password', 'Password')->default('pw'); + $p->select('select', 'Select')->options(['a' => 'Alpha', 'b' => 'Beta'])->default('b'); + $p->select('multiselect', 'MultiSelect')->multiple()->options(['a' => 'Alpha', 'b' => 'Beta'])->default(['a']); + $p->suggest('suggest', 'Suggest')->options(['utc' => 'UTC', 'gmt' => 'GMT'])->default('utc'); + $p->search('search', 'Search')->options(['a' => 'Alpha', 'b' => 'Beta'])->default('b'); + $p->search('multisearch', 'MultiSearch')->multiple()->options(['a' => 'Alpha', 'b' => 'Beta'])->default(['b']); + $p->reorder('reorder', 'Reorder')->options(['a' => 'Alpha', 'b' => 'Beta', 'c' => 'Gamma']); + $p->confirm('confirm', 'Confirm')->default(TRUE); + $p->toggle('toggle', 'Toggle')->options(['on' => 'On', 'off' => 'Off'])->default('off'); + $p->filePicker('filepicker', 'FilePicker')->startIn($picker_start); + $p->filePicker('multifilepicker', 'MultiFilePicker')->multiple()->startIn($picker_start); + $p->pause('pause', 'Pause'); + $p->progress('progress', 'Progress')->steps(3)->run(static function (ProgressReporter $reporter): void { + for ($step = 1; $step <= 3; $step++) { + $reporter->advance('step ' . $step); + } + }); + }); + } + +} diff --git a/tests/phpunit/Fixtures/Form/AllWidgetsForm.php b/tests/phpunit/Fixtures/Form/AllWidgetsForm.php deleted file mode 100644 index 956a5b54..00000000 --- a/tests/phpunit/Fixtures/Form/AllWidgetsForm.php +++ /dev/null @@ -1,62 +0,0 @@ -panel('widgets', 'Widgets', function (PanelBuilder $p) use ($picker_start): void { - $p->note('note', 'Note')->description('A read-only note field.'); - $p->text('text', 'Text')->default('txt'); - $p->template('template', 'Template')->pattern('{{head}}-{{tail}}')->default('a-b'); - $p->number('number', 'Number')->default(7); - $p->rating('rating', 'Rating')->captions([1 => 'Poor', 5 => 'Excellent'])->default(4); - $p->calendar('date', 'Calendar')->default('2026-07-15'); - $p->textarea('textarea', 'Textarea')->default('note'); - $p->password('password', 'Password')->default('pw'); - $p->select('select', 'Select')->options(['a' => 'Alpha', 'b' => 'Beta'])->default('b'); - $p->select('multiselect', 'MultiSelect')->multiple()->options(['a' => 'Alpha', 'b' => 'Beta'])->default(['a']); - $p->suggest('suggest', 'Suggest')->options(['utc' => 'UTC', 'gmt' => 'GMT'])->default('utc'); - $p->search('search', 'Search')->options(['a' => 'Alpha', 'b' => 'Beta'])->default('b'); - $p->search('multisearch', 'MultiSearch')->multiple()->options(['a' => 'Alpha', 'b' => 'Beta'])->default(['b']); - $p->reorder('reorder', 'Reorder')->options(['a' => 'Alpha', 'b' => 'Beta', 'c' => 'Gamma']); - $p->confirm('confirm', 'Confirm')->default(TRUE); - $p->toggle('toggle', 'Toggle')->options(['on' => 'On', 'off' => 'Off'])->default('off'); - $p->filePicker('filepicker', 'FilePicker')->startIn($picker_start); - $p->filePicker('multifilepicker', 'MultiFilePicker')->multiple()->startIn($picker_start); - $p->pause('pause', 'Pause'); - $p->progress('progress', 'Progress')->steps(3)->run(static function (ProgressReporter $reporter): void { - for ($step = 1; $step <= 3; $step++) { - $reporter->advance('step ' . $step); - } - }); - }); - } - -} diff --git a/tests/phpunit/Fixtures/Handler/Spy.php b/tests/phpunit/Fixtures/Handler/Spy.php deleted file mode 100644 index 3d1e341d..00000000 --- a/tests/phpunit/Fixtures/Handler/Spy.php +++ /dev/null @@ -1,51 +0,0 @@ -option('accent', 'cool'); } diff --git a/tests/phpunit/Fixtures/Theme/CapableTheme.php b/tests/phpunit/Fixtures/Theme/CapableTheme.php new file mode 100644 index 00000000..24c3aaf5 --- /dev/null +++ b/tests/phpunit/Fixtures/Theme/CapableTheme.php @@ -0,0 +1,68 @@ +color = $color; + $this->unicode = $unicode; + $this->isDark = $is_dark; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function breadcrumbLabel(string $text): string { + return $this->paint($this->isDark() ? Sgr::of(Sgr::Cyan) : Sgr::of(Sgr::Blue), $text); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function breadcrumbSeparator(): string { + return $this->glyph('›', '>'); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function fieldEntry(string $text, bool $chosen, bool $focused = FALSE): string { + return $this->paint($this->emphasize(Sgr::of(Sgr::Green), $chosen || $focused), $text); + } + +} diff --git a/tests/phpunit/Fixtures/Theme/FloorTheme.php b/tests/phpunit/Fixtures/Theme/FloorTheme.php new file mode 100644 index 00000000..ddfa0d34 --- /dev/null +++ b/tests/phpunit/Fixtures/Theme/FloorTheme.php @@ -0,0 +1,19 @@ +paint('1;96', $text); + protected function accent(): string { + return '1;96'; } } diff --git a/tests/phpunit/Traits/AssertsPagingTrait.php b/tests/phpunit/Traits/AssertsPagingTrait.php index c0f559cd..d7082012 100644 --- a/tests/phpunit/Traits/AssertsPagingTrait.php +++ b/tests/phpunit/Traits/AssertsPagingTrait.php @@ -8,16 +8,16 @@ use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Theme\DefaultTheme; -use DrevOps\Tui\Widget\Capability\PagingCapableInterface; -use DrevOps\Tui\Widget\WidgetInterface; +use DrevOps\Tui\Field\Capability\PagingCapableInterface; +use DrevOps\Tui\Field\FieldInterface; /** - * Shared paging assertions for the list widgets. + * Shared paging assertions for the list fields. * - * Every paged widget honours the same contract: a non-positive page size is + * Every paged field honours the same contract: a non-positive page size is * rejected at construction, a long list clips to the page with a "more below" * indicator, and the window follows the cursor down. A test supplies a factory - * closure building its widget at a given page size. + * closure building its field at a given page size. */ trait AssertsPagingTrait { @@ -35,7 +35,7 @@ protected static function pagingOptions(): array { * A non-positive page size is rejected at construction. * * @param \Closure $factory - * Builds the widget: `fn (int $page_size): WidgetInterface`. + * Builds the field: `fn (int $page_size): FieldInterface`. * @param int $page_size * The invalid page size to pass. */ @@ -50,18 +50,18 @@ protected function assertRejectsNonPositivePageSize(\Closure $factory, int $page * A long list clips to the page and the window follows the cursor down. * * @param \Closure $factory - * Builds the widget over the paging fixture at a page size of two: - * `fn (int $page_size): WidgetInterface`. + * Builds the field over the paging fixture at a page size of two: + * `fn (int $page_size): FieldInterface`. * @param int $downs * The Down presses that carry the cursor onto the third item. */ protected function assertPagesAndFollowsCursor(\Closure $factory, int $downs = 2): void { - $widget = $factory(2); - $this->assertInstanceOf(WidgetInterface::class, $widget); - $this->assertInstanceOf(PagingCapableInterface::class, $widget); - $this->assertSame(2, $widget->pageSize()); + $field = $factory(2); + $this->assertInstanceOf(FieldInterface::class, $field); + $this->assertInstanceOf(PagingCapableInterface::class, $field); + $this->assertSame(2, $field->pageSize()); - $view = Ansi::strip($widget->view(new DefaultTheme())); + $view = Ansi::strip($field->view(new DefaultTheme())); $this->assertStringContainsString('Apple', $view); $this->assertStringContainsString('Banana', $view); @@ -69,10 +69,10 @@ protected function assertPagesAndFollowsCursor(\Closure $factory, int $downs = 2 $this->assertStringContainsString('▼', $view); for ($i = 0; $i < $downs; $i++) { - $widget->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); } - $scrolled = Ansi::strip($widget->view(new DefaultTheme())); + $scrolled = Ansi::strip($field->view(new DefaultTheme())); // The window followed the cursor: the "more above" indicator shows and // the first option has scrolled off. diff --git a/tests/phpunit/Unit/Answers/AnswersTest.php b/tests/phpunit/Unit/Answers/AnswersTest.php index 07f803db..53c0a9ee 100644 --- a/tests/phpunit/Unit/Answers/AnswersTest.php +++ b/tests/phpunit/Unit/Answers/AnswersTest.php @@ -49,7 +49,7 @@ public function testEmpty(): void { $this->assertSame('', $answers->toSummary()); } - public function testForFormSnapshotsQuestions(): void { + public function testForTreeSnapshotsQuestions(): void { $form = Form::create('T') ->panel('general', 'General', function (PanelBuilder $p): void { $p->text('name', 'Site name'); @@ -58,9 +58,9 @@ public function testForFormSnapshotsQuestions(): void { $sp->confirm('debug', 'Debug'); }); }) - ->build(); + ->root(); - $answers = Answers::forForm($form, ['name' => 'Acme', 'debug' => TRUE], ['name' => Provenance::Edited]); + $answers = Answers::forTree($form, ['name' => 'Acme', 'debug' => TRUE], ['name' => Provenance::Edited]); // Snapshots exist only for active questions, in form order. $this->assertSame(['name', 'debug'], array_keys($answers->items)); @@ -80,15 +80,15 @@ public function testForFormSnapshotsQuestions(): void { $this->assertSame(['General', 'Advanced'], $debug->panels); } - public function testForFormSplitsTemplateAnswerIntoItsParts(): void { + public function testForTreeSplitsTemplateAnswerIntoItsParts(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $p): void { $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{grade}}'); $p->text('name', 'Name'); }) - ->build(); + ->root(); - $answers = Answers::forForm($form, ['crate' => 'valley-a', 'name' => 'Acme'], []); + $answers = Answers::forTree($form, ['crate' => 'valley-a', 'name' => 'Acme'], []); // The value stays whole and the parts ride alongside it. $this->assertSame('valley-a', $answers->value('crate')); @@ -105,9 +105,9 @@ public function testPartsAreEmptyWhenTheAnswerDoesNotMatchTheShape(): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{grade}}'); }) - ->build(); + ->root(); - $answers = Answers::forForm($form, ['crate' => 'nope'], []); + $answers = Answers::forTree($form, ['crate' => 'nope'], []); $this->assertSame([], $answers->parts('crate')); } @@ -117,9 +117,9 @@ public function testToSummaryDelegates(): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->text('name', 'Name'); }) - ->build(); + ->root(); - $summary = Answers::forForm($form, ['name' => 'Acme'], ['name' => Provenance::Edited])->toSummary(); + $summary = Answers::forTree($form, ['name' => 'Acme'], ['name' => Provenance::Edited])->toSummary(); $this->assertStringContainsString('P', $summary); $this->assertStringContainsString('Name: Acme (edited)', $summary); @@ -130,8 +130,8 @@ public function testToSummaryFollowsColorCapability(): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->text('name', 'Order at [Basket](https://example.com/basket)'); }) - ->build(); - $answers = Answers::forForm($form, ['name' => 'Weekly'], ['name' => Provenance::Edited]); + ->root(); + $answers = Answers::forTree($form, ['name' => 'Weekly'], ['name' => Provenance::Edited]); $no_color = getenv('NO_COLOR'); $term = getenv('TERM'); diff --git a/tests/phpunit/Unit/Answers/SummaryFormatterTest.php b/tests/phpunit/Unit/Answers/SummaryFormatterTest.php index db45e4d8..e3d45386 100644 --- a/tests/phpunit/Unit/Answers/SummaryFormatterTest.php +++ b/tests/phpunit/Unit/Answers/SummaryFormatterTest.php @@ -38,8 +38,8 @@ public function testFormatsGroupedByPanel(): void { ->panel('empty', 'Empty', function (PanelBuilder $p): void { $p->text('gone', 'Gone')->when(new Condition('name', eq: 'never')); }) - ->build(); - $answers = Answers::forForm( + ->root(); + $answers = Answers::forTree( $form, ['name' => 'Acme', 'machine' => 'acme', 'profile' => 'standard', 'debug' => TRUE], ['name' => Provenance::Edited, 'machine' => Provenance::Derived, 'profile' => Provenance::Default, 'debug' => Provenance::Edited], @@ -68,8 +68,8 @@ public function testFormatsListValues(): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->select('mods', 'Mods')->multiple(); }) - ->build(); - $answers = Answers::forForm($form, ['mods' => ['a', 'b']], ['mods' => Provenance::Edited]); + ->root(); + $answers = Answers::forTree($form, ['mods' => ['a', 'b']], ['mods' => Provenance::Edited]); $summary = (new SummaryFormatter())->format($answers); @@ -82,8 +82,8 @@ public function testMasksPasswordValues(): void { $p->password('token', 'Token'); $p->password('unset', 'Unset'); }) - ->build(); - $answers = Answers::forForm($form, ['token' => 's3cret-long', 'unset' => ''], ['token' => Provenance::Edited, 'unset' => Provenance::Default]); + ->root(); + $answers = Answers::forTree($form, ['token' => 's3cret-long', 'unset' => ''], ['token' => Provenance::Edited, 'unset' => Provenance::Default]); $summary = (new SummaryFormatter())->format($answers); @@ -103,8 +103,8 @@ public function testResolvesLinkedLabels(): void { ->panel('p', 'See [Orchard](https://example.com/orchard)', function (PanelBuilder $p): void { $p->text('name', 'Order at [Basket](https://example.com/basket)'); }) - ->build(); - $answers = Answers::forForm($form, ['name' => 'Weekly'], ['name' => Provenance::Edited]); + ->root(); + $answers = Answers::forTree($form, ['name' => 'Weekly'], ['name' => Provenance::Edited]); // Without hyperlink support the label and heading degrade to text (url). $plain = (new SummaryFormatter())->format($answers); diff --git a/tests/phpunit/Unit/Block/BlockTest.php b/tests/phpunit/Unit/Block/BlockTest.php new file mode 100644 index 00000000..1778fbc5 --- /dev/null +++ b/tests/phpunit/Unit/Block/BlockTest.php @@ -0,0 +1,254 @@ +theme(); + + $this->assertSame('Orchard › Delivery', (new Breadcrumb('Orchard', 'Delivery'))->render($theme)); + } + + public function testBreadcrumbOfOneSegmentDrawsNoSeparator(): void { + $this->assertSame('Orchard', (new Breadcrumb('Orchard'))->render($this->theme())); + } + + public function testBreadcrumbRedrawsItselfAsTheTrailChanges(): void { + $breadcrumb = new Breadcrumb('Orchard'); + + $this->assertSame('Orchard › Delivery', $breadcrumb->trail('Orchard', 'Delivery')->render($this->theme())); + } + + public function testLegendReadsAsKeyThenWhatItDoes(): void { + $legend = (new Legend())->entry('↵', 'accept')->entry('ESC', 'cancel'); + + $this->assertSame('↵ to accept · ESC to cancel', $legend->render($this->theme())); + } + + public function testLegendWithNoKeysDrawsNothing(): void { + $this->assertSame('', (new Legend())->render($this->theme())); + } + + public function testLegendReadsItsKeysOutOfTheBindingsItAdvertises(): void { + $keys = KeyMapManager::create()->navigation(); + $legend = (new Legend())->advertise($keys, new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('select', Action::Activate)); + + // The glyph comes from the live binding rather than from a second copy of + // it, so a retuned key changes the line that advertises it. + $this->assertSame('↑/↓ to move · ↵ to select', $legend->render($this->theme())); + } + + public function testLegendDropsFragmentWhoseActionsNothingReaches(): void { + $keys = KeyMapManager::create()->navigation(); + $legend = (new Legend())->advertise($keys, new Hint('grab', Action::Grab), new Hint('quit', Action::Quit)); + + // Nothing in this scope grabs, so the fragment is dropped rather than drawn + // as a label with no key in front of it. + $this->assertSame('Q to quit', $legend->render($this->theme())); + } + + public function testAdvertisedKeysReplaceTheOnesWrittenByHand(): void { + $legend = (new Legend())->entry('↵', 'accept'); + + $legend->advertise(KeyMapManager::create()->navigation(), new Hint('quit', Action::Quit)); + $this->assertSame('Q to quit', $legend->render($this->theme())); + + $this->assertSame('', $legend->clear()->render($this->theme())); + } + + public function testAdvertisedKeysAreDrawnInTheActiveLanguage(): void { + Translator::setShared(new Translator('uk')); + + // A fragment is translated where it is declared and the line around it + // where it is drawn, so nothing English is left between the key and what + // it does - Ukrainian joins the two without a preposition. + $legend = (new Legend())->advertise(KeyMapManager::create()->navigation(), new Hint('move', Action::MoveUp)); + + $this->assertSame('↑ перемістити', $legend->render($this->theme())); + } + + public function testLegendOutOfRoomDropsWholeHintsFromTheEnd(): void { + $legend = (new Legend())->entry('↑/↓', 'move')->entry('↵', 'accept')->entry('ESC', 'cancel'); + $whole = $legend->render($this->theme()); + + // A hint cut mid-word reads as a different word, so a narrow frame loses + // its last hints whole - and the narrowest frame still shows the first. + $short = $legend->render(new DefaultTheme(Ansi::width($whole) + 3, ['color' => FALSE])); + $tight = $legend->render(new DefaultTheme(12, ['color' => FALSE])); + + $this->assertStringEndsWith('↵ to accept', $short); + $this->assertSame('↑/↓ to move', $tight); + } + + public function testMarkupDrawsItsBodyAndItsTitleWhenItHasOne(): void { + $theme = $this->theme(); + + $this->assertSame('Weighed at the bench.', (new Markup('note', 'Weighed at the bench.'))->render($theme)); + $this->assertSame("Yields\nTwelve crates.", (new Markup('yields', 'Twelve crates.', 'Yields'))->render($theme)); + } + + public function testMarkupKeepsTheLineBreaksItWasGiven(): void { + $rendered = (new Markup('note', "First.\nSecond."))->render($this->theme()); + + $this->assertSame(['First.', 'Second.'], explode("\n", $rendered)); + } + + public function testMarkupInBorderIsBoxedAroundTheSameContent(): void { + $markup = (new Markup('notice', 'Deliveries leave at dawn.', 'Notice'))->bordered(); + + $this->assertTrue($markup->isBordered()); + + $lines = explode("\n", $markup->render($this->theme())); + + $this->assertStringStartsWith('╭', $lines[0]); + $this->assertStringContainsString('Notice', $lines[1]); + $this->assertStringContainsString('Deliveries leave at dawn.', $lines[2]); + $this->assertStringStartsWith('╰', $lines[3]); + } + + public function testMarkupAsTableAlignsTheRowsUnderTheirHeaders(): void { + $markup = (new Markup('yields', 'Yields per crate'))->table(['Produce', 'Crates'], [['Apple', '12'], ['Carrot', '8']]); + + $this->assertSame(['Produce', 'Crates'], $markup->tableSpec()?->headers); + + $rendered = $markup->render($this->theme()); + + $this->assertStringContainsString('Yields per crate', $rendered); + $this->assertStringContainsString('│ Produce │ Crates │', $rendered); + $this->assertStringContainsString('│ Apple │ 12 │', $rendered); + $this->assertStringContainsString('│ Carrot │ 8 │', $rendered); + } + + public function testMarkupWithNothingToSayDrawsNoCardAroundIt(): void { + // A card with neither a title, a body nor a grid has nothing to frame, so + // it draws nothing rather than an empty box. + $this->assertSame('', (new Markup('spacer', ''))->bordered()->render($this->theme())); + } + + public function testMarkupCarriesNoTableUntilItIsGivenOne(): void { + $this->assertNotInstanceOf(TableSpec::class, (new Markup('yields', 'Yields per crate'))->tableSpec()); + $this->assertFalse((new Markup('yields', 'Yields per crate'))->isBordered()); + } + + public function testMarkupHandsBackTheContentItDraws(): void { + $markup = new Markup('notice', 'Deliveries leave at dawn.', 'Notice'); + + $this->assertSame('Notice', $markup->titleText()); + $this->assertSame('Deliveries leave at dawn.', $markup->bodyText()); + $this->assertSame('Weighed at the bench.', $markup->body('Weighed at the bench.')->bodyText()); + } + + public function testProgressHandsBackTheWorkItRuns(): void { + $work = static function (ProgressReporter $reporter): void { + $reporter->advance(); + }; + + $this->assertNotInstanceOf(\Closure::class, (new Progress('packing', 'Packing crates'))->workload()); + $this->assertSame($work, (new Progress('packing', 'Packing crates'))->work($work)->workload()); + } + + public function testProgressTrailsTheIndicatorWithWhatTheWorkIsDoing(): void { + $bar = (new Progress('packing', 'Packing crates'))->steps(10)->advance(4, 'Apples'); + $spinner = (new Progress('fetching', 'Fetching the price list'))->label('Orchards'); + + $this->assertSame('Packing crates [████░░░░░░] 4/10 Apples', $bar->render($this->theme())); + $this->assertSame('⠋ Fetching the price list Orchards', $spinner->render($this->theme())); + } + + public function testActionsFrameEveryLabelAndMarkTheFocusedOne(): void { + $actions = (new Actions())->action('submit', 'Submit')->action('cancel', 'Cancel'); + + $this->assertSame('[ Submit ] [ Cancel ]', $actions->render($this->theme())); + } + + public function testActionsTakeAnyLabelsTheFormDeclares(): void { + $actions = (new Actions())->action('save', 'Save draft')->action('submit', 'Submit'); + + $this->assertSame(['save', 'submit'], $actions->names()); + } + + public function testProgressDrawsBarWhenTheWorkReportsTotal(): void { + $progress = (new Progress('packing', 'Packing crates'))->steps(10)->advance(4); + + $this->assertSame('Packing crates [████░░░░░░] 4/10', $progress->render($this->theme())); + } + + public function testProgressDrawsSpinnerWhenTheLengthIsUnknown(): void { + $progress = new Progress('fetching', 'Fetching the price list'); + + $this->assertSame('⠋ Fetching the price list', $progress->render($this->theme())); + } + + public function testProgressAdvancesNoFurtherThanItsTotal(): void { + $progress = (new Progress('packing', 'Packing'))->steps(3)->advance(9); + + $this->assertSame('Packing [██████████] 3/3', $progress->render($this->theme())); + } + + #[DataProvider('dataProviderEveryBlockRefusesThemeThatCannotDrawIt')] + public function testEveryBlockRefusesThemeThatCannotDrawIt(\Closure $make, string $says): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($says); + + // A theme is only required to be a ThemeInterface; the elements a block + // needs are declared separately, so one that declares none cannot draw it - + // and the failure names both rather than leaving a blank line. + $make()->render($this->createStub(ThemeInterface::class)); + } + + public static function dataProviderEveryBlockRefusesThemeThatCannotDrawIt(): \Iterator { + yield 'breadcrumb' => [static fn(): Breadcrumb => new Breadcrumb('Orchard'), 'cannot draw a breadcrumb']; + yield 'legend' => [static fn(): Legend => (new Legend())->entry('↵', 'accept'), 'cannot draw a legend']; + yield 'markup' => [static fn(): Markup => new Markup('intro', 'Pick the produce.'), 'cannot draw markup']; + yield 'actions' => [static fn(): Actions => (new Actions())->action('submit', 'Submit'), 'cannot draw actions']; + yield 'progress' => [static fn(): Progress => new Progress('packing', 'Packing'), 'cannot draw progress']; + yield 'panel' => [static fn(): Panel => new Panel('main', 'Delivery'), 'cannot draw a panel']; + yield 'field' => [static fn(): Field => new Field('courier', 'Courier'), 'cannot draw a field']; + } + + /** + * A theme with colour off, so the assertions read as plain strings. + */ + protected function theme(): DefaultTheme { + return new DefaultTheme(80, ['color' => FALSE]); + } + +} diff --git a/tests/phpunit/Unit/Block/CapabilityTest.php b/tests/phpunit/Unit/Block/CapabilityTest.php new file mode 100644 index 00000000..1fac59ed --- /dev/null +++ b/tests/phpunit/Unit/Block/CapabilityTest.php @@ -0,0 +1,269 @@ + str_starts_with($interface, self::CAPABILITIES); + $claimed = array_values(array_filter(class_implements($block) ?: [], $capability)); + + sort($claimed); + sort($granted); + + $this->assertSame($granted, $claimed); + } + + public static function dataProviderBlockClaimsExactlyWhatItIsGranted(): \Iterator { + yield 'panel' => [ + Panel::class, + [BindCapableInterface::class, DescendCapableInterface::class, FocusCapableInterface::class, OverlayCapableInterface::class], + ]; + + yield 'field' => [ + Field::class, + [ + BindCapableInterface::class, + CaptureCapableInterface::class, + CollectCapableInterface::class, + ConstrainCapableInterface::class, + DependCapableInterface::class, + FocusCapableInterface::class, + RejectCapableInterface::class, + ], + ]; + + yield 'markup' => [Markup::class, [DependCapableInterface::class]]; + yield 'breadcrumb' => [Breadcrumb::class, []]; + yield 'legend' => [Legend::class, []]; + yield 'actions' => [Actions::class, [ActivateCapableInterface::class, FocusCapableInterface::class, RejectCapableInterface::class]]; + yield 'progress' => [Progress::class, [ActivateCapableInterface::class, DependCapableInterface::class, FocusCapableInterface::class]]; + } + + #[DataProvider('dataProviderCursorMovesOntoBlockAndOffAgain')] + public function testCursorMovesOntoBlockAndOffAgain(FocusCapableInterface $block): void { + $this->assertFalse($block->isFocused()); + $this->assertTrue($block->focus()->isFocused()); + $this->assertFalse($block->blur()->isFocused()); + } + + public static function dataProviderCursorMovesOntoBlockAndOffAgain(): \Iterator { + yield 'panel' => [new Panel('delivery', 'Delivery')]; + yield 'field' => [new Field('courier', 'Courier')]; + yield 'actions' => [new Actions()]; + yield 'progress' => [new Progress('packing', 'Packing crates')]; + } + + #[DataProvider('dataProviderBlockIsThereUnlessItsConditionSaysOtherwise')] + public function testBlockIsThereUnlessItsConditionSaysOtherwise(DependCapableInterface $block): void { + $this->assertTrue($block->isActive()); + $this->assertFalse($block->when(static fn(): bool => FALSE)->isActive()); + } + + public static function dataProviderBlockIsThereUnlessItsConditionSaysOtherwise(): \Iterator { + yield 'field' => [new Field('certifier', 'Certifier')]; + yield 'markup' => [new Markup('certified', 'Organic crates need current certification.')]; + yield 'progress' => [new Progress('packing', 'Packing crates')]; + } + + public function testBlockHandsBackWhatDecidesWhetherItIsThere(): void { + $declared = new Condition('organic', eq: TRUE); + $block = new Markup('certified', 'Organic crates need current certification.'); + + $this->assertNull($block->condition()); + $this->assertSame($declared, $block->when($declared)->condition()); + } + + #[DataProvider('dataProviderConditionIsAnsweredAgainstTheAnswersSoFar')] + public function testConditionIsAnsweredAgainstTheAnswersSoFar(DependCapableInterface $block, \Closure|ConditionInterface $when, array $answers, bool $active): void { + $this->assertSame($active, $block->when($when)->isActive($answers)); + } + + public static function dataProviderConditionIsAnsweredAgainstTheAnswersSoFar(): \Iterator { + $declared = new Condition('organic', eq: TRUE); + $written = static fn(array $answers): bool => ($answers['organic'] ?? FALSE) === TRUE; + // A condition that asks for nothing is answered without them, which is why + // both shapes are declared the same way. + $standing = static fn(): bool => FALSE; + + yield 'field, declared' => [new Field('certifier', 'Certifier'), $declared, ['organic' => TRUE], TRUE]; + yield 'field, declared and unmet' => [new Field('certifier', 'Certifier'), $declared, ['organic' => FALSE], FALSE]; + yield 'field, written' => [new Field('certifier', 'Certifier'), $written, ['organic' => TRUE], TRUE]; + yield 'field, written and unmet' => [new Field('certifier', 'Certifier'), $written, [], FALSE]; + yield 'markup, declared' => [new Markup('certified', 'Certification is current.'), $declared, ['organic' => TRUE], TRUE]; + yield 'markup, written and unmet' => [new Markup('certified', 'Certification is current.'), $written, ['organic' => FALSE], FALSE]; + yield 'progress, declared' => [new Progress('packing', 'Packing crates'), $declared, ['organic' => TRUE], TRUE]; + yield 'progress, asking for nothing' => [new Progress('packing', 'Packing crates'), $standing, ['organic' => TRUE], FALSE]; + } + + public function testBlockBindsWhateverItsScopeResolvesAndNothingElse(): void { + // A block lists no keys of its own: what it binds is what its scope + // resolves, so retuning one is a declaration about the form rather than + // about every block that draws it. + $panel = (new Panel('delivery', 'Delivery'))->enter(); + + $this->assertTrue($panel->binds(Key::named(KeyName::Up))); + $this->assertTrue($panel->binds(Key::named(KeyName::Enter))); + $this->assertTrue($panel->binds(Key::char('?'))); + $this->assertFalse($panel->binds(Key::char('x'))); + } + + public function testNestedPanelTakesNoKeyUntilYouHaveGoneIntoIt(): void { + $panel = new Panel('advanced', 'Advanced'); + + $this->assertFalse($panel->binds(Key::named(KeyName::Up))); + $this->assertTrue($panel->enter()->binds(Key::named(KeyName::Up))); + $this->assertFalse($panel->leave()->binds(Key::named(KeyName::Up))); + } + + public function testBlockAnswersToTheBindingsItIsGiven(): void { + $keys = KeyMapManager::create('vim'); + $panel = (new Panel('delivery', 'Delivery'))->enter(); + + $this->assertFalse($panel->binds(Key::char('j'))); + + $this->assertTrue($panel->bind($keys)->binds(Key::char('j'))); + $this->assertSame($keys->navigation(), $panel->bindings()); + } + + public function testOpenFieldBindsEveryPrintableKeyAndClosedOneBindsNone(): void { + $field = new Field('courier', 'Courier'); + + // The same key stops at an open field and travels outward from a closed + // one, which is one rule rather than an exception written for the help key. + $this->assertFalse($field->binds(Key::char('?'))); + $this->assertFalse($field->binds(Key::named(KeyName::Enter))); + $this->assertTrue($field->open()->binds(Key::char('?'))); + $this->assertTrue($field->binds(Key::named(KeyName::Enter))); + $this->assertFalse($field->close()->binds(Key::char('?'))); + } + + public function testOpenFieldTakesPrintableKeyOnlyWhereItsKindTakesTypedInput(): void { + $basket = (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple', 'Apple')->open(); + + // A single choice is walked with the cursor rather than typed into, so a + // printable key is not something being typed and travels outward. + $this->assertFalse($basket->binds(Key::char('x'))); + $this->assertTrue($basket->binds(Key::named(KeyName::Down))); + } + + public function testOpenFieldAdvertisesItsEditorsKeysAndClosedOneAdvertisesNone(): void { + $field = new Field('courier', 'Courier'); + + $this->assertSame([], $field->hints()); + $this->assertEquals($field->open()->editor()?->hints(), $field->hints()); + $this->assertSame($field->editor()?->keys(), $field->bindings()); + } + + public function testActivatingActionsPressesTheButtonTheCursorRestsOn(): void { + $actions = (new Actions())->action('submit', 'Submit')->action('cancel', 'Cancel'); + + $this->assertSame('submit', $actions->selected()); + $this->assertNull($actions->activated()); + + $this->assertTrue($actions->select('cancel')->activate()); + $this->assertSame('cancel', $actions->activated()); + } + + public function testWithheldSubmitLeavesTheButtonUnpressed(): void { + $actions = (new Actions())->action('submit', 'Submit')->refuse('Basket contents is required.'); + + $this->assertFalse($actions->activate()); + $this->assertNull($actions->activated()); + + $this->assertTrue($actions->refuse(NULL)->activate()); + $this->assertSame('submit', $actions->activated()); + } + + public function testActionsWithNoButtonsHaveNothingToPress(): void { + $this->assertFalse((new Actions())->activate()); + } + + public function testActivatingProgressRunsItsWork(): void { + $progress = (new Progress('packing', 'Packing crates'))->steps(4); + + $progress->work(static function (ProgressReporter $reporter): void { + $reporter->advance(); + $reporter->advance(); + }); + + $this->assertTrue($progress->activate()); + $this->assertStringContainsString('2/4', $progress->render(new DefaultTheme(80, ['color' => FALSE]))); + } + + public function testProgressWithNoWorkDoesNothingWhenActivated(): void { + $this->assertFalse((new Progress('packing', 'Packing crates'))->activate()); + } + + public function testWorkSaysWhatItIsDoingAsItAdvances(): void { + $progress = (new Progress('packing', 'Packing crates'))->steps(4); + + $progress->work(static function (ProgressReporter $reporter): void { + $reporter->advance('Apples'); + $reporter->advance(); + }); + + $this->assertTrue($progress->activate()); + $this->assertSame(2, $progress->current()); + $this->assertSame(4, $progress->total()); + // A step that says nothing leaves the label where the last one set it. + $this->assertSame('Apples', $progress->labelText()); + $this->assertSame('Packing crates', $progress->caption()); + } + +} diff --git a/tests/phpunit/Unit/Block/EntryTest.php b/tests/phpunit/Unit/Block/EntryTest.php new file mode 100644 index 00000000..65e4f8cd --- /dev/null +++ b/tests/phpunit/Unit/Block/EntryTest.php @@ -0,0 +1,295 @@ + 'Apple', 'b' => 'Banana']); + + $this->assertCount(2, $options); + $this->assertSame('a', $options[0]->value); + $this->assertSame('Apple', $options[0]->label); + $this->assertSame(OptionKind::Option, $options[0]->kind); + $this->assertTrue($options[0]->selectable()); + } + + public function testListLabelDefaultsToValue(): void { + $options = Option::list(['a' => '']); + + $this->assertSame('a', $options[0]->label); + } + + public function testListFromOptionsPassesThrough(): void { + $sep = new Option('', '', '', OptionKind::Separator); + $options = Option::list([new Option('a', 'Apple'), $sep]); + + $this->assertSame('Apple', $options[0]->label); + $this->assertSame($sep, $options[1]); + } + + public function testListMixed(): void { + $options = Option::list(['a' => 'Apple', new Option('b', 'Banana', '', OptionKind::Option, TRUE, 'nope')]); + + $this->assertSame('a', $options[0]->value); + $this->assertTrue($options[1]->disabled); + $this->assertSame('nope', $options[1]->disabledReason); + } + + #[DataProvider('dataProviderSelectable')] + public function testSelectable(Option $option, bool $expected): void { + $this->assertSame($expected, $option->selectable()); + } + + public static function dataProviderSelectable(): \Iterator { + yield 'plain option' => [new Option('a', 'A'), TRUE]; + yield 'disabled option' => [new Option('a', 'A', '', OptionKind::Option, TRUE), FALSE]; + yield 'separator' => [new Option('', '', '', OptionKind::Separator), FALSE]; + yield 'heading' => [new Option('', 'Group', '', OptionKind::Heading), FALSE]; + } + + #[DataProvider('dataProviderConstrainsToOptions')] + public function testConstrainsToOptions(FieldType $type, bool $expected): void { + $this->assertSame($expected, $type->constrainsToOptions()); + } + + public static function dataProviderConstrainsToOptions(): \Iterator { + yield [FieldType::Select, TRUE]; + yield [FieldType::Search, TRUE]; + yield [FieldType::Reorder, TRUE]; + yield [FieldType::Suggest, FALSE]; + yield [FieldType::Text, FALSE]; + yield [FieldType::Confirm, FALSE]; + } + + #[DataProvider('dataProviderIsMultiChoice')] + public function testIsMultiChoice(FieldType $type, bool $multiple, bool $expected): void { + $this->assertSame($expected, (new Field('f', 'F', $type))->multiple($multiple)->isMultiChoice()); + } + + public static function dataProviderIsMultiChoice(): \Iterator { + yield 'multiple select' => [FieldType::Select, TRUE, TRUE]; + yield 'multiple search' => [FieldType::Search, TRUE, TRUE]; + yield 'reorder' => [FieldType::Reorder, FALSE, TRUE]; + yield 'multiple file picker' => [FieldType::FilePicker, TRUE, FALSE]; + yield 'single select' => [FieldType::Select, FALSE, FALSE]; + yield 'single search' => [FieldType::Search, FALSE, FALSE]; + yield 'text' => [FieldType::Text, FALSE, FALSE]; + } + + #[DataProvider('dataProviderCollectsList')] + public function testCollectsList(FieldType $type, bool $multiple, bool $expected): void { + $this->assertSame($expected, (new Field('f', 'F', $type))->multiple($multiple)->collectsList()); + } + + public static function dataProviderCollectsList(): \Iterator { + yield 'multiple select' => [FieldType::Select, TRUE, TRUE]; + yield 'multiple search' => [FieldType::Search, TRUE, TRUE]; + yield 'multiple file picker' => [FieldType::FilePicker, TRUE, TRUE]; + yield 'reorder' => [FieldType::Reorder, FALSE, TRUE]; + yield 'single select' => [FieldType::Select, FALSE, FALSE]; + yield 'single file picker' => [FieldType::FilePicker, FALSE, FALSE]; + yield 'text' => [FieldType::Text, FALSE, FALSE]; + } + + #[DataProvider('dataProviderAcceptsValue')] + public function testAcceptsValue(FieldType $type, bool $multiple, mixed $value, bool $expected): void { + $this->assertSame($expected, (new Field('f', 'F', $type))->multiple($multiple)->acceptsValue($value)); + } + + public static function dataProviderAcceptsValue(): \Iterator { + yield 'confirm accepts bool' => [FieldType::Confirm, FALSE, TRUE, TRUE]; + yield 'confirm rejects string' => [FieldType::Confirm, FALSE, 'yes', FALSE]; + yield 'pause accepts bool' => [FieldType::Pause, FALSE, FALSE, TRUE]; + yield 'multiple accepts list' => [FieldType::Select, TRUE, ['a'], TRUE]; + yield 'multiple rejects scalar' => [FieldType::Select, TRUE, 'a', FALSE]; + yield 'reorder accepts list' => [FieldType::Reorder, FALSE, ['a'], TRUE]; + yield 'number accepts int' => [FieldType::Number, FALSE, 42, TRUE]; + yield 'number rejects numeric string' => [FieldType::Number, FALSE, '42', FALSE]; + yield 'calendar accepts empty' => [FieldType::Calendar, FALSE, '', TRUE]; + yield 'calendar accepts iso date' => [FieldType::Calendar, FALSE, '2026-07-16', TRUE]; + yield 'calendar rejects non-date' => [FieldType::Calendar, FALSE, 'nope', FALSE]; + yield 'text accepts string' => [FieldType::Text, FALSE, 'x', TRUE]; + yield 'text rejects int' => [FieldType::Text, FALSE, 1, FALSE]; + } + + #[DataProvider('dataProviderValueKind')] + public function testValueKind(FieldType $type, bool $multiple, string $expected): void { + $this->assertSame($expected, (new Field('f', 'F', $type))->multiple($multiple)->valueKind()); + } + + public static function dataProviderValueKind(): \Iterator { + yield 'confirm' => [FieldType::Confirm, FALSE, 'a boolean']; + yield 'pause' => [FieldType::Pause, FALSE, 'a boolean']; + yield 'multiple' => [FieldType::Select, TRUE, 'a list']; + yield 'reorder' => [FieldType::Reorder, FALSE, 'a list']; + yield 'number' => [FieldType::Number, FALSE, 'a number']; + yield 'calendar' => [FieldType::Calendar, FALSE, 'a date (YYYY-MM-DD)']; + yield 'text' => [FieldType::Text, FALSE, 'a string']; + } + + #[DataProvider('dataProviderSupportsMultiple')] + public function testSupportsMultiple(FieldType $type, bool $expected): void { + $this->assertSame($expected, $type->supportsMultiple()); + } + + public static function dataProviderSupportsMultiple(): \Iterator { + yield 'select' => [FieldType::Select, TRUE]; + yield 'search' => [FieldType::Search, TRUE]; + yield 'file picker' => [FieldType::FilePicker, TRUE]; + yield 'reorder' => [FieldType::Reorder, FALSE]; + yield 'number' => [FieldType::Number, FALSE]; + yield 'text' => [FieldType::Text, FALSE]; + } + + #[DataProvider('dataProviderIsPresentational')] + public function testIsPresentational(FieldType $type, bool $expected): void { + $this->assertSame($expected, $type->isPresentational()); + } + + public static function dataProviderIsPresentational(): \Iterator { + yield 'note' => [FieldType::Note, TRUE]; + // A pause renders but still carries a boolean answer, so it is not + // presentational. + yield 'pause' => [FieldType::Pause, FALSE]; + yield 'text' => [FieldType::Text, FALSE]; + yield 'confirm' => [FieldType::Confirm, FALSE]; + } + + public function testNoteLabel(): void { + $this->assertSame('Note', FieldType::Note->label()); + } + + public function testDeclaringMultipleOnUnsupportedTypeIsRefused(): void { + $this->expectException(FormException::class); + $this->expectExceptionMessage('Field "n" of type "number" does not collect several values'); + + (new FieldBuilder('n', 'N', FieldType::Number))->multiple(); + } + + public function testSelectableValues(): void { + $this->assertSame(['standard', 'minimal'], $this->selectField()->selectableValues()); + } + + #[DataProvider('dataProviderOptionError')] + public function testOptionError(FieldType $type, bool $multiple, array $options, mixed $value, ?string $expected): void { + $this->assertSame($expected, self::offering($type, $multiple, $options)->entryError($value)); + } + + public static function dataProviderOptionError(): \Iterator { + $options = [ + new Option('standard', 'Standard'), + new Option('minimal', 'Minimal'), + new Option('demo', 'Demo', '', OptionKind::Option, TRUE, 'unavailable'), + new Option('legacy', 'Legacy', '', OptionKind::Option, TRUE), + new Option('', '', '', OptionKind::Separator), + ]; + yield 'selectable value' => [FieldType::Select, FALSE, $options, 'standard', NULL]; + yield 'disabled with reason' => [FieldType::Select, FALSE, $options, 'demo', 'option "demo" is disabled: unavailable']; + yield 'disabled without reason' => [FieldType::Select, FALSE, $options, 'legacy', 'option "legacy" is disabled']; + yield 'unknown value' => [FieldType::Select, FALSE, $options, 'bogus', 'value "bogus" is not one of: standard, minimal']; + yield 'unconstrained type' => [FieldType::Suggest, FALSE, $options, 'bogus', NULL]; + yield 'no options' => [FieldType::Select, FALSE, [], 'bogus', NULL]; + yield 'multi valid' => [FieldType::Select, TRUE, $options, ['standard', 'minimal'], NULL]; + yield 'multi disabled item' => [FieldType::Select, TRUE, $options, ['standard', 'demo'], 'option "demo" is disabled: unavailable']; + yield 'multi non-array' => [FieldType::Select, TRUE, $options, 'standard', 'value must be a list']; + yield 'reorder full permutation' => [FieldType::Reorder, FALSE, $options, ['minimal', 'standard'], NULL]; + yield 'reorder partial' => [FieldType::Reorder, FALSE, $options, ['standard'], 'must rank every option exactly once (standard, minimal)']; + yield 'reorder duplicate' => [FieldType::Reorder, FALSE, $options, ['standard', 'standard'], 'must rank every option exactly once (standard, minimal)']; + yield 'reorder unknown item' => [FieldType::Reorder, FALSE, $options, ['standard', 'bogus'], 'value "bogus" is not one of: standard, minimal']; + yield 'reorder non-array' => [FieldType::Reorder, FALSE, $options, 'standard', 'value must be a list']; + } + + /** + * Tests completing and de-duplicating a desired ordering. + * + * @param list $allowed + * The full set of values, in declared order. + * @param list $desired + * The requested ordering. + * @param list $expected + * The resolved permutation. + */ + #[DataProvider('dataProviderCanonicalOrder')] + public function testCanonicalOrder(array $allowed, array $desired, array $expected): void { + $this->assertSame($expected, Field::canonicalOrder($allowed, $desired)); + } + + /** + * Data provider for testCanonicalOrder(). + * + * @return \Iterator, list, list}> + * The allowed values, desired order and resolved permutation. + */ + public static function dataProviderCanonicalOrder(): \Iterator { + yield 'empty desired keeps declared order' => [['a', 'b', 'c'], [], ['a', 'b', 'c']]; + yield 'full desired preserved' => [['a', 'b', 'c'], ['c', 'b', 'a'], ['c', 'b', 'a']]; + yield 'partial desired completed' => [['a', 'b', 'c'], ['c'], ['c', 'a', 'b']]; + yield 'unknown desired dropped' => [['a', 'b', 'c'], ['x', 'b'], ['b', 'a', 'c']]; + yield 'duplicate desired collapsed' => [['a', 'b', 'c'], ['b', 'b', 'a'], ['b', 'a', 'c']]; + yield 'no allowed values' => [[], ['a'], []]; + } + + /** + * A select field mixing selectable, disabled and structural rows. + */ + protected function selectField(): Field { + return self::offering(FieldType::Select, FALSE, [ + new Option('standard', 'Standard'), + new Option('', 'Group', '', OptionKind::Heading), + new Option('minimal', 'Minimal'), + new Option('', '', '', OptionKind::Separator), + new Option('demo', 'Demo', '', OptionKind::Option, TRUE, 'unavailable'), + ]); + } + + /** + * A field offering the given rows, each declared as the kind of row it is. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind of answer it collects. + * @param bool $multiple + * Whether it collects several values. + * @param array $options + * The rows it offers. + * + * @return \DrevOps\Tui\Block\Field + * The field. + */ + protected static function offering(FieldType $type, bool $multiple, array $options): Field { + $field = (new Field('f', 'F', $type))->multiple($multiple); + + foreach ($options as $option) { + match ($option->kind) { + OptionKind::Heading => $field->heading($option->label), + OptionKind::Separator => $field->separator(), + OptionKind::Option => $field->entry($option->value, $option->label, $option->description, $option->disabled, $option->disabledReason), + }; + } + + return $field; + } + +} diff --git a/tests/phpunit/Unit/Block/FieldBlockTest.php b/tests/phpunit/Unit/Block/FieldBlockTest.php new file mode 100644 index 00000000..f041fcb8 --- /dev/null +++ b/tests/phpunit/Unit/Block/FieldBlockTest.php @@ -0,0 +1,874 @@ +default('Valley Runs'); + + $this->assertSame(Mode::View, $field->mode()); + $this->assertSame(' Courier Valley Runs', $field->render($this->theme())); + + // The selector column is drawn whether or not the cursor is on the row, so + // a row never shifts sideways as the cursor arrives on it. + $this->assertSame('❯ Courier Valley Runs', $field->focus()->render($this->theme())); + } + + public function testOpeningFieldSwitchesItToEditMode(): void { + $field = new Field('courier', 'Courier'); + + $this->assertSame(Mode::Edit, $field->open()->mode()); + $this->assertSame(Mode::View, $field->close()->mode()); + } + + public function testTheLabelStaysPutAcrossBothModes(): void { + $field = (new Field('basket', 'Basket', FieldType::Select))->entry('apple', 'Apple'); + + $this->assertStringStartsWith(' Basket', $field->render($this->theme())); + $this->assertStringStartsWith(' Basket', $field->open()->render($this->theme())); + } + + public function testEditModeOpensOntoTheEntriesItWasGiven(): void { + $field = (new Field('basket', 'Basket', FieldType::Select))->entry('apple', 'Apple')->entry('carrot', 'Carrot'); + + $rendered = $field->open()->render($this->theme()); + + $this->assertStringContainsString('Apple', $rendered); + $this->assertStringContainsString('Carrot', $rendered); + } + + public function testOpeningFieldHandsTheValueRegionToTheEditorItsKindOpensOnto(): void { + $courier = (new Field('courier', 'Courier'))->default('Valley Runs'); + $basket = (new Field('basket', 'Basket', FieldType::Select))->entry('apple', 'Apple'); + + $this->assertNotInstanceOf(FieldInterface::class, $courier->editor()); + + $editor = $courier->open()->editor(); + + $this->assertInstanceOf(Text::class, $editor); + $this->assertInstanceOf(Select::class, $basket->open()->editor()); + + // The editor starts from the answer the field holds, so opening a field + // shows what is there rather than an empty one. + $this->assertSame('Valley Runs', $editor->value()); + + $this->assertNotInstanceOf(FieldInterface::class, $courier->close()->editor()); + } + + public function testKindThatOnlyDrawsHasNothingToOpenOnto(): void { + $note = new Field('weighing', 'Weighed at the bench.', FieldType::Note); + + $this->assertSame(Mode::View, $note->open()->mode()); + $this->assertNotInstanceOf(FieldInterface::class, $note->editor()); + } + + public function testOnlyWhatWasAcceptedReachesTheResult(): void { + $field = new Field('courier', 'Courier'); + + $this->assertNull($field->value()); + + $this->assertTrue($field->accept('Valley Runs')); + $this->assertSame('Valley Runs', $field->value()); + } + + public function testDraftIsDiscardedUnlessItIsAccepted(): void { + $field = (new Field('courier', 'Courier'))->default('Valley Runs'); + + $field->open()->draft('Coast Runs'); + $this->assertSame('Valley Runs', $field->value()); + + $field->close(); + $this->assertSame('Valley Runs', $field->value()); + } + + public function testAcceptingDraftMakesItTheValue(): void { + $field = (new Field('courier', 'Courier'))->default('Valley Runs'); + + $field->open()->draft('Coast Runs'); + + $this->assertTrue($field->accept()); + $this->assertSame('Coast Runs', $field->value()); + $this->assertSame(Mode::View, $field->mode()); + } + + public function testTypingIntoAnOpenFieldIsWhatFillsTheValueRegion(): void { + $field = (new Field('courier', 'Courier'))->open(); + + foreach (str_split('Coast') as $char) { + $field->capture(Key::char($char)); + } + + $this->assertStringContainsString('Coast', $field->render($this->theme())); + + $field->capture(Key::named(KeyName::Enter)); + + $this->assertSame('Coast', $field->value()); + $this->assertSame(Mode::View, $field->mode()); + } + + public function testSpaceTogglesAnEntryOfAnOpenMultipleSelect(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot') + ->open(); + + // Space belongs to the kind rather than to whatever sends the key: the + // field binds what its editor binds, so nothing above it knows of a toggle. + $field->capture(Key::named(KeyName::Space)); + $field->capture(Key::named(KeyName::Down)); + $field->capture(Key::named(KeyName::Space)); + $field->capture(Key::named(KeyName::Enter)); + + $this->assertSame(['apple', 'carrot'], $field->value()); + } + + public function testConfirmFieldAnswersTheKeysItsOwnKindBinds(): void { + $field = (new Field('organic', 'Organic only?', FieldType::Confirm))->open(); + + $field->capture(Key::char('y')); + $field->capture(Key::named(KeyName::Enter)); + + $this->assertTrue($field->value()); + } + + public function testCancellingAnOpenFieldLeavesTheAnswerWhereItWas(): void { + $field = (new Field('courier', 'Courier'))->default('Valley Runs')->open(); + + $field->capture(Key::char('X')); + $field->capture(Key::named(KeyName::Escape)); + + $this->assertSame('Valley Runs', $field->value()); + $this->assertSame(Mode::View, $field->mode()); + } + + public function testTheEditorOffersAndTheFieldRefuses(): void { + $field = (new Field('courier', 'Courier')) + ->validate(static fn(mixed $value): ?string => $value === 'Coast' ? 'Coast Runs do not deliver here.' : NULL) + ->open(); + + foreach (str_split('Coast') as $char) { + $field->capture(Key::char($char)); + } + + $field->capture(Key::named(KeyName::Enter)); + + // What a field will not take is the field's to refuse rather than the + // editor's, so a refused value leaves the field open on what was offered. + $this->assertSame(Mode::Edit, $field->mode()); + $this->assertSame('Coast Runs do not deliver here.', $field->refusal()); + $this->assertStringContainsString('Coast Runs do not deliver here.', $field->render($this->theme())); + $this->assertNull($field->value()); + + // The editor starts again from what was offered, so correcting it carries + // on from what is on screen rather than from nothing. + $field->capture(Key::char('X')); + $field->capture(Key::named(KeyName::Enter)); + + $this->assertSame('CoastX', $field->value()); + } + + public function testSettledFieldHasNoEditorToTakeTheKey(): void { + $this->assertFalse((new Field('courier', 'Courier'))->capture(Key::char('x'))); + } + + public function testConstraintSaysWhatIsAcceptableBeforeYouAct(): void { + $field = (new Field('weight', 'Weight'))->constrain('a number between 200 and 9000'); + + $this->assertSame('a number between 200 and 9000', $field->constraint()); + $this->assertNull($field->refusal()); + } + + public function testRefusedValueIsExplainedAndDoesNotBecomeTheValue(): void { + $field = (new Field('weight', 'Weight')) + ->default(1200) + ->validate(static fn(mixed $value): ?string => is_int($value) && $value >= 200 ? NULL : 'Enter at least 200.'); + + $this->assertFalse($field->accept(10)); + $this->assertSame('Enter at least 200.', $field->refusal()); + $this->assertSame(1200, $field->value()); + } + + public function testRefusalClearsAsSoonAsValueIsAcceptable(): void { + $field = (new Field('weight', 'Weight')) + ->validate(static fn(mixed $value): ?string => is_int($value) && $value >= 200 ? NULL : 'Enter at least 200.'); + + $field->accept(10); + $this->assertNotNull($field->refusal()); + + $this->assertTrue($field->accept(400)); + $this->assertNull($field->refusal()); + } + + public function testFieldIsAskedForUnlessItsConditionSaysOtherwise(): void { + $field = new Field('organic', 'Organic only?'); + + $this->assertTrue($field->isActive()); + $this->assertFalse($field->when(static fn(): bool => FALSE)->isActive()); + } + + public function testHelpIsNeverDrawnInTheRowThatOffersIt(): void { + $field = (new Field('basket', 'Basket'))->help('Every crate is weighed at the packing bench.'); + + $this->assertSame('Every crate is weighed at the packing bench.', $field->helpText()); + $this->assertStringNotContainsString('packing bench', $field->render($this->theme())); + } + + public function testFieldCollectsTextUnlessItIsToldOtherwise(): void { + $this->assertSame(FieldType::Text, (new Field('courier', 'Courier'))->type()); + $this->assertSame(FieldType::Number, (new Field('weight', 'Weight', FieldType::Number))->type()); + $this->assertSame('weight', (new Field('weight', 'Weight'))->id()); + $this->assertSame('Weight', (new Field('weight', 'Weight'))->label()); + } + + #[DataProvider('dataProviderDeclarationReadsBackAsItWasWritten')] + public function testDeclarationReadsBackAsItWasWritten(\Closure $declare, \Closure $read, mixed $expected): void { + $field = $declare(new Field('basket', 'Basket contents', FieldType::Select)); + + $this->assertEquals($expected, $read($field)); + } + + public static function dataProviderDeclarationReadsBackAsItWasWritten(): \Iterator { + $derive = new Derive('{{courier}}-run', 'machine'); + $discover = new PathExists('composer.json'); + $picker = new FilePickerConstraints(FilePickerMode::File, ['csv'], 2048); + $template = new Template('{{orchard}}-{{fruit}}'); + + yield 'description' => [ + static fn(Field $field): Field => $field->description('Pick the produce.'), + static fn(Field $field): string => $field->descriptionText(), + 'Pick the produce.', + ]; + + yield 'help' => [ + static fn(Field $field): Field => $field->help('Crates are weighed at the bench.'), + static fn(Field $field): string => $field->helpText(), + 'Crates are weighed at the bench.', + ]; + + yield 'placeholder' => [ + static fn(Field $field): Field => $field->placeholder('E.g. Golden Beetroot'), + static fn(Field $field): string => $field->placeholderText(), + 'E.g. Golden Beetroot', + ]; + + yield 'completion' => [ + static fn(Field $field): Field => $field->complete(['Apple', 'Apricot']), + static fn(Field $field): array|\Closure => $field->completion(), + ['Apple', 'Apricot'], + ]; + + yield 'ghost' => [ + static fn(Field $field): Field => $field->ghost(), + static fn(Field $field): bool => $field->hasGhost(), + TRUE, + ]; + + yield 'page size' => [ + static fn(Field $field): Field => $field->paginate(8), + static fn(Field $field): ?int => $field->pageSize(), + 8, + ]; + + yield 'multiple' => [ + static fn(Field $field): Field => $field->multiple(), + static fn(Field $field): bool => $field->isMultiple(), + TRUE, + ]; + + yield 'required' => [ + static fn(Field $field): Field => $field->required(), + static fn(Field $field): bool => $field->isRequired(), + TRUE, + ]; + + yield 'revealable' => [ + static fn(Field $field): Field => $field->revealable(), + static fn(Field $field): bool => $field->isRevealable(), + TRUE, + ]; + + yield 'confirmation' => [ + static fn(Field $field): Field => $field->confirmation(), + static fn(Field $field): bool => $field->hasConfirmation(), + TRUE, + ]; + + yield 'external editor' => [ + static fn(Field $field): Field => $field->externalEditor(), + static fn(Field $field): bool => $field->hasExternalEditor(), + TRUE, + ]; + + yield 'standalone' => [ + static fn(Field $field): Field => $field->standalone(), + static fn(Field $field): RenderMode => $field->renderMode(), + RenderMode::Standalone, + ]; + + yield 'inline' => [ + static fn(Field $field): Field => $field->standalone(FALSE), + static fn(Field $field): RenderMode => $field->renderMode(), + RenderMode::Inline, + ]; + + yield 'derive' => [ + static fn(Field $field): Field => $field->derive($derive), + static fn(Field $field): ?Derive => $field->derivation(), + $derive, + ]; + + yield 'discover' => [ + static fn(Field $field): Field => $field->discover($discover), + static fn(Field $field): mixed => $field->discovery(), + $discover, + ]; + + yield 'environment name' => [ + static fn(Field $field): Field => $field->env('ORCHARD_BASKET'), + static fn(Field $field): string => $field->envName(), + 'ORCHARD_BASKET', + ]; + + yield 'environment aliases' => [ + static fn(Field $field): Field => $field->envAliases(['BASKET', 'CRATE']), + static fn(Field $field): array => $field->aliases(), + ['BASKET', 'CRATE'], + ]; + + yield 'picker constraints' => [ + static fn(Field $field): Field => $field->picker($picker), + static fn(Field $field): FilePickerConstraints => $field->pickerConstraints(), + $picker, + ]; + + yield 'picker start' => [ + static fn(Field $field): Field => $field->startIn('/orchard'), + static fn(Field $field): string => $field->pickerStart(), + '/orchard', + ]; + + yield 'picker hidden entries' => [ + static fn(Field $field): Field => $field->showHidden(), + static fn(Field $field): bool => $field->showsHidden(), + TRUE, + ]; + + yield 'template' => [ + static fn(Field $field): Field => $field->pattern($template), + static fn(Field $field): ?Template => $field->template(), + $template, + ]; + + yield 'rating captions' => [ + static fn(Field $field): Field => $field->captions([1 => 'Unripe', 5 => 'Ripe']), + static fn(Field $field): array => $field->ratingCaptions(), + [1 => 'Unripe', 5 => 'Ripe'], + ]; + + yield 'query minimum length' => [ + static fn(Field $field): Field => $field->query(static fn(): array => [])->minQuery(3), + static fn(Field $field): int => $field->queryMinLength(), + 3, + ]; + + yield 'schema default' => [ + static fn(Field $field): Field => $field->schemaDefault('apple'), + static fn(Field $field): mixed => $field->schemaDefaultValue(), + 'apple', + ]; + } + + public function testDeclaredNullDefaultIsStillDeclaredInMachineOutput(): void { + $field = new Field('basket', 'Basket contents'); + + $this->assertFalse($field->hasSchemaDefault()); + $this->assertTrue($field->schemaDefault(NULL)->hasSchemaDefault()); + $this->assertNull($field->schemaDefaultValue()); + } + + #[DataProvider('dataProviderEachBoundReachesTheValueItMeasures')] + public function testEachBoundReachesTheValueItMeasures(Field $field, mixed $value, ?string $violation): void { + $this->assertSame($violation, $field->boundsViolation($value)); + } + + public static function dataProviderEachBoundReachesTheValueItMeasures(): \Iterator { + $numbers = static fn(): Field => (new Field('weight', 'Weight', FieldType::Number))->bounds(new NumberBounds(200, 9000)); + $dates = static fn(): Field => (new Field('harvest', 'Harvest date', FieldType::Calendar)) + ->dates(new DateBounds(new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'))); + $counts = static fn(): Field => (new Field('basket', 'Basket contents', FieldType::Select))->multiple()->selections(new SelectionBounds(2, 3)); + + yield 'number in range' => [$numbers(), 1200, NULL]; + yield 'number below' => [$numbers(), 10, 'between 200 and 9000']; + yield 'number above' => [$numbers(), 90000, 'between 200 and 9000']; + yield 'date in range' => [$dates(), '2026-07-15', NULL]; + yield 'date outside' => [$dates(), '2025-07-15', 'between 2026-01-01 and 2026-12-31']; + yield 'count in range' => [$counts(), ['apple', 'carrot'], NULL]; + yield 'count below' => [$counts(), ['apple'], 'between 2 and 3 items']; + yield 'unbounded' => [new Field('courier', 'Courier'), 'Valley Runs', NULL]; + } + + public function testBoundsAreReadBackAsTheObjectsTheyWereDeclaredWith(): void { + $numbers = new NumberBounds(200, 9000); + $dates = new DateBounds(new \DateTimeImmutable('2026-01-01')); + $counts = new SelectionBounds(2, 3); + + $field = (new Field('basket', 'Basket contents'))->bounds($numbers)->dates($dates)->selections($counts); + + $this->assertSame($numbers, $field->numberBounds()); + $this->assertSame($dates, $field->dateBounds()); + $this->assertSame($counts, $field->selectionBounds()); + } + + public function testValueOutsideDeclaredBoundIsRefusedWithTheRangeItMissed(): void { + $field = (new Field('weight', 'Weight', FieldType::Number))->default(1200)->bounds(new NumberBounds(200, 9000)); + + $this->assertFalse($field->accept(10)); + $this->assertSame('must be between 200 and 9000.', $field->refusal()); + $this->assertSame(1200, $field->value()); + + $this->assertTrue($field->accept(400)); + } + + #[DataProvider('dataProviderRequiredFieldRefusesAnEmptyAnswer')] + public function testRequiredFieldRefusesAnEmptyAnswer(mixed $value, bool $accepted): void { + $field = (new Field('basket', 'Basket contents'))->required(); + + $this->assertSame($accepted, $field->accept($value)); + } + + public static function dataProviderRequiredFieldRefusesAnEmptyAnswer(): \Iterator { + yield 'empty string' => ['', FALSE]; + yield 'empty list' => [[], FALSE]; + yield 'nothing' => [NULL, FALSE]; + // Strictly compared, so a decision against and a count of none are answers + // rather than omissions. + yield 'false' => [FALSE, TRUE]; + yield 'zero' => [0, TRUE]; + yield 'a value' => ['apple', TRUE]; + } + + public function testMissingRequiredAnswerIsExplainedByTheLabelUnlessItSaysOtherwise(): void { + $derived = (new Field('basket', 'Basket contents'))->required(); + $declared = (new Field('basket', 'Basket contents'))->required(TRUE, 'Pick at least one crate.'); + + $this->assertSame('Basket contents is required.', $derived->requiredViolation('')); + $this->assertSame('Pick at least one crate.', $declared->requiredViolation('')); + $this->assertNull((new Field('basket', 'Basket contents'))->requiredViolation('')); + } + + public function testFieldHandsBackWhatItWasDeclaredToRefuseAndNormalizeWith(): void { + $validate = static fn(mixed $value): ?string => NULL; + $transform = static fn(mixed $value): mixed => $value; + $field = (new Field('courier', 'Courier'))->required(TRUE, 'Name the courier.')->validate($validate)->transform($transform); + + $this->assertSame('Name the courier.', $field->requiredMessage()); + $this->assertSame($validate, $field->validator()); + $this->assertSame($transform, $field->transformer()); + + $bare = new Field('courier', 'Courier'); + + $this->assertSame('', $bare->requiredMessage()); + $this->assertNotInstanceOf(\Closure::class, $bare->validator()); + $this->assertNotInstanceOf(\Closure::class, $bare->transformer()); + } + + public function testAcceptedValueIsNormalizedBeforeItIsHeld(): void { + $field = (new Field('courier', 'Courier'))->transform(static fn(mixed $value): mixed => is_string($value) ? trim($value) : $value); + + $this->assertTrue($field->accept(' Valley Runs ')); + $this->assertSame('Valley Runs', $field->value()); + } + + public function testRefusedValueIsNeverNormalized(): void { + $field = (new Field('courier', 'Courier')) + ->transform(static fn(mixed $value): string => 'transformed') + ->validate(static fn(mixed $value): string => 'Enter a courier.'); + + $this->assertFalse($field->accept('Valley Runs')); + $this->assertNull($field->value()); + } + + #[DataProvider('dataProviderAnswerIsListWhenTheFieldCollectsOne')] + public function testAnswerIsListWhenTheFieldCollectsOne(Field $field, bool $list, bool $choice): void { + $this->assertSame($list, $field->collectsList()); + $this->assertSame($choice, $field->isMultiChoice()); + } + + public static function dataProviderAnswerIsListWhenTheFieldCollectsOne(): \Iterator { + yield 'one value' => [new Field('courier', 'Courier'), FALSE, FALSE]; + yield 'several picks' => [(new Field('basket', 'Basket', FieldType::Select))->multiple(), TRUE, TRUE]; + yield 'several paths' => [(new Field('crates', 'Crates', FieldType::FilePicker))->multiple(), TRUE, FALSE]; + yield 'a ranking' => [new Field('order', 'Order', FieldType::Reorder), TRUE, TRUE]; + } + + public function testEntriesAreDrawnInTheOrderTheyWereDeclared(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select)) + ->heading('Fruit') + ->entry('apple', 'Apple') + ->separator() + ->heading('Vegetables') + ->entry('carrot', 'Carrot'); + + $kinds = array_map(static fn(object $entry): OptionKind => $entry->kind, $field->entries()); + + $expected = [OptionKind::Heading, OptionKind::Option, OptionKind::Separator, OptionKind::Heading, OptionKind::Option]; + $this->assertSame($expected, $kinds); + $this->assertSame(['apple', 'carrot'], $field->selectableValues()); + } + + public function testEntryValueStaysTheStringItWasDeclaredAs(): void { + // Rows are held as a list carrying their own value, so a numeric-looking + // value is never coerced the way an array key would be. + $field = (new Field('grade', 'Grade', FieldType::Select))->entry('0', 'Unripe')->entry('1', 'Ripe'); + + $this->assertSame(['0', '1'], $field->selectableValues()); + $this->assertSame('Unripe', $field->entryOf('0')?->label); + $this->assertTrue($field->accept('0')); + $this->assertSame('0', $field->value()); + } + + public function testDeclaringValueTwiceReplacesTheEntryWhereItAlreadySits(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select)) + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot') + ->entry('apple', 'Golden Apple'); + + $this->assertCount(2, $field->entries()); + $this->assertSame('Golden Apple', $field->entryOf('apple')?->label); + $this->assertSame(['apple', 'carrot'], $field->selectableValues()); + } + + public function testEntryWithNoLabelDrawsItsOwnValue(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple'); + + $this->assertSame('apple', $field->entryOf('apple')?->label); + $this->assertNotInstanceOf(Option::class, $field->entryOf('carrot')); + } + + #[DataProvider('dataProviderValueOutsideTheEntriesIsRefused')] + public function testValueOutsideTheEntriesIsRefused(Field $field, mixed $value, ?string $error): void { + $this->assertSame($error, $field->entryError($value)); + } + + public static function dataProviderValueOutsideTheEntriesIsRefused(): \Iterator { + $basket = static fn(): Field => (new Field('basket', 'Basket contents', FieldType::Select)) + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot', disabled: TRUE, disabled_reason: 'Out of season.') + ->entry('turnip', 'Turnip', disabled: TRUE); + + yield 'a listed value' => [$basket(), 'apple', NULL]; + yield 'an unlisted value' => [$basket(), 'plum', 'value "plum" is not one of: apple']; + yield 'a disabled value' => [$basket(), 'carrot', 'option "carrot" is disabled: Out of season.']; + yield 'a disabled value with no reason' => [$basket(), 'turnip', 'option "turnip" is disabled']; + yield 'no entries at all' => [new Field('basket', 'Basket', FieldType::Select), 'plum', NULL]; + yield 'nothing to constrain' => [new Field('courier', 'Courier'), 'Valley Runs', NULL]; + + yield 'one value where a list is owed' => [ + (new Field('basket', 'Basket', FieldType::Select))->multiple()->entry('apple', 'Apple'), + 'apple', + 'value must be a list', + ]; + + yield 'a ranking that misses an entry' => [ + (new Field('order', 'Order', FieldType::Reorder))->entry('apple')->entry('carrot'), + ['apple'], + 'must rank every option exactly once (apple, carrot)', + ]; + + yield 'a full ranking' => [ + (new Field('order', 'Order', FieldType::Reorder))->entry('apple')->entry('carrot'), + ['apple', 'carrot'], + NULL, + ]; + + yield 'a list of listed values' => [ + (new Field('basket', 'Basket', FieldType::Select))->multiple()->entry('apple')->entry('carrot'), + ['apple', 'carrot'], + NULL, + ]; + + yield 'a list carrying an unlisted value' => [ + (new Field('basket', 'Basket', FieldType::Select))->multiple()->entry('apple')->entry('carrot'), + ['apple', 'plum'], + 'value "plum" is not one of: apple, carrot', + ]; + } + + public function testValueIsRefusedWhenTheQueryResolvedToNothingThatCarriesIt(): void { + // Entries that follow a query constrain the value to whatever they resolved + // to, so resolving to nothing means the value does not exist. + $field = (new Field('basket', 'Basket contents', FieldType::Search))->query(static fn(): array => []); + + $this->assertSame('value "plum" was not found', $field->entryError('plum')); + } + + #[DataProvider('dataProviderEntriesAreUnsettledWhileSomethingOwesThem')] + public function testEntriesAreUnsettledWhileSomethingOwesThem(Field $field, bool $settled, bool $dynamic): void { + $this->assertSame($settled, $field->hasSettledEntries()); + $this->assertSame($dynamic, $field->hasDynamicEntries()); + } + + public static function dataProviderEntriesAreUnsettledWhileSomethingOwesThem(): \Iterator { + $field = static fn(): Field => new Field('basket', 'Basket contents', FieldType::Select); + + yield 'declared' => [$field()->entry('apple', 'Apple'), TRUE, FALSE]; + yield 'loaded once' => [$field()->load(static fn(): array => []), FALSE, FALSE]; + yield 'resolved from the answers' => [$field()->resolve(static fn(array $answers): array => []), FALSE, TRUE]; + yield 'resolved from a query' => [$field()->query(static fn(): array => []), FALSE, TRUE]; + } + + public function testWhatOwesTheEntriesIsReadBackAsItWasDeclared(): void { + $loader = static fn(): array => ['apple' => 'Apple']; + $resolver = static fn(array $answers): array => ['carrot' => 'Carrot']; + $source = static fn(string $query, array $answers): array => ['plum' => 'Plum']; + + $field = (new Field('basket', 'Basket contents', FieldType::Search))->load($loader)->resolve($resolver)->query($source); + + $this->assertSame($loader, $field->loader()); + $this->assertSame($resolver, $field->resolver()); + $this->assertSame($source, $field->source()); + } + + public function testSettlingTheEntriesRetiresTheLoaderThatOwedThem(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select))->load(static fn(): array => ['apple' => 'Apple']); + + $field->settle(['apple' => 'Apple', 'carrot' => 'Carrot']); + + $this->assertSame(['apple', 'carrot'], $field->selectableValues()); + $this->assertNotInstanceOf(\Closure::class, $field->loader()); + $this->assertTrue($field->hasSettledEntries()); + } + + public function testEntriesThatAreNotMapOfLabelsSettleToNone(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple', 'Apple'); + + $this->assertSame([], $field->settle('not a map')->entries()); + } + + public function testValueThatDoesNotFitTheDeclaredShapeIsRefused(): void { + $field = (new Field('crate', 'Crate code', FieldType::Template))->pattern(new Template('{{orchard}}-{{fruit}}')); + + $this->assertNull($field->templateError('valley-apple')); + $this->assertNull($field->templateError('')); + $this->assertNull((new Field('courier', 'Courier'))->templateError('anything')); + + $this->assertFalse($field->accept('valley')); + $this->assertStringContainsString('does not match the template', (string) $field->refusal()); + } + + public function testPathOutsideTheDeclaredLimitsIsRefused(): void { + $field = (new Field('manifest', 'Manifest', FieldType::FilePicker)) + ->picker(new FilePickerConstraints(FilePickerMode::File, ['csv'])); + + $this->assertNull($field->pickerViolation('')); + $this->assertSame('an existing file', $field->pickerViolation('/orchard/missing.csv')); + // Limits left on a field that browses nothing govern nothing. + $this->assertNull((new Field('courier', 'Courier'))->picker($field->pickerConstraints())->pickerViolation('/orchard/missing.csv')); + + $this->assertFalse($field->accept('/orchard/missing.csv')); + $this->assertSame('must be an existing file.', $field->refusal()); + } + + #[DataProvider('dataProviderDeclarationThatCouldNotBeHonouredIsRefused')] + public function testDeclarationThatCouldNotBeHonouredIsRefused(\Closure $declare, string $says): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($says); + + $declare(new Field('basket', 'Basket contents', FieldType::Select)); + } + + public static function dataProviderDeclarationThatCouldNotBeHonouredIsRefused(): \Iterator { + yield 'an unportable name' => [ + static fn(Field $field): Field => $field->env('ORCHARD-BASKET'), + 'which is not portable', + ]; + + yield 'an unportable alias' => [ + static fn(Field $field): Field => $field->envAliases(['9BASKET']), + 'which is not portable', + ]; + + yield 'an alias repeating the name' => [ + static fn(Field $field): Field => $field->env('BASKET')->envAliases(['BASKET']), + 'declares the environment variable "BASKET" twice', + ]; + + yield 'an alias repeating an alias' => [ + static fn(Field $field): Field => $field->envAliases(['CRATE', 'CRATE']), + 'declares the environment variable "CRATE" twice', + ]; + + yield 'a caption off the scale' => [ + static fn(Field $field): Field => $field->bounds(new NumberBounds(1, 5))->captions([9 => 'Ripe']), + 'captions the point 9, which is outside its scale of between 1 and 5', + ]; + + yield 'a page of no rows' => [ + static fn(Field $field): Field => $field->paginate(0), + 'a page shows at least one', + ]; + + yield 'a query floor of no characters' => [ + static fn(Field $field): Field => $field->minQuery(0), + 'it must be at least one character', + ]; + } + + public function testEditModeDrawsGroupedDisabledAndDividedEntries(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select)) + ->heading('Fruit') + ->entry('apple', 'Apple') + ->separator() + ->entry('carrot', 'Carrot', disabled: TRUE, disabled_reason: 'Out of season.') + ->default('apple'); + + $lines = explode("\n", $field->open()->render($this->theme())); + + $this->assertStringContainsString('Fruit', $lines[0]); + $this->assertStringContainsString('Apple', $lines[1]); + $this->assertStringContainsString('─', $lines[2]); + $this->assertStringContainsString('Carrot', $lines[3]); + $this->assertStringContainsString('Out of season.', $lines[3]); + } + + public function testDescriptionIsDrawnWhetherOrNotTheFieldIsOpen(): void { + // What is being asked is worth saying before the row is opened: a reader + // decides what to answer without opening anything. + $field = (new Field('basket', 'Basket contents'))->description('Pick the produce.')->default('apple'); + + $this->assertStringContainsString('Pick the produce.', $field->render($this->theme())); + $this->assertStringContainsString('Pick the produce.', $field->open()->render($this->theme())); + } + + public function testDescriptionUnderSettledRowLinesUpWithTheAnswer(): void { + $field = (new Field('basket', 'Basket contents'))->description('Pick the produce.')->default('apple'); + + // The explanation steps in to the column the answer starts in, so the row + // above it reads as the thing it explains. + $this->assertSame([' Basket contents apple', ' Pick the produce.'], explode("\n", $field->render($this->theme()))); + } + + public function testChosenEntryIsMarkedAcrossOneValueAndSeveral(): void { + $one = (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple', 'Apple')->entry('carrot', 'Carrot')->default('carrot'); + $several = (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot') + ->entry('plum', 'Plum') + ->default(['apple', 'carrot']); + + // A single choice is a radio list and several is a checkbox list, which is + // the editor's shape rather than the row's: the field hands the region over + // and the kind decides what fills it. + $this->assertSame(['○ Apple', '● Carrot'], $this->entryLines($one)); + $this->assertSame(['❯ ◼ Apple', '◼ Carrot', '◻ Plum'], $this->entryLines($several)); + } + + public function testSeveralAnswersReadAsOneLineWhileTheFieldIsSettled(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot') + ->default(['apple', 'carrot']); + + $this->assertSame(' Basket contents apple, carrot', $field->render($this->theme())); + } + + public function testRowSaysItsEntriesAreStillComingRatherThanReadingAsEmpty(): void { + $field = (new Field('basket', 'Basket contents', FieldType::Select))->load(static fn(): array => ['apple' => 'Apple']); + + // Nothing has asked the loader yet, which is what a reader meets while a + // panel that fetches its own rows is being opened. + $this->assertSame(' Basket contents …', $field->render($this->theme())); + + $this->assertSame(' Basket contents', $field->settle(['apple' => 'Apple'])->render($this->theme())); + } + + public function testBadgeSitsAtTheEdgeOfTheFrameRatherThanBesideTheAnswer(): void { + $field = (new Field('courier', 'Courier'))->default('Valley Runs')->badge('edited'); + + $row = $field->render(new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None])); + + // The badge belongs in a column of its own, so it is set against the width + // the theme lays the frame out to rather than against the answer's length. + $this->assertStringStartsWith(' Courier Valley Runs', $row); + $this->assertStringEndsWith(' edited ', $row); + $this->assertSame(40, Ansi::width($row)); + } + + public function testOpenFieldSaysWhatItAcceptsUntilSomethingIsRefused(): void { + $field = (new Field('weight', 'Weight', FieldType::Number)) + ->constrain('a weight between 200 and 9000') + ->bounds(new NumberBounds(200, 9000)) + ->open(); + + $this->assertStringContainsString('a weight between 200 and 9000', $field->render($this->theme())); + + // The two share one line and never appear together, so the refusal is what + // is drawn from the moment there is one. + $this->assertFalse($field->accept(10)); + + $rendered = $field->render($this->theme()); + $this->assertStringContainsString('must be between 200 and 9000.', $rendered); + $this->assertStringNotContainsString('a weight between 200 and 9000', $rendered); + } + + /** + * The entry rows an open field draws, with the label prefix taken off. + * + * @param \DrevOps\Tui\Block\Field $field + * The field. + * + * @return list + * The rows. + */ + protected function entryLines(Field $field): array { + $lines = explode("\n", $field->open()->render($this->theme())); + + return array_map(static fn(string $line): string => trim(str_replace('Basket contents', '', $line)), $lines); + } + + /** + * A theme with colour off, so the assertions read as plain strings. + */ + protected function theme(): DefaultTheme { + return new DefaultTheme(80, ['color' => FALSE]); + } + +} diff --git a/tests/phpunit/Unit/Block/FieldDeclarationTest.php b/tests/phpunit/Unit/Block/FieldDeclarationTest.php new file mode 100644 index 00000000..e0fc7283 --- /dev/null +++ b/tests/phpunit/Unit/Block/FieldDeclarationTest.php @@ -0,0 +1,371 @@ +panel('general', 'General', function (PanelBuilder $p): void { + $p->text('name')->default('Acme')->required(); + $p->text('email'); + }) + ->panel('orchard', 'Orchard', function (PanelBuilder $p): void { + $p->select('basket')->option('standard', 'Standard'); + $p->panel('advanced', 'Advanced', function (PanelBuilder $sp): void { + $sp->confirm('trace'); + }); + }) + ->root(); + + $this->assertSame('Demo', $root->title()); + $this->assertCount(2, $root->children()); + + $general = $root->children()[0]; + $this->assertSame('general', $general->id()); + $this->assertCount(2, $general->fields()); + + $name = $general->fields()[0]; + $this->assertSame(FieldType::Text, $name->type()); + $this->assertSame('Acme', $name->value()); + $this->assertTrue($name->isRequired()); + + $orchard = $root->children()[1]; + $basket = $orchard->fields()[0]; + $this->assertSame(FieldType::Select, $basket->type()); + $this->assertSame('Standard', $basket->entryOf('standard')?->label); + $this->assertNotInstanceOf(Option::class, $basket->entryOf('missing')); + + // The trail reaches every panel and every field beneath the root. + $this->assertCount(1, $orchard->children()); + $this->assertSame('advanced', $orchard->children()[0]->id()); + $this->assertCount(4, Tree::fields($root)); + } + + /** + * Tests when an empty value on a required field yields a message. + * + * @param bool $required + * Whether the field is required. + * @param string $message + * The declared message, empty to derive one from the label. + * @param mixed $value + * The candidate value. + * @param string|null $expected + * The expected message, or NULL when the value is accepted. + */ + #[DataProvider('dataProviderRequiredViolation')] + public function testRequiredViolation(bool $required, string $message, mixed $value, ?string $expected): void { + $field = (new Field('plot', 'Garden plot name'))->required($required, $message); + + $this->assertSame($expected, $field->requiredViolation($value)); + } + + /** + * Data provider for testRequiredViolation(). + * + * @return \Iterator + * The required flag, the declared message, the value and the expectation. + */ + public static function dataProviderRequiredViolation(): \Iterator { + $derived = 'Garden plot name is required.'; + $declared = 'The garden plot name is required.'; + + yield 'empty string' => [TRUE, '', '', $derived]; + yield 'empty list' => [TRUE, '', [], $derived]; + yield 'null' => [TRUE, '', NULL, $derived]; + yield 'declared message wins over the label' => [TRUE, $declared, '', $declared]; + yield 'non-empty string' => [TRUE, '', 'North bed', NULL]; + yield 'non-empty list' => [TRUE, '', ['a'], NULL]; + // Only the three empty shapes count: a falsy scalar is an answer, not an + // omission, so a FALSE confirm and a 0 number both pass. + yield 'false' => [TRUE, '', FALSE, NULL]; + yield 'zero' => [TRUE, '', 0, NULL]; + yield 'zero string' => [TRUE, '', '0', NULL]; + yield 'optional field ignores an empty value' => [FALSE, '', '', NULL]; + yield 'optional field ignores a declared message' => [FALSE, $declared, '', NULL]; + } + + /** + * Tests that a variable name that cannot be honoured is refused. + * + * @param string $env_name + * The declared name, or empty to keep the mechanical one. + * @param list $aliases + * The declared aliases. + * @param string $expected + * The expected message. + */ + #[DataProvider('dataProviderEnvNameViolationThrows')] + public function testEnvNameViolationThrows(string $env_name, array $aliases, string $expected): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected); + + $field = new Field('crate_size', 'Crate size'); + + if ($env_name !== '') { + $field->env($env_name); + } + + $field->envAliases($aliases); + } + + /** + * Data provider for testEnvNameViolationThrows(). + * + * @return \Iterator,string}> + * The declared name, its aliases and the expected message. + */ + public static function dataProviderEnvNameViolationThrows(): \Iterator { + yield 'name starting with a digit' => ['1CRATE', [], 'declares the environment variable name "1CRATE", which is not portable']; + yield 'name with a hyphen' => ['OLD-CRATE', [], 'declares the environment variable name "OLD-CRATE", which is not portable']; + yield 'name with a space' => ['OLD CRATE', [], 'declares the environment variable name "OLD CRATE", which is not portable']; + yield 'alias with a hyphen' => ['', ['OLD-CRATE'], 'declares the environment variable name "OLD-CRATE", which is not portable']; + yield 'empty alias' => ['', [''], 'declares the environment variable name "", which is not portable']; + yield 'alias repeating the name' => ['NEW_CRATE', ['NEW_CRATE'], 'declares the environment variable "NEW_CRATE" twice']; + yield 'alias declared twice' => ['', ['OLD_CRATE', 'OLD_CRATE'], 'declares the environment variable "OLD_CRATE" twice']; + } + + /** + * Tests that a name that can be honoured is kept as declared. + * + * @param string $env_name + * The declared name, or empty to keep the mechanical one. + * @param list $aliases + * The declared aliases. + */ + #[DataProvider('dataProviderEnvNameAccepted')] + public function testEnvNameAccepted(string $env_name, array $aliases): void { + $field = new Field('crate_size', 'Crate size'); + + if ($env_name !== '') { + $field->env($env_name); + } + + $field->envAliases($aliases); + + $this->assertSame($env_name, $field->envName()); + $this->assertSame($aliases, $field->aliases()); + } + + /** + * Data provider for testEnvNameAccepted(). + * + * @return \Iterator}> + * The declared name and its aliases. + */ + public static function dataProviderEnvNameAccepted(): \Iterator { + yield 'nothing declared' => ['', []]; + yield 'name only' => ['NEW_CRATE', []]; + yield 'aliases only' => ['', ['OLD_CRATE', 'OLDER_CRATE']]; + yield 'name and aliases' => ['NEW_CRATE', ['OLD_CRATE']]; + yield 'leading underscore' => ['_CRATE', []]; + yield 'digits after the first character' => ['CRATE_2', []]; + yield 'lowercase is left as declared' => ['old_crate', []]; + } + + public function testTemplateFieldWithoutShapeIsRefusedWhenTheFormIsDeclared(): void { + $this->expectException(FormException::class); + $this->expectExceptionMessage('Field "crate" is a template field but declares no pattern'); + + Form::create('T')->panel('p', 'P', static function (PanelBuilder $p): void { + $p->add(new Field('crate', 'Crate', FieldType::Template)); + })->root(); + } + + /** + * Tests the reason an answer does not fit the shape it must have. + * + * @param mixed $value + * The candidate value. + * @param string|null $expected + * The expected reason, or NULL when the value fits. + */ + #[DataProvider('dataProviderTemplateError')] + public function testTemplateError(mixed $value, ?string $expected): void { + $field = (new Field('crate', 'Crate', FieldType::Template))->pattern(new Template('{{a}}-{{b}}', ['b' => 'Beta'], [ + 'b' => static fn(string $part): ?string => $part === 'ok' ? NULL : 'must be ok', + ])); + + $this->assertSame($expected, $field->templateError($value)); + } + + /** + * Data provider for testTemplateError(). + * + * @return \Iterator + * The value and the reason it is refused, or NULL when it fits. + */ + public static function dataProviderTemplateError(): \Iterator { + yield 'fits the shape' => ['one-ok', NULL]; + yield 'slot rejected' => ['one-bad', 'Beta: must be ok']; + yield 'shape mismatch' => ['nope', '"nope" does not match the template "{{a}}-{{b}}".']; + // An unfilled template is left to the required check, and a non-string is + // left to the type check, so neither is reported here. + yield 'empty' => ['', NULL]; + yield 'not a string' => [42, NULL]; + } + + public function testTemplateErrorIsNullWithoutShape(): void { + $this->assertNull((new Field('name', 'Name'))->templateError('anything')); + } + + /** + * Tests the slot values recovered from an assembled answer. + * + * @param mixed $value + * The assembled answer. + * @param array $expected + * The value of each slot, keyed by slot name. + */ + #[DataProvider('dataProviderTemplateParts')] + public function testTemplateParts(mixed $value, array $expected): void { + $field = (new Field('crate', 'Crate', FieldType::Template))->pattern(new Template('{{a}}-{{b}}')); + + $this->assertSame($expected, $field->templateParts($value)); + } + + /** + * Data provider for testTemplateParts(). + * + * @return \Iterator}> + * The answer and the slots recovered from it. + */ + public static function dataProviderTemplateParts(): \Iterator { + yield 'fits the shape' => ['one-two', ['a' => 'one', 'b' => 'two']]; + yield 'shape mismatch' => ['nope', []]; + yield 'not a string' => [42, []]; + } + + public function testTemplatePartsAreEmptyWithoutShape(): void { + $this->assertSame([], (new Field('name', 'Name'))->templateParts('one-two')); + } + + public function testCaptionsOnFieldWithNoScaleThrows(): void { + $this->expectException(FormException::class); + $this->expectExceptionMessage('Field "f" of type "text" draws no scale to caption; captions apply to rating fields.'); + + Form::create('T')->panel('p', 'P', static function (PanelBuilder $p): void { + $p->add((new Field('f', 'F'))->captions([1 => 'Poor'])); + })->root(); + } + + public function testCaptionsOnAnUnboundedRatingAreKept(): void { + // The builder always closes a rating's scale; a hand-built field without + // one has no range to check a caption against, so every point passes. + $field = (new Field('f', 'F', FieldType::Rating))->captions([99 => 'Far out']); + + $this->assertSame([99 => 'Far out'], $field->ratingCaptions()); + } + + /** + * Tests that only a field drawing a buffer accepts ghost text. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind of answer the field collects. + * @param bool $accepted + * Whether it draws a buffer to ghost. + */ + #[DataProvider('dataProviderPlaceholderIsRejectedWhenTypeHasNoInput')] + public function testPlaceholderIsRejectedWhenTypeHasNoInput(FieldType $type, bool $accepted): void { + if (!$accepted) { + $this->expectException(FormException::class); + $this->expectExceptionMessage(sprintf('Field "f" of type "%s" shows no placeholder', $type->value)); + } + + $field = (new Field('f', 'F', $type))->placeholder('E.g. Golden Beetroot'); + + if ($type === FieldType::Template) { + $field->pattern(new Template('{{a}}-{{b}}')); + } + + Form::create('T')->panel('p', 'P', static fn(PanelBuilder $p): PanelBuilder => $p->add($field))->root(); + + $this->assertSame('E.g. Golden Beetroot', $field->placeholderText()); + } + + /** + * Data provider for testPlaceholderIsRejectedWhenTypeHasNoInput(). + * + * @return \Iterator + * Each kind, and whether it draws a buffer to ghost. + */ + public static function dataProviderPlaceholderIsRejectedWhenTypeHasNoInput(): \Iterator { + // The accepting types are spelled out rather than read back from + // supportsPlaceholder(), so a change to that set fails here instead of + // moving the expectation along with it. + $accepting = [ + FieldType::Text, + FieldType::Number, + FieldType::Textarea, + FieldType::Password, + FieldType::Suggest, + FieldType::Search, + ]; + + foreach (FieldType::cases() as $type) { + yield $type->value => [$type, in_array($type, $accepting, TRUE)]; + } + } + + /** + * Tests that every kind of field takes the long text behind its help key. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind of answer the field collects. + */ + #[DataProvider('dataProviderHelpIsAcceptedOnEveryType')] + public function testHelpIsAcceptedOnEveryType(FieldType $type): void { + $this->assertSame('Use the arrows.', (new Field('f', 'F', $type))->help('Use the arrows.')->helpText()); + } + + /** + * Data provider for testHelpIsAcceptedOnEveryType(). + * + * @return \Iterator + * Each kind of answer a field collects. + */ + public static function dataProviderHelpIsAcceptedOnEveryType(): \Iterator { + foreach (FieldType::cases() as $type) { + yield $type->value => [$type]; + } + } + + public function testFormDefaults(): void { + $builder = Form::create('T'); + $root = $builder->root(); + + $this->assertSame('', $builder->currentSubject()); + $this->assertSame('', $builder->currentEnvPrefix()); + $this->assertSame([], $builder->currentFixups()); + // Form chrome defaults (the global TUI runtime lives on the Tui facade). + $this->assertSame('', $builder->currentBanner()); + $this->assertInstanceOf(Panel::class, $root); + $this->assertTrue($root->currentButtons()->show); + $this->assertSame('Submit', $root->currentButtons()->submitLabel); + $this->assertSame('Cancel', $root->currentButtons()->cancelLabel); + } + +} diff --git a/tests/phpunit/Unit/Block/PanelTest.php b/tests/phpunit/Unit/Block/PanelTest.php new file mode 100644 index 00000000..bc1b192f --- /dev/null +++ b/tests/phpunit/Unit/Block/PanelTest.php @@ -0,0 +1,208 @@ +layout($layout); + + $this->assertSame($layout, $panel->currentLayout()); + } + + public function testItsBlocksGoIntoItsLayoutsRegions(): void { + $panel = (new Panel('delivery', 'Delivery'))->layout(new TwoColumnLayout()); + $courier = new Markup('courier', 'Valley Runs'); + + $panel->in('left')->add($courier); + + $this->assertSame([$courier], $panel->currentLayout()->in('left')->blocks()); + } + + public function testPanelWithoutLayoutHasNowhereToPutBlock(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Panel "delivery" has no layout, so it has no regions to place a block in.'); + + (new Panel('delivery', 'Delivery'))->in('left'); + } + + public function testNestedPanelDrawsTheWayInAsRowYouSelect(): void { + $child = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout()); + $theme = new DefaultTheme(40, ['color' => FALSE]); + + // The mark saying where the cursor is, what the panel is called, and the + // mark saying the row leads somewhere rather than opening in place. + $this->assertSame(' Advanced ›', $child->render($theme)); + $this->assertSame('❯ Advanced ›', $child->focus()->render($theme)); + } + + public function testNestedPanelSaysWhatItIsHoldingUnderItsRow(): void { + $child = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout())->description('What the packers need.'); + $child->in('content')->add((new Field('courier', 'Courier'))->default('Valley Runs')); + $child->in('content')->add((new Field('grade', 'Grade'))->default('Premium')); + + $theme = new DefaultTheme(40, ['color' => FALSE]); + + $this->assertSame([ + ' Advanced ›', + ' What the packers need.', + ' Valley Runs · Premium', + ], explode("\n", $child->render($theme))); + } + + public function testNestedPanelDrawnAsWindowShowsTheRowsThemselves(): void { + $child = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout())->description('What the packers need.'); + $child->in('content')->add((new Field('courier', 'Courier'))->default('Valley Runs')); + + $theme = new DefaultTheme(40, ['color' => FALSE]); + + // Where the row says what is behind it in one line, the window shows the + // rows behind it instead. + $this->assertSame([ + ' Advanced ›', + ' What the packers need.', + ' Courier Valley Runs', + ], explode("\n", $child->preview($theme))); + } + + public function testCompactPanelRowDropsEverythingButTheWayIn(): void { + $child = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout())->description('What the packers need.'); + $child->in('content')->add((new Field('courier', 'Courier'))->default('Valley Runs')); + + $theme = new DefaultTheme(40, ['color' => FALSE, 'spacing' => Spacing::Compact]); + + $this->assertSame(' Advanced ›', $child->render($theme)); + } + + public function testPanelYouAreInDrawsNoRowOfItsOwn(): void { + $panel = (new Panel('delivery', 'Delivery'))->layout(new DefaultLayout()); + + $this->assertFalse($panel->isEntered()); + $this->assertTrue($panel->enter()->isEntered()); + + // Its blocks draw instead, so asking it to draw a row is a mistake worth + // catching rather than a title nobody asked for. + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Panel "delivery" is entered, so its blocks draw rather than the panel itself.'); + + $panel->render(new DefaultTheme(40, ['color' => FALSE])); + } + + public function testPanelCarriesItsTitleIntoTheTrail(): void { + $panel = new Panel('delivery', 'Delivery'); + + $this->assertSame('delivery', $panel->id()); + $this->assertSame('Delivery', $panel->title()); + } + + public function testModalIsTheSamePanelDrawnOverWhatIsBehindIt(): void { + $panel = new Panel('confirm', 'Confirm delivery'); + + $this->assertFalse($panel->isModal()); + $this->assertTrue($panel->modal()->isModal()); + } + + public function testPanelCarriesTheStandingTextUnderItsTitle(): void { + $panel = new Panel('delivery', 'Delivery'); + + $this->assertSame('', $panel->descriptionText()); + $this->assertSame('Every crate leaves at dawn.', $panel->description('Every crate leaves at dawn.')->descriptionText()); + } + + public function testPanelLabelsTheWayOutOfIt(): void { + $panel = new Panel('confirm', 'Confirm delivery'); + $buttons = new Buttons(TRUE, 'Send', 'Keep packing'); + + $this->assertSame('Submit', $panel->currentButtons()->submitLabel); + $this->assertSame('Keep packing', $panel->buttons($buttons)->currentButtons()->cancelLabel); + } + + #[DataProvider('dataProviderPanelDrawnOverEverythingCannotHideItsOnlyWayOut')] + public function testPanelDrawnOverEverythingCannotHideItsOnlyWayOut(\Closure $declare): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Panel "confirm" draws over what is behind it, so its buttons are its only way out and cannot be hidden.'); + + $declare(new Panel('confirm', 'Confirm delivery')); + } + + public static function dataProviderPanelDrawnOverEverythingCannotHideItsOnlyWayOut(): \Iterator { + yield 'hidden after it overlays' => [static fn(Panel $panel): Panel => $panel->modal()->buttons(new Buttons(FALSE))]; + yield 'overlaid after they are hidden' => [static fn(Panel $panel): Panel => $panel->buttons(new Buttons(FALSE))->modal()]; + } + + public function testPanelPreparesWhatItNeedsOnceBeforeItIsFirstEntered(): void { + $prepared = 0; + $panel = (new Panel('delivery', 'Delivery'))->preload(static function () use (&$prepared): void { + $prepared++; + }); + + $this->assertTrue($panel->prepare()); + // Preparation happens once, so a panel entered again does not redo it. + $this->assertFalse($panel->prepare()); + $this->assertSame(1, $prepared); + } + + public function testPanelWithNothingToPrepareHasNothingToDo(): void { + $this->assertFalse((new Panel('delivery', 'Delivery'))->prepare()); + } + + public function testPanelHandsBackWhatItStillHasToPrepare(): void { + $work = static function (): void { + }; + $panel = (new Panel('delivery', 'Delivery'))->preload($work); + + $this->assertSame($work, $panel->preparation()); + // Doing it is what leaves nothing to do, so it is gone once it has run. + $panel->prepare(); + $this->assertNull($panel->preparation()); + } + + public function testPanelsNestedInOneSitSideBySideWhenItSaysSo(): void { + $panel = new Panel('delivery', 'Delivery'); + + $this->assertSame([], $panel->gridRows()); + $this->assertSame([1, 2], $panel->grid(1, 2)->gridRows()); + } + + public function testPanelHoldsTheSubPanelsYouCanDescendInto(): void { + $parent = (new Panel('main', 'Delivery'))->layout(new DefaultLayout()); + $child = new Panel('advanced', 'Advanced'); + + $parent->in('content')->add($child); + + $this->assertSame([$child], $parent->children()); + } + + public function testPanelWithNoLayoutHoldsNothingToDescendInto(): void { + $this->assertSame([], (new Panel('main', 'Delivery'))->children()); + } + + public function testBlockThatIsNotPanelIsNoDestination(): void { + $parent = (new Panel('main', 'Delivery'))->layout(new DefaultLayout()); + $parent->in('content')->add(new Markup('intro', 'Pick the produce.')); + + $this->assertSame([], $parent->children()); + } + +} diff --git a/tests/phpunit/Unit/Builder/BlockTreeTest.php b/tests/phpunit/Unit/Builder/BlockTreeTest.php new file mode 100644 index 00000000..ffa74a13 --- /dev/null +++ b/tests/phpunit/Unit/Builder/BlockTreeTest.php @@ -0,0 +1,344 @@ +panel($declare); + + $this->assertInstanceOf($expected, $panel->in('content')->blocks()[0]); + } + + /** + * Data provider for testDeclarationWritesTheBlockItsAnswerNeeds(). + * + * @return \Iterator + * A declaration, and the block class it writes. + */ + public static function dataProviderDeclarationWritesTheBlockItsAnswerNeeds(): \Iterator { + yield 'a question collects' => [static fn(PanelBuilder $p): FieldBuilder => $p->text('courier', 'Courier'), Field::class]; + yield 'a choice collects' => [static fn(PanelBuilder $p): FieldBuilder => $p->select('basket', 'Basket'), Field::class]; + yield 'a note only shows' => [static fn(PanelBuilder $p): FieldBuilder => $p->note('intro', 'Fresh produce'), Markup::class]; + yield 'markup only shows' => [static fn(PanelBuilder $p): Markup => $p->markup('intro', 'Fresh produce'), Markup::class]; + yield 'a progress row only runs' => [static fn(PanelBuilder $p): FieldBuilder => $p->progress('packing', 'Packing'), Progress::class]; + } + + public function testBlocksAreInTheRegionInTheOrderTheyWereWritten(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->text('courier', 'Courier'); + $p->markup('weighing', 'Weighed at the packing bench.'); + $p->number('weight', 'Basket weight'); + }); + + $ids = array_map(static fn(object $block): string => method_exists($block, 'id') ? (string) $block->id() : '', $panel->in('content')->blocks()); + + $this->assertSame(['courier', 'weighing', 'weight'], $ids); + } + + public function testTheBlockIsPlacedAsItIsWrittenRatherThanCopiedLater(): void { + $builder = new PanelBuilder('main', 'Delivery'); + $field = $builder->text('courier', 'Courier'); + $builder->seal(); + + $this->assertSame($field->block(), $builder->block()->in('content')->blocks()[0]); + } + + public function testWithoutDeclaredLayoutPanelKeepsItsOneRegion(): void { + $this->assertSame(['content'], $this->panel(static function (PanelBuilder $p): void { + $p->text('courier', 'Courier'); + })->currentLayout()->names()); + } + + public function testNamedLayoutGivesThePanelTheRegionsItDeclares(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->layout('two-column'); + $p->in('left')->text('courier', 'Courier'); + $p->in('right')->number('weight', 'Basket weight'); + }); + + $this->assertSame(['left', 'right'], $panel->currentLayout()->names()); + $this->assertCount(1, $panel->in('left')->blocks()); + $this->assertCount(1, $panel->in('right')->blocks()); + } + + public function testBlockGoesInTheFirstRegionUntilAnotherIsNamed(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->layout('two-column'); + $p->text('courier', 'Courier'); + }); + + $this->assertCount(1, $panel->in('left')->blocks()); + $this->assertCount(0, $panel->in('right')->blocks()); + } + + public function testGridArrangesTheSubPanelsWithoutTouchingTheRegions(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->layout(2); + $p->panel('left', 'Left', static fn(PanelBuilder $sp): FieldBuilder => $sp->text('one', 'One')); + $p->panel('right', 'Right', static fn(PanelBuilder $sp): FieldBuilder => $sp->text('two', 'Two')); + }); + + $this->assertSame([2], $panel->gridRows()); + $this->assertSame(['content'], $panel->currentLayout()->names()); + $this->assertCount(2, $panel->children()); + } + + /** + * Tests that an arrangement a panel could not honour is refused. + * + * @param \Closure $declare + * The declaration, given the panel builder. + * @param string $message + * The message it is refused with. + */ + #[DataProvider('dataProviderArrangementThatCouldNotBeHonouredIsRefused')] + public function testArrangementThatCouldNotBeHonouredIsRefused(\Closure $declare, string $message): void { + $this->expectException(FormException::class); + $this->expectExceptionMessage($message); + + $this->panel($declare); + } + + /** + * Data provider for testArrangementThatCouldNotBeHonouredIsRefused(). + * + * @return \Iterator + * A declaration the builder refuses, and the message it refuses it with. + */ + public static function dataProviderArrangementThatCouldNotBeHonouredIsRefused(): \Iterator { + yield 'a name beside a grid' => [ + static function (PanelBuilder $p): void { + $p->layout('two-column', 2); + }, + 'Panel "main" declares a layout name beside a grid of sub-panels', + ]; + + yield 'two names' => [ + static function (PanelBuilder $p): void { + $p->layout('two-column', 'default'); + }, + 'Panel "main" declares 2 layouts; a panel is arranged by one.', + ]; + + yield 'a name after the blocks' => [ + static function (PanelBuilder $p): void { + $p->text('courier', 'Courier'); + $p->layout('two-column'); + }, + 'Panel "main" declares a layout after placing blocks in the one it had', + ]; + } + + /** + * Tests that a presentation is the same block laid out another way. + * + * @param \Closure $declare + * The declaration, given the panel builder. + * @param bool $bordered + * Whether the block is drawn in a border. + * @param bool $tabular + * Whether the block carries a grid. + */ + #[DataProvider('dataProviderMarkupPresentationsAreOneBlockDrawnThreeWays')] + public function testMarkupPresentationsAreOneBlockDrawnThreeWays(\Closure $declare, bool $bordered, bool $tabular): void { + $block = $this->panel($declare)->in('content')->blocks()[0]; + + $this->assertInstanceOf(Markup::class, $block); + $this->assertSame($bordered, $block->isBordered()); + $this->assertSame($tabular, $block->tableSpec() instanceof TableSpec); + } + + /** + * Data provider for testMarkupPresentationsAreOneBlockDrawnThreeWays(). + * + * @return \Iterator + * A declaration, whether it is bordered, and whether it carries a grid. + */ + public static function dataProviderMarkupPresentationsAreOneBlockDrawnThreeWays(): \Iterator { + yield 'prose' => [ + static function (PanelBuilder $p): void { + $p->markup('weighing', 'Every crate is weighed at the packing bench.'); + }, + FALSE, + FALSE, + ]; + + yield 'a card' => [ + static function (PanelBuilder $p): void { + $p->markup('notice', 'Deliveries leave at dawn.')->bordered(); + }, + TRUE, + FALSE, + ]; + + yield 'a grid' => [ + static function (PanelBuilder $p): void { + $p->markup('yields', 'Yields per crate')->table(['Produce', 'Crates'], [['Apple', '12']]); + }, + FALSE, + TRUE, + ]; + + yield 'a note is the same block' => [ + static function (PanelBuilder $p): void { + $p->note('packing', 'Ready to pack')->description('Framed with a border.')->border(); + }, + TRUE, + FALSE, + ]; + } + + public function testNoteAndMarkupCarryTheSameTitleAndBody(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->note('note', 'Ready to pack')->description('Packed at dawn.'); + $p->markup('markup', 'Packed at dawn.', 'Ready to pack'); + }); + + $blocks = $panel->in('content')->blocks(); + $note = $blocks[0]; + $markup = $blocks[1]; + + $this->assertInstanceOf(Markup::class, $note); + $this->assertInstanceOf(Markup::class, $markup); + $this->assertSame([$markup->titleText(), $markup->bodyText()], [$note->titleText(), $note->bodyText()]); + } + + public function testMarkupAppearsOnlyWhenAnEarlierAnswerCallsForIt(): void { + $block = $this->panel(static function (PanelBuilder $p): void { + $p->markup('certified', 'Organic crates need current certification.')->when(new Condition('organic', eq: TRUE)); + })->in('content')->blocks()[0]; + + $this->assertInstanceOf(Markup::class, $block); + $this->assertTrue($block->isActive(['organic' => TRUE])); + $this->assertFalse($block->isActive(['organic' => FALSE])); + } + + public function testNestedPanelIsBlockInTheRegionAndPanelYouCanGoInto(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->text('courier', 'Courier'); + $p->panel('advanced', 'Advanced', static fn(PanelBuilder $sp): FieldBuilder => $sp->text('webroot', 'Web root')); + }); + + $blocks = $panel->in('content')->blocks(); + + $this->assertInstanceOf(Panel::class, $blocks[1]); + $this->assertSame([$blocks[1]], $panel->children()); + } + + public function testEveryDeclaredPanelHangsFromOneRoot(): void { + $form = Form::create('Orchard') + ->panel('delivery', 'Delivery', static fn(PanelBuilder $p): FieldBuilder => $p->text('courier', 'Courier')) + ->panel('packing', 'Packing', static fn(PanelBuilder $p): FieldBuilder => $p->text('bench', 'Bench')); + + $root = $form->root(); + + $this->assertSame('Orchard', $root->title()); + $this->assertSame(['delivery', 'packing'], array_map(static fn(Panel $panel): string => $panel->id(), $root->children())); + // The tree is the declaration, so asking for it again is the same tree + // rather than a second copy of it. + $this->assertSame($root, $form->root()); + } + + public function testTheTreeIsWrittenOnceAndHandedBackAsItStands(): void { + $form = Form::create('Orchard')->panel('delivery', 'Delivery', static function (PanelBuilder $p): void { + $p->select('basket', 'Basket contents')->option('apple', 'Apple')->default('apple'); + }); + + $block = $form->root()->children()[0]->in('content')->blocks()[0]; + + $this->assertInstanceOf(Field::class, $block); + $this->assertSame('apple', $block->value()); + + // The tree is written once and handed back as it stands, so a second call + // reaches the very blocks the first one did. + $this->assertSame($block, $form->root()->children()[0]->in('content')->blocks()[0]); + } + + public function testLayoutDeclaringNoRegionHasNowhereToPutBlock(): void { + LayoutManager::register('bare', BareLayoutFixture::class); + + $this->expectException(FormException::class); + $this->expectExceptionMessage('Panel "main" is arranged by a layout declaring no region'); + + $this->panel(static function (PanelBuilder $p): void { + $p->layout('bare'); + }); + } + + /** + * Declare a panel and hand back the block it declared. + * + * @param \Closure $declare + * The declaration, given the panel builder. + * + * @return \DrevOps\Tui\Block\Panel + * The panel block. + */ + protected function panel(\Closure $declare): Panel { + $builder = new PanelBuilder('main', 'Delivery'); + $declare($builder); + $builder->seal(); + + return $builder->block(); + } + +} + +/** + * An arrangement with nowhere to put anything. + */ +final class BareLayoutFixture extends AbstractLayout { + + /** + * Construct the layout. + */ + public function __construct() { + parent::__construct(Axis::Rows); + } + +} diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 211d1128..ec2c53b8 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -12,12 +12,14 @@ use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\Dotenv; use DrevOps\Tui\Model\DateBounds; -use DrevOps\Tui\Model\Field; +use DrevOps\Tui\Block\Field; +use DrevOps\Tui\Block\Markup; +use DrevOps\Tui\Block\Panel; +use DrevOps\Tui\Block\Tree; use DrevOps\Tui\Model\FieldType; use DrevOps\Tui\Model\FilePickerMode; use DrevOps\Tui\Model\Fixup; use DrevOps\Tui\Model\FormException; -use DrevOps\Tui\Model\Modal; use DrevOps\Tui\Model\NumberBounds; use DrevOps\Tui\Model\OptionKind; use DrevOps\Tui\Model\RenderMode; @@ -43,7 +45,7 @@ final class FormTest extends TestCase { public function testBuildsExpectedForm(): void { $fixup = new Fixup(set: 'a', to: 'b', when: new Condition('x', eq: 'y')); - $form = Form::create('My app', 'the project') + $builder = Form::create('My app', 'the project') ->banner('LOGO') ->buttons(TRUE, 'Install', 'Quit') ->envPrefix('APP_') @@ -62,78 +64,81 @@ public function testBuildsExpectedForm(): void { $p->panel('advanced', 'Advanced', function (PanelBuilder $sp): void { $sp->text('webroot', 'Web root')->default('web'); }); - }) - ->build(); - - $this->assertSame('My app', $form->title); - $this->assertSame('the project', $form->subject); - $this->assertSame('LOGO', $form->banner); - $this->assertTrue($form->buttons->show); - $this->assertSame('Install', $form->buttons->submitLabel); - $this->assertSame('Quit', $form->buttons->cancelLabel); - $this->assertSame('APP_', $form->envPrefix); - $this->assertSame([$fixup], $form->fixups); - $this->assertSame('General settings.', $form->panels[0]->description); - - $name = $form->field('name'); + }); + + $form = $builder->root(); + + $this->assertSame('My app', $form->title()); + $this->assertSame('the project', $builder->currentSubject()); + $this->assertSame('LOGO', $builder->currentBanner()); + $this->assertTrue($form->currentButtons()->show); + $this->assertSame('Install', $form->currentButtons()->submitLabel); + $this->assertSame('Quit', $form->currentButtons()->cancelLabel); + $this->assertSame('APP_', $builder->currentEnvPrefix()); + $this->assertSame([$fixup], $builder->currentFixups()); + $this->assertSame('General settings.', $form->children()[0]->descriptionText()); + + $name = self::fieldOf($form, 'name'); $this->assertInstanceOf(Field::class, $name); - $this->assertSame('Site name', $name->label); - $this->assertSame('The name.', $name->description); - $this->assertSame(FieldType::Text, $name->type); - $this->assertSame('Acme', $name->default); - $this->assertTrue($name->required); + $this->assertSame('Site name', $name->label()); + $this->assertSame('The name.', $name->descriptionText()); + $this->assertSame(FieldType::Text, $name->type()); + $this->assertSame('Acme', $name->value()); + $this->assertTrue($name->isRequired()); - $machine = $form->field('machine_name'); + $machine = self::fieldOf($form, 'machine_name'); $this->assertInstanceOf(Field::class, $machine); - $this->assertSame('{{ name }}', $machine->derive?->template); + $this->assertSame('{{ name }}', $machine->derivation()?->template); - $profile = $form->field('profile'); + $profile = self::fieldOf($form, 'profile'); $this->assertInstanceOf(Field::class, $profile); - $this->assertSame(FieldType::Select, $profile->type); - $this->assertSame('standard', $profile->default); - $this->assertSame('Standard', $profile->option('standard')?->label); + $this->assertSame(FieldType::Select, $profile->type()); + $this->assertSame('standard', $profile->value()); + $this->assertSame('Standard', $profile->entryOf('standard')?->label); - $services = $form->field('services'); + $services = self::fieldOf($form, 'services'); $this->assertInstanceOf(Field::class, $services); - $this->assertSame(FieldType::Select, $services->type); - $this->assertTrue($services->multiple); - $this->assertSame('Search', $services->option('solr')?->description); + $this->assertSame(FieldType::Select, $services->type()); + $this->assertTrue($services->isMultiple()); + $this->assertSame('Search', $services->entryOf('solr')?->description); - $docs = $form->field('docs'); + $docs = self::fieldOf($form, 'docs'); $this->assertInstanceOf(Field::class, $docs); - $this->assertSame(FieldType::Confirm, $docs->type); - $this->assertTrue($docs->default); - $this->assertSame(['field' => 'profile', 'eq' => 'standard'], $docs->when?->toArray()); + $this->assertSame(FieldType::Confirm, $docs->type()); + $this->assertTrue($docs->value()); + $condition = $docs->condition(); + $this->assertInstanceOf(Condition::class, $condition); + $this->assertSame(['field' => 'profile', 'eq' => 'standard'], $condition->toArray()); - $visibility = $form->field('visibility'); + $visibility = self::fieldOf($form, 'visibility'); $this->assertInstanceOf(Field::class, $visibility); - $this->assertSame(FieldType::Toggle, $visibility->type); - $this->assertSame('private', $visibility->default); - $this->assertSame('Public', $visibility->option('public')?->label); + $this->assertSame(FieldType::Toggle, $visibility->type()); + $this->assertSame('private', $visibility->value()); + $this->assertSame('Public', $visibility->entryOf('public')?->label); - $secret = $form->field('secret'); + $secret = self::fieldOf($form, 'secret'); $this->assertInstanceOf(Field::class, $secret); - $this->assertSame(FieldType::Password, $secret->type); - $this->assertTrue($secret->revealable); - $this->assertTrue($secret->confirm); + $this->assertSame(FieldType::Password, $secret->type()); + $this->assertTrue($secret->isRevealable()); + $this->assertTrue($secret->hasConfirmation()); - $timezone = $form->field('timezone'); + $timezone = self::fieldOf($form, 'timezone'); $this->assertInstanceOf(Field::class, $timezone); - $this->assertSame(FieldType::Suggest, $timezone->type); - $this->assertInstanceOf(Dotenv::class, $timezone->discover); - $this->assertSame('TZ', $timezone->discover->key); + $this->assertSame(FieldType::Suggest, $timezone->type()); + $this->assertInstanceOf(Dotenv::class, $timezone->discovery()); + $this->assertSame('TZ', $timezone->discovery()->key); - $ranking = $form->field('ranking'); + $ranking = self::fieldOf($form, 'ranking'); $this->assertInstanceOf(Field::class, $ranking); - $this->assertSame(FieldType::Reorder, $ranking->type); + $this->assertSame(FieldType::Reorder, $ranking->type()); // A partial declared default is completed to a full ranking in declared // order: the given values first, the remaining options appended. - $this->assertSame(['good', 'fast', 'cheap'], $ranking->default); + $this->assertSame(['good', 'fast', 'cheap'], $ranking->value()); - $webroot = $form->field('webroot'); + $webroot = self::fieldOf($form, 'webroot'); $this->assertInstanceOf(Field::class, $webroot); - $this->assertSame('web', $webroot->default); - $this->assertSame('Advanced', $form->panels[0]->panels[0]->title); + $this->assertSame('web', $webroot->value()); + $this->assertSame('Advanced', $form->children()[0]->children()[0]->title()); } public function testDefaultsAndFallbacks(): void { @@ -156,49 +161,51 @@ public function testDefaultsAndFallbacks(): void { $panel->pause('pa'); $panel->reorder('rk')->option('a')->option('b')->option('c'); }) - ->build(); + ->root(); // Type defaults when none is declared. - $this->assertSame('', $form->field('t')?->default); - $this->assertSame('', $form->field('s')?->default); - $this->assertSame([], $form->field('m')?->default); - $this->assertFalse($form->field('c')?->default); - $this->assertSame('', $form->field('g')?->default); - $this->assertSame(0, $form->field('n')?->default); - // A date with no explicit default is empty; the widget opens on today. - $this->assertSame('', $form->field('dt')?->default); - $this->assertSame('', $form->field('ta')?->default); - $this->assertSame('', $form->field('pw')?->default); + $this->assertSame('', self::fieldOf($form, 't')?->value()); + $this->assertSame('', self::fieldOf($form, 's')?->value()); + $this->assertSame([], self::fieldOf($form, 'm')?->value()); + $this->assertFalse(self::fieldOf($form, 'c')?->value()); + $this->assertSame('', self::fieldOf($form, 'g')?->value()); + $this->assertSame(0, self::fieldOf($form, 'n')?->value()); + // A date with no explicit default is empty; the field opens on today. + $this->assertSame('', self::fieldOf($form, 'dt')?->value()); + $this->assertSame('', self::fieldOf($form, 'ta')?->value()); + $this->assertSame('', self::fieldOf($form, 'pw')?->value()); // The password options are opt-in, so they default off. - $this->assertFalse($form->field('pw')->revealable); - $this->assertFalse($form->field('pw')->confirm); - $this->assertSame('', $form->field('se')?->default); - $this->assertSame([], $form->field('ms')?->default); + $password = self::fieldOf($form, 'pw'); + $this->assertInstanceOf(Field::class, $password); + $this->assertFalse($password->isRevealable()); + $this->assertFalse($password->hasConfirmation()); + $this->assertSame('', self::fieldOf($form, 'se')?->value()); + $this->assertSame([], self::fieldOf($form, 'ms')?->value()); // A toggle defaults to its first option, since it always holds a value. - $this->assertSame('on', $form->field('tg')?->default); + $this->assertSame('on', self::fieldOf($form, 'tg')?->value()); // A single picker defaults to an empty path; a multiple picker to no paths. - $this->assertSame('', $form->field('fp')?->default); - $this->assertSame([], $form->field('mfp')?->default); + $this->assertSame('', self::fieldOf($form, 'fp')?->value()); + $this->assertSame([], self::fieldOf($form, 'mfp')?->value()); // A reorder with no declared default ranks every option in declared order. - $this->assertSame(['a', 'b', 'c'], $form->field('rk')?->default); + $this->assertSame(['a', 'b', 'c'], self::fieldOf($form, 'rk')?->value()); // The picker options are opt-in, so they default off. - $this->assertSame(FilePickerMode::Any, $form->field('fp')->pickerConstraints->mode); - $this->assertSame('', $form->field('fp')->pickerStart); - $this->assertSame([], $form->field('fp')->pickerConstraints->extensions); - $this->assertFalse($form->field('fp')->pickerShowHidden); + $picker = self::fieldOf($form, 'fp'); + $this->assertInstanceOf(Field::class, $picker); + $this->assertSame(FilePickerMode::Any, $picker->pickerConstraints()->mode); + $this->assertSame('', $picker->pickerStart()); + $this->assertSame([], $picker->pickerConstraints()->extensions); + $this->assertFalse($picker->showsHidden()); // A pause defaults to acknowledged so headless runs never block on it. - $this->assertTrue($form->field('pa')?->default); + $this->assertTrue(self::fieldOf($form, 'pa')?->value()); // Label and option-label fall back to the id/value. - $this->assertSame('t', $form->field('t')->label); - $this->assertSame('a', $form->field('s')->option('a')?->label); + $this->assertSame('t', self::fieldOf($form, 't')?->label()); + $this->assertSame('a', self::fieldOf($form, 's')?->entryOf('a')?->label); // Form-level defaults (the global TUI runtime is tested on the Tui facade). - $this->assertSame('', $form->subject); - $this->assertTrue($form->buttons->show); - $this->assertSame('Submit', $form->buttons->submitLabel); - $this->assertSame('', $form->envPrefix); - $this->assertSame('', $form->panels[0]->description); + $this->assertTrue($form->currentButtons()->show); + $this->assertSame('Submit', $form->currentButtons()->submitLabel); + $this->assertSame('', $form->children()[0]->descriptionText()); } public function testStandaloneOptsOutOfInlineEditing(): void { @@ -209,14 +216,14 @@ public function testStandaloneOptsOutOfInlineEditing(): void { // A later standalone(FALSE) restores inline editing. $panel->text('c')->standalone()->standalone(FALSE); }) - ->build(); + ->root(); // A field is edited inline by default. - $this->assertSame(RenderMode::Inline, $form->field('a')?->render); + $this->assertSame(RenderMode::Inline, self::fieldOf($form, 'a')?->renderMode()); // Declaring it standalone opts out to the full-screen editor. - $this->assertSame(RenderMode::Standalone, $form->field('b')?->render); + $this->assertSame(RenderMode::Standalone, self::fieldOf($form, 'b')?->renderMode()); // standalone(FALSE) restores inline editing. - $this->assertSame(RenderMode::Inline, $form->field('c')?->render); + $this->assertSame(RenderMode::Inline, self::fieldOf($form, 'c')?->renderMode()); } public function testExternalEditorFlag(): void { @@ -225,10 +232,10 @@ public function testExternalEditorFlag(): void { $panel->textarea('notes', 'Notes')->externalEditor(); $panel->textarea('plain', 'Plain'); }) - ->build(); + ->root(); - $this->assertTrue($form->field('notes')?->externalEditor); - $this->assertFalse($form->field('plain')?->externalEditor); + $this->assertTrue(self::fieldOf($form, 'notes')?->hasExternalEditor()); + $this->assertFalse(self::fieldOf($form, 'plain')?->hasExternalEditor()); } public function testNoteField(): void { @@ -239,30 +246,29 @@ public function testNoteField(): void { $panel->note('boxed', 'Boxed')->border(); $panel->note('stock', 'Stock')->table(['Fruit', 'Qty'], [['Apple', '3'], ['Pear', '5']]); }) - ->build(); - - $intro = $form->field('intro'); - $this->assertInstanceOf(Field::class, $intro); - $this->assertSame(FieldType::Note, $intro->type); - // The title is the label and the body is carried by the description. - $this->assertSame('Getting started', $intro->label); - $this->assertSame('Fill in each field.', $intro->description); + ->root(); + + $intro = self::markupOf($form, 'intro'); + $this->assertInstanceOf(Markup::class, $intro); + // The title is the label and the body is what the card shows. + $this->assertSame('Getting started', $intro->titleText()); + $this->assertSame('Fill in each field.', $intro->bodyText()); // A note is not bordered unless it opts in. - $this->assertFalse($intro->bordered); + $this->assertFalse($intro->isBordered()); // A note carries no table unless it opts in. - $this->assertNotInstanceOf(TableSpec::class, $intro->table); + $this->assertNotInstanceOf(TableSpec::class, $intro->tableSpec()); // An omitted title stays empty rather than falling back to the id. - $this->assertSame('', $form->field('bare')?->label); + $this->assertSame('', self::markupOf($form, 'bare')?->titleText()); // ->border() draws the card inside a box. - $this->assertTrue($form->field('boxed')?->bordered); + $this->assertTrue(self::markupOf($form, 'boxed')?->isBordered()); - // ->table() stores the header cells and body rows on the field. - $stock = $form->field('stock'); - $this->assertInstanceOf(TableSpec::class, $stock?->table); - $this->assertSame(['Fruit', 'Qty'], $stock->table->headers); - $this->assertSame([['Apple', '3'], ['Pear', '5']], $stock->table->rows); + // ->table() stores the header cells and body rows on the block. + $stock = self::markupOf($form, 'stock')?->tableSpec(); + $this->assertInstanceOf(TableSpec::class, $stock); + $this->assertSame(['Fruit', 'Qty'], $stock->headers); + $this->assertSame([['Apple', '3'], ['Pear', '5']], $stock->rows); } public function testValidateAndTransformStored(): void { @@ -273,12 +279,12 @@ public function testValidateAndTransformStored(): void { ->panel('p', 'P', function (PanelBuilder $panel) use ($validator, $transformer): void { $panel->text('x')->validate($validator)->transform($transformer); }) - ->build(); + ->root(); - $field = $form->field('x'); + $field = self::fieldOf($form, 'x'); $this->assertInstanceOf(Field::class, $field); - $this->assertSame($validator, $field->validate); - $this->assertSame($transformer, $field->transform); + $this->assertSame($validator, $field->validator()); + $this->assertSame($transformer, $field->transformer()); } public function testRequiredFlagAndMessageStored(): void { @@ -289,26 +295,26 @@ public function testRequiredFlagAndMessageStored(): void { $panel->text('plot', 'Garden plot name')->required(message: 'The garden plot name is required.'); $panel->text('note', 'Delivery note')->required(FALSE); }) - ->build(); + ->root(); - $plain = $form->field('plain'); + $plain = self::fieldOf($form, 'plain'); $this->assertInstanceOf(Field::class, $plain); - $this->assertFalse($plain->required); - $this->assertSame('', $plain->requiredMessage); + $this->assertFalse($plain->isRequired()); + $this->assertSame('', $plain->requiredMessage()); - $name = $form->field('name'); + $name = self::fieldOf($form, 'name'); $this->assertInstanceOf(Field::class, $name); - $this->assertTrue($name->required); - $this->assertSame('', $name->requiredMessage); + $this->assertTrue($name->isRequired()); + $this->assertSame('', $name->requiredMessage()); - $plot = $form->field('plot'); + $plot = self::fieldOf($form, 'plot'); $this->assertInstanceOf(Field::class, $plot); - $this->assertTrue($plot->required); - $this->assertSame('The garden plot name is required.', $plot->requiredMessage); + $this->assertTrue($plot->isRequired()); + $this->assertSame('The garden plot name is required.', $plot->requiredMessage()); - $note = $form->field('note'); + $note = self::fieldOf($form, 'note'); $this->assertInstanceOf(Field::class, $note); - $this->assertFalse($note->required); + $this->assertFalse($note->isRequired()); } public function testCompletionSourceStored(): void { @@ -321,12 +327,12 @@ public function testCompletionSourceStored(): void { $panel->text('repo', 'Repo')->complete($closure); $panel->text('plain', 'Plain'); }) - ->build(); + ->root(); - $this->assertSame($list, $form->field('name')?->completion); - $this->assertSame($closure, $form->field('repo')?->completion); + $this->assertSame($list, self::fieldOf($form, 'name')?->completion()); + $this->assertSame($closure, self::fieldOf($form, 'repo')?->completion()); // A field with no completion source defaults to an empty list. - $this->assertSame([], $form->field('plain')?->completion); + $this->assertSame([], self::fieldOf($form, 'plain')?->completion()); } public function testEnvNameAndAliasesStored(): void { @@ -335,18 +341,18 @@ public function testEnvNameAndAliasesStored(): void { $panel->text('crate_size', 'Crate size')->env('LEGACY_CRATE')->envAliases(['OLD_CRATE', 'OLDER_CRATE']); $panel->text('grade', 'Grade'); }) - ->build(); + ->root(); - $crate = $form->field('crate_size'); + $crate = self::fieldOf($form, 'crate_size'); $this->assertInstanceOf(Field::class, $crate); - $this->assertSame('LEGACY_CRATE', $crate->envName); - $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], $crate->envAliases); + $this->assertSame('LEGACY_CRATE', $crate->envName()); + $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], $crate->aliases()); // A field that names nothing keeps the mechanical name and no aliases. - $grade = $form->field('grade'); + $grade = self::fieldOf($form, 'grade'); $this->assertInstanceOf(Field::class, $grade); - $this->assertSame('', $grade->envName); - $this->assertSame([], $grade->envAliases); + $this->assertSame('', $grade->envName()); + $this->assertSame([], $grade->aliases()); } public function testEnvAliasesAreReindexed(): void { @@ -354,9 +360,9 @@ public function testEnvAliasesAreReindexed(): void { ->panel('p', 'P', function (PanelBuilder $panel): void { $panel->text('crate_size', 'Crate size')->envAliases([2 => 'OLD_CRATE', 5 => 'OLDER_CRATE']); }) - ->build(); + ->root(); - $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], $form->field('crate_size')?->envAliases); + $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], self::fieldOf($form, 'crate_size')?->aliases()); } public function testGhostTextOptInStored(): void { @@ -366,12 +372,12 @@ public function testGhostTextOptInStored(): void { $panel->suggest('berry', 'Berry')->options(['Fig' => 'Fig'])->ghost(FALSE); $panel->suggest('plain', 'Plain')->options(['Pear' => 'Pear']); }) - ->build(); + ->root(); - $this->assertTrue($form->field('fruit')?->ghost); - $this->assertFalse($form->field('berry')?->ghost); + $this->assertTrue(self::fieldOf($form, 'fruit')?->hasGhost()); + $this->assertFalse(self::fieldOf($form, 'berry')?->hasGhost()); // Ghost-text is opt-in, so a field that never asks for it stays without. - $this->assertFalse($form->field('plain')?->ghost); + $this->assertFalse(self::fieldOf($form, 'plain')?->hasGhost()); } public function testTemplateAssembled(): void { @@ -385,17 +391,17 @@ public function testTemplateAssembled(): void { ->slot('grade', 'Grade', $grade) ->default('valley-a'); }) - ->build(); + ->root(); - $crate = $form->field('crate'); + $crate = self::fieldOf($form, 'crate'); $this->assertInstanceOf(Field::class, $crate); - $this->assertInstanceOf(Template::class, $crate->template); - $this->assertSame('{{orchard}}-{{grade}}', $crate->template->pattern()); - $this->assertSame(['orchard', 'grade'], $crate->template->placeholders()); - $this->assertSame('Orchard', $crate->template->labelOf('orchard')); - $this->assertSame($grade, $crate->template->validatorOf('grade')); - $this->assertNotInstanceOf(\Closure::class, $crate->template->validatorOf('orchard')); - $this->assertSame('valley-a', $crate->default); + $this->assertInstanceOf(Template::class, $crate->template()); + $this->assertSame('{{orchard}}-{{grade}}', $crate->template()->pattern()); + $this->assertSame(['orchard', 'grade'], $crate->template()->placeholders()); + $this->assertSame('Orchard', $crate->template()->labelOf('orchard')); + $this->assertSame($grade, $crate->template()->validatorOf('grade')); + $this->assertNotInstanceOf(\Closure::class, $crate->template()->validatorOf('orchard')); + $this->assertSame('valley-a', $crate->value()); } public function testSlotWithoutLabelOrValidatorLeavesBothUnset(): void { @@ -403,42 +409,42 @@ public function testSlotWithoutLabelOrValidatorLeavesBothUnset(): void { ->panel('p', 'P', function (PanelBuilder $panel): void { $panel->template('crate', 'Crate')->pattern('{{a}}-{{b}}')->slot('a'); }) - ->build(); + ->root(); - $template = $form->field('crate')?->template; + $template = self::fieldOf($form, 'crate')?->template(); $this->assertInstanceOf(Template::class, $template); $this->assertSame('a', $template->labelOf('a')); $this->assertNotInstanceOf(\Closure::class, $template->validatorOf('a')); } - public function testHintAndPlaceholderCarryOntoTheField(): void { + public function testHelpAndPlaceholderCarryOntoTheField(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { $panel->text('crop', 'Crop') ->description('The crop being logged.') - ->hint('Type a few letters to filter.') + ->help('Type a few letters to filter.') ->placeholder('E.g. Golden Beetroot'); }) - ->build(); + ->root(); - $crop = $form->field('crop'); + $crop = self::fieldOf($form, 'crop'); $this->assertInstanceOf(Field::class, $crop); - $this->assertSame('The crop being logged.', $crop->description); - $this->assertSame('Type a few letters to filter.', $crop->hint); - $this->assertSame('E.g. Golden Beetroot', $crop->placeholder); + $this->assertSame('The crop being logged.', $crop->descriptionText()); + $this->assertSame('Type a few letters to filter.', $crop->helpText()); + $this->assertSame('E.g. Golden Beetroot', $crop->placeholderText()); } - public function testHintAndPlaceholderDefaultToEmpty(): void { + public function testHelpAndPlaceholderDefaultToEmpty(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { $panel->text('crop', 'Crop'); }) - ->build(); + ->root(); - $crop = $form->field('crop'); + $crop = self::fieldOf($form, 'crop'); $this->assertInstanceOf(Field::class, $crop); - $this->assertSame('', $crop->hint); - $this->assertSame('', $crop->placeholder); + $this->assertSame('', $crop->helpText()); + $this->assertSame('', $crop->placeholderText()); } public function testPatternIgnoredOnNonTemplateField(): void { @@ -446,9 +452,9 @@ public function testPatternIgnoredOnNonTemplateField(): void { ->panel('p', 'P', function (PanelBuilder $panel): void { $panel->text('name', 'Name')->pattern('{{a}}-{{b}}'); }) - ->build(); + ->root(); - $this->assertNotInstanceOf(Template::class, $form->field('name')?->template); + $this->assertNotInstanceOf(Template::class, self::fieldOf($form, 'name')?->template()); } public function testNumberBoundsAssembled(): void { @@ -457,17 +463,17 @@ public function testNumberBoundsAssembled(): void { $panel->number('port', 'Port')->min(1)->max(65535)->step(5); $panel->number('plain', 'Plain'); }) - ->build(); + ->root(); - $port = $form->field('port'); + $port = self::fieldOf($form, 'port'); $this->assertInstanceOf(Field::class, $port); - $this->assertInstanceOf(NumberBounds::class, $port->bounds); - $this->assertSame(1, $port->bounds->min); - $this->assertSame(65535, $port->bounds->max); - $this->assertSame(5, $port->bounds->step); + $this->assertInstanceOf(NumberBounds::class, $port->numberBounds()); + $this->assertSame(1, $port->numberBounds()->min); + $this->assertSame(65535, $port->numberBounds()->max); + $this->assertSame(5, $port->numberBounds()->step); // A number with nothing declared carries no bounds - behaviour unchanged. - $this->assertNotInstanceOf(NumberBounds::class, $form->field('plain')?->bounds); + $this->assertNotInstanceOf(NumberBounds::class, self::fieldOf($form, 'plain')?->numberBounds()); } public function testRatingScaleAssembled(): void { @@ -476,39 +482,39 @@ public function testRatingScaleAssembled(): void { $panel->rating('nps', 'Recommend us')->min(0)->max(10); $panel->rating('taste', 'Taste'); }) - ->build(); + ->root(); - $nps = $form->field('nps'); + $nps = self::fieldOf($form, 'nps'); $this->assertInstanceOf(Field::class, $nps); - $this->assertInstanceOf(NumberBounds::class, $nps->bounds); - $this->assertSame(0, $nps->bounds->min); - $this->assertSame(10, $nps->bounds->max); - $this->assertSame(0, $nps->default); + $this->assertInstanceOf(NumberBounds::class, $nps->numberBounds()); + $this->assertSame(0, $nps->numberBounds()->min); + $this->assertSame(10, $nps->numberBounds()->max); + $this->assertSame(0, $nps->value()); // A rating with nothing declared still carries a scale: one to five, // sitting on its lowest point. - $taste = $form->field('taste'); - $this->assertInstanceOf(NumberBounds::class, $taste?->bounds); - $this->assertSame(1, $taste->bounds->min); - $this->assertSame(5, $taste->bounds->max); - $this->assertNull($taste->bounds->step); - $this->assertSame(1, $taste->default); + $taste = self::fieldOf($form, 'taste'); + $this->assertInstanceOf(NumberBounds::class, $taste?->numberBounds()); + $this->assertSame(1, $taste->numberBounds()->min); + $this->assertSame(5, $taste->numberBounds()->max); + $this->assertNull($taste->numberBounds()->step); + $this->assertSame(1, $taste->value()); } public function testRatingKeepsDeclaredDefault(): void { $form = Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('taste')->default(4)) - ->build(); + ->root(); - $this->assertSame(4, $form->field('taste')?->default); + $this->assertSame(4, self::fieldOf($form, 'taste')?->value()); } public function testRatingCaptionsAssembled(): void { $form = Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('taste')->captions([1 => 'Poor', 5 => 'Excellent'])) - ->build(); + ->root(); - $this->assertSame([1 => 'Poor', 5 => 'Excellent'], $form->field('taste')?->ratingCaptions); + $this->assertSame([1 => 'Poor', 5 => 'Excellent'], self::fieldOf($form, 'taste')?->ratingCaptions()); } #[DataProvider('dataProviderRatingCollapsedScaleThrows')] @@ -518,7 +524,7 @@ public function testRatingCollapsedScaleThrows(int $min, int $max): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('r')->min($min)->max($max)) - ->build(); + ->root(); } /** @@ -538,31 +544,31 @@ public function testDateBoundsAssembled(): void { $panel->calendar('birthday', 'Birthday')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday); $panel->calendar('plain', 'Plain'); }) - ->build(); + ->root(); - $birthday = $form->field('birthday'); + $birthday = self::fieldOf($form, 'birthday'); $this->assertInstanceOf(Field::class, $birthday); - $this->assertInstanceOf(DateBounds::class, $birthday->dateBounds); - $this->assertSame('2000-01-01', $birthday->dateBounds->min?->format('Y-m-d')); - $this->assertSame('2030-12-31', $birthday->dateBounds->max?->format('Y-m-d')); - $this->assertSame(Weekday::Sunday, $birthday->dateBounds->weekStart); + $this->assertInstanceOf(DateBounds::class, $birthday->dateBounds()); + $this->assertSame('2000-01-01', $birthday->dateBounds()->min?->format('Y-m-d')); + $this->assertSame('2030-12-31', $birthday->dateBounds()->max?->format('Y-m-d')); + $this->assertSame(Weekday::Sunday, $birthday->dateBounds()->weekStart); // A date with nothing declared still carries bounds, defaulting to a // Monday-first, open range. - $plain = $form->field('plain'); - $this->assertInstanceOf(DateBounds::class, $plain?->dateBounds); - $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->min); - $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->max); - $this->assertSame(Weekday::Monday, $plain->dateBounds->weekStart); + $plain = self::fieldOf($form, 'plain'); + $this->assertInstanceOf(DateBounds::class, $plain?->dateBounds()); + $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds()->min); + $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds()->max); + $this->assertSame(Weekday::Monday, $plain->dateBounds()->weekStart); } public function testDateBoundsIgnoredOnNonDateField(): void { $form = Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->minDate('2020-01-01')->weekStart(Weekday::Sunday)) - ->build(); + ->root(); // The date setters are inert on a non-date field: no bounds are attached. - $this->assertNotInstanceOf(DateBounds::class, $form->field('t')?->dateBounds); + $this->assertNotInstanceOf(DateBounds::class, self::fieldOf($form, 't')?->dateBounds()); } public function testPageSizeAssembled(): void { @@ -571,12 +577,12 @@ public function testPageSizeAssembled(): void { $panel->search('paged', 'Paged')->options(['a' => 'A'])->pageSize(5); $panel->search('plain', 'Plain')->options(['a' => 'A']); }) - ->build(); + ->root(); - $this->assertSame(5, $form->field('paged')?->pageSize); + $this->assertSame(5, self::fieldOf($form, 'paged')?->pageSize()); // A field with nothing declared carries no page size and uses the default. - $this->assertNull($form->field('plain')?->pageSize); + $this->assertNull(self::fieldOf($form, 'plain')?->pageSize()); } public function testSelectionBoundsAssembled(): void { @@ -587,29 +593,29 @@ public function testSelectionBoundsAssembled(): void { $panel->filePicker('files', 'Files')->multiple()->maxSelections(3); $panel->select('plain', 'Plain')->multiple()->option('a'); }) - ->build(); + ->root(); - $tags = $form->field('tags'); + $tags = self::fieldOf($form, 'tags'); $this->assertInstanceOf(Field::class, $tags); - $this->assertInstanceOf(SelectionBounds::class, $tags->selectionBounds); - $this->assertSame(2, $tags->selectionBounds->min); - $this->assertSame(4, $tags->selectionBounds->max); + $this->assertInstanceOf(SelectionBounds::class, $tags->selectionBounds()); + $this->assertSame(2, $tags->selectionBounds()->min); + $this->assertSame(4, $tags->selectionBounds()->max); // A min-only bound leaves the ceiling open. - $svc = $form->field('svc'); - $this->assertInstanceOf(SelectionBounds::class, $svc?->selectionBounds); - $this->assertSame(1, $svc->selectionBounds->min); - $this->assertNull($svc->selectionBounds->max); + $svc = self::fieldOf($form, 'svc'); + $this->assertInstanceOf(SelectionBounds::class, $svc?->selectionBounds()); + $this->assertSame(1, $svc->selectionBounds()->min); + $this->assertNull($svc->selectionBounds()->max); // A file picker also takes selection bounds; a max-only bound leaves the // floor open. - $files = $form->field('files'); - $this->assertInstanceOf(SelectionBounds::class, $files?->selectionBounds); - $this->assertNull($files->selectionBounds->min); - $this->assertSame(3, $files->selectionBounds->max); + $files = self::fieldOf($form, 'files'); + $this->assertInstanceOf(SelectionBounds::class, $files?->selectionBounds()); + $this->assertNull($files->selectionBounds()->min); + $this->assertSame(3, $files->selectionBounds()->max); // A multiple field with no selection limits carries none. - $this->assertNotInstanceOf(SelectionBounds::class, $form->field('plain')?->selectionBounds); + $this->assertNotInstanceOf(SelectionBounds::class, self::fieldOf($form, 'plain')?->selectionBounds()); } public function testFilePickerOptions(): void { @@ -618,22 +624,22 @@ public function testFilePickerOptions(): void { $panel->filePicker('config', 'Config')->startIn('/opt')->filesOnly()->extensions(['yml', 'yaml'])->showHidden()->maxSize(1048576); $panel->filePicker('assets', 'Assets')->multiple()->directoriesOnly(); }) - ->build(); + ->root(); - $form_field = $form->field('config'); + $form_field = self::fieldOf($form, 'config'); $this->assertInstanceOf(Field::class, $form_field); - $this->assertSame(FieldType::FilePicker, $form_field->type); - $this->assertSame(FilePickerMode::File, $form_field->pickerConstraints->mode); - $this->assertSame('/opt', $form_field->pickerStart); - $this->assertSame(['yml', 'yaml'], $form_field->pickerConstraints->extensions); - $this->assertSame(1048576, $form_field->pickerConstraints->maxSize); - $this->assertTrue($form_field->pickerShowHidden); - - $assets = $form->field('assets'); + $this->assertSame(FieldType::FilePicker, $form_field->type()); + $this->assertSame(FilePickerMode::File, $form_field->pickerConstraints()->mode); + $this->assertSame('/opt', $form_field->pickerStart()); + $this->assertSame(['yml', 'yaml'], $form_field->pickerConstraints()->extensions); + $this->assertSame(1048576, $form_field->pickerConstraints()->maxSize); + $this->assertTrue($form_field->showsHidden()); + + $assets = self::fieldOf($form, 'assets'); $this->assertInstanceOf(Field::class, $assets); - $this->assertSame(FieldType::FilePicker, $assets->type); - $this->assertTrue($assets->multiple); - $this->assertSame(FilePickerMode::Directory, $assets->pickerConstraints->mode); + $this->assertSame(FieldType::FilePicker, $assets->type()); + $this->assertTrue($assets->isMultiple()); + $this->assertSame(FilePickerMode::Directory, $assets->pickerConstraints()->mode); } public function testOptionKindsAndDisabled(): void { @@ -645,12 +651,12 @@ public function testOptionKindsAndDisabled(): void { ->separator() ->option('demo', 'Demo', 'A demo', disabled: TRUE, disabled_reason: 'requires PHP 8.4'); }) - ->build(); + ->root(); - $profile = $form->field('profile'); + $profile = self::fieldOf($form, 'profile'); $this->assertInstanceOf(Field::class, $profile); - $options = $profile->options; + $options = $profile->entries(); $this->assertCount(4, $options); $this->assertSame(OptionKind::Heading, $options[0]->kind); $this->assertSame('Recommended', $options[0]->label); @@ -667,14 +673,14 @@ public function testRepeatedOptionValueOverridesInPlace(): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->select('s')->option('a', 'First')->separator()->option('a', 'Second'); }) - ->build(); + ->root(); - $field = $form->field('s'); + $field = self::fieldOf($form, 's'); $this->assertInstanceOf(Field::class, $field); // The second declaration overrides the first in place; the separator stays. - $this->assertCount(2, $field->options); - $this->assertSame('Second', $field->option('a')?->label); + $this->assertCount(2, $field->entries()); + $this->assertSame('Second', $field->entryOf('a')?->label); $this->assertSame(['a'], $field->selectableValues()); } @@ -685,7 +691,7 @@ public function testToggleInvalidDefaultThrows(mixed $default): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('a')->option('b')->default($default)) - ->build(); + ->root(); } /** @@ -704,11 +710,11 @@ public static function dataProviderToggleInvalidDefaultThrows(): \Iterator { public function testToggleNumericStringOptionsDefaultToFirstValue(): void { $form = Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('flag')->option('0', 'Off')->option('1', 'On')) - ->build(); + ->root(); // The implicit default is the first option's value "0" as a string, not a // numeric-string coerced to int by the array key. - $this->assertSame('0', $form->field('flag')?->default); + $this->assertSame('0', self::fieldOf($form, 'flag')?->value()); } public function testReorderToleratesDirtyDefault(): void { @@ -717,12 +723,12 @@ public function testReorderToleratesDirtyDefault(): void { $p->reorder('rk')->option('a')->option('b')->default('notalist'); $p->reorder('rk2')->option('a')->option('b')->default(['b', 42, 'a']); }) - ->build(); + ->root(); // A non-list default falls back to the full declared order. - $this->assertSame(['a', 'b'], $form->field('rk')?->default); + $this->assertSame(['a', 'b'], self::fieldOf($form, 'rk')?->value()); // Non-string entries are ignored; the remaining values still complete it. - $this->assertSame(['b', 'a'], $form->field('rk2')?->default); + $this->assertSame(['b', 'a'], self::fieldOf($form, 'rk2')?->value()); } public function testModalPanelBuildsWithConfiguredButtons(): void { @@ -734,31 +740,28 @@ public function testModalPanelBuildsWithConfiguredButtons(): void { $m->confirm('sure'); }); }) - ->build(); + ->root(); - $modal = $form->panels[0]->panels[0]; + $modal = $form->children()[0]->children()[0]; $this->assertTrue($modal->isModal()); - $this->assertSame('This cannot be undone.', $modal->description); - - $config = $modal->modal; - $this->assertInstanceOf(Modal::class, $config); - $this->assertSame('Yes', $config->buttons->submitLabel); - $this->assertSame('No', $config->buttons->cancelLabel); - $this->assertTrue($config->buttons->show); + $this->assertSame('This cannot be undone.', $modal->descriptionText()); + $this->assertSame('Yes', $modal->currentButtons()->submitLabel); + $this->assertSame('No', $modal->currentButtons()->cancelLabel); + $this->assertTrue($modal->currentButtons()->show); } public function testModalDefaultsButtonLabels(): void { $form = Form::create('T') ->panel('m', 'M', fn(PanelBuilder $p): PanelBuilder => $p->modal()) - ->build(); + ->root(); - $config = $form->panels[0]->modal; - $this->assertInstanceOf(Modal::class, $config); - $this->assertSame('Submit', $config->buttons->submitLabel); - $this->assertSame('Cancel', $config->buttons->cancelLabel); + $modal = $form->children()[0]; + $this->assertTrue($modal->isModal()); + $this->assertSame('Submit', $modal->currentButtons()->submitLabel); + $this->assertSame('Cancel', $modal->currentButtons()->cancelLabel); } - public function testLayoutFlowsToTheDefinitionAndPanels(): void { + public function testLayoutFlowsToTheTreeAndItsPanels(): void { $form = Form::create('Demo') ->layout(1, 2) ->panel('a', 'A', function (PanelBuilder $p): void { @@ -768,12 +771,12 @@ public function testLayoutFlowsToTheDefinitionAndPanels(): void { }) ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('three', 'Three')) ->panel('c', 'C', fn(PanelBuilder $p): FieldBuilder => $p->text('four', 'Four')) - ->build(); + ->root(); - $this->assertSame([1, 2], $form->layout); - $this->assertSame([2], $form->panels[0]->layout); + $this->assertSame([1, 2], $form->gridRows()); + $this->assertSame([2], $form->children()[0]->gridRows()); // A panel without a declaration keeps the default row list. - $this->assertSame([], $form->panels[1]->layout); + $this->assertSame([], $form->children()[1]->gridRows()); } #[DataProvider('dataProviderBuildThrows')] @@ -795,7 +798,7 @@ public static function dataProviderBuildThrows(): \Iterator { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->template('crate', 'Crate')) - ->build(); + ->root(); }, 'Field "crate" is a template field but declares no pattern', ]; @@ -804,7 +807,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('r')->step(2)) - ->build(); + ->root(); }, 'Field "r" declares a step of 2 on a scale whose points are its steps', ]; @@ -813,7 +816,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->calendar('d')->minDate('2026-13-01')) - ->build(); + ->root(); }, 'Field "d" declares an invalid date "2026-13-01".', ]; @@ -824,7 +827,7 @@ static function (): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->calendar('d')->minDate('2026-12-31')->maxDate('2026-01-01'); }) - ->build(); + ->root(); }, 'Field "d" declares min date 2026-12-31 after max date 2026-01-01.', ]; @@ -833,7 +836,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->min(10)->max(1)) - ->build(); + ->root(); }, 'Field "n" declares min 10 greater than max 1.', ]; @@ -842,7 +845,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->step(0)) - ->build(); + ->root(); }, 'Field "n" declares a non-positive step 0.', ]; @@ -851,7 +854,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->filePicker('f')->maxSize(0)) - ->build(); + ->root(); }, 'Field "f" declares a maximum file size of 0 below one byte.', ]; @@ -860,7 +863,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->search('n')->pageSize(0)) - ->build(); + ->root(); }, 'Field "n" declares a non-positive page size 0.', ]; @@ -869,7 +872,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->multiple()) - ->build(); + ->root(); }, 'Field "t" of type "text" does not collect several values', ]; @@ -880,7 +883,7 @@ static function (): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->select('s')->minSelections(2)->option('a'); }) - ->build(); + ->root(); }, 'Field "s" declares selection limits but is not multiple', ]; @@ -891,7 +894,7 @@ static function (): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->select('s')->multiple()->minSelections(5)->maxSelections(2); }) - ->build(); + ->root(); }, 'Field "s" declares min 5 selections greater than max 2.', ]; @@ -902,7 +905,7 @@ static function (): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->select('s')->multiple()->minSelections(0); }) - ->build(); + ->root(); }, 'Selection bounds declare a minimum of 0 below one.', ]; @@ -912,7 +915,7 @@ static function (): void { Form::create('T') ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) - ->build(); + ->root(); }, 'Duplicate field id "x".', ]; @@ -921,7 +924,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('only')) - ->build(); + ->root(); }, 'Toggle field "t" must have exactly two options, 1 given.', ]; @@ -930,7 +933,7 @@ static function (): void { static function (): void { Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->reorder('r')->option('only')) - ->build(); + ->root(); }, 'Reorder field "r" must have at least two options, 1 given.', ]; @@ -941,7 +944,7 @@ static function (): void { ->panel('p', 'P', function (PanelBuilder $p): void { $p->reorder('r')->option('a')->separator()->option('b'); }) - ->build(); + ->root(); }, 'Reorder field "r" allows only plain options - no headings, separators or disabled rows.', ]; @@ -953,7 +956,7 @@ static function (): void { $m->modal(); $m->panel('nested', 'Nested', fn(PanelBuilder $n): FieldBuilder => $n->text('x')); }) - ->build(); + ->root(); }, 'Modal panel "confirm" cannot contain sub-panels.', ]; @@ -964,7 +967,7 @@ static function (): void { ->layout(1) ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('one', 'One')) ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'Two')) - ->build(); + ->root(); }, 'The layout of "Demo" declares 1 slot(s) for 2 panel(s).', ]; @@ -975,7 +978,7 @@ static function (): void { ->layout(2, 2) ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('one', 'One')) ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'Two')) - ->build(); + ->root(); }, 'The layout of "Demo" declares 4 slot(s) for 2 panel(s).', ]; @@ -987,7 +990,7 @@ static function (): void { $p->layout(2); $p->panel('a1', 'A1', fn(PanelBuilder $sp): FieldBuilder => $sp->text('one', 'One')); }) - ->build(); + ->root(); }, 'The layout of "a" declares 2 slot(s) for 1 panel(s).', ]; @@ -998,10 +1001,54 @@ static function (): void { ->layout(0, 2) ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('one', 'One')) ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'Two')) - ->build(); + ->root(); }, 'Every layout row of "Demo" must hold at least one panel.', ]; } + /** + * The field of a given id, anywhere in the declared tree. + * + * @param \DrevOps\Tui\Block\Panel $root + * The panel every declared panel hangs from. + * @param string $id + * The field id. + * + * @return \DrevOps\Tui\Block\Field|null + * The field, or NULL when the tree holds none of that id. + */ + protected static function fieldOf(Panel $root, string $id): ?Field { + foreach (Tree::fields($root) as $field) { + if ($field->id() === $id) { + return $field; + } + } + + return NULL; + } + + /** + * The markup block of a given id, anywhere in the declared tree. + * + * @param \DrevOps\Tui\Block\Panel $root + * The panel every declared panel hangs from. + * @param string $id + * The block id. + * + * @return \DrevOps\Tui\Block\Markup|null + * The block, or NULL when the tree holds none of that id. + */ + protected static function markupOf(Panel $root, string $id): ?Markup { + foreach (Tree::panels($root) as $panel) { + foreach ($panel->blocks() as $block) { + if ($block instanceof Markup && $block->id() === $id) { + return $block; + } + } + } + + return NULL; + } + } diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php index de943907..22b87b73 100644 --- a/tests/phpunit/Unit/DynamicOptionsTest.php +++ b/tests/phpunit/Unit/DynamicOptionsTest.php @@ -8,16 +8,16 @@ use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Derive\Derive; -use DrevOps\Tui\Engine\Engine; -use DrevOps\Tui\Engine\EngineException; +use DrevOps\Tui\Screen\Collector; +use DrevOps\Tui\CollectException; use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Model\Field; +use DrevOps\Tui\Block\Field; use DrevOps\Tui\Model\FieldType; use DrevOps\Tui\Model\FormException; use DrevOps\Tui\Model\Option; -use DrevOps\Tui\Render\PanelController; +use DrevOps\Tui\Screen\ScreenController; use DrevOps\Tui\Schema\AgentHelp; use DrevOps\Tui\Schema\OptionsResolver; use DrevOps\Tui\Schema\SchemaGenerator; @@ -36,9 +36,9 @@ #[CoversClass(Field::class)] #[CoversClass(FieldType::class)] #[CoversClass(Option::class)] -#[CoversClass(Engine::class)] +#[CoversClass(Collector::class)] #[CoversClass(OptionsResolver::class)] -#[CoversClass(PanelController::class)] +#[CoversClass(ScreenController::class)] #[CoversClass(SchemaGenerator::class)] #[CoversClass(SchemaValidator::class)] #[CoversClass(AgentHelp::class)] @@ -72,7 +72,7 @@ public function testResolvesTheOptionsOfTheAnsweredCategory(): void { public function testRejectsValueTheResolvedSetDoesNotHold(): void { // Apple is a real option - just not one this category resolves to. - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/not one of: carrot, potato, tomato/'); (new Tui($this->form()))->collect('{"category":"vegetable","item":"apple"}'); @@ -80,7 +80,7 @@ public function testRejectsValueTheResolvedSetDoesNotHold(): void { public function testRejectsValueWhenTheSetResolvesToNothing(): void { // With no list to offer, naming the value is all that can honestly be said. - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/"apple" was not found/'); (new Tui($this->form(static fn(): array => [])))->collect('{"category":"fruit","item":"apple"}'); @@ -178,18 +178,18 @@ public function testSuggestKeepsValueTheResolvedHintsDoNotHold(): void { public function testResolverReturningSomethingElseDegradesToNoOptions(): void { $form = $this->form(static fn(): mixed => 'not a map'); - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/"apple" was not found/'); (new Tui($form))->collect('{"category":"fruit","item":"apple"}'); } - public function testThrowingResolverBecomesAnEngineError(): void { + public function testThrowingResolverFailsTheCollection(): void { $form = $this->form(static function (): array { throw new \RuntimeException('The pantry is unreachable.'); }); - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/Could not load options for field "item": The pantry is unreachable\./'); (new Tui($form))->collect('{"category":"fruit"}'); @@ -325,7 +325,7 @@ public function testAgentHelpEnumeratesTheResolvedValues(): void { } public function testReconcilingLeavesNonChoiceValueAlone(): void { - $field = new Field('name', 'Order name', '', FieldType::Text, 'Pear'); + $field = (new Field('name', 'Order name', FieldType::Text))->default('Pear'); $this->assertSame('Pear', $field->reconcileValue('Pear')); } @@ -335,7 +335,7 @@ public function testRejectedDeclarationFailsWhenTheFormIsBuilt(\Closure $declare $this->expectException(FormException::class); $this->expectExceptionMessageMatches($message); - Form::create('Order')->panel('order', 'New order', $declare)->build(); + Form::create('Order')->panel('order', 'New order', $declare)->root(); } /** diff --git a/tests/phpunit/Unit/Engine/EngineAnswersTest.php b/tests/phpunit/Unit/Engine/EngineAnswersTest.php deleted file mode 100644 index 31e339e0..00000000 --- a/tests/phpunit/Unit/Engine/EngineAnswersTest.php +++ /dev/null @@ -1,61 +0,0 @@ -panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name')->default(''); - $p->text('machine')->default('')->derive(new Derive('{{name}}', 'machine')); - $p->text('gone')->default('x')->when(new Condition('name', eq: 'never')); - }) - ->build(); - $engine = new Engine($form, new HandlerRegistry()); - - $answers = $engine->collect(['name' => 'Acme Site'], new Context()); - - $this->assertSame('Acme Site', $answers->value('name')); - $this->assertSame('acme_site', $answers->value('machine')); - $this->assertSame(Provenance::Edited, $answers->provenanceOf('name')); - $this->assertSame(Provenance::Derived, $answers->provenanceOf('machine')); - $this->assertFalse($answers->has('gone')); - } - - public function testEmittedSetValidatesAgainstSchema(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name')->required()->default('Acme'); - $p->select('profile')->default('standard')->option('standard')->option('minimal'); - }) - ->build(); - $engine = new Engine($form, new HandlerRegistry()); - - $answers = $engine->collect([], new Context()); - - $errors = (new SchemaValidator($form))->validate($answers->values); - $this->assertSame([], $errors); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineConditionalTest.php b/tests/phpunit/Unit/Engine/EngineConditionalTest.php deleted file mode 100644 index ec949703..00000000 --- a/tests/phpunit/Unit/Engine/EngineConditionalTest.php +++ /dev/null @@ -1,192 +0,0 @@ -engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('theme')->default('olivero'); - $p->text('custom_theme')->default('mytheme')->when(new Condition('theme', eq: 'custom')); - }) - ->build() - ); - - $answers = $engine->collect([], new Context()); - $this->assertTrue($answers->has('theme')); - $this->assertFalse($answers->has('custom_theme')); - - $answers = $engine->collect(['theme' => 'custom'], new Context()); - $this->assertTrue($answers->has('custom_theme')); - $this->assertSame('mytheme', $answers->value('custom_theme')); - } - - public function testForceFixupAutoResolves(): void { - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('provision')->default('database'); - $p->text('database_source')->default('url'); - }) - ->fixup(new Fixup(set: 'database_source', to: 'none', when: new Condition('provision', eq: 'profile'))) - ->build() - ); - - $this->assertSame('url', $engine->collect([], new Context())->value('database_source')); - // No input for database_source: the fix-up resolves it without prompting. - $this->assertSame('none', $engine->collect(['provision' => 'profile'], new Context())->value('database_source')); - } - - public function testFixupTargetingNoteDoesNotReintroduceIt(): void { - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('provision')->default('database'); - $p->note('intro', 'Intro')->description('Welcome.'); - }) - // A fix-up that mistakenly targets a note is ignored, so the note never - // gains a value in the settled state. - ->fixup(new Fixup(set: 'intro', to: 'forced')) - ->build() - ); - - [$values] = $engine->resolveState([], new Context()); - - $this->assertArrayNotHasKey('intro', $values); - $this->assertArrayHasKey('provision', $values); - } - - public function testFixupReadingFromNoteKeepsTargetSettled(): void { - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('target')->default('kept'); - $p->note('intro', 'Intro')->description('Welcome.'); - }) - // Copying from a note would read its absent value and write NULL over - // the target; the rule is ignored, so the target keeps its value. - ->fixup(new Fixup(set: 'target', from: 'intro')) - ->build() - ); - - $this->assertSame('kept', $engine->collect([], new Context())->value('target')); - } - - public function testMultiFieldConditional(): void { - // A when can depend on any number of fields via all / any / not. - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('a')->default('x'); - $p->text('b')->default('y'); - $p->text('c')->default('z')->when(Condition::all(new Condition('a', eq: 'x'), new Condition('b', eq: 'y'))); - }) - ->build() - ); - - // Both conditions hold: c is active. - $this->assertTrue($engine->collect([], new Context())->has('c')); - - // One condition fails: c is gated out. - $this->assertFalse($engine->collect(['b' => 'other'], new Context())->has('c')); - } - - public function testMergeCustomFixup(): void { - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('profile')->default('standard'); - $p->text('profile_custom')->default(''); - }) - ->fixup(new Fixup(set: 'profile', from: 'profile_custom', when: new Condition('profile', eq: 'custom'))) - ->build() - ); - - $answers = $engine->collect(['profile' => 'custom', 'profile_custom' => 'my_profile'], new Context()); - $this->assertSame('my_profile', $answers->value('profile')); - } - - public function testCascadingDeactivation(): void { - $engine = $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('a')->default('x'); - $p->text('b')->default('y')->when(new Condition('a', eq: 'x')); - $p->text('c')->default('z')->when(new Condition('b', eq: 'y')); - }) - ->build() - ); - - $this->assertSame(['a' => 'x', 'b' => 'y', 'c' => 'z'], $engine->collect([], new Context())->values); - $this->assertSame(['a' => 'off'], $engine->collect(['a' => 'off'], new Context())->values); - } - - public function testResolveStateKeepsInactiveFields(): void { - $engine = $this->gatedEngine(); - - [$values, $provenance, $active] = $engine->resolveState([], new Context()); - - // The inactive field keeps its settled value and provenance; only the - // active map records that it is gated out. - $this->assertSame(['theme' => 'olivero', 'custom_theme' => 'mytheme'], $values); - $this->assertSame(['theme' => TRUE, 'custom_theme' => FALSE], $active); - $this->assertSame(Provenance::Default, $provenance['custom_theme']); - } - - public function testSettleReappliesFormLogic(): void { - $engine = $this->gatedEngine(); - - [$active] = $engine->settle(['theme' => 'custom', 'custom_theme' => 'mytheme'], [], new Context()); - $this->assertSame(['theme' => TRUE, 'custom_theme' => TRUE], $active); - - [$active] = $engine->settle(['theme' => 'olivero', 'custom_theme' => 'mytheme'], [], new Context()); - $this->assertFalse($active['custom_theme']); - } - - /** - * Build an engine over a form whose second field is gated by the first. - */ - protected function gatedEngine(): Engine { - return $this->engine( - Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('theme')->default('olivero'); - $p->text('custom_theme')->default('mytheme')->when(new Condition('theme', eq: 'custom')); - }) - ->build() - ); - } - - /** - * Build an engine over the given form definition with no handlers. - * - * @param \DrevOps\Tui\Model\FormDefinition $form - * The form definition. - */ - protected function engine(FormDefinition $form): Engine { - return new Engine($form, new HandlerRegistry()); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php deleted file mode 100644 index 353aaef7..00000000 --- a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php +++ /dev/null @@ -1,239 +0,0 @@ -engine(function (PanelBuilder $p): void { - $p->text('name')->default(fn (Context $c): string => 'from-' . basename($c->directory)); - }); - - $answers = $engine->collect([], new Context('some/project')); - - $this->assertSame('from-project', $answers->value('name')); - $this->assertSame(Provenance::Default, $answers->provenanceOf('name')); - } - - public function testDeclaredDefaultOverriddenByInput(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name')->default(fn (Context $c): string => 'dynamic'); - }); - - $this->assertSame(['name' => 'given'], $engine->collect(['name' => 'given'], new Context())->values); - } - - public function testDeclaredValidateRejects(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name')->validate(fn (mixed $v): ?string => $v === 'ok' ? NULL : 'Must be "ok".'); - }); - - $this->assertSame(['name' => 'ok'], $engine->collect(['name' => 'ok'], new Context())->values); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "name": Must be "ok".'); - $engine->collect(['name' => 'nope'], new Context()); - } - - /** - * A required field rejects every empty supplied input, whatever its shape. - * - * @param string $id - * The id of the field the value is supplied for. - * @param mixed $value - * The supplied input. - * @param string $expected - * The expected exception message. - */ - #[DataProvider('dataProviderRequiredRejectsEmpty')] - public function testRequiredRejectsEmpty(string $id, mixed $value, string $expected): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required(); - $p->select('crates', 'Crates')->multiple()->required()->option('a')->option('b'); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage($expected); - $engine->collect([$id => $value], new Context()); - } - - /** - * Data provider for testRequiredRejectsEmpty(). - * - * @return \Iterator - * The field id, the empty value supplied for it and the expected message. - */ - public static function dataProviderRequiredRejectsEmpty(): \Iterator { - yield 'empty string' => ['name', '', 'Invalid value for field "name": Produce name is required.']; - yield 'null' => ['name', NULL, 'Invalid value for field "name": Produce name is required.']; - yield 'empty list' => ['crates', [], 'Invalid value for field "crates": Crates is required.']; - } - - public function testRequiredAcceptsValue(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required(); - }); - - $this->assertSame(['name' => 'Pear'], $engine->collect(['name' => 'Pear'], new Context())->values); - } - - public function testRequiredMessageOverridesTheDerivedOne(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('plot', 'Garden plot name')->required(message: 'The garden plot name is required.'); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "plot": The garden plot name is required.'); - $engine->collect(['plot' => ''], new Context()); - } - - public function testRequiredRunsBeforeTheDeclaredValidator(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required()->validate(fn (mixed $v): ?string => $v === 'Pear' ? NULL : 'Only pears keep.'); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "name": Produce name is required.'); - $engine->collect(['name' => ''], new Context()); - } - - public function testRequiredLeavesAnUnsuppliedFieldAlone(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required(); - }); - - // The guard weighs supplied inputs only, so an empty default is - // collected as it stands - reporting it is the schema validator's job. - $this->assertSame(['name' => ''], $engine->collect([], new Context())->values); - } - - public function testRequiredIgnoresAnInactiveField(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('mode', 'Mode'); - $p->text('plot', 'Garden plot name')->required()->when(new Condition('mode', eq: 'custom')); - }); - - $this->assertSame(['mode' => 'standard'], $engine->collect(['mode' => 'standard', 'plot' => ''], new Context())->values); - } - - public function testNumberBoundsRejectOutOfRange(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->number('port')->min(1)->max(10); - }); - - $this->assertSame(['port' => 5], $engine->collect(['port' => 5], new Context())->values); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "port": must be between 1 and 10.'); - $engine->collect(['port' => 50], new Context()); - } - - public function testNumberBoundsRejectOutOfRangeFloat(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->number('port')->min(1)->max(10); - }); - - // A float outside the range is rejected too - bounds are not integer-gated. - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "port": must be between 1 and 10.'); - $engine->collect(['port' => 50.5], new Context()); - } - - public function testDateBoundsRejectOutOfRange(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->calendar('due')->minDate('2026-01-01')->maxDate('2026-12-31'); - }); - - $this->assertSame(['due' => '2026-06-15'], $engine->collect(['due' => '2026-06-15'], new Context())->values); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "due": must be between 2026-01-01 and 2026-12-31.'); - $engine->collect(['due' => '2027-01-01'], new Context()); - } - - public function testDeclaredTransformApplies(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name')->transform(fn (mixed $v): mixed => is_string($v) ? trim($v) : $v); - }); - - $this->assertSame(['name' => 'Acme'], $engine->collect(['name' => ' Acme '], new Context())->values); - } - - public function testTransformedInputDrivesConditions(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('mode')->transform(fn (mixed $v): mixed => is_string($v) ? trim($v) : $v); - $p->text('extra')->default('on')->when(new Condition('mode', eq: 'custom')); - }); - - $answers = $engine->collect(['mode' => ' custom '], new Context()); - - // Inputs normalize before stabilization, so the condition matches the - // trimmed value and activates the dependent field. - $this->assertSame(['mode' => 'custom', 'extra' => 'on'], $answers->values); - } - - public function testDeclaredDiscoverClosure(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name')->discover(fn (Context $c): string => 'seen-' . basename($c->directory)); - }); - - $answers = $engine->collect([], new Context('some/project', [], TRUE)); - - $this->assertSame('seen-project', $answers->value('name')); - $this->assertSame(Provenance::Detected, $answers->provenanceOf('name')); - } - - public function testDeclarationWinsOverHandler(): void { - // The "spy" field resolves to the Spy fixture class, but the declared - // closures take precedence over its reusable statics. - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('spy') - ->validate(fn (mixed $v): ?string => NULL) - ->transform(fn (mixed $v): mixed => is_string($v) ? $v . '?' : $v); - }); - - $answers = $engine->collect(['spy' => 'declared'], new Context('project')); - - $this->assertSame('declared?', $answers->value('spy')); - $this->assertSame([], Spy::$calls); - } - - /** - * Build an engine over a single panel wired to the fixture handlers. - * - * @param \Closure $build - * The callback receiving the panel builder to declare its fields. - */ - protected function engine(\Closure $build): Engine { - $form = Form::create('T')->panel('p', 'p', $build)->build(); - - return new Engine($form, new HandlerRegistry(['DrevOps\\Tui\\Tests\\Fixtures\\Handler'])); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineDeriveTest.php b/tests/phpunit/Unit/Engine/EngineDeriveTest.php deleted file mode 100644 index 55955527..00000000 --- a/tests/phpunit/Unit/Engine/EngineDeriveTest.php +++ /dev/null @@ -1,75 +0,0 @@ -engine(); - - $answers = $engine->collect(['name' => 'Acme Site'], new Context()); - - $this->assertSame('acme_site', $answers->value('machine')); - $this->assertSame('acme-site.com', $answers->value('domain')); - $this->assertSame(Provenance::Derived, $answers->provenanceOf('machine')); - $this->assertSame(Provenance::Edited, $answers->provenanceOf('name')); - } - - public function testOverrideHoldsWhileFollowersUpdate(): void { - $engine = $this->engine(); - - // Machine is pinned; the domain still follows the pinned machine, not name. - $answers = $engine->collect(['name' => 'Acme Site', 'machine' => 'custom'], new Context()); - - $this->assertSame('custom', $answers->value('machine')); - $this->assertSame('custom.com', $answers->value('domain')); - $this->assertSame(Provenance::Override, $answers->provenanceOf('machine')); - $this->assertSame(Provenance::Derived, $answers->provenanceOf('domain')); - } - - public function testResetRelinks(): void { - $engine = $this->engine(); - - // Pinned on the first run. - $engine->collect(['name' => 'Acme', 'machine' => 'pinned'], new Context()); - // Re-running without the machine input relinks (reset) and re-derives. - $answers = $engine->collect(['name' => 'Acme'], new Context()); - - $this->assertSame('acme', $answers->value('machine')); - $this->assertSame(Provenance::Derived, $answers->provenanceOf('machine')); - } - - /** - * Build an engine with a name -> machine -> domain derivation chain. - */ - protected function engine(): Engine { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name')->default(''); - $p->text('machine')->default('')->derive(new Derive('{{name}}', 'machine')); - $p->text('domain')->default('')->derive(new Derive('{{machine}}.com', 'host')); - }) - ->build(); - - return new Engine($form, new HandlerRegistry()); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineDiscoveryTest.php b/tests/phpunit/Unit/Engine/EngineDiscoveryTest.php deleted file mode 100644 index 48f11a50..00000000 --- a/tests/phpunit/Unit/Engine/EngineDiscoveryTest.php +++ /dev/null @@ -1,100 +0,0 @@ - "DRUPAL_PROFILE=minimal\nPROFILE=from_env\n", - 'composer.json' => '{"name": "acme/site"}', - ]); - $this->dir = vfsStream::url('project'); - } - - public function testDetectsInUpdateMode(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('profile')->default('standard')->discover(new Dotenv('DRUPAL_PROFILE')); - $p->text('name')->default('')->discover(new JsonValue('composer.json', 'name')); - }); - - $answers = $engine->collect([], new Context($this->dir, [], TRUE)); - - $this->assertSame('minimal', $answers->value('profile')); - $this->assertSame('acme/site', $answers->value('name')); - $this->assertSame(Provenance::Detected, $answers->provenanceOf('profile')); - $this->assertSame(Provenance::Detected, $answers->provenanceOf('name')); - } - - public function testFreshInstallDiscoversNothing(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('profile')->default('standard')->discover(new Dotenv('DRUPAL_PROFILE')); - }); - - $answers = $engine->collect([], new Context($this->dir, [], FALSE)); - - $this->assertSame('standard', $answers->value('profile')); - $this->assertSame(Provenance::Default, $answers->provenanceOf('profile')); - } - - public function testInputWinsOverDetected(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('profile')->default('standard')->discover(new Dotenv('DRUPAL_PROFILE')); - }); - - $answers = $engine->collect(['profile' => 'demo'], new Context($this->dir, [], TRUE)); - - $this->assertSame('demo', $answers->value('profile')); - $this->assertSame(Provenance::Edited, $answers->provenanceOf('profile')); - } - - public function testDetectedWinsOverDerived(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('src')->default('seed'); - $p->text('profile')->default('')->derive(new Derive('{{src}}'))->discover(new Dotenv('PROFILE')); - }); - - $answers = $engine->collect([], new Context($this->dir, [], TRUE)); - - $this->assertSame('from_env', $answers->value('profile')); - $this->assertSame(Provenance::Detected, $answers->provenanceOf('profile')); - } - - /** - * Build an engine over a single panel with no handlers. - * - * @param \Closure $build - * The callback receiving the panel builder to declare its fields. - */ - protected function engine(\Closure $build): Engine { - return new Engine(Form::create('T')->panel('p', 'p', $build)->build(), new HandlerRegistry()); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineNonInteractiveTest.php b/tests/phpunit/Unit/Engine/EngineNonInteractiveTest.php deleted file mode 100644 index 5d483fc4..00000000 --- a/tests/phpunit/Unit/Engine/EngineNonInteractiveTest.php +++ /dev/null @@ -1,192 +0,0 @@ - "DETECTED=from_env\n"]); - $dir = vfsStream::url('proj'); - - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('src')->default('seed'); - $p->text('target')->default('static')->derive(new Derive('d-{{src}}'))->discover(new Dotenv('DETECTED')); - }) - ->build(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - // Static default is overtaken by the derived value (fresh install). - $inputs = $resolver->resolve($form->fields(), '', []); - $this->assertSame('d-seed', $engine->collect($inputs, new Context($dir, [], FALSE))->value('target')); - - // Detected (update mode) wins over derived. - $inputs = $resolver->resolve($form->fields(), '', []); - $this->assertSame('from_env', $engine->collect($inputs, new Context($dir, [], TRUE))->value('target')); - - // Env wins over detected. - $inputs = $resolver->resolve($form->fields(), '', ['APP_TARGET' => 'from_env_var']); - $this->assertSame('from_env_var', $engine->collect($inputs, new Context($dir, [], TRUE))->value('target')); - - // --prompts wins over env. - $inputs = $resolver->resolve($form->fields(), '{"target": "from_prompts"}', ['APP_TARGET' => 'from_env_var']); - $this->assertSame('from_prompts', $engine->collect($inputs, new Context($dir, [], TRUE))->value('target')); - } - - public function testReorderHeadlessParity(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->reorder('ranking')->options(['a' => 'A', 'b' => 'B', 'c' => 'C'])->default(['b']); - }) - ->build(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - // No input: the declared default, completed to a full ranking. - $inputs = $resolver->resolve($form->fields(), '', []); - $this->assertSame(['b', 'a', 'c'], $engine->collect($inputs, new Context('', [], FALSE))->value('ranking')); - - // An env comma list is coerced to an ordered ranking. - $inputs = $resolver->resolve($form->fields(), '', ['APP_RANKING' => 'c, a, b']); - $this->assertSame(['c', 'a', 'b'], $engine->collect($inputs, new Context('', [], FALSE))->value('ranking')); - - // A --prompts JSON array is taken as the ranking directly. - $inputs = $resolver->resolve($form->fields(), '{"ranking": ["c", "b", "a"]}', []); - $this->assertSame(['c', 'b', 'a'], $engine->collect($inputs, new Context('', [], FALSE))->value('ranking')); - } - - public function testNoteCollectsNoAnswer(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - // The validator returns an error and the transformer throws if a - // note is ever guarded or transformed; neither runs for a note. - $p->note('intro', 'Intro')->description('Welcome.') - ->validate(static fn(mixed $value): string => 'notes are never validated') - ->transform(static function (mixed $value): mixed { - throw new \RuntimeException('notes are never transformed'); - }); - $p->text('name')->default('pear'); - // A note may be gated like any field, but still carries no answer. - $p->note('gated', 'Gated')->when(new Condition('name', eq: 'pear')); - // A table is presentational too, so a note carrying one collects - // nothing. - $p->note('summary', 'Summary')->table(['Fruit', 'Qty'], [['Apple', '3']]); - }) - ->build(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - // Stray supplied values for the notes - even a malformed one - are ignored. - $inputs = $resolver->resolve($form->fields(), '{"intro": ["not", "a", "string"]}', ['APP_GATED' => 'ignored', 'APP_SUMMARY' => 'ignored']); - $answers = $engine->collect($inputs, new Context('', [], FALSE)); - - // No note contributes a value, provenance or self-describing item - not - // even the table-bearing one handed a supplied value. - $this->assertArrayNotHasKey('intro', $answers->values); - $this->assertArrayNotHasKey('gated', $answers->values); - $this->assertArrayNotHasKey('summary', $answers->values); - $this->assertArrayNotHasKey('intro', $answers->provenance); - $this->assertArrayNotHasKey('summary', $answers->provenance); - $this->assertNotInstanceOf(Answer::class, $answers->item('intro')); - $this->assertNotInstanceOf(Answer::class, $answers->item('summary')); - $this->assertFalse($answers->has('gated')); - $this->assertFalse($answers->has('summary')); - - // The real field between the notes still collects normally. - $this->assertSame('pear', $answers->value('name')); - } - - public function testReorderRejectsIncompletePermutation(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->reorder('ranking')->option('a')->option('b')->option('c'); - }) - ->build(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - $inputs = $resolver->resolve($form->fields(), '', ['APP_RANKING' => 'a, b']); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "ranking": must rank every option exactly once (a, b, c)'); - - $engine->collect($inputs, new Context('', [], FALSE)); - } - - public function testMultipleSelectionWithinBoundsAccepted(): void { - $form = $this->boundedTagsForm(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - $inputs = $resolver->resolve($form->fields(), '{"tags": ["a", "b"]}', []); - - $this->assertSame(['a', 'b'], $engine->collect($inputs, new Context('', [], FALSE))->value('tags')); - } - - public function testMultipleSelectionBelowMinRejected(): void { - $form = $this->boundedTagsForm(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - $inputs = $resolver->resolve($form->fields(), '{"tags": ["a"]}', []); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "tags": must be between 2 and 3 items.'); - - $engine->collect($inputs, new Context('', [], FALSE)); - } - - public function testMultipleSelectionAboveMaxRejected(): void { - $form = $this->boundedTagsForm(); - $resolver = new InputResolver('APP_'); - $engine = new Engine($form, new HandlerRegistry()); - - $inputs = $resolver->resolve($form->fields(), '{"tags": ["a", "b", "c", "d"]}', []); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "tags": must be between 2 and 3 items.'); - - $engine->collect($inputs, new Context('', [], FALSE)); - } - - /** - * A form with a single count-bounded multiple select "tags". - * - * @return \DrevOps\Tui\Model\FormDefinition - * The form. - */ - protected function boundedTagsForm(): FormDefinition { - return Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->select('tags')->multiple()->minSelections(2)->maxSelections(3)->option('a')->option('b')->option('c')->option('d'); - }) - ->build(); - } - -} diff --git a/tests/phpunit/Unit/Engine/EngineTest.php b/tests/phpunit/Unit/Engine/EngineTest.php deleted file mode 100644 index 00252257..00000000 --- a/tests/phpunit/Unit/Engine/EngineTest.php +++ /dev/null @@ -1,284 +0,0 @@ -engine(function (PanelBuilder $p): void { - $p->text('spy')->default('seed'); - $p->text('plain'); - }); - - $answers = $engine->collect(['spy' => 'given'], new Context('project')); - - // The supplied input flows through the discovered static transform. - $this->assertSame('given!', $answers->value('spy')); - // A field with no input keeps its default untouched by the guards. - $this->assertSame('', $answers->value('plain')); - // Lifecycle order per field: normalize first, then validate. - $this->assertSame(['transform', 'validate'], Spy::$calls); - } - - public function testInvalidValueThrows(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('machine_name'); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "machine_name"'); - // The MachineName fixture rejects an empty supplied input. - $engine->collect(['machine_name' => ''], new Context('project')); - } - - public function testDiscoveredTransformNormalizes(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('machine_name'); - }); - - $answers = $engine->collect(['machine_name' => 'ACME'], new Context('project')); - - $this->assertSame('acme', $answers->value('machine_name')); - } - - #[DataProvider('dataProviderCollectRejectsNonSelectableOption')] - public function testCollectRejectsNonSelectableOption(\Closure $build, mixed $value, string $message): void { - $engine = $this->engine($build); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage($message); - $engine->collect(['choice' => $value], new Context('project')); - } - - public static function dataProviderCollectRejectsNonSelectableOption(): \Iterator { - yield 'select disabled' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->select('choice')), 'demo', 'Invalid value for field "choice": option "demo" is disabled: unavailable']; - yield 'select unknown' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->select('choice')), 'bogus', 'Invalid value for field "choice": value "bogus" is not one of: standard, minimal']; - yield 'search disabled' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->search('choice')), 'demo', 'Invalid value for field "choice": option "demo" is disabled: unavailable']; - yield 'search unknown' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->search('choice')), 'bogus', 'Invalid value for field "choice": value "bogus" is not one of: standard, minimal']; - yield 'multiselect disabled' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->select('choice')->multiple()), ['demo'], 'Invalid value for field "choice": option "demo" is disabled: unavailable']; - yield 'multiselect unknown' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->select('choice')->multiple()), ['bogus'], 'Invalid value for field "choice": value "bogus" is not one of: standard, minimal']; - yield 'multisearch disabled' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->search('choice')->multiple()), ['demo'], 'Invalid value for field "choice": option "demo" is disabled: unavailable']; - yield 'multisearch unknown' => [static fn(PanelBuilder $p): FieldBuilder => self::choiceOptions($p->search('choice')->multiple()), ['bogus'], 'Invalid value for field "choice": value "bogus" is not one of: standard, minimal']; - } - - #[DataProvider('dataProviderCollectRejectsTemplateAnswer')] - public function testCollectRejectsTemplateAnswer(string $value, string $message): void { - $engine = $this->engine(static fn(PanelBuilder $p): FieldBuilder => self::crate($p)); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage($message); - $engine->collect(['crate' => $value], new Context('project')); - } - - public static function dataProviderCollectRejectsTemplateAnswer(): \Iterator { - yield 'shape mismatch' => ['nope', 'Invalid value for field "crate": "nope" does not match the template "{{orchard}}-{{grade}}".']; - yield 'slot rejected' => ['valley-z', 'Invalid value for field "crate": Grade: use a single letter a-c']; - } - - public function testCollectAcceptsAndSplitsTemplateAnswer(): void { - $engine = $this->engine(static fn(PanelBuilder $p): FieldBuilder => self::crate($p)); - - $answers = $engine->collect(['crate' => 'valley-a'], new Context('project')); - - // The answer stays the assembled string; the parts come back off it. - $this->assertSame('valley-a', $answers->value('crate')); - $this->assertSame(['orchard' => 'valley', 'grade' => 'a'], $answers->parts('crate')); - } - - public function testCollectAcceptsAnEmptyTemplateAnswer(): void { - $engine = $this->engine(static fn(PanelBuilder $p): FieldBuilder => self::crate($p)); - - // An empty answer is an unfilled template, left to the required check - // rather than rejected as a shape mismatch. - $answers = $engine->collect(['crate' => ''], new Context('project')); - - $this->assertSame('', $answers->value('crate')); - $this->assertSame([], $answers->parts('crate')); - } - - public function testCollectAcceptsSelectableOptions(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->select('profile')->option('standard')->option('minimal'); - $p->select('mods')->multiple()->option('a')->option('b'); - $p->search('engine')->option('solr')->option('none'); - $p->search('tags')->multiple()->option('x')->option('y'); - }); - - $answers = $engine->collect(['profile' => 'standard', 'mods' => ['a', 'b'], 'engine' => 'solr', 'tags' => ['x']], new Context('project')); - - $this->assertSame('standard', $answers->value('profile')); - $this->assertSame(['a', 'b'], $answers->value('mods')); - $this->assertSame('solr', $answers->value('engine')); - $this->assertSame(['x'], $answers->value('tags')); - } - - public function testCollectRejectsNonArrayMultiValue(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->select('mods')->multiple()->option('a')->option('b'); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "mods": must be a list'); - $engine->collect(['mods' => 'notalist'], new Context('project')); - } - - public function testCollectAcceptsRatingPoint(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->rating('taste')->min(1)->max(5); - $p->rating('untouched')->min(1)->max(5); - }); - - $answers = $engine->collect(['taste' => 4], new Context('project')); - - $this->assertSame(4, $answers->value('taste')); - // With nothing supplied a rating settles on the lowest point of its scale. - $this->assertSame(1, $answers->value('untouched')); - } - - #[DataProvider('dataProviderCollectRejectsRatingValue')] - public function testCollectRejectsRatingValue(mixed $value, string $message): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->rating('taste')->min(1)->max(5); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage($message); - $engine->collect(['taste' => $value], new Context('project')); - } - - /** - * Data provider for testCollectRejectsRatingValue(). - * - * @return \Iterator - * A value no scale point could be, and the reported reason. - */ - public static function dataProviderCollectRejectsRatingValue(): \Iterator { - yield 'above the scale' => [9, 'Invalid value for field "taste": must be between 1 and 5.']; - yield 'below the scale' => [0, 'Invalid value for field "taste": must be between 1 and 5.']; - yield 'not a number' => ['great', 'Invalid value for field "taste": must be a whole number.']; - // A scale has no point between its points, so a fraction names none of them - // even when it falls inside the range. - yield 'between two points' => [3.5, 'Invalid value for field "taste": must be a whole number.']; - } - - public function testCollectRejectsFilePickerConstraintViolation(): void { - vfsStream::setup('root', NULL, ['big.yml' => str_repeat('a', 500)]); - $root = vfsStream::url('root'); - $engine = $this->engine(function (PanelBuilder $p): void { - $p->filePicker('cfg')->filesOnly()->extensions(['yml'])->maxSize(100); - }); - - $this->expectException(EngineException::class); - $this->expectExceptionMessage('Invalid value for field "cfg": must be a file no larger than 100 B'); - $engine->collect(['cfg' => $root . '/big.yml'], new Context('project')); - } - - public function testCollectAcceptsFilePickerWithinConstraints(): void { - vfsStream::setup('root', NULL, ['ok.yml' => str_repeat('a', 10)]); - $root = vfsStream::url('root'); - $engine = $this->engine(function (PanelBuilder $p): void { - $p->filePicker('cfg')->filesOnly()->extensions(['yml'])->maxSize(100); - }); - - $answers = $engine->collect(['cfg' => $root . '/ok.yml'], new Context('project')); - - $this->assertSame($root . '/ok.yml', $answers->value('cfg')); - } - - public function testCollectRejectsDetectedFilePickerConstraintViolation(): void { - vfsStream::setup('root', NULL, ['ok.yml' => str_repeat('a', 10), 'big.yml' => str_repeat('a', 500)]); - $root = vfsStream::url('root'); - $engine = $this->engine(function (PanelBuilder $p) use ($root): void { - $p->filePicker('cfg')->maxSize(100)->default($root . '/ok.yml')->discover(fn(Context $context): string => $root . '/big.yml'); - }); - - // In update mode a detected path over the size limit is not adopted: the - // field falls back to its declared default instead. - $answers = $engine->collect([], new Context('project', [], TRUE)); - - $this->assertSame($root . '/ok.yml', $answers->value('cfg')); - } - - public function testCollectRejectsDetectedEmptyValueOnRequiredField(): void { - $engine = $this->engine(function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required()->default('Pear')->discover(fn(Context $context): string => ''); - }); - - // A blank detected value would leave a required field empty, so it is not - // adopted: the field falls back to its declared default instead. - $answers = $engine->collect([], new Context('project', [], TRUE)); - - $this->assertSame('Pear', $answers->value('name')); - $this->assertSame(Provenance::Default, $answers->provenanceOf('name')); - } - - /** - * Add a shared standard/minimal/disabled option set to a choice builder. - * - * @param \DrevOps\Tui\Builder\FieldBuilder $builder - * The choice field builder. - * - * @return \DrevOps\Tui\Builder\FieldBuilder - * The same builder, for chaining. - */ - protected static function choiceOptions(FieldBuilder $builder): FieldBuilder { - return $builder->option('standard')->option('minimal')->option('demo', 'Demo', disabled: TRUE, disabled_reason: 'unavailable'); - } - - /** - * Declare a template field whose second slot takes a single letter a-c. - * - * @param \DrevOps\Tui\Builder\PanelBuilder $panel - * The panel builder. - * - * @return \DrevOps\Tui\Builder\FieldBuilder - * The field builder. - */ - protected static function crate(PanelBuilder $panel): FieldBuilder { - return $panel->template('crate') - ->pattern('{{orchard}}-{{grade}}') - ->slot('grade', 'Grade', static fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c'); - } - - /** - * Build an engine over a single panel wired to the fixture namespace. - * - * @param \Closure $build - * The callback receiving the panel builder to declare its fields. - */ - protected function engine(\Closure $build): Engine { - $form = Form::create('T')->panel('p', 'p', $build)->build(); - $registry = new HandlerRegistry(['DrevOps\\Tui\\Tests\\Fixtures\\Handler']); - - return new Engine($form, $registry); - } - -} diff --git a/tests/phpunit/Unit/Field/CalendarTest.php b/tests/phpunit/Unit/Field/CalendarTest.php new file mode 100644 index 00000000..e6da52ef --- /dev/null +++ b/tests/phpunit/Unit/Field/CalendarTest.php @@ -0,0 +1,248 @@ +assertSame('2026-07-15', $field->value()); + } + + public function testOpensOnTodayWhenEmpty(): void { + $field = new Calendar(); + + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $field->value()); + } + + public function testInvalidSeedFallsBackToToday(): void { + $field = new Calendar('not-a-date'); + + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $field->value()); + } + + #[DataProvider('dataProviderNavigation')] + public function testNavigation(Key $key, string $expected): void { + $field = new Calendar('2026-07-15'); + + $field->handle($key); + + $this->assertSame($expected, $field->value()); + } + + public static function dataProviderNavigation(): \Iterator { + yield 'left is previous day' => [Key::named(KeyName::Left), '2026-07-14']; + yield 'right is next day' => [Key::named(KeyName::Right), '2026-07-16']; + yield 'up is previous week' => [Key::named(KeyName::Up), '2026-07-08']; + yield 'down is next week' => [Key::named(KeyName::Down), '2026-07-22']; + yield 'page up is previous month' => [Key::named(KeyName::PageUp), '2026-06-15']; + yield 'page down is next month' => [Key::named(KeyName::PageDown), '2026-08-15']; + yield 'home is first of month' => [Key::named(KeyName::Home), '2026-07-01']; + yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31']; + } + + #[DataProvider('dataProviderVimNavigation')] + public function testVimNavigation(Key $key, string $expected): void { + // Injecting the vim scope map proves day and week movement resolve through + // the key bindings: the vim preset reaches the same moves via h/j/k/l. + $field = (new Calendar('2026-07-15'))->setKeys(KeyMapManager::create('vim')->forField(FieldType::Calendar)); + + $field->handle($key); + + $this->assertSame($expected, $field->value()); + } + + public static function dataProviderVimNavigation(): \Iterator { + yield 'h is previous day' => [Key::char('h'), '2026-07-14']; + yield 'l is next day' => [Key::char('l'), '2026-07-16']; + yield 'k is previous week' => [Key::char('k'), '2026-07-08']; + yield 'j is next week' => [Key::char('j'), '2026-07-22']; + } + + #[DataProvider('dataProviderPageMonthClampsToShortMonth')] + public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void { + $field = new Calendar($seed); + + $field->handle($key); + + $this->assertSame($expected, $field->value()); + } + + public static function dataProviderPageMonthClampsToShortMonth(): \Iterator { + // Jan 31 has no counterpart in the shorter month, so the day caps to + // that month's end. + yield 'jan 31 to non-leap feb' => ['2026-01-31', Key::named(KeyName::PageDown), '2026-02-28']; + yield 'jan 31 to leap feb' => ['2024-01-31', Key::named(KeyName::PageDown), '2024-02-29']; + yield 'mar 31 back to feb' => ['2026-03-31', Key::named(KeyName::PageUp), '2026-02-28']; + yield 'oct 31 to nov' => ['2026-10-31', Key::named(KeyName::PageDown), '2026-11-30']; + } + + public function testUnhandledKeysAreNoOps(): void { + $field = new Calendar('2026-07-15'); + + // An unmapped character and an unmapped named key both leave the cursor + // in place. + $field->handle(Key::char('z')); + $field->handle(Key::named(KeyName::Tab)); + + $this->assertSame('2026-07-15', $field->value()); + } + + public function testNavigationClampsWithinBounds(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); + $field = new Calendar('2026-07-11', bounds: $bounds); + + // A week back would land before the minimum, so it clamps to the minimum. + $field->handle(Key::named(KeyName::Up)); + $this->assertSame('2026-07-10', $field->value()); + + // Already on the minimum, a further step left stays on it. + $field->handle(Key::named(KeyName::Left)); + $this->assertSame('2026-07-10', $field->value()); + + // The end of the month is past the maximum, so it clamps to the maximum. + $field->handle(Key::named(KeyName::End)); + $this->assertSame('2026-07-20', $field->value()); + } + + public function testStepByMovesByDaysClampedToBounds(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); + $field = new Calendar('2026-07-15', bounds: $bounds); + + $field->stepBy(3); + $this->assertSame('2026-07-18', $field->value()); + + $field->stepBy(-30); + $this->assertSame('2026-07-10', $field->value()); + } + + public function testConstructionClampsSeedIntoBounds(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); + + $this->assertSame('2026-07-10', (new Calendar('2026-07-01', bounds: $bounds))->value()); + $this->assertSame('2026-07-20', (new Calendar('2026-07-31', bounds: $bounds))->value()); + } + + public function testAcceptReturnsIsoDate(): void { + $field = new Calendar('2026-07-15'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame('2026-07-16', $value); + $this->assertTrue($field->isComplete()); + } + + public function testCancel(): void { + $field = new Calendar('2026-07-15'); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testValidatorErrorIsShown(): void { + $field = (new Calendar('2026-07-15'))->setHandlers(validate: static fn(mixed $value): string => 'No dates allowed.'); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('No dates allowed.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testRendersCalendar(): void { + $field = new Calendar('2026-07-15'); + + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString('July 2026', $view); + // The cursor day is bracketed. + $this->assertStringContainsString('[15]', $view); + // The weekday header defaults to a Monday-first week. + $this->assertMatchesRegularExpression('/Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa\s+Su/', $view); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Calendar('2026-07-15'))->hints()); + + $this->assertSame(['move by day', 'move by week', 'accept', 'cancel'], $labels); + } + + public function testWeekStartRotatesHeaderAndLayout(): void { + $sunday = Ansi::strip((new Calendar('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)))->view(new DefaultTheme())); + + // A Sunday-first week reorders the weekday header. + $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $sunday); + + // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the + // month one column right, so the first row holds only days 1-4 (through + // Saturday) and day 5 (Sunday) starts the next row. The default + // Monday-first week fits days 1-5 in the first row. The first grid row is + // the third rendered line. + $monday = Ansi::strip((new Calendar('2026-07-15'))->view(new DefaultTheme())); + $this->assertStringContainsString('5', explode("\n", $monday)[2]); + $this->assertStringNotContainsString('5', explode("\n", $sunday)[2]); + } + + public function testAsciiRendering(): void { + $field = new Calendar('2026-07-15'); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $view = $field->view($theme); + + $this->assertStringContainsString('July 2026', $view); + // The bracket keeps the cursor day distinguishable without colour. + $this->assertStringContainsString('[15]', $view); + } + + public function testDimsOutOfRangeDays(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10')); + $field = new Calendar('2026-07-15', bounds: $bounds); + $theme = new DefaultTheme(); + + $view = $field->view($theme); + + // A day before the minimum is rendered dimmed, not plain. + $this->assertStringContainsString($theme->fieldEntryNote(sprintf(' %2d ', 5)), $view); + // The cursor day stays bracketed and highlighted. + $this->assertStringContainsString($theme->fieldEntry('[15]', FALSE, TRUE), $view); + } + + public function testDimsDaysPastMaximum(): void { + $bounds = new DateBounds(max: new \DateTimeImmutable('2026-07-20')); + $field = new Calendar('2026-07-15', bounds: $bounds); + $theme = new DefaultTheme(); + + $view = $field->view($theme); + + // A day after the maximum is dimmed too, guarding the upper bound. + $this->assertStringContainsString($theme->fieldEntryNote(sprintf(' %2d ', 25)), $view); + } + +} diff --git a/tests/phpunit/Unit/Field/ConfirmTest.php b/tests/phpunit/Unit/Field/ConfirmTest.php new file mode 100644 index 00000000..702b5029 --- /dev/null +++ b/tests/phpunit/Unit/Field/ConfirmTest.php @@ -0,0 +1,97 @@ +assertFalse($field->value()); + $this->assertStringContainsString('● No', Ansi::strip($field->view(new DefaultTheme()))); + + $field->handle(Key::named(KeyName::Space)); + $this->assertTrue($field->value()); + $this->assertStringContainsString('● Yes', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testValidatorErrorShownInView(): void { + $field = (new Confirm(FALSE))->setHandlers(validate: static fn (mixed $value): string => 'Not allowed.'); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Not allowed.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testCharYesNo(): void { + $field = new Confirm(FALSE); + + $field->handle(Key::char('y')); + $this->assertTrue($field->value()); + + $field->handle(Key::char('n')); + $this->assertFalse($field->value()); + + $field->handle(Key::char('z')); + $this->assertFalse($field->value()); + } + + public function testStepByFlipsOnOddSteps(): void { + $field = new Confirm(); + + $field->stepBy(1); + $this->assertTrue($field->value()); + + // An even step lands back on the same value. + $field->stepBy(2); + $this->assertTrue($field->value()); + + $field->stepBy(-1); + $this->assertFalse($field->value()); + } + + public function testAccept(): void { + $field = new Confirm(TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertTrue($value); + $this->assertTrue($field->isComplete()); + } + + public function testCancel(): void { + $field = new Confirm(FALSE); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Confirm(FALSE))->hints()); + + $this->assertSame(['answer yes or no', 'toggle', 'accept', 'cancel'], $labels); + } + +} diff --git a/tests/phpunit/Unit/Field/FieldFactoryTest.php b/tests/phpunit/Unit/Field/FieldFactoryTest.php new file mode 100644 index 00000000..29e58e4b --- /dev/null +++ b/tests/phpunit/Unit/Field/FieldFactoryTest.php @@ -0,0 +1,458 @@ +assertSame($expected, (new FieldFactory())->open($block, $current)->value()); + } + + /** + * Data provider for testSeedsValueFromCurrent(). + * + * @return \Iterator + * The block, the value it is handed and the value the field opens on. + */ + public static function dataProviderSeedsValueFromCurrent(): \Iterator { + yield 'text' => [new BlockField('f', 'F'), 'Acme', 'Acme']; + yield 'number from an integer' => [new BlockField('f', 'F', FieldType::Number), 8080, 8080]; + yield 'number from a non-numeric' => [new BlockField('f', 'F', FieldType::Number), 'oops', 0]; + yield 'rating from a non-numeric' => [self::ratingBlock(), 'oops', 1]; + yield 'template from the assembled value' => [self::templateBlock(), 'one-two', 'one-two']; + yield 'template from a non-string' => [self::templateBlock(), 42, '-']; + // The seed order flows through: the given value first, the remaining + // entry appended to complete the ranking. + yield 'reorder completes the ranking' => [self::blockWithEntries(FieldType::Reorder), ['b'], ['b', 'a']]; + yield 'multiple from a non-list' => [self::blockWithEntries(FieldType::Select)->multiple(), 'notalist', []]; + // A multiple choice block opens on the list value, proving the multiple + // flag reaches the select and search fields. + yield 'multiple select' => [self::blockWithEntries(FieldType::Select)->multiple(), ['a', 'b'], ['a', 'b']]; + yield 'multiple search' => [self::blockWithEntries(FieldType::Search)->multiple(), ['a'], ['a']]; + } + + public function testFilePickerSeedsValueFromCurrent(): void { + // Kept out of the seeding provider: the picker reads a directory, and a + // virtual one lists nothing without depending on what the host happens to + // have on disk. + vfsStream::setup('crates'); + $start = vfsStream::url('crates'); + + // A current path outside the start is ignored and the empty directory + // lists nothing, so the value is empty. + $single = (new BlockField('f', 'F', FieldType::FilePicker))->startIn($start); + $this->assertSame('', (new FieldFactory())->open($single, 'x')->value()); + + // The multiple picker yields a list seeded from the current value, proving + // the multiple flag is threaded through. + $multi = (new BlockField('g', 'G', FieldType::FilePicker))->startIn($start)->multiple(); + $this->assertSame(['/a', '/b'], (new FieldFactory())->open($multi, ['/a', '/b'])->value()); + } + + public function testDateWithNonStringCurrentOpensOnToday(): void { + // Kept out of the seeding provider: a static provider would fix "today" at + // collection time. Bracketing the call keeps a midnight rollover between + // the two reads from failing the run. + $before = (new \DateTimeImmutable('today'))->format('Y-m-d'); + $field = (new FieldFactory())->open(new BlockField('f', 'F', FieldType::Calendar), 42); + $after = (new \DateTimeImmutable('today'))->format('Y-m-d'); + + $this->assertContains($field->value(), [$before, $after]); + } + + public function testPasswordFlagsPassedThrough(): void { + $block = (new BlockField('f', 'F', FieldType::Password))->revealable()->confirmation(); + + $field = (new FieldFactory())->open($block, 'secret'); + + // Revealable shows through the reveal hint the field contributes. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $field->hints()); + $this->assertContains('reveal', $labels); + + // Confirm shows through the two-step flow: the first Enter does not accept. + $field->handle(Key::named(KeyName::Enter)); + $this->assertFalse($field->isComplete()); + } + + public function testNumberBoundsPassedThrough(): void { + $block = (new BlockField('f', 'F', FieldType::Number))->bounds(new NumberBounds(0, 10)); + + $field = (new FieldFactory())->open($block, 5); + + // Bounds show through the adjust hint the field contributes and stepping. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $field->hints()); + $this->assertContains('adjust', $labels); + $field->handle(Key::named(KeyName::Up)); + $this->assertSame(6, $field->value()); + } + + public function testRatingScaleAndCaptionsPassedThrough(): void { + $field = (new FieldFactory())->open(self::ratingBlock(), 3); + + $this->assertStringContainsString('●●●○○ 3/5 Fair', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testDateBoundsPassedThrough(): void { + $block = (new BlockField('f', 'F', FieldType::Calendar))->dates(new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'))); + + $field = (new FieldFactory())->open($block, '2026-07-01'); + + // The seed is clamped into the block's declared range. + $this->assertSame('2026-07-10', $field->value()); + } + + /** + * Tests when a textarea offers the handoff to the reader's own editor. + * + * @param bool $opted_in + * Whether the block opted in. + * @param bool $available + * Whether an editor is launchable here. + * @param bool $expected + * Whether the handoff is offered. + */ + #[DataProvider('dataProviderTextareaExternalEditorHandoff')] + public function testTextareaExternalEditorHandoff(bool $opted_in, bool $available, bool $expected): void { + $block = (new BlockField('f', 'F', FieldType::Textarea))->externalEditor($opted_in); + + $field = (new FieldFactory(externalEditorAvailable: $available))->open($block, 'x'); + $this->assertInstanceOf(Textarea::class, $field); + + $field->handle(Key::char("\x05")); + $this->assertSame($expected, $field->wantsExternalEdit()); + } + + /** + * Data provider for testTextareaExternalEditorHandoff(). + * + * @return \Iterator + * Whether the block opted in, whether an editor is available, and whether + * the handoff is offered. + */ + public static function dataProviderTextareaExternalEditorHandoff(): \Iterator { + yield 'opted in and available' => [TRUE, TRUE, TRUE]; + yield 'opted in but unavailable' => [TRUE, FALSE, FALSE]; + yield 'available but not opted in' => [FALSE, TRUE, FALSE]; + } + + public function testInjectsScopedKeymapIntoField(): void { + // The vim preset binds j to move-down in the select scope, so the injected + // field responds to j where a default-preset field would not. + $field = (new FieldFactory(KeyMapManager::create('vim')))->open(self::blockWithEntries(FieldType::Select), 'a'); + + $field->handle(Key::char('j')); + + $this->assertSame('b', $field->value()); + } + + public function testSuggestReceivesSelectableValuesOnly(): void { + $block = (new BlockField('tz', 'TZ', FieldType::Suggest)) + ->entry('utc', 'UTC') + ->entry('gmt', 'GMT', '', TRUE) + ->separator(); + + $view = (new FieldFactory())->open($block, '')->view(new DefaultTheme()); + + $this->assertStringContainsString('utc', $view); + $this->assertStringNotContainsString('gmt', $view); + } + + public function testPerOptionDescriptionReachesChoiceField(): void { + $block = (new BlockField('f', 'F', FieldType::Select)) + ->entry('a', 'Apple', 'Crisp and sweet.') + ->entry('b', 'Banana', 'Rich in potassium.'); + + $view = Ansi::strip((new FieldFactory())->open($block, 'a')->view(new DefaultTheme())); + + $this->assertStringContainsString('Crisp and sweet.', $view); + } + + public function testPerOptionDescriptionReachesSuggest(): void { + $block = (new BlockField('f', 'F', FieldType::Suggest))->entry('apple', 'Apple', 'Crisp and sweet.'); + + $field = (new FieldFactory())->open($block, ''); + $field->handle(Key::named(KeyName::Down)); + + $this->assertStringContainsString('Crisp and sweet.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testTextCompletionStaticListReachesField(): void { + $block = (new BlockField('name', 'Name'))->complete(['acme-site']); + + $view = (new FieldFactory())->open($block, 'ac')->view(new DefaultTheme()); + + // The matching candidate's remaining suffix shows as dimmed ghost-text. + $this->assertStringContainsString('me-site', $view); + } + + public function testSuggestGhostFlagReachesField(): void { + $off = (new BlockField('fruit', 'Fruit', FieldType::Suggest))->entry('Apple', 'Apple')->entry('Apricot', 'Apricot'); + $this->assertStringNotContainsString("\033[90m", (new FieldFactory())->open($off, 'ap')->view(new DefaultTheme())); + + $on = (new BlockField('fruit', 'Fruit', FieldType::Suggest))->entry('Apple', 'Apple')->entry('Apricot', 'Apricot')->ghost(); + $view = (new FieldFactory())->open($on, 'ap')->view(new DefaultTheme()); + + // The opted-in field previews the leading candidate's remaining suffix. + $this->assertStringContainsString('ple', $view); + $this->assertStringContainsString("\033[90m", $view); + } + + public function testTextCompletionCoercesInvalidResult(): void { + // A mistyped source degrades to no completion rather than erroring: a list + // with non-strings is filtered, and a non-list result is ignored. + $items = (new BlockField('a', 'A'))->complete(static fn(array $answers): array => [123, NULL]); + $this->assertStringNotContainsString("\033[90m", (new FieldFactory())->open($items, 'ac')->view(new DefaultTheme())); + + $scalar = (new BlockField('b', 'B'))->complete(static fn(array $answers): string => 'oops'); + $this->assertStringNotContainsString("\033[90m", (new FieldFactory())->open($scalar, 'ac')->view(new DefaultTheme())); + } + + /** + * Tests that a declared placeholder ghosts every field with a buffer. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind. + */ + #[DataProvider('dataProviderPlaceholderReachesEveryCapableField')] + public function testPlaceholderReachesEveryCapableField(FieldType $type): void { + $block = (new BlockField('f', 'F', $type))->placeholder('E.g. Golden Beetroot'); + + $view = Ansi::strip((new FieldFactory())->open($block, '')->view(new DefaultTheme())); + + $this->assertStringContainsString('E.g. Golden Beetroot', $view); + } + + /** + * Data provider for testPlaceholderReachesEveryCapableField(). + * + * @return \Iterator + * The kinds that draw a buffer to ghost. + */ + public static function dataProviderPlaceholderReachesEveryCapableField(): \Iterator { + foreach (FieldType::cases() as $type) { + if ($type->supportsPlaceholder()) { + yield $type->value => [$type]; + } + } + } + + public function testFieldWithoutPlaceholderGhostsNothing(): void { + $this->assertStringNotContainsString("\033[90m", (new FieldFactory())->open(new BlockField('f', 'F'), '')->view(new DefaultTheme())); + } + + /** + * Tests the field a block's kind opens onto. + * + * @param \DrevOps\Tui\Block\Field $block + * The block to open. + * @param mixed $current + * The value the block holds. + * @param class-string $expected + * The field the factory builds. + */ + #[DataProvider('dataProviderOpensBlockByKind')] + public function testOpensBlockByKind(BlockField $block, mixed $current, string $expected): void { + $this->assertInstanceOf($expected, (new FieldFactory())->open($block, $current)); + } + + /** + * Data provider for testOpensBlockByKind(). + * + * @return \Iterator + * The block, the value it opens on and the field class it builds. + */ + public static function dataProviderOpensBlockByKind(): \Iterator { + yield 'text' => [new BlockField('f', 'F'), 'x', Text::class]; + yield 'confirm' => [new BlockField('f', 'F', FieldType::Confirm), TRUE, Confirm::class]; + yield 'toggle' => [self::blockWithEntries(FieldType::Toggle), 'a', Toggle::class]; + yield 'select' => [self::blockWithEntries(FieldType::Select), 'a', Select::class]; + yield 'multiple select' => [self::blockWithEntries(FieldType::Select)->multiple(), ['a'], Select::class]; + yield 'search' => [self::blockWithEntries(FieldType::Search), 'a', Search::class]; + yield 'suggest' => [self::blockWithEntries(FieldType::Suggest), 'a', Suggest::class]; + yield 'reorder' => [self::blockWithEntries(FieldType::Reorder), ['a', 'b'], Reorder::class]; + yield 'file picker' => [new BlockField('f', 'F', FieldType::FilePicker), '', FilePicker::class]; + yield 'number' => [new BlockField('f', 'F', FieldType::Number), 42, Number::class]; + yield 'rating' => [(new BlockField('f', 'F', FieldType::Rating))->bounds(new NumberBounds(1, 5)), 3, Rating::class]; + yield 'calendar' => [new BlockField('f', 'F', FieldType::Calendar), '2026-07-15', Calendar::class]; + yield 'textarea' => [new BlockField('f', 'F', FieldType::Textarea), 'x', Textarea::class]; + yield 'password' => [new BlockField('f', 'F', FieldType::Password), 'x', Password::class]; + yield 'pause' => [new BlockField('f', 'F', FieldType::Pause), NULL, Pause::class]; + yield 'template' => [(new BlockField('f', 'F', FieldType::Template))->pattern(new TemplateModel('{{a}}-{{b}}')), '', Template::class]; + } + + /** + * Tests the kinds that only draw and so have nothing to open onto. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind. + */ + #[DataProvider('dataProviderKindThatOnlyDrawsCannotBeOpened')] + public function testKindThatOnlyDrawsCannotBeOpened(FieldType $type): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('only draws, so there is nothing to open it onto'); + + (new FieldFactory())->open(new BlockField('f', 'F', $type)); + } + + /** + * Data provider for testKindThatOnlyDrawsCannotBeOpened(). + * + * @return \Iterator + * The kinds. + */ + public static function dataProviderKindThatOnlyDrawsCannotBeOpened(): \Iterator { + yield 'note' => [FieldType::Note]; + yield 'progress' => [FieldType::Progress]; + } + + public function testOpeningTheBlockCarriesItsDeclarationOntoTheField(): void { + $block = (new BlockField('f', 'F', FieldType::Select)) + ->multiple() + ->entry('a', 'A') + ->entry('b', 'B') + ->paginate(1) + ->placeholder('Pick some produce'); + + $field = (new FieldFactory())->open($block, ['b']); + + $this->assertSame(['b'], $field->value()); + $this->assertEquals(KeyMapManager::create()->forField(FieldType::Select, TRUE), $field->keys()); + + // One page of one row, so only the row the cursor is on is drawn. + $view = Ansi::strip($field->view(new DefaultTheme(40, ['color' => FALSE]))); + $this->assertStringContainsString('A', $view); + $this->assertStringNotContainsString('B', $view); + } + + public function testOpeningTheBlockWiresNoValidatorBecauseTheBlockRefuses(): void { + $block = (new BlockField('f', 'F')) + ->required() + ->validate(static fn(mixed $value): string => 'Never acceptable.'); + + $field = (new FieldFactory())->open($block, ''); + $field->handle(Key::named(KeyName::Enter)); + + // What a block will not take is the block's own to refuse, so the field it + // opened onto offers the value rather than measuring it a second time. + $this->assertTrue($field->isComplete()); + $this->assertNull($field->error()); + } + + public function testOpeningTheBlockDrivenByQuerySourceLeavesTheListToIt(): void { + $block = (new BlockField('f', 'F', FieldType::Search))->query(static fn(): array => ['a' => 'A']); + + $field = (new FieldFactory())->open($block, ''); + + $this->assertInstanceOf(QueryOptionsCapableInterface::class, $field); + $this->assertTrue($field->isQueryDriven()); + } + + public function testOpeningTheRatingBlockWithNoScaleSaysSo(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('is a rating field carrying no closed scale'); + + (new FieldFactory())->open(new BlockField('f', 'F', FieldType::Rating), 1); + } + + public function testOpeningTheTemplateBlockWithNoShapeSaysSo(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('is a template field carrying no template'); + + (new FieldFactory())->open(new BlockField('f', 'F', FieldType::Template), ''); + } + + public function testOpeningTheTextBlockResolvesCompletionAgainstTheAnswers(): void { + $block = (new BlockField('f', 'F'))->complete(static fn(array $answers): array => [(string) ($answers['courier'] ?? '')]); + + $field = (new FieldFactory())->open($block, 'Val', ['courier' => 'Valley Runs']); + $field->handle(Key::named(KeyName::Tab)); + + $this->assertSame('Valley Runs', $field->value()); + } + + /** + * A block of the given kind with two entries. + * + * @param \DrevOps\Tui\Model\FieldType $type + * The kind. + * + * @return \DrevOps\Tui\Block\Field + * The block. + */ + protected static function blockWithEntries(FieldType $type): BlockField { + return (new BlockField('f', 'F', $type))->entry('a', 'A')->entry('b', 'B'); + } + + /** + * A template block with a two-slot shape. + * + * @return \DrevOps\Tui\Block\Field + * The block. + */ + protected static function templateBlock(): BlockField { + return (new BlockField('f', 'F', FieldType::Template))->pattern(new TemplateModel('{{a}}-{{b}}')); + } + + /** + * A rating block over a one-to-five scale with one captioned point. + * + * @return \DrevOps\Tui\Block\Field + * The block. + */ + protected static function ratingBlock(): BlockField { + return (new BlockField('f', 'F', FieldType::Rating))->bounds(new NumberBounds(1, 5))->captions([3 => 'Fair']); + } + +} diff --git a/tests/phpunit/Unit/Field/FilePickerTest.php b/tests/phpunit/Unit/Field/FilePickerTest.php new file mode 100644 index 00000000..9258ce88 --- /dev/null +++ b/tests/phpunit/Unit/Field/FilePickerTest.php @@ -0,0 +1,566 @@ + ['guide.md' => '', 'intro.txt' => ''], + 'src' => [ + 'Theme' => ['Ocean.php' => ''], + 'Utils' => ['Foo.php' => '', 'Bar.php' => ''], + 'readme.md' => '', + 'util.php' => '', + ], + 'empty' => [], + '.hidden' => ['secret.txt' => ''], + '.env' => '', + 'README.md' => '', + 'composer.json' => '', + ]); + $this->root = vfsStream::url('root'); + } + + public function testOpensAtStartDirectoriesFirst(): void { + $field = new FilePicker($this->root); + + // The first entry is the first directory, sorted case-insensitively. + $this->assertSame($this->root . '/docs', $field->value()); + + $view = $this->render($field); + $this->assertStringContainsString('docs/', $view); + $this->assertStringContainsString('README.md', $view); + // Hidden entries stay out of sight until revealed. + $this->assertStringNotContainsString('.env', $view); + $this->assertStringNotContainsString('.hidden', $view); + } + + public function testDescendAndAscend(): void { + $field = new FilePicker($this->root); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $this->assertSame($this->root . '/src', $field->value()); + + $field->handle(Key::named(KeyName::Right)); + // Inside src the first entry is the Theme directory. + $this->assertSame($this->root . '/src/Theme', $field->value()); + + // Ascending returns to the parent with the directory just left highlighted. + $field->handle(Key::named(KeyName::Left)); + $this->assertSame($this->root . '/src', $field->value()); + } + + public function testCannotAscendAboveStart(): void { + $field = new FilePicker($this->root); + + $field->handle(Key::named(KeyName::Left)); + $field->handle(Key::named(KeyName::Left)); + + $this->assertSame($this->root . '/docs', $field->value()); + } + + public function testRightOnFileDoesNotDescend(): void { + // README.md is the first file; highlight it, then Right is a no-op. + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::File)); + + // Files-only lists directories (navigable) then files. + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $this->assertSame($this->root . '/composer.json', $field->value()); + + $field->handle(Key::named(KeyName::Right)); + $this->assertSame($this->root . '/composer.json', $field->value()); + } + + public function testAnyModeEnterOnDirectorySelectsIt(): void { + $field = new FilePicker($this->root); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame($this->root . '/docs', $value); + $this->assertTrue($field->isComplete()); + } + + public function testAnyModeSelectFileAfterDescending(): void { + $field = new FilePicker($this->root); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Right), + Key::named(KeyName::Enter), + )); + + // Right descends into docs; Enter accepts its first file. + $this->assertSame($this->root . '/docs/guide.md', $value); + } + + public function testFileModeEnterOnDirectoryDescends(): void { + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::File)); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + )); + + // The first Enter descends into docs (a directory is not selectable); + // the second accepts its first file. + $this->assertSame($this->root . '/docs/guide.md', $value); + } + + public function testDirectoryModeHidesFilesAndSelectsDirectory(): void { + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::Directory)); + + $view = $this->render($field); + $this->assertStringContainsString('docs/', $view); + // Files are hidden entirely in directory mode. + $this->assertStringNotContainsString('README.md', $view); + $this->assertStringNotContainsString('composer.json', $view); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + $this->assertSame($this->root . '/docs', $value); + } + + public function testExtensionFilterLimitsFiles(): void { + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::File, ['MD'])); + + // Descend into src (docs, empty, src -> src is third). + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Right)); + + $view = $this->render($field); + // Directories stay navigable; only .md files pass the (case-insensitive) + // extension filter, so util.php is filtered out. + $this->assertStringContainsString('Theme/', $view); + $this->assertStringContainsString('readme.md', $view); + $this->assertStringNotContainsString('util.php', $view); + } + + public function testTabTogglesHiddenEntries(): void { + $field = new FilePicker($this->root); + + $this->assertStringNotContainsString('.env', $this->render($field)); + + $field->handle(Key::named(KeyName::Tab)); + + $view = $this->render($field); + $this->assertStringContainsString('.env', $view); + $this->assertStringContainsString('.hidden/', $view); + } + + public function testTypeToFilterNarrowsEntries(): void { + $field = new FilePicker($this->root); + + foreach (str_split('read') as $char) { + $field->handle(Key::char($char)); + } + + // Only README.md contains "read". + $this->assertSame('read', $field->filter()); + $this->assertSame($this->root . '/README.md', $field->value()); + $this->assertStringContainsString('README.md', $this->render($field)); + + // Clearing the filter restores the full listing. + foreach (range(1, 4) as $ignored) { + $field->handle(Key::named(KeyName::Backspace)); + } + $this->assertSame($this->root . '/docs', $field->value()); + } + + public function testTypeToFilterFoldsCaseBeyondAscii(): void { + vfsStream::setup('accents', NULL, ['Äpfel.md' => '', 'pears.md' => '']); + $field = new FilePicker(vfsStream::url('accents')); + + $field->handle(Key::char('ä')); + + // A lowercase non-ASCII query matches its uppercase entry, which a + // byte-level fold would miss. + $this->assertSame(vfsStream::url('accents') . '/Äpfel.md', $field->value()); + $this->assertStringNotContainsString('pears.md', $this->render($field)); + } + + public function testBackspaceAscendsWhenFilterEmpty(): void { + $field = new FilePicker($this->root); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Right)); + $this->assertSame($this->root . '/src/Theme', $field->value()); + + $field->handle(Key::named(KeyName::Backspace)); + $this->assertSame($this->root . '/src', $field->value()); + } + + public function testMultipleTogglesAndAccepts(): void { + $field = new FilePicker($this->root, multiple: TRUE); + + $field->handle(Key::named(KeyName::Space)); + $this->assertSame([$this->root . '/docs'], $field->value()); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Space)); + $this->assertSame([$this->root . '/docs', $this->root . '/src'], $field->value()); + + // Toggling an already-selected entry removes it. + $field->handle(Key::named(KeyName::Space)); + $this->assertSame([$this->root . '/docs'], $field->value()); + + $field->handle(Key::named(KeyName::Enter)); + $this->assertTrue($field->isComplete()); + $this->assertSame([$this->root . '/docs'], $field->value()); + } + + public function testMultipleAccumulatesAcrossDirectories(): void { + $field = new FilePicker($this->root, multiple: TRUE); + + // Select the docs directory, then descend into src and select Theme. + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Right)); + $field->handle(Key::named(KeyName::Space)); + + $this->assertSame([$this->root . '/docs', $this->root . '/src/Theme'], $field->value()); + } + + public function testMultipleSpaceIgnoresNonSelectableDirectory(): void { + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::File), multiple: TRUE); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + // The first entry is a directory, which files-only mode cannot select. + $field->handle(Key::named(KeyName::Space)); + $this->assertSame([], $field->value()); + + // Selectable files carry a checkbox; navigable directories carry a spacer. + $view = $field->view($theme); + $this->assertStringContainsString('[ ] README.md', $view); + $this->assertStringContainsString('docs/', $view); + } + + public function testMultipleSpaceInEmptyDirectoryIsSafe(): void { + $field = new FilePicker($this->root, multiple: TRUE); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Right)); + $field->handle(Key::named(KeyName::Space)); + + $this->assertSame([], $field->value()); + } + + public function testSeedWithMissingBasenameHighlightsTop(): void { + // A default under the start whose entry does not exist opens at the start + // directory with the top entry highlighted. + $field = new FilePicker($this->root, $this->root . '/nope.txt'); + + $this->assertSame($this->root . '/docs', $field->value()); + } + + public function testRootBreadcrumb(): void { + $field = new FilePicker('/'); + + $lines = explode("\n", Ansi::strip($field->view(new DefaultTheme()))); + $this->assertSame('/', $lines[0]); + } + + public function testNonexistentStartIsEmpty(): void { + $field = new FilePicker($this->root . '/missing'); + + $this->assertSame('', $field->value()); + $this->assertStringContainsString('(empty)', $this->render($field)); + } + + public function testMultipleSeedsSelectionFromDefault(): void { + $field = new FilePicker($this->root, [$this->root . '/README.md'], multiple: TRUE); + + $this->assertSame([$this->root . '/README.md'], $field->value()); + // The browser opens at the seeded path's directory with it highlighted. + $this->assertStringContainsString('README.md', $this->render($field)); + } + + public function testSingleSeededDefaultOpensAtItsDirectory(): void { + $field = new FilePicker($this->root, $this->root . '/src/readme.md'); + + $this->assertSame($this->root . '/src/readme.md', $field->value()); + // The breadcrumb reflects the opened sub-directory. + $this->assertStringContainsString('root/src', $this->render($field)); + } + + public function testSeedIgnoredWhenOutsideStart(): void { + $field = new FilePicker($this->root, '/somewhere/else.txt'); + + // A default outside the start directory is ignored; the browser opens at + // the start. + $this->assertSame($this->root . '/docs', $field->value()); + } + + public function testEmptyDirectory(): void { + $field = new FilePicker($this->root); + + // Highlight and descend into the empty directory. + $field->handle(Key::named(KeyName::Down)); + $this->assertSame($this->root . '/empty', $field->value()); + + $field->handle(Key::named(KeyName::Right)); + $this->assertSame('', $field->value()); + $this->assertStringContainsString('(empty)', $this->render($field)); + + // Moving, descending and accepting in an empty directory are all no-ops. + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Right)); + $field->handle(Key::named(KeyName::Enter)); + $this->assertFalse($field->isComplete()); + $this->assertSame('', $field->value()); + } + + public function testCancel(): void { + $field = new FilePicker($this->root); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertNull($value); + $this->assertTrue($field->isCancelled()); + } + + public function testAsciiRendering(): void { + $field = new FilePicker($this->root); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $view = $field->view($theme); + + // The cursor row carries the ASCII marker; directories carry a slash. + $this->assertStringContainsString('> docs/', $view); + $this->assertStringContainsString('src/', $view); + } + + public function testMultipleAsciiCheckboxes(): void { + $field = new FilePicker($this->root, multiple: TRUE); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $this->assertStringContainsString('[ ] docs/', $field->view($theme)); + + $field->handle(Key::named(KeyName::Space)); + $this->assertStringContainsString('[x] docs/', $field->view($theme)); + } + + public function testHintsRenderPerMode(): void { + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + // A single picker binds no toggle key, so that fragment drops and Accept + // reads "select"; the browse and hidden fragments are always present. + $single = Ansi::strip($this->legendOf($theme, KeyMapManager::create()->forField(FieldType::FilePicker), ...(new FilePicker($this->root))->hints())); + $this->assertStringNotContainsString('SPACE to select', $single); + $this->assertStringContainsString('open', $single); + $this->assertStringContainsString('TAB to show hidden', $single); + + // Multiple mode leads with the toggle key and Accept reads "accept". + $multiple = Ansi::strip($this->legendOf($theme, KeyMapManager::create()->forField(FieldType::FilePicker, TRUE), ...(new FilePicker($this->root, multiple: TRUE))->hints())); + $this->assertStringContainsString('SPACE to select', $multiple); + $this->assertStringContainsString('accept', $multiple); + } + + public function testScrollsLargeDirectory(): void { + $files = []; + foreach (range(0, 29) as $index) { + $files[sprintf('file%02d.txt', $index)] = ''; + } + vfsStream::setup('big', NULL, $files); + $field = new FilePicker(vfsStream::url('big')); + $theme = new DefaultTheme(76, ['color' => FALSE]); + + $top = $field->view($theme); + $this->assertStringContainsString('file00.txt', $top); + $this->assertStringNotContainsString('file29.txt', $top); + // A window that clips below shows the down indicator only. + $this->assertStringContainsString('▼', $top); + $this->assertStringNotContainsString('▲', $top); + + foreach (range(1, 29) as $ignored) { + $field->handle(Key::named(KeyName::Down)); + } + + $bottom = $field->view($theme); + $this->assertStringContainsString('file29.txt', $bottom); + $this->assertStringNotContainsString('file00.txt', $bottom); + $this->assertStringContainsString('▲', $bottom); + } + + public function testValueReflectsHighlightBeforeAccept(): void { + $field = new FilePicker($this->root); + + // Before acceptance the value tracks the highlighted entry. + $this->assertSame($this->root . '/docs', $field->value()); + + $field->handle(Key::named(KeyName::Down)); + $this->assertSame($this->root . '/empty', $field->value()); + + // Moving back up restores the earlier highlight. + $field->handle(Key::named(KeyName::Up)); + $this->assertSame($this->root . '/docs', $field->value()); + } + + public function testDefaultsToWorkingDirectoryWhenStartEmpty(): void { + $field = new class($this->root . '/docs') extends FilePicker { + + public function __construct(protected string $directory) { + parent::__construct(''); + } + + #[\Override] + protected function currentDirectory(): string { + return $this->directory; + } + + }; + + // With no start the browser roots at the current working directory, so + // the breadcrumb is its basename and its entries are listed. + $view = $this->render($field); + $this->assertStringContainsString('docs', $view); + $this->assertStringContainsString('guide.md', $view); + } + + public function testMultipleRejectsBelowMinWithInlineError(): void { + $field = new FilePicker($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(2)); + + // Selecting one entry is below the minimum of two. + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Select at least 2 items.', $this->render($field)); + } + + public function testMultipleAcceptsWithinBounds(): void { + $field = new FilePicker($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(1, 2)); + + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertTrue($field->isComplete()); + $this->assertSame([$this->root . '/docs'], $field->value()); + } + + public function testMultipleSelectionHintShownBelowEntries(): void { + $field = new FilePicker($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(2, 3)); + + // The active limit is surfaced, capitalized, below the entries. + $this->assertStringContainsString('Select between 2 and 3 items.', $this->render($field)); + } + + public function testRejectsOversizeFileWithInlineError(): void { + vfsStream::setup('sized', NULL, ['big.txt' => str_repeat('a', 200), 'tiny.txt' => str_repeat('a', 10)]); + $root = vfsStream::url('sized'); + $field = new FilePicker($root, constraints: new FilePickerConstraints(maxSize: 100)); + + // big.txt (200 bytes) is highlighted first and exceeds the 100-byte limit. + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Choose a file no larger than 100 B.', $this->render($field)); + } + + public function testAcceptsFileWithinSizeLimit(): void { + vfsStream::setup('sized', NULL, ['tiny.txt' => str_repeat('a', 10)]); + $root = vfsStream::url('sized'); + $field = new FilePicker($root, constraints: new FilePickerConstraints(maxSize: 100)); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame($root . '/tiny.txt', $value); + $this->assertTrue($field->isComplete()); + } + + public function testConstraintHintShownBelowEntries(): void { + $field = new FilePicker($this->root, constraints: new FilePickerConstraints(FilePickerMode::File, ['md'], 2097152)); + + // The active limits are surfaced below the entries as a hint. + $this->assertStringContainsString('Files only. Extensions: md. Max 2 MB.', $this->render($field)); + } + + public function testConstraintHintGivesWayToInlineError(): void { + vfsStream::setup('sized', NULL, ['big.txt' => str_repeat('a', 200)]); + $root = vfsStream::url('sized'); + $field = new FilePicker($root, constraints: new FilePickerConstraints(maxSize: 100)); + + $field->handle(Key::named(KeyName::Enter)); + + $view = $this->render($field); + // The inline error replaces the persistent hint so the two never stack. + $this->assertStringContainsString('Choose a file no larger than 100 B.', $view); + $this->assertStringNotContainsString('Max 100 B.', $view); + } + + /** + * Render a field's view with the default theme, stripped of ANSI codes. + * + * @param \DrevOps\Tui\Field\FilePicker $field + * The field. + * + * @return string + * The plain-text view. + */ + protected function render(FilePicker $field): string { + return Ansi::strip($field->view(new DefaultTheme())); + } + + /** + * The legend a set of bindings and hint fragments comes to. + * + * @param \DrevOps\Tui\Theme\DefaultTheme $theme + * The theme. + * @param \DrevOps\Tui\Input\ScopedKeyMap $keys + * The bindings a key press resolves against. + * @param \DrevOps\Tui\Input\Hint ...$hints + * What those keys do. + * + * @return string + * The drawn legend. + */ + protected function legendOf(DefaultTheme $theme, ScopedKeyMap $keys, Hint ...$hints): string { + return (new Legend())->advertise($keys, ...$hints)->render($theme); + } + +} diff --git a/tests/phpunit/Unit/Widget/MatcherTest.php b/tests/phpunit/Unit/Field/MatcherTest.php similarity index 96% rename from tests/phpunit/Unit/Widget/MatcherTest.php rename to tests/phpunit/Unit/Field/MatcherTest.php index 7ad06af0..a85167e8 100644 --- a/tests/phpunit/Unit/Widget/MatcherTest.php +++ b/tests/phpunit/Unit/Field/MatcherTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace DrevOps\Tui\Tests\Unit\Widget; +namespace DrevOps\Tui\Tests\Unit\Field; use DrevOps\Tui\Model\Option; use DrevOps\Tui\Model\OptionKind; -use DrevOps\Tui\Widget\Matcher; -use DrevOps\Tui\Widget\MatchResult; -use DrevOps\Tui\Widget\MatchTier; +use DrevOps\Tui\Field\Matcher; +use DrevOps\Tui\Field\MatchResult; +use DrevOps\Tui\Field\MatchTier; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -20,7 +20,7 @@ #[CoversClass(Matcher::class)] #[CoversClass(MatchResult::class)] #[CoversClass(MatchTier::class)] -#[Group('widget')] +#[Group('field')] final class MatcherTest extends TestCase { public function testEmptyNeedleMatchesWithZeroScore(): void { diff --git a/tests/phpunit/Unit/Field/NumberTest.php b/tests/phpunit/Unit/Field/NumberTest.php new file mode 100644 index 00000000..02d877b4 --- /dev/null +++ b/tests/phpunit/Unit/Field/NumberTest.php @@ -0,0 +1,201 @@ +assertSame(8080, $value); + $this->assertTrue($field->isComplete()); + } + + public function testRejectsNonDigits(): void { + $field = new Number(); + + $value = FieldRunner::run($field, ArrayKeyStream::of('4a2 x!', Key::named(KeyName::Enter))); + + $this->assertSame(42, $value); + } + + public function testLeadingMinusOnly(): void { + $field = new Number(); + + $field->handle(Key::char('-')); + $field->handle(Key::char('7')); + // A second minus, no longer at the start, is ignored. + $field->handle(Key::char('-')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertSame(-7, $field->value()); + } + + public function testMinusRejectedMidBuffer(): void { + $field = new Number('12'); + + $field->handle(Key::named(KeyName::Left)); + $field->handle(Key::named(KeyName::Left)); + // The cursor is at the start, but a minus cannot join an existing one. + $field->handle(Key::char('-')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertSame(-12, $field->value()); + } + + public function testEmptyBufferAcceptsZero(): void { + $field = new Number(); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(0, $value); + } + + public function testSeededFromCurrentAndRendersCaret(): void { + $field = new Number('42'); + + $this->assertStringContainsString('42', $field->view(new DefaultTheme())); + $this->assertStringContainsString('█', $field->view(new DefaultTheme())); + } + + public function testArrowsInertAndUnhintedWithoutBounds(): void { + $field = new Number('5'); + + // With no bounds the arrows fall through to the inert text handling. + $field->handle(Key::named(KeyName::Up)); + $field->handle(Key::named(KeyName::Down)); + + $this->assertSame(5, $field->value()); + + // Without bounds it contributes only the shared accept/cancel hints. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $field->hints()); + $this->assertSame(['accept', 'cancel'], $labels); + } + + public function testStepByInertWithoutBounds(): void { + $field = new Number('5'); + + $field->stepBy(1); + + $this->assertSame(5, $field->value()); + } + + public function testCancel(): void { + $field = new Number('5'); + + FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + } + + public function testUpDownStepByOneWithinBounds(): void { + $field = new Number('5', bounds: new NumberBounds(0, 10)); + + $field->handle(Key::named(KeyName::Up)); + $this->assertSame(6, $field->value()); + + $field->handle(Key::named(KeyName::Down)); + $this->assertSame(5, $field->value()); + } + + public function testStepClampsToMax(): void { + $field = new Number('9', bounds: new NumberBounds(0, 10, 3)); + + $field->handle(Key::named(KeyName::Up)); + + $this->assertSame(10, $field->value()); + } + + public function testStepClampsToMin(): void { + $field = new Number('1', bounds: new NumberBounds(0, 10, 3)); + + $field->handle(Key::named(KeyName::Down)); + + $this->assertSame(0, $field->value()); + } + + public function testAcceptsInRangeValue(): void { + $field = new Number('', bounds: new NumberBounds(1, 10)); + + $value = FieldRunner::run($field, ArrayKeyStream::of('5', Key::named(KeyName::Enter))); + + $this->assertSame(5, $value); + $this->assertTrue($field->isComplete()); + } + + public function testRejectsOutOfRangeInline(): void { + $field = new Number('', bounds: new NumberBounds(1, 10)); + + $field->handle(Key::char('5')); + $field->handle(Key::char('0')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Enter a number between 1 and 10.', $field->view(new DefaultTheme())); + } + + public function testSteppingClearsStaleError(): void { + $field = new Number('', bounds: new NumberBounds(1, 10)); + + $field->handle(Key::char('5')); + $field->handle(Key::char('0')); + $field->handle(Key::named(KeyName::Enter)); + $this->assertStringContainsString('Enter a number', $field->view(new DefaultTheme())); + + // Stepping produces a clamped, in-range value, so the error clears. + $field->handle(Key::named(KeyName::Up)); + + $this->assertSame(10, $field->value()); + $this->assertStringNotContainsString('Enter a number', $field->view(new DefaultTheme())); + } + + public function testHintsWhenBounded(): void { + $field = new Number('5', bounds: new NumberBounds(0, 10)); + + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], $field->hints()); + + $this->assertSame([ + ['adjust', [Action::Increment, Action::Decrement]], + ['accept', [Action::Accept]], + ['cancel', [Action::Cancel]], + ], $hints); + } + + public function testPlaceholderGhostsAnEmptyBufferOnly(): void { + $field = (new Number())->setPlaceholder('E.g. 1200'); + + $this->assertStringContainsString('E.g. 1200', $field->view(new DefaultTheme())); + + $field->handle(Key::char('4')); + + $this->assertStringNotContainsString('E.g. 1200', $field->view(new DefaultTheme())); + } + +} diff --git a/tests/phpunit/Unit/Widget/PasswordDisplayTest.php b/tests/phpunit/Unit/Field/PasswordDisplayTest.php similarity index 81% rename from tests/phpunit/Unit/Widget/PasswordDisplayTest.php rename to tests/phpunit/Unit/Field/PasswordDisplayTest.php index 4be1ebe3..22d6589b 100644 --- a/tests/phpunit/Unit/Widget/PasswordDisplayTest.php +++ b/tests/phpunit/Unit/Field/PasswordDisplayTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace DrevOps\Tui\Tests\Unit\Widget; +namespace DrevOps\Tui\Tests\Unit\Field; -use DrevOps\Tui\Widget\PasswordDisplay; +use DrevOps\Tui\Field\PasswordDisplay; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -14,7 +14,7 @@ * Tests the password display cycle. */ #[CoversClass(PasswordDisplay::class)] -#[Group('widget')] +#[Group('field')] final class PasswordDisplayTest extends TestCase { #[DataProvider('dataProviderNext')] @@ -25,7 +25,7 @@ public function testNext(PasswordDisplay $from, PasswordDisplay $to): void { /** * Data provider for testNext(). * - * @return \Iterator + * @return \Iterator * The current display and the one that follows it. */ public static function dataProviderNext(): \Iterator { diff --git a/tests/phpunit/Unit/Field/PasswordTest.php b/tests/phpunit/Unit/Field/PasswordTest.php new file mode 100644 index 00000000..a6a75ec5 --- /dev/null +++ b/tests/phpunit/Unit/Field/PasswordTest.php @@ -0,0 +1,223 @@ +assertSame('s3cret', $value); + } + + public function testMaskedViewCountsCharactersNotBytes(): void { + $field = new Password('éé'); + + // Two characters mask as exactly two glyphs, whatever their byte length. + $this->assertSame('**|', $field->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))); + } + + public function testViewMasksEveryCharacter(): void { + $field = new Password('abc'); + + $view = $field->view(new DefaultTheme()); + + $this->assertStringNotContainsString('abc', $view); + $this->assertStringNotContainsString('a', $view); + $this->assertSame(3, substr_count($view, '•')); + $this->assertStringContainsString('█', $view); + } + + public function testValidationErrorShownUnderMask(): void { + $field = (new Password(''))->setHandlers(validate: fn(mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'Required.'); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Required.', $field->view(new DefaultTheme())); + } + + public function testRevealToggleCyclesDisplayModes(): void { + $field = new Password('abc', revealable: TRUE); + $theme = new DefaultTheme(); + + // Masked by default: one glyph per character, the value never shown. + $this->assertSame(3, substr_count($field->view($theme), '•')); + $this->assertStringNotContainsString('abc', $field->view($theme)); + + // Tab reveals the plaintext. + $field->handle(Key::named(KeyName::Tab)); + $this->assertStringContainsString('abc', $field->view($theme)); + + // Tab again hides it entirely: neither the value nor its length shows. + $field->handle(Key::named(KeyName::Tab)); + $hidden = $field->view($theme); + $this->assertStringNotContainsString('abc', $hidden); + $this->assertStringNotContainsString('•', $hidden); + + // Tab a third time returns to the masked default. + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame(3, substr_count($field->view($theme), '•')); + } + + public function testToggleIgnoredWhenNotRevealable(): void { + $field = new Password('abc'); + $theme = new DefaultTheme(); + + $field->handle(Key::named(KeyName::Tab)); + + // Tab neither revealed the value nor was inserted as a character. + $this->assertSame(3, substr_count($field->view($theme), '•')); + $this->assertStringNotContainsString('abc', $field->view($theme)); + } + + public function testCancel(): void { + $field = new Password('x'); + + FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + } + + public function testToggleRevealInertWhenNotRevealable(): void { + $field = new Password('secret'); + + $field->toggleReveal(); + + $this->assertStringNotContainsString('secret', $field->view(new DefaultTheme())); + } + + public function testRevealDoesNotChangeAcceptedValue(): void { + $field = new Password('', revealable: TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of('sekret', Key::named(KeyName::Tab), Key::named(KeyName::Enter))); + + $this->assertSame('sekret', $value); + } + + public function testHintShownOnlyWhenRevealable(): void { + $revealable = array_map(static fn(Hint $hint): string => $hint->label, (new Password('x', revealable: TRUE))->hints()); + $this->assertContains('reveal', $revealable); + + $plain = array_map(static fn(Hint $hint): string => $hint->label, (new Password('x'))->hints()); + $this->assertNotContains('reveal', $plain); + } + + public function testConfirmAcceptsMatchingEntries(): void { + $theme = new DefaultTheme(); + $field = new Password('', confirm: TRUE); + + // The first Enter stashes the entry and prompts for a second pass. + $this->type($field, 'pw'); + $field->handle(Key::named(KeyName::Enter)); + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('re-enter to confirm', $field->view($theme)); + + // A matching second entry accepts, with the plain value preserved. + $this->type($field, 'pw'); + $field->handle(Key::named(KeyName::Enter)); + $this->assertTrue($field->isComplete()); + $this->assertSame('pw', $field->value()); + } + + public function testConfirmRejectsMismatchAndRestarts(): void { + $theme = new DefaultTheme(); + $field = new Password('', confirm: TRUE); + + $this->type($field, 'pw'); + $field->handle(Key::named(KeyName::Enter)); + $this->type($field, 'zz'); + $field->handle(Key::named(KeyName::Enter)); + + // The mismatch is rejected with a clear message and both entries cleared. + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Passwords do not match.', $field->view($theme)); + $this->assertStringNotContainsString('re-enter to confirm', $field->view($theme)); + + // A fresh matching pair now accepts. + $this->type($field, 'pw'); + $field->handle(Key::named(KeyName::Enter)); + $this->type($field, 'pw'); + $field->handle(Key::named(KeyName::Enter)); + $this->assertTrue($field->isComplete()); + $this->assertSame('pw', $field->value()); + } + + public function testConfirmRevalidatesMatchedValue(): void { + $theme = new DefaultTheme(); + $field = (new Password('', confirm: TRUE))->setHandlers(validate: fn(mixed $value): string => 'Too weak.'); + + $this->type($field, 'x'); + $field->handle(Key::named(KeyName::Enter)); + $this->type($field, 'x'); + $field->handle(Key::named(KeyName::Enter)); + + // Entries match, but the validator still rejects the value and restarts. + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Too weak.', $field->view($theme)); + $this->assertStringNotContainsString('re-enter to confirm', $field->view($theme)); + } + + public function testPlaceholderGhostsAnEmptyBufferInEveryDisplayMode(): void { + $field = (new Password('', revealable: TRUE))->setPlaceholder('At least 12 characters'); + $theme = new DefaultTheme(); + + // An empty buffer hides nothing, so the prompt shows masked, plaintext and + // hidden alike. + $this->assertStringContainsString('At least 12 characters', $field->view($theme)); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertStringContainsString('At least 12 characters', $field->view($theme)); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertStringContainsString('At least 12 characters', $field->view($theme)); + } + + public function testPlaceholderClearsOnceTheEntryIsMasked(): void { + $field = (new Password())->setPlaceholder('At least 12 characters'); + + $this->type($field, 's3cret'); + + $this->assertStringNotContainsString('At least 12 characters', $field->view(new DefaultTheme())); + } + + /** + * Type a run of printable characters into a field. + * + * @param \DrevOps\Tui\Field\Password $field + * The field. + * @param string $text + * The characters to type. + */ + protected function type(Password $field, string $text): void { + foreach (str_split($text) as $char) { + $field->handle(Key::char($char)); + } + } + +} diff --git a/tests/phpunit/Unit/Field/PauseTest.php b/tests/phpunit/Unit/Field/PauseTest.php new file mode 100644 index 00000000..40911669 --- /dev/null +++ b/tests/phpunit/Unit/Field/PauseTest.php @@ -0,0 +1,73 @@ +assertTrue($value); + $this->assertTrue($field->isComplete()); + } + + public function testSpaceAcknowledges(): void { + $field = new Pause(); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Space))); + + $this->assertTrue($value); + } + + public function testOtherKeysIgnored(): void { + $field = new Pause(); + + $field->handle(Key::char('x')); + $field->handle(Key::named(KeyName::Down)); + + $this->assertFalse($field->isComplete()); + $this->assertFalse($field->value()); + } + + public function testCancelAndView(): void { + $field = new Pause(); + + // The prompt key glyph is drawn from the live binding (Enter by default). + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('Press ', $view); + $this->assertStringContainsString('to continue', $view); + $this->assertStringContainsString('↵', $view); + + $field->handle(Key::named(KeyName::Escape)); + $this->assertTrue($field->isCancelled()); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Pause())->hints()); + + $this->assertSame(['continue', 'cancel'], $labels); + } + +} diff --git a/tests/phpunit/Unit/Field/RatingTest.php b/tests/phpunit/Unit/Field/RatingTest.php new file mode 100644 index 00000000..38646adc --- /dev/null +++ b/tests/phpunit/Unit/Field/RatingTest.php @@ -0,0 +1,184 @@ +assertSame(3, $field->value()); + $this->assertStringContainsString('●●●○○ 3/5', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testStepsAlongTheScale(): void { + $field = new Rating(3); + + $field->handle(Key::named(KeyName::Right)); + $this->assertSame(4, $field->value()); + + $field->handle(Key::named(KeyName::Left)); + $this->assertSame(3, $field->value()); + } + + #[DataProvider('dataProviderStepKeys')] + public function testStepKeys(Key $key, int $expected): void { + $field = new Rating(3); + + $field->handle($key); + + $this->assertSame($expected, $field->value()); + } + + /** + * Data provider for testStepKeys(). + * + * @return \Iterator + * Each stepping key and the point it moves to from three. + */ + public static function dataProviderStepKeys(): \Iterator { + yield 'right' => [Key::named(KeyName::Right), 4]; + yield 'up' => [Key::named(KeyName::Up), 4]; + yield 'left' => [Key::named(KeyName::Left), 2]; + yield 'down' => [Key::named(KeyName::Down), 2]; + } + + #[DataProvider('dataProviderClampsAtEnds')] + public function testClampsAtEnds(int $start, int $delta, int $expected): void { + $field = new Rating($start); + + $field->stepBy($delta); + + $this->assertSame($expected, $field->value()); + } + + /** + * Data provider for testClampsAtEnds(). + * + * @return \Iterator + * The starting point, the step and the point it settles on. + */ + public static function dataProviderClampsAtEnds(): \Iterator { + yield 'stops at the top' => [5, 1, 5]; + yield 'stops at the bottom' => [1, -1, 1]; + yield 'a long step lands on the end' => [3, 99, 5]; + yield 'a long backward step lands on the end' => [3, -99, 1]; + } + + #[DataProvider('dataProviderSeedIsClamped')] + public function testSeedIsClamped(int $seed, int $expected): void { + $this->assertSame($expected, (new Rating($seed))->value()); + } + + /** + * Data provider for testSeedIsClamped(). + * + * @return \Iterator + * The seed value and the point it is moved onto. + */ + public static function dataProviderSeedIsClamped(): \Iterator { + yield 'below the scale' => [-4, 1]; + yield 'above the scale' => [99, 5]; + yield 'on the scale' => [2, 2]; + } + + public function testDigitJumpsToPoint(): void { + $field = new Rating(1); + + $field->handle(Key::char('4')); + $this->assertSame(4, $field->value()); + + // A digit the scale does not reach leaves the choice alone. + $field->handle(Key::char('9')); + $this->assertSame(4, $field->value()); + + // Nor does a non-digit character move it. + $field->handle(Key::char('x')); + $this->assertSame(4, $field->value()); + } + + public function testDigitBelowScaleIsIgnored(): void { + $field = new Rating(5, 3, 8); + + $field->handle(Key::char('1')); + + $this->assertSame(5, $field->value()); + } + + public function testCaptionOfTheChosenPoint(): void { + $field = new Rating(1, 1, 5, [1 => 'Poor', 5 => 'Excellent']); + $theme = new DefaultTheme(); + + $this->assertStringContainsString('●○○○○ 1/5 Poor', Ansi::strip($field->view($theme))); + + // An uncaptioned point renders the scale alone. + $field->stepBy(1); + $this->assertStringContainsString('●●○○○ 2/5', Ansi::strip($field->view($theme))); + $this->assertStringNotContainsString('Poor', Ansi::strip($field->view($theme))); + } + + public function testCustomScale(): void { + $field = new Rating(3, 0, 10); + + $this->assertStringContainsString('●●●●○○○○○○○ 3/10', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testAsciiRendering(): void { + $field = new Rating(2); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $this->assertStringContainsString('**--- 2/5', $field->view($theme)); + } + + public function testCaptionFoldsToOneLine(): void { + $field = new Rating(1, 1, 5, [1 => "Poor\nby any measure"]); + + $this->assertStringContainsString('1/5 Poor by any measure', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testAccept(): void { + $field = new Rating(3); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame(4, $value); + $this->assertTrue($field->isComplete()); + } + + public function testCancel(): void { + $field = new Rating(3); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Rating(1))->hints()); + + $this->assertSame(['adjust', 'accept', 'cancel'], $labels); + } + +} diff --git a/tests/phpunit/Unit/Field/ReorderTest.php b/tests/phpunit/Unit/Field/ReorderTest.php new file mode 100644 index 00000000..ce2e6e66 --- /dev/null +++ b/tests/phpunit/Unit/Field/ReorderTest.php @@ -0,0 +1,284 @@ +assertStringContainsString('Crisp and sweet.', Ansi::strip($field->view(new DefaultTheme()))); + + $field->handle(Key::named(KeyName::Down)); + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Stays crisp when kept cold.', $view); + $this->assertStringNotContainsString('Crisp and sweet.', $view); + } + + public function testNonSelectableItemShowsNoDescription(): void { + // The cursor starts on the non-selectable heading, so its description + // never renders beneath the list. + $field = new Reorder([ + new Option('', 'Group', 'group note', OptionKind::Heading), + new Option('a', 'Apple', 'Crisp and sweet.'), + ]); + + $this->assertStringNotContainsString('group note', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testEmptyReorderRendersNoDescription(): void { + // A reorder with no items must render without touching a highlighted row. + $field = new Reorder([]); + + $this->assertSame('', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testGrabAndMoveDownAccepts(): void { + $field = new Reorder(self::options()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['b', 'c', 'a'], $value); + } + + public function testNavigateThenGrabMoveUp(): void { + $field = new Reorder(self::options()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Up), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'c', 'b'], $value); + } + + public function testDefaultOrder(): void { + $field = new Reorder(self::options(), ['c', 'a']); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['c', 'a', 'b'], $value); + } + + public function testDefaultCompletesAndCleans(): void { + // A partial default with an unknown ("x") and a duplicate ("b") still + // resolves to a full ranking: known values first, remainder appended. + $field = new Reorder(self::options(), ['b', 'x', 'b']); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['b', 'a', 'c'], $value); + } + + public function testCancelReturnsNull(): void { + $field = new Reorder(self::options()); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertNull($value); + $this->assertTrue($field->isCancelled()); + } + + public function testGrabbedClampsAtTop(): void { + $field = new Reorder(self::options()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Up), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b', 'c'], $value); + } + + public function testGrabbedClampsAtBottom(): void { + $field = new Reorder(self::options()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b', 'c'], $value); + } + + public function testNavigationClampsAtTop(): void { + $field = new Reorder(self::options()); + + // Up at the top is a no-op; grabbing then moving down still works. + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Up), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['b', 'a', 'c'], $value); + } + + public function testNavigationClampsAtBottom(): void { + $field = new Reorder(self::options()); + + // A third Down stays on the last row; grabbing then moving up still works. + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Up), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'c', 'b'], $value); + } + + public function testGrabTogglesOffThenNavigates(): void { + $field = new Reorder(self::options()); + + // Grab then drop: the following Down navigates rather than moving the item. + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b', 'c'], $value); + } + + public function testLiveValueReflectsMovesBeforeAccept(): void { + $field = new Reorder(self::options()); + + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Down)); + + $this->assertSame(['b', 'a', 'c'], $field->value()); + } + + public function testViewMarkersDegradeWithUnicodeMode(): void { + $field = new Reorder(self::options()); + + $before = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('❯', $before); + $this->assertStringNotContainsString('↑↓', $before); + + $field->handle(Key::named(KeyName::Space)); + + $grabbed = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('↑↓', $grabbed); + + $ascii = Ansi::strip($field->view(new DefaultTheme(76, ['unicode' => FALSE]))); + $this->assertStringContainsString('^v', $ascii); + } + + public function testHints(): void { + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], (new Reorder(self::options()))->hints()); + + $this->assertSame([ + ['move', [Action::MoveUp, Action::MoveDown]], + ['grab', [Action::Grab]], + ['accept', [Action::Accept]], + ['cancel', [Action::Cancel]], + ], $hints); + } + + public function testHintsWhileHoldingItem(): void { + $field = new Reorder(self::options()); + $field->handle(Key::named(KeyName::Space)); + + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], $field->hints()); + + // Holding an item swaps to reorder/drop labels and drops the accept hint - + // the form cannot be accepted while an item is held. + $this->assertSame([ + ['reorder', [Action::MoveUp, Action::MoveDown]], + ['drop', [Action::Grab]], + ['cancel', [Action::Cancel]], + ], $hints); + } + + public function testEnterDropsHeldItemInsteadOfAccepting(): void { + $field = new Reorder(self::options()); + + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Enter)); + + // Enter dropped the held item rather than accepting the form. + $this->assertFalse($field->isComplete()); + $this->assertSame(['b', 'a', 'c'], $field->value()); + + // A second Enter, with nothing held, accepts. + $field->handle(Key::named(KeyName::Enter)); + $this->assertTrue($field->isComplete()); + } + + public function testRejectsNonPositivePageSize(): void { + $this->assertRejectsNonPositivePageSize(static fn(int $size): Reorder => new Reorder(self::options(), page_size: $size), -3); + } + + public function testPagesLongList(): void { + $this->assertPagesAndFollowsCursor(static fn(int $size): Reorder => new Reorder(self::pagingOptions(), page_size: $size)); + } + + /** + * The three-item fixture used across most cases. + * + * @return array + * The value => label option map. + */ + protected static function options(): array { + return ['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry']; + } + +} diff --git a/tests/phpunit/Unit/Field/SearchTest.php b/tests/phpunit/Unit/Field/SearchTest.php new file mode 100644 index 00000000..161253c8 --- /dev/null +++ b/tests/phpunit/Unit/Field/SearchTest.php @@ -0,0 +1,423 @@ + + */ + protected array $labels = ['gha' => 'GitHub Actions', 'circleci' => 'CircleCI', 'none' => 'None']; + + /** + * The options used across the multiple-choice tests. + * + * @var array + */ + protected array $services = ['clamav' => 'ClamAV', 'redis' => 'Redis', 'solr' => 'Solr']; + + public function testShowsHighlightedOptionDescription(): void { + $field = new Search([ + new Option('apple', 'Apple', 'Crisp and sweet.'), + new Option('banana', 'Banana', 'Rich in potassium.'), + ], 'apple'); + + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Crisp and sweet.', $view); + $this->assertStringNotContainsString('Rich in potassium.', $view); + + $field->handle(Key::named(KeyName::Down)); + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Rich in potassium.', $view); + $this->assertStringNotContainsString('Crisp and sweet.', $view); + } + + public function testNoDescriptionWhenFilterMatchesNothing(): void { + $field = new Search([new Option('apple', 'Apple', 'Crisp and sweet.')]); + + // A query that matches nothing leaves no highlighted option, so no + // description line is appended. + $field->handle(Key::char('z')); + + $this->assertStringNotContainsString('Crisp', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testDescriptionFollowsFilteredHighlight(): void { + $field = new Search([ + new Option('apple', 'Apple', 'Crisp and sweet.'), + new Option('banana', 'Banana', 'Rich in potassium.'), + ]); + + $field->handle(Key::char('b')); + $field->handle(Key::char('a')); + $field->handle(Key::char('n')); + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString('Rich in potassium.', $view); + $this->assertStringNotContainsString('Crisp and sweet.', $view); + } + + public function testFilterNarrowsAndEnterAcceptsValue(): void { + $field = new Search($this->labels); + + $value = FieldRunner::run($field, ArrayKeyStream::of('circle', Key::named(KeyName::Enter))); + + $this->assertSame('circleci', $value); + } + + public function testDefaultSeedsHighlight(): void { + $field = new Search($this->labels, 'none'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame('none', $value); + } + + public function testArrowsMoveHighlight(): void { + $field = new Search($this->labels); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Down), Key::named(KeyName::Enter))); + + $this->assertSame('circleci', $value); + } + + public function testEnterIgnoredWhenNothingMatches(): void { + $field = new Search($this->labels); + + $field->handle(Key::char('z')); + $field->handle(Key::char('z')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + + $field->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertTrue($field->isComplete()); + $this->assertSame('gha', $field->value()); + } + + public function testBackspaceRemovesWholeMultibyteCharacter(): void { + $field = new Search($this->labels); + + // One backspace removes the whole multibyte character, not one byte, so + // the cleared filter shows every option again instead of matching nothing. + $field->handle(Key::char('é')); + $field->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertTrue($field->isComplete()); + $this->assertSame('gha', $field->value()); + } + + public function testSpaceIsPartOfTheQuery(): void { + $field = new Search($this->labels); + + $value = FieldRunner::run($field, ArrayKeyStream::of('hub', Key::named(KeyName::Space), Key::named(KeyName::Backspace), Key::named(KeyName::Enter))); + + $this->assertSame('gha', $value); + } + + public function testViewShowsQueryAndVisibleOptions(): void { + $field = new Search($this->labels); + + $field->handle(Key::char('c')); + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString('c█', $view); + $this->assertStringContainsString('CircleCI', $view); + $this->assertStringNotContainsString('None', $view); + $this->assertSame('c', $field->filter()); + } + + public function testCancel(): void { + $field = new Search($this->labels); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + $this->assertNull($value); + } + + public function testNavigationSkipsNonSelectable(): void { + $field = new Search($this->mixedOptions()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + )); + + $this->assertSame('d', $value); + } + + public function testUpSkipsBackOverNonSelectable(): void { + $field = new Search($this->mixedOptions()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Up), + Key::named(KeyName::Enter), + )); + + $this->assertSame('b', $value); + } + + public function testDefaultOnDisabledFallsBackToFirstSelectable(): void { + $field = new Search($this->mixedOptions(), 'c'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame('a', $value); + } + + public function testFilterDropsHeadingsAndSeparators(): void { + $field = new Search($this->mixedOptions()); + + $field->handle(Key::char('b')); + $field->handle(Key::char('a')); + $field->handle(Key::char('n')); + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString('Banana', $view); + $this->assertStringNotContainsString('Fruits', $view); + $this->assertStringNotContainsString('Apple', $view); + $this->assertStringNotContainsString('──', $view); + } + + public function testDisabledMatchingFilterNotAccepted(): void { + $field = new Search($this->mixedOptions()); + + $field->handle(Key::char('e')); + $field->handle(Key::char('r')); + $field->handle(Key::char('r')); + $this->assertStringContainsString('Cherry (out of stock)', Ansi::strip($field->view(new DefaultTheme()))); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + } + + public function testRendersHeadingSeparatorAndDisabled(): void { + $view = Ansi::strip((new Search($this->mixedOptions()))->view(new DefaultTheme())); + + $this->assertStringContainsString('Fruits', $view); + $this->assertStringContainsString('Cherry (out of stock)', $view); + $this->assertStringContainsString('──', $view); + } + + public function testFuzzyMatchesNonContiguousSubsequence(): void { + $field = new Search(['gha' => 'GitHub Actions', 'gitlab' => 'GitLab CI', 'circle' => 'CircleCI']); + + $value = FieldRunner::run($field, ArrayKeyStream::of('gha', Key::named(KeyName::Enter))); + + $this->assertSame('gha', $value); + } + + public function testRanksPrefixAheadOfLooserSubsequence(): void { + $field = new Search(['alpha' => 'Alpha', 'beta' => 'Beta', 'palace' => 'Palace']); + + // "pa" prefixes Palace but only scatters through Alpha, so Palace ranks + // first and the cursor lands on it even though Alpha is declared earlier. + $value = FieldRunner::run($field, ArrayKeyStream::of('pa', Key::named(KeyName::Enter))); + + $this->assertSame('palace', $value); + } + + public function testHighlightsMatchedCharacters(): void { + $theme = new DefaultTheme(); + $field = new Search(['palace' => 'Palace', 'alpha' => 'Alpha']); + + $field->handle(Key::char('p')); + $field->handle(Key::char('a')); + $view = $field->view($theme); + + $this->assertStringContainsString($theme->fieldEntryMatch('Pa'), $view); + $this->assertStringContainsString('Palace', Ansi::strip($view)); + } + + public function testRejectsNonPositivePageSize(): void { + $this->assertRejectsNonPositivePageSize(static fn(int $size): Search => new Search(['a' => 'A'], page_size: $size), -2); + } + + public function testPagesLongOptionList(): void { + $this->assertPagesAndFollowsCursor(static fn(int $size): Search => new Search(self::pagingOptions(), page_size: $size)); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Search($this->labels))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + + public function testMultipleFilterToggleAndAccept(): void { + $field = new Search($this->services, [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of('sol', Key::named(KeyName::Space), Key::named(KeyName::Enter))); + + $this->assertSame(['solr'], $value); + } + + public function testMultipleSeededSelectionKept(): void { + $field = new Search($this->services, ['redis'], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['redis'], $value); + } + + public function testMultipleViewShowsQueryLineAboveOptions(): void { + $field = new Search($this->services, [], TRUE); + + $field->handle(Key::char('r')); + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString("r█\n", $view); + $this->assertStringContainsString('Redis', $view); + $this->assertStringNotContainsString('ClamAV', $view); + } + + public function testMultipleHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Search($this->services, [], TRUE))->hints()); + + $this->assertSame(['select', 'move', 'select none or all', 'accept', 'cancel'], $labels); + } + + public function testMultipleSkipsNonSelectableWhenToggling(): void { + $field = new Search($this->mixedOptions(), [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b', 'd'], $value); + } + + public function testMultipleRendersKindsBelowQueryLine(): void { + $view = Ansi::strip((new Search($this->mixedOptions(), [], TRUE))->view(new DefaultTheme())); + + $this->assertStringContainsString("█\n", $view); + $this->assertStringContainsString('Fruits', $view); + $this->assertStringContainsString('Cherry (out of stock)', $view); + $this->assertStringContainsString('──', $view); + } + + public function testMultipleFuzzyMatchesNonContiguousSubsequence(): void { + $field = new Search(['banana' => 'Banana', 'apple' => 'Apple', 'cherry' => 'Cherry'], [], TRUE); + + // "bn" is not a substring of any label but is a subsequence of "Banana". + $value = FieldRunner::run($field, ArrayKeyStream::of('bn', Key::named(KeyName::Space), Key::named(KeyName::Enter))); + + $this->assertSame(['banana'], $value); + } + + public function testMultipleHighlightsMatchedCharacters(): void { + $theme = new DefaultTheme(); + $field = new Search(['banana' => 'Banana'], [], TRUE); + + $field->handle(Key::char('b')); + $field->handle(Key::char('n')); + $view = $field->view($theme); + + // The non-contiguous match highlights each hit character on its own, + // leaving the intervening characters unstyled. + $this->assertStringContainsString($theme->fieldEntryMatch('B'), $view); + $this->assertStringContainsString($theme->fieldEntryMatch('n'), $view); + $this->assertStringContainsString('Banana', Ansi::strip($view)); + } + + public function testMultiplePagesLongOptionList(): void { + $this->assertPagesAndFollowsCursor(static fn(int $size): Search => new Search(self::pagingOptions(), [], TRUE, page_size: $size)); + } + + public function testMultipleRejectsBelowMinWithInlineError(): void { + $field = new Search($this->services, [], TRUE, selection_bounds: new SelectionBounds(2)); + + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Select at least 2 items.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testMultipleAcceptsWithinBounds(): void { + $field = new Search($this->services, [], TRUE, selection_bounds: new SelectionBounds(1, 2)); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['clamav'], $value); + $this->assertTrue($field->isComplete()); + } + + public function testMultipleSelectionHintShownBelowQueryLine(): void { + $field = new Search($this->services, [], TRUE, selection_bounds: new SelectionBounds(2, 3)); + + // The active limit is surfaced before it is reached. + $this->assertStringContainsString('Select between 2 and 3 items.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testPlaceholderGhostsAnEmptyQueryOnly(): void { + $field = (new Search($this->services))->setPlaceholder('Type to filter'); + $theme = new DefaultTheme(); + + $this->assertStringContainsString('Type to filter', Ansi::strip($field->view($theme))); + + $field->handle(Key::char('c')); + + $this->assertStringNotContainsString('Type to filter', Ansi::strip($field->view($theme))); + } + +} diff --git a/tests/phpunit/Unit/Field/SelectTest.php b/tests/phpunit/Unit/Field/SelectTest.php new file mode 100644 index 00000000..413e314c --- /dev/null +++ b/tests/phpunit/Unit/Field/SelectTest.php @@ -0,0 +1,534 @@ + 'Apple', 'b' => 'Banana', 'c' => 'Cherry'], 'a'); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Up), + Key::named(KeyName::Enter), + )); + + $this->assertSame('b', $value); + $this->assertStringContainsString('●', $field->view(new DefaultTheme())); + } + + public function testDefaultHighlight(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], 'b'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame('b', $value); + } + + public function testBoundsClamp(): void { + $field = new Select(['a' => 'A', 'b' => 'B']); + + $field->handle(Key::named(KeyName::Up)); + $this->assertSame('a', $field->value()); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $this->assertSame('b', $field->value()); + } + + public function testValidatorErrorShownInView(): void { + $field = (new Select(['a' => 'A', 'b' => 'B'], 'a'))->setHandlers(validate: static fn (mixed $value): string => 'Not allowed.'); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Not allowed.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testCancel(): void { + $field = new Select(['a' => 'A', 'b' => 'B']); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testSetKeysInjectsBindings(): void { + // An injected scope map takes over from the lazy default: the vim select + // scope binds j to move-down, which the default preset does not. + $field = (new Select(['a' => 'A', 'b' => 'B'], 'a')) + ->setKeys(KeyMapManager::create('vim')->forField(FieldType::Select)); + + $field->handle(Key::char('j')); + + $this->assertSame('b', $field->value()); + } + + public function testNavigationSkipsHeadingsSeparatorsAndDisabled(): void { + $field = new Select($this->mixedOptions()); + + // From Apple (0): Down skips the heading to Banana (2); Down skips the + // separator and the disabled Cherry to Date (5). + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + )); + + $this->assertSame('d', $value); + } + + public function testUpSkipsBackOverDisabled(): void { + $field = new Select($this->mixedOptions()); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Up), + Key::named(KeyName::Enter), + )); + + $this->assertSame('b', $value); + } + + public function testDefaultOnDisabledFallsBackToFirstSelectable(): void { + $field = new Select($this->mixedOptions(), 'c'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame('a', $value); + } + + public function testRendersHeadingSeparatorAndDisabledReason(): void { + $view = Ansi::strip((new Select($this->mixedOptions()))->view(new DefaultTheme())); + + $this->assertStringContainsString('Fruits', $view); + $this->assertStringContainsString('Cherry (out of stock)', $view); + $this->assertStringContainsString('──', $view); + } + + public function testShowsHighlightedOptionDescription(): void { + $field = new Select([ + new Option('apple', 'Apple', 'Crisp and sweet.'), + new Option('banana', 'Banana', 'Rich in potassium.'), + ], 'apple'); + + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Crisp and sweet.', $view); + $this->assertStringNotContainsString('Rich in potassium.', $view); + + $field->handle(Key::named(KeyName::Down)); + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Rich in potassium.', $view); + $this->assertStringNotContainsString('Crisp and sweet.', $view); + } + + public function testOmitsDescriptionWhenHighlightedOptionHasNone(): void { + $field = new Select([ + new Option('apple', 'Apple', 'Crisp and sweet.'), + new Option('banana', 'Banana'), + ], 'banana'); + + // The highlighted Banana has no description, and Apple's never leaks in. + $this->assertSame("○ Apple\n● Banana", Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testMultipleShowsCursorOptionDescription(): void { + $field = new Select([ + new Option('apple', 'Apple', 'Crisp and sweet.'), + new Option('banana', 'Banana', 'Rich in potassium.'), + ], [], TRUE); + + $this->assertStringContainsString('Crisp and sweet.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testWrapsDescriptionToContentWidth(): void { + $field = new Select([ + new Option('apple', 'Apple', 'Crisp and sweet and best eaten fresh from the tree.'), + ], 'apple'); + + $theme = new DefaultTheme(24); + $lines = explode("\n", Ansi::strip($field->view($theme))); + + // The option row plus at least two wrapped description lines, each fitting. + $this->assertGreaterThan(2, count($lines)); + foreach (array_slice($lines, 1) as $line) { + $this->assertLessThanOrEqual($theme->contentWidth(), mb_strlen($line)); + } + } + + public function testOmitsDescriptionWhenPanelTooNarrow(): void { + $field = new Select([new Option('apple', 'Apple', 'Crisp and sweet.')], 'apple'); + + $this->assertStringNotContainsString('Crisp', Ansi::strip($field->view(new DefaultTheme(6)))); + } + + public function testNonSelectableRowDescriptionNeverShows(): void { + // With no selectable option the cursor parks on the heading; its + // description must not render as an option description. + $field = new Select([new Option('', 'Fruit', 'group note', OptionKind::Heading)]); + + $this->assertStringNotContainsString('group note', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testNoSelectableRowYieldsNoValue(): void { + $field = new Select([new Option('', 'Group', '', OptionKind::Heading)]); + + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertSame('', $field->value()); + } + + public function testRejectsNonPositivePageSize(): void { + $this->assertRejectsNonPositivePageSize(static fn(int $size): Select => new Select(['a' => 'A'], page_size: $size), 0); + } + + public function testPagesLongOptionList(): void { + $this->assertPagesAndFollowsCursor(static fn(int $size): Select => new Select(self::pagingOptions(), page_size: $size)); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Select(['a' => 'A']))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + + public function testMultipleToggleAndAccept(): void { + $field = new Select(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry'], [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b'], $value); + } + + public function testMultipleDefaultSelected(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], ['b'], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['b'], $value); + } + + public function testMultipleFilterNarrowsThenToggles(): void { + $field = new Select(['apple' => 'Apple', 'apricot' => 'Apricot', 'banana' => 'Banana'], [], TRUE); + + $field->handle(Key::char('b')); + $field->handle(Key::char('a')); + $field->handle(Key::char('n')); + $this->assertStringContainsString('Banana', $field->view(new DefaultTheme())); + $this->assertStringNotContainsString('Apple', $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Space)); + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['banana'], $value); + } + + public function testMultipleFilterBackspaceRestoresList(): void { + $field = new Select(['apple' => 'Apple', 'banana' => 'Banana'], [], TRUE); + + $field->handle(Key::char('b')); + $this->assertStringNotContainsString('Apple', $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Backspace)); + $this->assertStringContainsString('Apple', $field->view(new DefaultTheme())); + } + + public function testMultipleSelectAllAndNone(): void { + $field = new Select(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE); + + $field->handle(Key::named(KeyName::Right)); + $this->assertSame(['a', 'b', 'c'], $field->value()); + + $field->handle(Key::named(KeyName::Left)); + $this->assertSame([], $field->value()); + } + + public function testMultipleCancel(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], [], TRUE); + + FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + } + + public function testMultipleUpMovesCursorBack(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Up), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a'], $value); + } + + public function testMultipleToggleOffDeselects(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], ['b'], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame([], $value); + } + + public function testMultipleToggleWithNoMatchesIsNoop(): void { + $field = new Select(['a' => 'Apple'], [], TRUE); + + $field->handle(Key::char('z')); + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame([], $value); + } + + public function testMultipleHints(): void { + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], (new Select(['a' => 'A'], [], TRUE))->hints()); + + $this->assertSame([ + ['select', [Action::Toggle]], + ['move', [Action::MoveUp, Action::MoveDown]], + ['select none or all', [Action::SelectNone, Action::SelectAll]], + ['accept', [Action::Accept]], + ['cancel', [Action::Cancel]], + ], $hints); + } + + public function testMultipleSpaceSkipsDisabledAndTogglesSelectable(): void { + $field = new Select($this->mixedOptions(), [], TRUE); + + // Toggle Apple, skip the heading to Banana and toggle it, skip the + // separator and the disabled Cherry to Date and toggle it. + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a', 'b', 'd'], $value); + } + + public function testMultipleSelectAllSkipsDisabled(): void { + $field = new Select($this->mixedOptions(), [], TRUE); + + $field->handle(Key::named(KeyName::Right)); + + $this->assertSame(['a', 'b', 'd'], $field->value()); + } + + public function testMultipleDefaultExcludesDisabled(): void { + $field = new Select($this->mixedOptions(), ['c', 'a'], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame(['a'], $value); + } + + public function testMultipleFilterDropsHeadingsAndSeparators(): void { + $field = new Select($this->mixedOptions(), [], TRUE); + + $field->handle(Key::char('b')); + $field->handle(Key::char('a')); + $field->handle(Key::char('n')); + $view = Ansi::strip($field->view(new DefaultTheme())); + + $this->assertStringContainsString('Banana', $view); + $this->assertStringNotContainsString('Fruits', $view); + $this->assertStringNotContainsString('Apple', $view); + $this->assertStringNotContainsString('──', $view); + } + + public function testMultipleDisabledMatchingFilterIsShownButNotToggleable(): void { + $field = new Select($this->mixedOptions(), [], TRUE); + + $field->handle(Key::char('e')); + $field->handle(Key::char('r')); + $field->handle(Key::char('r')); + $this->assertStringContainsString('Cherry (out of stock)', Ansi::strip($field->view(new DefaultTheme()))); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame([], $value); + } + + public function testMultipleRendersHeadingSeparatorAndDisabled(): void { + $view = Ansi::strip((new Select($this->mixedOptions(), [], TRUE))->view(new DefaultTheme())); + + $this->assertStringContainsString('Fruits', $view); + $this->assertStringContainsString('Cherry (out of stock)', $view); + $this->assertStringContainsString('──', $view); + } + + public function testMultipleFilterStaysSubstringNotFuzzy(): void { + $field = new Select(['banana' => 'Banana', 'apple' => 'Apple'], [], TRUE); + + // "bn" is a subsequence of "Banana" but not a substring, so the checkbox + // list - which stays substring-only - narrows it away. + $field->handle(Key::char('b')); + $field->handle(Key::char('n')); + + $this->assertStringNotContainsString('Banana', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testMultipleRejectsNonPositivePageSize(): void { + $this->assertRejectsNonPositivePageSize(static fn(int $size): Select => new Select(['a' => 'A'], [], TRUE, page_size: $size), -3); + } + + public function testMultiplePagesLongOptionList(): void { + $this->assertPagesAndFollowsCursor(static fn(int $size): Select => new Select(self::pagingOptions(), [], TRUE, page_size: $size)); + } + + public function testMultipleRejectsBelowMinWithInlineError(): void { + $field = new Select(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE, selection_bounds: new SelectionBounds(2)); + + // One selection is below the minimum of two, so the accept is rejected. + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Select at least 2 items.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testMultipleRejectsAboveMaxWithInlineError(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], [], TRUE, selection_bounds: new SelectionBounds(NULL, 1)); + + // Two selections exceed the maximum of one. + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Space)); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Select at most 1 item.', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testMultipleAcceptsWithinBounds(): void { + $field = new Select(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); + + $value = FieldRunner::run($field, ArrayKeyStream::of( + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + )); + + $this->assertSame(['a'], $value); + $this->assertTrue($field->isComplete()); + } + + public function testMultipleSelectionHintShownInView(): void { + $field = new Select(['a' => 'A', 'b' => 'B'], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); + $view = Ansi::strip($field->view(new DefaultTheme())); + + // The active limit is surfaced, capitalized, below the option list. + $this->assertStringContainsString('Select between 1 and 2 items.', $view); + $this->assertGreaterThan(strpos($view, 'B'), strpos($view, 'Select between 1 and 2 items.')); + } + + public function testHighlightedDetailSitsAboveTheConstraint(): void { + $field = new Select([ + new Option('a', 'A', 'Crisp and sweet, the everyday choice.'), + new Option('b', 'B'), + ], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); + + $view = Ansi::strip($field->view(new DefaultTheme())); + + // The detail changes as the highlight moves, so it belongs against the list + // it follows rather than below a limit that never moves. + $this->assertLessThan(strpos($view, 'Select between 1 and 2 items.'), strpos($view, 'Crisp and sweet, the everyday choice.')); + } + + public function testMultipleSelectionHintReadsApartFromAnOptionDescription(): void { + $theme = new DefaultTheme(); + $field = new Select([ + new Option('a', 'A', 'Crisp and sweet, the everyday choice.'), + new Option('b', 'B'), + ], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); + + $view = $field->view($theme); + + // The two lines sit next to each other, so drawn in one style the limit + // the field is stating cannot be told from prose about the highlighted + // option. + $this->assertStringContainsString($this->styleOf($theme->fieldConstraint(...)) . 'Select between 1 and 2 items.', $view); + $this->assertStringContainsString($this->styleOf($theme->fieldEntryDescription(...)) . 'Crisp and sweet, the everyday choice.', $view); + $this->assertNotSame($this->styleOf($theme->fieldConstraint(...)), $this->styleOf($theme->fieldEntryDescription(...))); + } + + /** + * The escape sequence a theme style opens with. + * + * @param callable(string): string $style + * The style to sample. + * + * @return string + * The sequence preceding the styled text. + */ + protected function styleOf(callable $style): string { + $sample = $style('@'); + + return substr($sample, 0, (int) strpos($sample, '@')); + } + +} diff --git a/tests/phpunit/Unit/Field/SuggestTest.php b/tests/phpunit/Unit/Field/SuggestTest.php new file mode 100644 index 00000000..0e5baec9 --- /dev/null +++ b/tests/phpunit/Unit/Field/SuggestTest.php @@ -0,0 +1,397 @@ +assertSame('UTC', $value); + } + + public function testNarrowsAndSelectsSuggestion(): void { + $field = new Suggest(['UTC', 'Europe/London', 'Australia/Sydney']); + + $field->handle(Key::char('l')); + $field->handle(Key::char('o')); + $field->handle(Key::char('n')); + $this->assertStringContainsString('Europe/London', Ansi::strip($field->view(new DefaultTheme()))); + $this->assertStringNotContainsString('Australia/Sydney', Ansi::strip($field->view(new DefaultTheme()))); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Down), Key::named(KeyName::Enter))); + + $this->assertSame('Europe/London', $value); + } + + public function testEmptyBufferListsAll(): void { + $field = new Suggest(['x', 'y']); + + $field->handle(Key::named(KeyName::Down)); + $this->assertSame('x', $field->value()); + $this->assertStringContainsString('y', $field->view(new DefaultTheme())); + } + + public function testBackspaceAndUpResetHighlight(): void { + $field = new Suggest(['abc', 'abd']); + + $field->handle(Key::char('a')); + $field->handle(Key::named(KeyName::Down)); + $this->assertSame('abc', $field->value()); + + $field->handle(Key::named(KeyName::Up)); + $this->assertSame('a', $field->value()); + + $field->handle(Key::char('b')); + $field->handle(Key::named(KeyName::Backspace)); + $this->assertSame('a', $field->value()); + } + + public function testBufferExposesTheLiveQuery(): void { + $field = new Suggest(['alpha']); + + $field->handle(Key::char('a')); + + $this->assertSame('a', $field->buffer()); + } + + public function testCancel(): void { + $field = new Suggest(['x', 'y']); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testSpaceAppendsToBuffer(): void { + $field = new Suggest(['x', 'y']); + + $field->handle(Key::char('a')); + $field->handle(Key::named(KeyName::Space)); + + $this->assertSame('a ', $field->value()); + } + + public function testFuzzyMatchesNonContiguousSubsequence(): void { + $field = new Suggest(['GitHub Actions', 'GitLab CI', 'CircleCI']); + + $value = FieldRunner::run($field, ArrayKeyStream::of('gha', Key::named(KeyName::Down), Key::named(KeyName::Enter))); + + $this->assertSame('GitHub Actions', $value); + } + + public function testRanksPrefixAheadOfLooserSubsequence(): void { + $field = new Suggest(['Alpha', 'Beta', 'Palace']); + + // "pa" is a prefix of Palace but only a scattered subsequence of Alpha, so + // Palace ranks first and the first Down lands on it. + $field->handle(Key::char('p')); + $field->handle(Key::char('a')); + $field->handle(Key::named(KeyName::Down)); + + $this->assertSame('Palace', $field->value()); + } + + public function testHighlightsMatchedCharacters(): void { + $theme = new DefaultTheme(); + $field = new Suggest(['Alpha', 'Beta', 'Palace']); + + $field->handle(Key::char('p')); + $field->handle(Key::char('a')); + $view = $field->view($theme); + + // The matched "Pa" prefix is themed as a match run; the label is intact + // once the styling is stripped. + $this->assertStringContainsString($theme->fieldEntryMatch('Pa'), $view); + $this->assertStringContainsString('Palace', Ansi::strip($view)); + } + + public function testShowsHighlightedSuggestionDescription(): void { + $field = new Suggest(['apple', 'apricot'], '', NULL, ['apple' => 'Crisp and sweet.', 'apricot' => 'Small and tart.']); + + // With nothing highlighted yet (cursor detached), no description shows. + $this->assertStringNotContainsString('Crisp and sweet.', Ansi::strip($field->view(new DefaultTheme()))); + + $field->handle(Key::named(KeyName::Down)); + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Crisp and sweet.', $view); + $this->assertStringNotContainsString('Small and tart.', $view); + + $field->handle(Key::named(KeyName::Down)); + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Small and tart.', $view); + $this->assertStringNotContainsString('Crisp and sweet.', $view); + } + + public function testOmitsDescriptionForSuggestionWithoutEntry(): void { + $field = new Suggest(['apple', 'pear'], '', NULL, ['apple' => 'Crisp and sweet.']); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + + // The highlighted Pear has no description entry, so nothing is appended. + $this->assertStringNotContainsString('Crisp', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testRejectsNonPositivePageSize(): void { + $this->assertRejectsNonPositivePageSize(static fn(int $size): Suggest => new Suggest(['x'], page_size: $size), 0); + } + + public function testPagesLongSuggestionList(): void { + // The highlight starts detached (-1), so three Downs reach the third item. + $this->assertPagesAndFollowsCursor(static fn(int $size): Suggest => new Suggest(array_values(self::pagingOptions()), page_size: $size), 3); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Suggest(['UTC', 'GMT']))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + + public function testPlaceholderGhostsAnEmptyQueryOnly(): void { + $field = (new Suggest(['Pear', 'Plum']))->setPlaceholder('Type to filter'); + $theme = new DefaultTheme(); + + $this->assertStringContainsString('Type to filter', Ansi::strip($field->view($theme))); + + $field->handle(Key::char('P')); + + $this->assertStringNotContainsString('Type to filter', Ansi::strip($field->view($theme))); + } + + public function testPlaceholderNeverCompetesWithGhostText(): void { + $field = (new Suggest(['Apple'], '', NULL, [], TRUE))->setPlaceholder('Type to filter'); + $theme = new DefaultTheme(); + + // Both occupy the one slot after the caret, but a completion needs a typed + // query and a placeholder an empty one, so the slot is never contested. + $this->assertStringContainsString('Type to filter', Ansi::strip($field->queryLine($theme))); + + $field->handle(Key::char('a')); + $line = Ansi::strip($field->queryLine($theme)); + $this->assertStringContainsString('pple', $line); + $this->assertStringNotContainsString('Type to filter', $line); + } + + public function testGhostTextIsOptIn(): void { + $field = new Suggest(['Apple', 'Apricot']); + + $field->handle(Key::char('a')); + + // Without the opt-in the query line carries no dimmed suffix, and the keys + // that would accept one are inert. + $view = $field->view(new DefaultTheme()); + $this->assertStringNotContainsString("\033[90m", $view); + + $field->handle(Key::named(KeyName::Tab)); + $field->handle(Key::named(KeyName::Right)); + $this->assertSame('a', $field->value()); + } + + public function testGhostTextRendersDimmedSuffix(): void { + $field = new Suggest(['Apple', 'Apricot'], '', NULL, [], TRUE); + + $field->handle(Key::char('a')); + $field->handle(Key::char('p')); + + // The leading candidate's remainder is previewed dimmed (SGR 90) after the + // caret, while the value stays the typed query until it is accepted. + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('ple', $view); + $this->assertStringContainsString("\033[90m", $view); + $this->assertSame('ap', $field->value()); + } + + public function testTabAcceptsGhostText(): void { + $field = new Suggest(['Apple', 'Apricot'], '', NULL, [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of('ap', Key::named(KeyName::Tab), Key::named(KeyName::Enter))); + + // Accepting adopts the candidate's own casing. + $this->assertSame('Apple', $value); + } + + public function testRightAcceptsGhostText(): void { + $field = new Suggest(['Apple', 'Apricot'], '', NULL, [], TRUE); + + $value = FieldRunner::run($field, ArrayKeyStream::of('ap', Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame('Apple', $value); + } + + public function testAcceptingGhostTextKeepsTheListAvailable(): void { + $field = new Suggest(['Apple', 'Apple pie', 'Apricot'], '', NULL, [], TRUE); + + $field->handle(Key::char('a')); + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('Apple', $field->value()); + + // The completion re-queries rather than selecting: the narrowed list is + // still open and still arrows into. + $view = Ansi::strip($field->view(new DefaultTheme())); + $this->assertStringContainsString('Apple pie', $view); + $this->assertStringNotContainsString('Apricot', $view); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Down)); + $this->assertSame('Apple pie', $field->value()); + } + + public function testGhostTextSuppressedWhileSuggestionHighlighted(): void { + $field = new Suggest(['Apple', 'Apricot'], '', NULL, [], TRUE); + + $field->handle(Key::char('a')); + $this->assertStringContainsString("\033[90m", $field->view(new DefaultTheme())); + + // Arrowing into the list makes the highlighted row the live value, so a + // preview of the typed query would contradict it. + $field->handle(Key::named(KeyName::Down)); + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + + // Tab and Right stay inert while a row is highlighted. + $field->handle(Key::named(KeyName::Tab)); + $field->handle(Key::named(KeyName::Right)); + $this->assertSame('Apple', $field->value()); + } + + public function testGhostTextSuppressedWithoutColour(): void { + $theme = new DefaultTheme(76, ['color' => FALSE]); + $field = new Suggest(['Apricot'], '', NULL, [], TRUE); + + $field->handle(Key::char('a')); + + // Without colour the preview cannot be dimmed, so it is dropped rather than + // rendered as plain text indistinguishable from the typed query. The + // suggestion itself still lists below, and no escapes leak into the line. + $this->assertSame('a' . $theme->fieldCaret(), $field->queryLine($theme)); + $this->assertStringNotContainsString("\033", $field->view($theme)); + } + + public function testGhostTextCompletesPrefixesNotFuzzyMatches(): void { + $field = new Suggest(['Green apple'], '', NULL, [], TRUE); + + $field->handle(Key::char('g')); + $field->handle(Key::char('a')); + + // "ga" is a scattered subsequence, so the row is listed but there is no + // suffix to draw after the caret. + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('Green apple', Ansi::strip($view)); + $this->assertStringNotContainsString("\033[90m", $view); + } + + public function testFullyTypedSuggestionHasNoGhostText(): void { + $field = new Suggest(['Fig'], '', NULL, [], TRUE); + + $field->handle(Key::char('f')); + $field->handle(Key::char('i')); + $field->handle(Key::char('g')); + + // The query already equals the only candidate; nothing is left to preview. + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + } + + public function testEmptyQueryShowsNoGhostText(): void { + // With nothing typed there is no prefix to complete. + $field = new Suggest(['Apple'], '', NULL, [], TRUE); + + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + } + + public function testGhostTextIsUnicodeAware(): void { + // Folding is per code point, so a non-ASCII prefix matches and the suffix + // renders whole rather than splitting mid-character. + $field = new Suggest(['Éclair'], '', NULL, [], TRUE); + + $field->handle(Key::char('é')); + $this->assertStringContainsString('clair', $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('Éclair', $field->value()); + } + + public function testGhostTextCompletesQuerySourcedRows(): void { + $field = new Suggest([], '', NULL, [], TRUE); + $field->driveByQuery(); + + $field->handle(Key::char('p')); + $field->applyQuery('p', Option::list(['Pepper' => 'Pepper', 'Potato' => 'Potato'])); + + // A query source's rows are already the answer and are never ranked again + // locally, so the preview is simply their first prefix match. + $this->assertStringContainsString('epper', $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('Pepper', $field->value()); + } + + public function testGhostTextSuppressedWhileQueryIsInFlight(): void { + $theme = new DefaultTheme(); + $field = new Suggest([], '', NULL, [], TRUE); + $field->driveByQuery(); + $field->applyQuery('', Option::list(['Apricot' => 'Apricot'])); + + $field->handle(Key::char('a')); + $this->assertStringContainsString("\033[90m", $field->queryLine($theme)); + + // The rows still held answer the previous query, and the list showing them + // has already given way to the loading indicator; previewing one of them + // would put back the answer being withdrawn. + $field->beginQuery(); + $this->assertSame('a' . $theme->fieldCaret(), $field->queryLine($theme)); + + // Once the new rows settle the preview returns, drawn from them. + $field->applyQuery('a', Option::list(['Apple' => 'Apple'])); + $this->assertStringContainsString('pple', $field->queryLine($theme)); + } + + public function testGhostTextBacksOffWhenTheQueryStopsMatching(): void { + $field = new Suggest(['Apple'], '', NULL, [], TRUE); + + $field->handle(Key::char('a')); + $this->assertStringContainsString("\033[90m", $field->view(new DefaultTheme())); + + // A typo drops every prefix candidate, so the preview disappears and Tab + // leaves the query untouched. + $field->handle(Key::char('z')); + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('az', $field->value()); + } + +} diff --git a/tests/phpunit/Unit/Field/TemplateTest.php b/tests/phpunit/Unit/Field/TemplateTest.php new file mode 100644 index 00000000..fa432ffc --- /dev/null +++ b/tests/phpunit/Unit/Field/TemplateTest.php @@ -0,0 +1,244 @@ +assertSame('one-two', $field->value()); + } + + public function testSeedsEmptyWhenTheValueDoesNotMatch(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}'), 'nope'); + + $this->assertSame('-', $field->value()); + } + + public function testTypingFillsTheSlotHoldingTheCaret(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}')); + + $value = FieldRunner::run($field, ArrayKeyStream::of('one', Key::named(KeyName::Enter))); + + $this->assertSame('one-', $value); + } + + #[DataProvider('dataProviderMovesBetweenSlots')] + public function testMovesBetweenSlots(array $keys, string $expected): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}-{{c}}')); + + $value = FieldRunner::run($field, ArrayKeyStream::of(...[...$keys, Key::named(KeyName::Enter)])); + + $this->assertSame($expected, $value); + } + + public static function dataProviderMovesBetweenSlots(): \Iterator { + $tab = Key::named(KeyName::Tab); + $down = Key::named(KeyName::Down); + $up = Key::named(KeyName::Up); + + yield 'tab advances' => [['x', $tab, 'y', $tab, 'z'], 'x-y-z']; + yield 'down advances like tab' => [['x', $down, 'y', $down, 'z'], 'x-y-z']; + yield 'up goes back' => [['x', $tab, 'y', $up, 'z'], 'xz-y-']; + yield 'forward wraps to the first slot' => [[$tab, $tab, $tab, 'x'], 'x--']; + yield 'back wraps to the last slot' => [[$up, 'x'], '--x']; + } + + public function testEditsTheSlotItReturnsTo(): void { + $tab = Key::named(KeyName::Tab); + $field = new Template(new TemplateModel('{{a}}-{{b}}'), 'one-two'); + + // Tab away and back, then delete a character: the value comes back with + // the buffer, so the edit lands on the original text and not on an empty + // slot. + $value = FieldRunner::run($field, ArrayKeyStream::of($tab, $tab, Key::named(KeyName::Backspace), Key::named(KeyName::Enter))); + + $this->assertSame('on-two', $value); + } + + public function testMovesTheCaretInsideTheActiveSlot(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}'), 'one-two'); + + // Left steps back inside the slot, so the inserted character lands before + // the last one rather than at the end. + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Left), 'X', Key::named(KeyName::Enter))); + + $this->assertSame('onXe-two', $value); + } + + public function testValidatesTheSlotBeingLeftWithoutHoldingTheCaret(): void { + $field = new Template($this->gradedTemplate()); + $theme = new DefaultTheme(); + + $field->handle(Key::char('z')); + $field->handle(Key::named(KeyName::Tab)); + + // The rejected slot reports its error, but the caret has still moved on. + $this->assertStringContainsString('Grade: use a single letter a-c', $field->view($theme)); + $this->assertStringContainsString('filling in Crate', $field->view($theme)); + } + + public function testClearsTheErrorWhenTheSlotBecomesValid(): void { + $field = new Template($this->gradedTemplate()); + $theme = new DefaultTheme(); + + $field->handle(Key::char('z')); + $field->handle(Key::named(KeyName::Tab)); + $field->handle(Key::named(KeyName::Tab)); + $field->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::char('b')); + $field->handle(Key::named(KeyName::Tab)); + + $this->assertStringNotContainsString('use a single letter a-c', $field->view($theme)); + } + + public function testAcceptRejectsAnInvalidSlotAndTakesTheCaretToIt(): void { + $field = new Template($this->gradedTemplate()); + $theme = new DefaultTheme(); + + // Fill the second slot, then accept while the first is still invalid. + $field->handle(Key::named(KeyName::Tab)); + $field->handle(Key::char('9')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Grade: use a single letter a-c', $field->view($theme)); + $this->assertStringContainsString('filling in Grade', $field->view($theme)); + } + + public function testAcceptsOnceEverySlotIsValid(): void { + $field = new Template($this->gradedTemplate()); + + $value = FieldRunner::run($field, ArrayKeyStream::of('a', Key::named(KeyName::Tab), '9', Key::named(KeyName::Enter))); + + $this->assertSame('a-9', $value); + } + + public function testAcceptRejectsSlotHoldingTheSeparator(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}', ['a' => 'Head'])); + $theme = new DefaultTheme(); + + // "one-x" would move the boundary, so the answer would read back as + // a="one", b="x-two" - nothing like what was typed. + FieldRunner::run($field, ArrayKeyStream::of('one-x', Key::named(KeyName::Tab), 'two', Key::named(KeyName::Enter))); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Head: must not contain "-".', $field->view($theme)); + $this->assertStringContainsString('filling in Head', $field->view($theme)); + } + + public function testAcceptAllowsTheSeparatorInTheLastSlot(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}')); + + // The last slot runs to the end of the string, so it can hold the + // separator without moving any boundary. + $value = FieldRunner::run($field, ArrayKeyStream::of('one', Key::named(KeyName::Tab), 'two-x', Key::named(KeyName::Enter))); + + $this->assertSame('one-two-x', $value); + } + + public function testFieldValidatorRunsAgainstTheAssembledValue(): void { + $field = (new Template(new TemplateModel('{{a}}-{{b}}'))) + ->setHandlers(validate: static fn(mixed $value): ?string => $value === 'one-two' ? NULL : 'Unknown crate.'); + $theme = new DefaultTheme(); + + $field->handle(Key::named(KeyName::Enter)); + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Unknown crate.', $field->view($theme)); + } + + public function testTransformerAppliesToTheAssembledValue(): void { + $field = (new Template(new TemplateModel('{{a}}-{{b}}'), 'one-two')) + ->setHandlers(transform: static fn(mixed $value): string => is_string($value) ? strtoupper($value) : ''); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Enter))); + + $this->assertSame('ONE-TWO', $value); + } + + public function testCancel(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}'), 'one-two'); + + FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + } + + #[DataProvider('dataProviderRendersTheShape')] + public function testRendersTheShape(bool $unicode, string $caret): void { + $field = new Template(new TemplateModel('crate {{a}}-{{b}} ready', ['b' => 'Tail']), 'crate one-two ready'); + + $view = $field->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => $unicode])); + + // The fixed text frames the filled slots, and the caret marks the live one. + $this->assertStringContainsString('crate one' . $caret . '-two ready', $view); + $this->assertStringContainsString('filling in a', $view); + } + + public static function dataProviderRendersTheShape(): \Iterator { + yield 'unicode' => [TRUE, '█']; + yield 'ascii' => [FALSE, '|']; + } + + public function testEmptySlotShowsItsLabelAsHint(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}', ['b' => 'Tail'])); + + $view = $field->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE])); + + // The caret sits on the first slot; the empty second one names itself so + // the shape does not collapse to its fixed text alone. + $this->assertStringContainsString('|-Tail', $view); + } + + public function testFilledSlotShowsItsValueNotItsLabel(): void { + $field = new Template(new TemplateModel('{{a}}-{{b}}', ['b' => 'Tail']), 'one-two'); + + $view = $field->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE])); + + $this->assertStringContainsString('-two', $view); + $this->assertStringNotContainsString('Tail', $view); + } + + public function testHintLeadsWithSlotNavigation(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Template(new TemplateModel('{{a}}-{{b}}')))->hints()); + + $this->assertSame(['move between parts', 'accept', 'cancel'], $labels); + } + + /** + * A two-slot template whose first slot takes a single letter a-c. + * + * @return \DrevOps\Tui\Model\Template + * The template. + */ + protected function gradedTemplate(): TemplateModel { + return new TemplateModel('{{grade}}-{{crate}}', ['grade' => 'Grade', 'crate' => 'Crate'], [ + 'grade' => static fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c', + ]); + } + +} diff --git a/tests/phpunit/Unit/Field/TextTest.php b/tests/phpunit/Unit/Field/TextTest.php new file mode 100644 index 00000000..b1652315 --- /dev/null +++ b/tests/phpunit/Unit/Field/TextTest.php @@ -0,0 +1,285 @@ +assertSame('Acme', $value); + $this->assertTrue($field->isComplete()); + } + + public function testTransformApplied(): void { + $field = (new Text(''))->setHandlers(transform: fn(mixed $value): string => is_string($value) ? strtoupper($value) : ''); + + $value = FieldRunner::run($field, ArrayKeyStream::of('acme', Key::named(KeyName::Enter))); + + $this->assertSame('ACME', $value); + } + + public function testValidationBlocksThenAccepts(): void { + $validate = fn(mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'Required.'; + $field = (new Text(''))->setHandlers($validate); + + $field->handle(Key::named(KeyName::Enter)); + $this->assertFalse($field->isComplete()); + $this->assertSame('Required.', $field->error()); + $this->assertStringContainsString('Required.', $field->view(new DefaultTheme())); + + $field->handle(Key::char('a')); + $field->handle(Key::char('b')); + $field->handle(Key::named(KeyName::Enter)); + + $this->assertTrue($field->isComplete()); + $this->assertNull($field->error()); + $this->assertSame('ab', $field->value()); + } + + public function testCursorEditingAndBackspace(): void { + $field = new Text('ac'); + + $field->handle(Key::named(KeyName::Left)); + $field->handle(Key::char('b')); + $this->assertSame('abc', $field->value()); + + $field->handle(Key::named(KeyName::Backspace)); + $this->assertSame('ac', $field->value()); + + $field->handle(Key::named(KeyName::Right)); + $this->assertStringContainsString('█', $field->view(new DefaultTheme())); + } + + public function testMultibyteEditingKeepsCharacterBoundaries(): void { + $field = new Text(); + + // One Backspace removes a whole multi-byte character, not one byte. + $field->handle(Key::char('é')); + $field->handle(Key::char('x')); + $field->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::named(KeyName::Backspace)); + $this->assertSame('', $field->value()); + + // Left moves over a whole character, so an insertion cannot split it. + $field->handle(Key::char('é')); + $field->handle(Key::named(KeyName::Left)); + $field->handle(Key::char('a')); + $this->assertSame('aé', $field->value()); + } + + public function testBufferExposesTheLiveInput(): void { + $field = new Text('ab'); + + $field->handle(Key::char('c')); + + $this->assertSame('abc', $field->buffer()); + } + + public function testCancel(): void { + $field = new Text('x'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + $this->assertNull($value); + } + + public function testSpaceInsertsSpace(): void { + $field = new Text(); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Space), Key::char('b'), Key::named(KeyName::Enter))); + + $this->assertSame('a b', $value); + } + + public function testHints(): void { + // A plain field contributes the shared accept/cancel hints. + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Text())->hints()); + + $this->assertSame(['accept', 'cancel'], $labels); + } + + public function testGhostTextRendersDimmedSuffix(): void { + // The first candidate is skipped (no prefix match); the second completes. + $field = new Text('', ['other', 'acme-site']); + + $field->handle(Key::char('a')); + $field->handle(Key::char('c')); + + // The typed prefix stays put and the remaining suffix is dimmed (SGR 90). + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('me-site', $view); + $this->assertStringContainsString("\033[90m", $view); + + // The ghost is a preview: the value stays the typed text until accepted. + $this->assertSame('ac', $field->value()); + } + + public function testTabAcceptsCompletion(): void { + $field = new Text('', ['acme-site']); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Tab), Key::named(KeyName::Enter))); + + $this->assertSame('acme-site', $value); + } + + public function testRightAtEndAcceptsCompletion(): void { + $field = new Text('', ['acme-site']); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame('acme-site', $value); + } + + public function testRightMidBufferMovesCaretWithoutCompleting(): void { + $field = new Text('ab', ['abcdef']); + + // With the caret off the end there is no ghost, so Right advances the caret + // rather than accepting a completion. + $field->handle(Key::named(KeyName::Left)); + $field->handle(Key::named(KeyName::Right)); + + $this->assertSame('ab', $field->value()); + } + + public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void { + $field = new Text('', ['GitHub']); + + $field->handle(Key::char('g')); + $field->handle(Key::char('i')); + $field->handle(Key::named(KeyName::Tab)); + + // A lower-case prefix matches and accepting adopts the candidate's case. + $this->assertSame('GitHub', $field->value()); + } + + public function testGhostTextIsUnicodeAware(): void { + // strtolower() folds only ASCII, so a non-ASCII prefix must fold with + // mbstring; the multibyte suffix must render whole, not split mid-byte. + $field = new Text('', ['Éclair']); + + $field->handle(Key::char('é')); + $this->assertStringContainsString('clair', $field->view(new DefaultTheme())); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('Éclair', $field->value()); + } + + public function testNoMatchLeavesPlainField(): void { + $field = new Text('', ['acme-site']); + + $field->handle(Key::char('z')); + + // No candidate starts with "z": no dimmed ghost, and Tab is inert. + $view = $field->view(new DefaultTheme()); + $this->assertStringNotContainsString("\033[90m", $view); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertSame('z', $field->value()); + } + + public function testFullyTypedCandidateHasNoGhost(): void { + $field = new Text('', ['php']); + + $field->handle(Key::char('p')); + $field->handle(Key::char('h')); + $field->handle(Key::char('p')); + + // The buffer already equals the only candidate; nothing is left to ghost. + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + } + + public function testEmptyBufferShowsNoGhost(): void { + // With nothing typed there is no prefix to complete, so no ghost renders. + $field = new Text('', ['acme-site']); + + $this->assertStringNotContainsString("\033[90m", $field->view(new DefaultTheme())); + } + + public function testGhostSuppressedInNoAnsiMode(): void { + $field = new Text('', ['acme-site']); + + $field->handle(Key::char('a')); + $field->handle(Key::char('c')); + + // Without colour the ghost cannot be dimmed, so it is suppressed and no + // escape sequences leak into the plain-text line. + $view = $field->view(new DefaultTheme(76, ['color' => FALSE])); + $this->assertStringNotContainsString('me-site', $view); + $this->assertStringNotContainsString("\033", $view); + } + + public function testPlaceholderGhostsAnEmptyBuffer(): void { + $field = (new Text())->setPlaceholder('E.g. Golden Beetroot'); + + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('E.g. Golden Beetroot', $view); + $this->assertStringContainsString("\033[90m", $view); + + // The placeholder is not a value: the field still reads as unanswered. + $this->assertSame('', $field->value()); + } + + public function testPlaceholderClearsOnFirstKeystroke(): void { + $field = (new Text())->setPlaceholder('E.g. Golden Beetroot'); + + $field->handle(Key::char('a')); + + $this->assertStringNotContainsString('E.g. Golden Beetroot', $field->view(new DefaultTheme())); + } + + public function testPlaceholderNeverCompetesWithCompletion(): void { + $field = (new Text('', ['acme-site']))->setPlaceholder('E.g. Golden Beetroot'); + + // A completion needs a typed prefix and a placeholder needs an empty + // buffer, so the one ghost slot is never contested. + $field->handle(Key::char('a')); + + $view = $field->view(new DefaultTheme()); + $this->assertStringContainsString('cme-site', $view); + $this->assertStringNotContainsString('E.g. Golden Beetroot', $view); + } + + public function testPlaceholderSuppressedInNoAnsiMode(): void { + $field = (new Text())->setPlaceholder('E.g. Golden Beetroot'); + + // Without colour it would read as a typed value rather than as a prompt. + $this->assertStringNotContainsString('E.g. Golden Beetroot', $field->view(new DefaultTheme(76, ['color' => FALSE]))); + } + + public function testUndeclaredPlaceholderGhostsNothing(): void { + $this->assertStringNotContainsString("\033[90m", (new Text())->view(new DefaultTheme())); + } + +} diff --git a/tests/phpunit/Unit/Field/TextareaTest.php b/tests/phpunit/Unit/Field/TextareaTest.php new file mode 100644 index 00000000..30bc0617 --- /dev/null +++ b/tests/phpunit/Unit/Field/TextareaTest.php @@ -0,0 +1,165 @@ +assertSame("one\ntwo", $value); + $this->assertTrue($field->isComplete()); + } + + public function testUpAndDownMoveAcrossLines(): void { + $field = new Textarea("ab\ncd"); + + // The cursor starts at the end of "cd"; Up keeps the column on "ab". + $field->handle(Key::named(KeyName::Up)); + $field->handle(Key::char('X')); + + $this->assertSame("abX\ncd", $field->value()); + + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::char('Y')); + + $this->assertSame("abX\ncdY", $field->value()); + } + + public function testUpClampsAtFirstLineAndDownAtLast(): void { + $field = new Textarea('solo'); + + $field->handle(Key::named(KeyName::Up)); + $field->handle(Key::named(KeyName::Down)); + $field->handle(Key::named(KeyName::Tab)); + + $this->assertSame('solo', $field->value()); + } + + public function testUpFromLongerLineClampsColumn(): void { + $field = new Textarea("a\nlonger"); + + $field->handle(Key::named(KeyName::Up)); + $field->handle(Key::char('Z')); + + $this->assertSame("aZ\nlonger", $field->value()); + } + + public function testViewShowsError(): void { + $field = (new Textarea('x'))->setHandlers(validate: fn(mixed $value): string => 'Nope.'); + + $field->handle(Key::named(KeyName::Tab)); + $this->assertStringContainsString('Nope.', $field->view(new DefaultTheme())); + } + + public function testCancel(): void { + $field = new Textarea('x'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Escape))); + + $this->assertTrue($field->isCancelled()); + $this->assertNull($value); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Textarea('x'))->hints()); + + $this->assertSame(['insert a newline', 'accept', 'cancel'], $labels); + } + + public function testEditorKeyRequestsHandoffWhenEnabled(): void { + $field = new Textarea('draft', externalEdit: TRUE); + + $field->handle(Key::char("\x05")); + + $this->assertTrue($field->wantsExternalEdit()); + // The buffer is untouched until the captured value is applied. + $this->assertSame('draft', $field->value()); + $this->assertFalse($field->isComplete()); + } + + public function testEditorKeySwallowedWhenDisabled(): void { + $field = new Textarea('draft'); + + $field->handle(Key::char("\x05")); + + $this->assertFalse($field->wantsExternalEdit()); + // The control key is swallowed, never inserted as a raw byte. + $this->assertSame('draft', $field->value()); + } + + public function testApplyExternalEditReplacesBufferAndAccepts(): void { + $field = new Textarea('old', externalEdit: TRUE); + $field->handle(Key::char("\x05")); + + $field->applyExternalEdit("new\ntext"); + + $this->assertSame("new\ntext", $field->value()); + $this->assertTrue($field->isComplete()); + $this->assertFalse($field->wantsExternalEdit()); + } + + public function testApplyExternalEditNullKeepsBufferAndStaysEditing(): void { + $field = new Textarea('keep', externalEdit: TRUE); + $field->handle(Key::char("\x05")); + + $field->applyExternalEdit(NULL); + + $this->assertSame('keep', $field->value()); + $this->assertFalse($field->isComplete()); + $this->assertFalse($field->wantsExternalEdit()); + } + + public function testApplyExternalEditRunsValidator(): void { + $field = (new Textarea('x', externalEdit: TRUE))->setHandlers(validate: fn(mixed $value): string => 'Nope.'); + + $field->applyExternalEdit('bad'); + + $this->assertFalse($field->isComplete()); + $this->assertStringContainsString('Nope.', $field->view(new DefaultTheme())); + } + + public function testEditorHintOnlyWhenEnabled(): void { + $enabled = array_map(static fn(Hint $hint): string => $hint->label, (new Textarea('x', externalEdit: TRUE))->hints()); + $this->assertContains('open the editor', $enabled); + + $disabled = array_map(static fn(Hint $hint): string => $hint->label, (new Textarea('x'))->hints()); + $this->assertNotContains('open the editor', $disabled); + } + + public function testPlaceholderGhostsAnEmptyBufferOnly(): void { + $field = (new Textarea())->setPlaceholder('E.g. Crisp and sweet'); + + $this->assertStringContainsString('E.g. Crisp and sweet', $field->view(new DefaultTheme())); + + $field->handle(Key::char('C')); + + $this->assertStringNotContainsString('E.g. Crisp and sweet', $field->view(new DefaultTheme())); + } + +} diff --git a/tests/phpunit/Unit/Field/ToggleTest.php b/tests/phpunit/Unit/Field/ToggleTest.php new file mode 100644 index 00000000..b314c395 --- /dev/null +++ b/tests/phpunit/Unit/Field/ToggleTest.php @@ -0,0 +1,146 @@ + 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + $this->assertSame('enabled', $field->value()); + $this->assertStringContainsString('● Enabled', Ansi::strip($field->view(new DefaultTheme()))); + + $field->handle(Key::named(KeyName::Space)); + $this->assertSame('disabled', $field->value()); + $this->assertStringContainsString('● Disabled', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testHonoursExplicitDefault(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'disabled'); + + $this->assertSame('disabled', $field->value()); + $this->assertStringContainsString('● Disabled', Ansi::strip($field->view(new DefaultTheme()))); + } + + public function testUnknownDefaultFallsBackToFirst(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'nope'); + + $this->assertSame('enabled', $field->value()); + } + + #[DataProvider('dataProviderFlipKeys')] + public function testFlipKeys(Key $key): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + + $field->handle($key); + + $this->assertSame('disabled', $field->value()); + } + + /** + * Data provider for testFlipKeys(). + * + * @return \Iterator + * Each key that flips the switch. + */ + public static function dataProviderFlipKeys(): \Iterator { + yield 'space' => [Key::named(KeyName::Space)]; + yield 'left' => [Key::named(KeyName::Left)]; + yield 'right' => [Key::named(KeyName::Right)]; + yield 'up' => [Key::named(KeyName::Up)]; + yield 'down' => [Key::named(KeyName::Down)]; + } + + public function testDirectSelectionByFirstLetter(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + + $field->handle(Key::char('d')); + $this->assertSame('disabled', $field->value()); + + $field->handle(Key::char('e')); + $this->assertSame('enabled', $field->value()); + + // Selection is case-insensitive. + $field->handle(Key::char('D')); + $this->assertSame('disabled', $field->value()); + + // A letter matching neither label is a no-op. + $field->handle(Key::char('z')); + $this->assertSame('disabled', $field->value()); + } + + public function testFirstLetterCollisionSelectsFirstLabel(): void { + $field = new Toggle(['public' => 'Public', 'private' => 'Private'], 'private'); + + // Both labels start with "p"; the first-declared label wins. + $field->handle(Key::char('p')); + $this->assertSame('public', $field->value()); + + // The colliding label stays reachable by flipping. + $field->handle(Key::named(KeyName::Space)); + $this->assertSame('private', $field->value()); + } + + public function testAccept(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + + $value = FieldRunner::run($field, ArrayKeyStream::of(Key::named(KeyName::Space), Key::named(KeyName::Enter))); + + $this->assertSame('disabled', $value); + $this->assertTrue($field->isComplete()); + } + + public function testCancel(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + + $field->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($field->isCancelled()); + } + + public function testAsciiRendering(): void { + $field = new Toggle(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $view = $field->view($theme); + + $this->assertStringContainsString('(*) Enabled', $view); + $this->assertStringContainsString('( ) Disabled', $view); + } + + public function testFlipWithoutOptionsIsSafe(): void { + $field = new Toggle([]); + + $field->handle(Key::named(KeyName::Space)); + + $this->assertSame('', $field->value()); + } + + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new Toggle(['on' => 'On', 'off' => 'Off']))->hints()); + + $this->assertSame(['toggle', 'accept', 'cancel'], $labels); + } + +} diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php index 72fad473..1737234f 100644 --- a/tests/phpunit/Unit/Input/KeyMapTest.php +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -123,8 +123,9 @@ public static function dataProviderDefaultBindings(): \Iterator { public function testForFieldFallsBackToBaseInstance(): void { $map = KeyMapManager::create(); - // Select has no overrides, so it is the very same base instance. - $this->assertSame($map->scope(Scope::base()), $map->forField(FieldType::Select)); + // A single search filters by typed text, so it neither overrides a base + // binding nor takes the help key: it is the very same base instance. + $this->assertSame($map->scope(Scope::base()), $map->forField(FieldType::Search)); } #[DataProvider('dataProviderVimPreset')] diff --git a/tests/phpunit/Unit/Model/BuiltModelTest.php b/tests/phpunit/Unit/Model/BuiltModelTest.php deleted file mode 100644 index 0c34c0cb..00000000 --- a/tests/phpunit/Unit/Model/BuiltModelTest.php +++ /dev/null @@ -1,354 +0,0 @@ -panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name')->default('Acme')->required(); - $p->text('email'); - }) - ->panel('drupal', 'Drupal', function (PanelBuilder $p): void { - $p->select('profile')->option('standard', 'Standard'); - $p->panel('advanced', 'Advanced', function (PanelBuilder $sp): void { - $sp->confirm('theme_debug'); - }); - }) - ->build(); - - $this->assertSame('Demo', $form->title); - $this->assertSame('Acme', $form->subject); - $this->assertCount(2, $form->panels); - - $general = $form->panels[0]; - $this->assertSame('general', $general->id); - $this->assertCount(2, $general->fields); - - $name = $general->fields[0]; - $this->assertSame(FieldType::Text, $name->type); - $this->assertSame('Acme', $name->default); - $this->assertTrue($name->required); - - $drupal = $form->panels[1]; - $profile = $drupal->fields[0]; - $this->assertSame(FieldType::Select, $profile->type); - $standard = $profile->option('standard'); - $this->assertInstanceOf(Option::class, $standard); - $this->assertSame('Standard', $standard->label); - $this->assertNotInstanceOf(Option::class, $profile->option('missing')); - - $this->assertCount(1, $drupal->panels); - $this->assertSame('advanced', $drupal->panels[0]->id); - - // field() resolves nested fields across sub-panels. - $this->assertSame('theme_debug', $form->field('theme_debug')?->id); - $this->assertNotInstanceOf(Field::class, $form->field('nope')); - $this->assertCount(4, $form->fields()); - } - - /** - * Only an empty value on a required field yields a violation message. - * - * @param bool $required - * Whether the field is required. - * @param string $message - * The declared message, empty to derive one from the label. - * @param mixed $value - * The candidate value. - * @param string|null $expected - * The expected message, or NULL when the value is accepted. - */ - #[DataProvider('dataProviderRequiredViolation')] - public function testRequiredViolation(bool $required, string $message, mixed $value, ?string $expected): void { - $field = new Field('plot', 'Garden plot name', '', FieldType::Text, '', required: $required, requiredMessage: $message); - - $this->assertSame($expected, $field->requiredViolation($value)); - } - - /** - * Data provider for testRequiredViolation(). - * - * @return \Iterator - * The required flag, the declared message, the value and the expectation. - */ - public static function dataProviderRequiredViolation(): \Iterator { - $derived = 'Garden plot name is required.'; - $declared = 'The garden plot name is required.'; - - yield 'empty string' => [TRUE, '', '', $derived]; - yield 'empty list' => [TRUE, '', [], $derived]; - yield 'null' => [TRUE, '', NULL, $derived]; - yield 'declared message wins over the label' => [TRUE, $declared, '', $declared]; - yield 'non-empty string' => [TRUE, '', 'North bed', NULL]; - yield 'non-empty list' => [TRUE, '', ['a'], NULL]; - // Only the three empty shapes count: a falsy scalar is an answer, not an - // omission, so a FALSE confirm and a 0 number both pass. - yield 'false' => [TRUE, '', FALSE, NULL]; - yield 'zero' => [TRUE, '', 0, NULL]; - yield 'zero string' => [TRUE, '', '0', NULL]; - yield 'optional field ignores an empty value' => [FALSE, '', '', NULL]; - yield 'optional field ignores a declared message' => [FALSE, $declared, '', NULL]; - } - - /** - * Tests that a name that cannot be honoured is rejected at construction. - * - * @param string $env_name - * The declared name, or empty to keep the mechanical one. - * @param list $aliases - * The declared aliases. - * @param string $expected - * The expected exception message. - */ - #[DataProvider('dataProviderEnvNameViolationThrows')] - public function testEnvNameViolationThrows(string $env_name, array $aliases, string $expected): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage($expected); - - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: $env_name, envAliases: $aliases); - } - - public static function dataProviderEnvNameViolationThrows(): \Iterator { - yield 'name starting with a digit' => ['1CRATE', [], 'Field "crate_size" declares the environment variable name "1CRATE", which is not a portable name']; - yield 'name with a hyphen' => ['OLD-CRATE', [], 'Field "crate_size" declares the environment variable name "OLD-CRATE", which is not a portable name']; - yield 'name with a space' => ['OLD CRATE', [], 'Field "crate_size" declares the environment variable name "OLD CRATE", which is not a portable name']; - yield 'alias with a hyphen' => ['', ['OLD-CRATE'], 'Field "crate_size" declares the environment variable alias "OLD-CRATE", which is not a portable name']; - yield 'empty alias' => ['', [''], 'Field "crate_size" declares the environment variable alias "", which is not a portable name']; - yield 'alias repeating the name' => ['NEW_CRATE', ['NEW_CRATE'], 'Field "crate_size" declares "NEW_CRATE" as both its environment variable name and an alias of it']; - yield 'alias declared twice' => ['', ['OLD_CRATE', 'OLD_CRATE'], 'Field "crate_size" declares the environment variable alias "OLD_CRATE" more than once']; - } - - /** - * Tests that a name that can be honoured is kept as declared. - * - * @param string $env_name - * The declared name, or empty to keep the mechanical one. - * @param list $aliases - * The declared aliases. - */ - #[DataProvider('dataProviderEnvNameAccepted')] - public function testEnvNameAccepted(string $env_name, array $aliases): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: $env_name, envAliases: $aliases); - - $this->assertSame($env_name, $field->envName); - $this->assertSame($aliases, $field->envAliases); - } - - public static function dataProviderEnvNameAccepted(): \Iterator { - yield 'nothing declared' => ['', []]; - yield 'name only' => ['NEW_CRATE', []]; - yield 'aliases only' => ['', ['OLD_CRATE', 'OLDER_CRATE']]; - yield 'name and aliases' => ['NEW_CRATE', ['OLD_CRATE']]; - yield 'leading underscore' => ['_CRATE', []]; - yield 'digits after the first character' => ['CRATE_2', []]; - yield 'lowercase is left as declared' => ['old_crate', []]; - } - - public function testTemplateFieldWithoutTemplateThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "crate" is a template field but declares no pattern'); - - new Field('crate', 'Crate', '', FieldType::Template, ''); - } - - #[DataProvider('dataProviderTemplateError')] - public function testTemplateError(mixed $value, ?string $expected): void { - $field = new Field('crate', 'Crate', '', FieldType::Template, '', template: new Template('{{a}}-{{b}}', ['b' => 'Beta'], [ - 'b' => static fn(string $part): ?string => $part === 'ok' ? NULL : 'must be ok', - ])); - - $this->assertSame($expected, $field->templateError($value)); - } - - public static function dataProviderTemplateError(): \Iterator { - yield 'fits the shape' => ['one-ok', NULL]; - yield 'slot rejected' => ['one-bad', 'Beta: must be ok']; - yield 'shape mismatch' => ['nope', '"nope" does not match the template "{{a}}-{{b}}".']; - // An unfilled template is left to the required check, and a non-string is - // left to the type check, so neither is reported here. - yield 'empty' => ['', NULL]; - yield 'not a string' => [42, NULL]; - } - - public function testTemplateErrorIsNullWithoutTemplate(): void { - $this->assertNull((new Field('name', 'Name', '', FieldType::Text, ''))->templateError('anything')); - } - - #[DataProvider('dataProviderTemplateParts')] - public function testTemplateParts(mixed $value, array $expected): void { - $field = new Field('crate', 'Crate', '', FieldType::Template, '', template: new Template('{{a}}-{{b}}')); - - $this->assertSame($expected, $field->templateParts($value)); - } - - public static function dataProviderTemplateParts(): \Iterator { - yield 'fits the shape' => ['one-two', ['a' => 'one', 'b' => 'two']]; - yield 'shape mismatch' => ['nope', []]; - yield 'not a string' => [42, []]; - } - - public function testTemplatePartsAreEmptyWithoutTemplate(): void { - $this->assertSame([], (new Field('name', 'Name', '', FieldType::Text, ''))->templateParts('one-two')); - } - - public function testPanelItemCount(): void { - $panel = new Panel('p', 'P', '', [new Field('a', 'A', '', FieldType::Text, '')], [new Panel('s', 'S', '')]); - - $this->assertSame(2, $panel->itemCount()); - $this->assertSame(0, (new Panel('empty', 'E', ''))->itemCount()); - } - - public function testTypeDefaults(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->select('ms')->multiple(); - $p->confirm('cb'); - $p->text('tx'); - }) - ->build(); - - $this->assertSame([], $form->field('ms')?->default); - $this->assertFalse($form->field('cb')?->default); - $this->assertSame('', $form->field('tx')?->default); - } - - public function testSchemaDefault(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('with')->schemaDefault('static'); - $p->text('without'); - }) - ->build(); - - $with = $form->field('with'); - $this->assertInstanceOf(Field::class, $with); - $this->assertTrue($with->hasSchemaDefault); - $this->assertSame('static', $with->schemaDefault); - - $without = $form->field('without'); - $this->assertInstanceOf(Field::class, $without); - $this->assertFalse($without->hasSchemaDefault); - $this->assertNull($without->schemaDefault); - } - - public function testSelectionBoundsOnNonMultipleFieldThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "s" declares selection limits but does not collect several values.'); - - new Field('s', 'S', '', FieldType::Text, '', selectionBounds: new SelectionBounds(2, 3)); - } - - public function testCaptionsOnFieldWithNoScaleThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "f" of type "text" draws no scale to caption; ->captions() applies to rating fields.'); - - new Field('f', 'F', '', FieldType::Text, '', ratingCaptions: [1 => 'Poor']); - } - - public function testCaptionOutsideTheScaleThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "f" captions the point 9, which is outside its scale of between 1 and 5.'); - - new Field('f', 'F', '', FieldType::Rating, 1, bounds: new NumberBounds(1, 5), ratingCaptions: [9 => 'Nope']); - } - - public function testCaptionsWithinTheScaleAreKept(): void { - $field = new Field('f', 'F', '', FieldType::Rating, 1, bounds: new NumberBounds(1, 5), ratingCaptions: [1 => 'Poor', 5 => 'Excellent']); - - $this->assertSame([1 => 'Poor', 5 => 'Excellent'], $field->ratingCaptions); - } - - public function testCaptionsOnAnUnboundedRatingAreKept(): void { - // The builder always closes a rating's scale; a hand-built field without - // one has no range to check a caption against, so every point passes. - $field = new Field('f', 'F', '', FieldType::Rating, 1, ratingCaptions: [99 => 'Far out']); - - $this->assertSame([99 => 'Far out'], $field->ratingCaptions); - } - - #[DataProvider('dataProviderPlaceholderIsRejectedWhenTypeHasNoInput')] - public function testPlaceholderIsRejectedWhenTypeHasNoInput(FieldType $type, bool $accepted): void { - if (!$accepted) { - $this->expectException(FormException::class); - $this->expectExceptionMessage(sprintf('Field "f" of type "%s" shows no placeholder', $type->value)); - } - - $field = new Field('f', 'F', '', $type, '', template: $type === FieldType::Template ? new Template('{{a}}-{{b}}') : NULL, placeholder: 'E.g. Golden Beetroot'); - - $this->assertSame('E.g. Golden Beetroot', $field->placeholder); - } - - public static function dataProviderPlaceholderIsRejectedWhenTypeHasNoInput(): \Iterator { - // The accepting types are spelled out rather than read back from - // supportsPlaceholder(), so a change to that set fails here instead of - // moving the expectation along with it. - $accepting = [ - FieldType::Text, - FieldType::Number, - FieldType::Textarea, - FieldType::Password, - FieldType::Suggest, - FieldType::Search, - ]; - - foreach (FieldType::cases() as $type) { - yield $type->value => [$type, in_array($type, $accepting, TRUE)]; - } - } - - #[DataProvider('dataProviderHintIsAcceptedOnEveryType')] - public function testHintIsAcceptedOnEveryType(FieldType $type): void { - $field = new Field('f', 'F', '', $type, '', template: $type === FieldType::Template ? new Template('{{a}}-{{b}}') : NULL, hint: 'Use the arrows.'); - - $this->assertSame('Use the arrows.', $field->hint); - } - - public static function dataProviderHintIsAcceptedOnEveryType(): \Iterator { - foreach (FieldType::cases() as $type) { - yield $type->value => [$type]; - } - } - - public function testFormDefaults(): void { - $form = Form::create('T')->build(); - - $this->assertSame('', $form->subject); - $this->assertSame('', $form->envPrefix); - $this->assertSame([], $form->fixups); - // Form chrome defaults (the global TUI runtime lives on the Tui facade). - $this->assertSame('', $form->banner); - $this->assertTrue($form->buttons->show); - $this->assertSame('Submit', $form->buttons->submitLabel); - $this->assertSame('Cancel', $form->buttons->cancelLabel); - } - -} diff --git a/tests/phpunit/Unit/Model/FormDefinitionTest.php b/tests/phpunit/Unit/Model/FormDefinitionTest.php deleted file mode 100644 index 5518a945..00000000 --- a/tests/phpunit/Unit/Model/FormDefinitionTest.php +++ /dev/null @@ -1,122 +0,0 @@ - $field->id, $form->fields()); - - $this->assertSame(['f1', 'f2', 'f3'], $ids); - } - - public function testFieldFindsByIdAcrossTree(): void { - $form = new FormDefinition('T', 'S', [ - new Panel('a', 'A', '', [new Field('top', 'T', '', FieldType::Text, '')], [ - new Panel('b', 'B', '', [new Field('deep', 'D', '', FieldType::Text, '')]), - ]), - ]); - - $this->assertSame('top', $form->field('top')?->id); - $this->assertSame('deep', $form->field('deep')?->id); - $this->assertNotInstanceOf(Field::class, $form->field('missing')); - } - - /** - * Tests the conditional depth stamped onto each field. - * - * @param array $rules - * The `when` rule of each field, keyed by the field id to declare. - * @param array $expected - * The depth each field is expected to resolve to, keyed by field id. - */ - #[DataProvider('dataProviderConditionalDepth')] - public function testConditionalDepth(array $rules, array $expected): void { - $fields = []; - foreach ($rules as $id => $rule) { - $fields[] = new Field($id, strtoupper($id), '', FieldType::Text, '', when: $rule); - } - - $form = new FormDefinition('T', 'S', [new Panel('p', 'P', '', $fields)]); - - $actual = []; - foreach ($form->fields() as $field) { - $actual[$field->id] = $field->conditionalDepth; - } - - $this->assertSame($expected, $actual); - } - - public static function dataProviderConditionalDepth(): \Iterator { - yield 'unconditional fields stay flat' => [ - ['a' => NULL, 'b' => NULL], - ['a' => 0, 'b' => 0], - ]; - yield 'a rule over an unconditional field is one deep' => [ - ['a' => NULL, 'b' => new Condition('a', eq: 'x')], - ['a' => 0, 'b' => 1], - ]; - yield 'a chain deepens by one per link' => [ - [ - 'a' => NULL, - 'b' => new Condition('a', eq: 'x'), - 'c' => new Condition('b', eq: 'y'), - 'd' => new Condition('c', eq: 'z'), - ], - ['a' => 0, 'b' => 1, 'c' => 2, 'd' => 3], - ]; - yield 'a composite takes its deepest reference' => [ - [ - 'a' => NULL, - 'b' => new Condition('a', eq: 'x'), - 'c' => Condition::all(new Condition('a', eq: 'x'), new Condition('b', eq: 'y')), - ], - ['a' => 0, 'b' => 1, 'c' => 2], - ]; - yield 'a rule referencing an unknown field is one deep' => [ - ['a' => new Condition('nowhere', eq: 'x')], - ['a' => 1], - ]; - yield 'a forward reference resolves like a backward one' => [ - ['a' => new Condition('b', eq: 'x'), 'b' => new Condition('c', eq: 'y'), 'c' => NULL], - ['a' => 2, 'b' => 1, 'c' => 0], - ]; - yield 'a field referencing itself does not deepen forever' => [ - ['a' => new Condition('a', eq: 'x')], - ['a' => 1], - ]; - yield 'a cycle between two fields terminates' => [ - ['a' => new Condition('b', eq: 'x'), 'b' => new Condition('a', eq: 'y')], - ['a' => 2, 'b' => 1], - ]; - yield 'a negated rule counts like any other' => [ - ['a' => NULL, 'b' => Condition::not(new Condition('a', eq: 'x'))], - ['a' => 0, 'b' => 1], - ]; - } - -} diff --git a/tests/phpunit/Unit/Model/ModalTest.php b/tests/phpunit/Unit/Model/ModalTest.php deleted file mode 100644 index 6b7a02df..00000000 --- a/tests/phpunit/Unit/Model/ModalTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertTrue($modal->buttons->show); - $this->assertSame('Submit', $modal->buttons->submitLabel); - $this->assertSame('Cancel', $modal->buttons->cancelLabel); - } - - public function testCustomButtons(): void { - $modal = new Modal(new Buttons(TRUE, 'Yes', 'No')); - - $this->assertSame('Yes', $modal->buttons->submitLabel); - $this->assertSame('No', $modal->buttons->cancelLabel); - } - - public function testHiddenButtonsRejected(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('A modal dialog must show its buttons.'); - - new Modal(new Buttons(FALSE)); - } - -} diff --git a/tests/phpunit/Unit/Model/OptionTest.php b/tests/phpunit/Unit/Model/OptionTest.php deleted file mode 100644 index c931b1e3..00000000 --- a/tests/phpunit/Unit/Model/OptionTest.php +++ /dev/null @@ -1,287 +0,0 @@ - 'Apple', 'b' => 'Banana']); - - $this->assertCount(2, $options); - $this->assertSame('a', $options[0]->value); - $this->assertSame('Apple', $options[0]->label); - $this->assertSame(OptionKind::Option, $options[0]->kind); - $this->assertTrue($options[0]->selectable()); - } - - public function testListLabelDefaultsToValue(): void { - $options = Option::list(['a' => '']); - - $this->assertSame('a', $options[0]->label); - } - - public function testListFromOptionsPassesThrough(): void { - $sep = new Option('', '', '', OptionKind::Separator); - $options = Option::list([new Option('a', 'Apple'), $sep]); - - $this->assertSame('Apple', $options[0]->label); - $this->assertSame($sep, $options[1]); - } - - public function testListMixed(): void { - $options = Option::list(['a' => 'Apple', new Option('b', 'Banana', '', OptionKind::Option, TRUE, 'nope')]); - - $this->assertSame('a', $options[0]->value); - $this->assertTrue($options[1]->disabled); - $this->assertSame('nope', $options[1]->disabledReason); - } - - #[DataProvider('dataProviderSelectable')] - public function testSelectable(Option $option, bool $expected): void { - $this->assertSame($expected, $option->selectable()); - } - - public static function dataProviderSelectable(): \Iterator { - yield 'plain option' => [new Option('a', 'A'), TRUE]; - yield 'disabled option' => [new Option('a', 'A', '', OptionKind::Option, TRUE), FALSE]; - yield 'separator' => [new Option('', '', '', OptionKind::Separator), FALSE]; - yield 'heading' => [new Option('', 'Group', '', OptionKind::Heading), FALSE]; - } - - #[DataProvider('dataProviderConstrainsToOptions')] - public function testConstrainsToOptions(FieldType $type, bool $expected): void { - $this->assertSame($expected, $type->constrainsToOptions()); - } - - public static function dataProviderConstrainsToOptions(): \Iterator { - yield [FieldType::Select, TRUE]; - yield [FieldType::Search, TRUE]; - yield [FieldType::Reorder, TRUE]; - yield [FieldType::Suggest, FALSE]; - yield [FieldType::Text, FALSE]; - yield [FieldType::Confirm, FALSE]; - } - - #[DataProvider('dataProviderIsMultiChoice')] - public function testIsMultiChoice(FieldType $type, bool $multiple, bool $expected): void { - $field = new Field('f', 'F', '', $type, $multiple ? [] : '', multiple: $multiple); - - $this->assertSame($expected, $field->isMultiChoice()); - } - - public static function dataProviderIsMultiChoice(): \Iterator { - yield 'multiple select' => [FieldType::Select, TRUE, TRUE]; - yield 'multiple search' => [FieldType::Search, TRUE, TRUE]; - yield 'reorder' => [FieldType::Reorder, FALSE, TRUE]; - yield 'multiple file picker' => [FieldType::FilePicker, TRUE, FALSE]; - yield 'single select' => [FieldType::Select, FALSE, FALSE]; - yield 'single search' => [FieldType::Search, FALSE, FALSE]; - yield 'text' => [FieldType::Text, FALSE, FALSE]; - } - - #[DataProvider('dataProviderCollectsList')] - public function testCollectsList(FieldType $type, bool $multiple, bool $expected): void { - $field = new Field('f', 'F', '', $type, $multiple ? [] : '', multiple: $multiple); - - $this->assertSame($expected, $field->collectsList()); - } - - public static function dataProviderCollectsList(): \Iterator { - yield 'multiple select' => [FieldType::Select, TRUE, TRUE]; - yield 'multiple search' => [FieldType::Search, TRUE, TRUE]; - yield 'multiple file picker' => [FieldType::FilePicker, TRUE, TRUE]; - yield 'reorder' => [FieldType::Reorder, FALSE, TRUE]; - yield 'single select' => [FieldType::Select, FALSE, FALSE]; - yield 'single file picker' => [FieldType::FilePicker, FALSE, FALSE]; - yield 'text' => [FieldType::Text, FALSE, FALSE]; - } - - #[DataProvider('dataProviderAcceptsValue')] - public function testAcceptsValue(FieldType $type, bool $multiple, mixed $value, bool $expected): void { - $field = new Field('f', 'F', '', $type, $multiple ? [] : '', multiple: $multiple); - - $this->assertSame($expected, $field->acceptsValue($value)); - } - - public static function dataProviderAcceptsValue(): \Iterator { - yield 'confirm accepts bool' => [FieldType::Confirm, FALSE, TRUE, TRUE]; - yield 'confirm rejects string' => [FieldType::Confirm, FALSE, 'yes', FALSE]; - yield 'pause accepts bool' => [FieldType::Pause, FALSE, FALSE, TRUE]; - yield 'multiple accepts list' => [FieldType::Select, TRUE, ['a'], TRUE]; - yield 'multiple rejects scalar' => [FieldType::Select, TRUE, 'a', FALSE]; - yield 'reorder accepts list' => [FieldType::Reorder, FALSE, ['a'], TRUE]; - yield 'number accepts int' => [FieldType::Number, FALSE, 42, TRUE]; - yield 'number rejects numeric string' => [FieldType::Number, FALSE, '42', FALSE]; - yield 'calendar accepts empty' => [FieldType::Calendar, FALSE, '', TRUE]; - yield 'calendar accepts iso date' => [FieldType::Calendar, FALSE, '2026-07-16', TRUE]; - yield 'calendar rejects non-date' => [FieldType::Calendar, FALSE, 'nope', FALSE]; - yield 'text accepts string' => [FieldType::Text, FALSE, 'x', TRUE]; - yield 'text rejects int' => [FieldType::Text, FALSE, 1, FALSE]; - } - - #[DataProvider('dataProviderValueKind')] - public function testValueKind(FieldType $type, bool $multiple, string $expected): void { - $field = new Field('f', 'F', '', $type, $multiple ? [] : '', multiple: $multiple); - - $this->assertSame($expected, $field->valueKind()); - } - - public static function dataProviderValueKind(): \Iterator { - yield 'confirm' => [FieldType::Confirm, FALSE, 'a boolean']; - yield 'pause' => [FieldType::Pause, FALSE, 'a boolean']; - yield 'multiple' => [FieldType::Select, TRUE, 'a list']; - yield 'reorder' => [FieldType::Reorder, FALSE, 'a list']; - yield 'number' => [FieldType::Number, FALSE, 'a number']; - yield 'calendar' => [FieldType::Calendar, FALSE, 'a date (YYYY-MM-DD)']; - yield 'text' => [FieldType::Text, FALSE, 'a string']; - } - - #[DataProvider('dataProviderSupportsMultiple')] - public function testSupportsMultiple(FieldType $type, bool $expected): void { - $this->assertSame($expected, $type->supportsMultiple()); - } - - public static function dataProviderSupportsMultiple(): \Iterator { - yield 'select' => [FieldType::Select, TRUE]; - yield 'search' => [FieldType::Search, TRUE]; - yield 'file picker' => [FieldType::FilePicker, TRUE]; - yield 'reorder' => [FieldType::Reorder, FALSE]; - yield 'number' => [FieldType::Number, FALSE]; - yield 'text' => [FieldType::Text, FALSE]; - } - - #[DataProvider('dataProviderIsPresentational')] - public function testIsPresentational(FieldType $type, bool $expected): void { - $this->assertSame($expected, $type->isPresentational()); - } - - public static function dataProviderIsPresentational(): \Iterator { - yield 'note' => [FieldType::Note, TRUE]; - // A pause renders but still carries a boolean answer, so it is not - // presentational. - yield 'pause' => [FieldType::Pause, FALSE]; - yield 'text' => [FieldType::Text, FALSE]; - yield 'confirm' => [FieldType::Confirm, FALSE]; - } - - public function testNoteLabel(): void { - $this->assertSame('Note', FieldType::Note->label()); - } - - public function testConstructorRejectsMultipleOnUnsupportedType(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "n" of type "number" does not collect several values'); - - new Field('n', 'N', '', FieldType::Number, 0, multiple: TRUE); - } - - public function testFieldOptionScan(): void { - $field = $this->selectField(); - - $this->assertSame('Standard', $field->option('standard')?->label); - // A disabled option is still found by value. - $this->assertTrue($field->option('demo')?->disabled); - // Missing values and structural rows are not returned. - $this->assertNotInstanceOf(Option::class, $field->option('missing')); - $this->assertNotInstanceOf(Option::class, $field->option('')); - } - - public function testSelectableValues(): void { - $this->assertSame(['standard', 'minimal'], $this->selectField()->selectableValues()); - } - - #[DataProvider('dataProviderOptionError')] - public function testOptionError(FieldType $type, bool $multiple, array $options, mixed $value, ?string $expected): void { - $field = new Field('f', 'F', '', $type, $multiple ? [] : '', $options, multiple: $multiple); - - $this->assertSame($expected, $field->optionError($value)); - } - - public static function dataProviderOptionError(): \Iterator { - $options = [ - new Option('standard', 'Standard'), - new Option('minimal', 'Minimal'), - new Option('demo', 'Demo', '', OptionKind::Option, TRUE, 'unavailable'), - new Option('legacy', 'Legacy', '', OptionKind::Option, TRUE), - new Option('', '', '', OptionKind::Separator), - ]; - yield 'selectable value' => [FieldType::Select, FALSE, $options, 'standard', NULL]; - yield 'disabled with reason' => [FieldType::Select, FALSE, $options, 'demo', 'option "demo" is disabled: unavailable']; - yield 'disabled without reason' => [FieldType::Select, FALSE, $options, 'legacy', 'option "legacy" is disabled']; - yield 'unknown value' => [FieldType::Select, FALSE, $options, 'bogus', 'value "bogus" is not one of: standard, minimal']; - yield 'unconstrained type' => [FieldType::Suggest, FALSE, $options, 'bogus', NULL]; - yield 'no options' => [FieldType::Select, FALSE, [], 'bogus', NULL]; - yield 'multi valid' => [FieldType::Select, TRUE, $options, ['standard', 'minimal'], NULL]; - yield 'multi disabled item' => [FieldType::Select, TRUE, $options, ['standard', 'demo'], 'option "demo" is disabled: unavailable']; - yield 'multi non-array' => [FieldType::Select, TRUE, $options, 'standard', 'value must be a list']; - yield 'reorder full permutation' => [FieldType::Reorder, FALSE, $options, ['minimal', 'standard'], NULL]; - yield 'reorder partial' => [FieldType::Reorder, FALSE, $options, ['standard'], 'must rank every option exactly once (standard, minimal)']; - yield 'reorder duplicate' => [FieldType::Reorder, FALSE, $options, ['standard', 'standard'], 'must rank every option exactly once (standard, minimal)']; - yield 'reorder unknown item' => [FieldType::Reorder, FALSE, $options, ['standard', 'bogus'], 'value "bogus" is not one of: standard, minimal']; - yield 'reorder non-array' => [FieldType::Reorder, FALSE, $options, 'standard', 'value must be a list']; - } - - /** - * Tests completing and de-duplicating a desired ordering. - * - * @param list $allowed - * The full set of values, in declared order. - * @param list $desired - * The requested ordering. - * @param list $expected - * The resolved permutation. - */ - #[DataProvider('dataProviderCanonicalOrder')] - public function testCanonicalOrder(array $allowed, array $desired, array $expected): void { - $this->assertSame($expected, Field::canonicalOrder($allowed, $desired)); - } - - /** - * Data provider for testCanonicalOrder(). - * - * @return \Iterator, list, list}> - * The allowed values, desired order and resolved permutation. - */ - public static function dataProviderCanonicalOrder(): \Iterator { - yield 'empty desired keeps declared order' => [['a', 'b', 'c'], [], ['a', 'b', 'c']]; - yield 'full desired preserved' => [['a', 'b', 'c'], ['c', 'b', 'a'], ['c', 'b', 'a']]; - yield 'partial desired completed' => [['a', 'b', 'c'], ['c'], ['c', 'a', 'b']]; - yield 'unknown desired dropped' => [['a', 'b', 'c'], ['x', 'b'], ['b', 'a', 'c']]; - yield 'duplicate desired collapsed' => [['a', 'b', 'c'], ['b', 'b', 'a'], ['b', 'a', 'c']]; - yield 'no allowed values' => [[], ['a'], []]; - } - - /** - * A select field mixing selectable, disabled and structural rows. - */ - protected function selectField(): Field { - return new Field('profile', 'Profile', '', FieldType::Select, '', [ - new Option('standard', 'Standard'), - new Option('', 'Group', '', OptionKind::Heading), - new Option('minimal', 'Minimal'), - new Option('', '', '', OptionKind::Separator), - new Option('demo', 'Demo', '', OptionKind::Option, TRUE, 'unavailable'), - ]); - } - -} diff --git a/tests/phpunit/Unit/Model/PanelTest.php b/tests/phpunit/Unit/Model/PanelTest.php deleted file mode 100644 index eaeb1fb7..00000000 --- a/tests/phpunit/Unit/Model/PanelTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertSame(3, $panel->itemCount()); - } - - public function testIsModal(): void { - $plain = new Panel('p', 'P', ''); - $this->assertFalse($plain->isModal()); - $this->assertNotInstanceOf(Modal::class, $plain->modal); - - $dialog = new Panel('d', 'D', '', [], [], new Modal()); - $this->assertTrue($dialog->isModal()); - $this->assertInstanceOf(Modal::class, $dialog->modal); - } - -} diff --git a/tests/phpunit/Unit/ProgressFieldTest.php b/tests/phpunit/Unit/ProgressFieldTest.php new file mode 100644 index 00000000..7bf6fba4 --- /dev/null +++ b/tests/phpunit/Unit/ProgressFieldTest.php @@ -0,0 +1,152 @@ +form($this->work(), 3)->root()->children()[0]->in('content')->blocks()[0]; + + $this->assertInstanceOf(Progress::class, $block); + $this->assertSame(3, $block->total()); + $this->assertInstanceOf(\Closure::class, $block->workload()); + } + + public function testNonPositiveStepsAreRejected(): void { + $this->expectException(FormException::class); + + (new FieldBuilder('apply', 'Apply', FieldType::Progress))->steps(-1); + } + + public function testActivatingDeterminateRowRunsTheWorkAndCollectsNoAnswer(): void { + $steps = 0; + $work = function (ProgressReporter $reporter) use (&$steps): void { + for ($index = 1; $index <= 3; $index++) { + $steps++; + $reporter->advance('packed ' . $index); + } + }; + + // Drill into the panel, then activate the progress row. + $tester = (new TuiTester($this->form($work, 3)))->rows(12); + $answers = $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + $this->assertSame(3, $steps); + $this->assertFalse($answers->has('apply')); + // The label the work set through advance() shows beside the bar. + $this->assertStringContainsString('packed 3', $tester->display()); + } + + public function testActivatingIndeterminateRowTicksLikeSpinner(): void { + $ticks = 0; + $work = function (ProgressReporter $reporter) use (&$ticks): void { + for ($index = 0; $index < 4; $index++) { + $ticks++; + $reporter->advance(); + } + }; + + // No steps declared, so the indicator is an indeterminate spinner. + $answers = (new TuiTester($this->form($work)))->rows(12)->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + $this->assertSame(4, $ticks); + $this->assertFalse($answers->has('apply')); + } + + public function testActivatingRowWithoutWorkIsNoOp(): void { + $form = Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p): void { + $p->progress('apply', 'Apply')->steps(3); + }); + + $answers = (new TuiTester($form))->rows(12)->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + $this->assertFalse($answers->has('apply')); + } + + public function testHeadlessCollectionOmitsProgressRow(): void { + $answers = (new Tui($this->form($this->work(), 3)))->collect('{}'); + + $this->assertFalse($answers->has('apply')); + } + + public function testTheAnswerSchemaOmitsProgressRow(): void { + $form = Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p): void { + $p->text('name', 'Name'); + $p->progress('apply', 'Apply')->steps(1)->run($this->work()); + })->root(); + + $schema = (new AgentHelp($form))->generate(); + + // A real question keeps its schema property; the progress row carries none. + $this->assertStringContainsString('name', $schema); + $this->assertStringNotContainsString('apply', $schema); + } + + /** + * A single-panel form whose only row is a progress row. + * + * @param \Closure $work + * The work the row runs when activated. + * @param int|null $steps + * The step count for a determinate bar, or NULL for a spinner. + * + * @return \DrevOps\Tui\Builder\Form + * The form. + */ + protected function form(\Closure $work, ?int $steps = NULL): Form { + return Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p) use ($work, $steps): void { + $builder = $p->progress('apply', 'Apply')->run($work); + + if ($steps !== NULL) { + $builder->steps($steps); + } + }); + } + + /** + * A single-step work closure, for cases that never actually run it. + * + * @return \Closure + * The work. + */ + protected function work(): \Closure { + return static function (ProgressReporter $reporter): void { + $reporter->advance(); + }; + } + +} diff --git a/tests/phpunit/Unit/ProgressWidgetTest.php b/tests/phpunit/Unit/ProgressWidgetTest.php deleted file mode 100644 index f9194071..00000000 --- a/tests/phpunit/Unit/ProgressWidgetTest.php +++ /dev/null @@ -1,153 +0,0 @@ -form($this->work(), 3)->build()->fields()[0]; - - $this->assertSame(FieldType::Progress, $field->type); - $this->assertSame(3, $field->progressSteps); - $this->assertInstanceOf(\Closure::class, $field->progressWork); - $this->assertNull($field->progressCurrent); - } - - public function testNonPositiveStepsAreRejected(): void { - $this->expectException(FormException::class); - - (new FieldBuilder('apply', 'Apply', FieldType::Progress))->steps(-1); - } - - public function testActivatingDeterminateRowRunsTheWorkAndCollectsNoAnswer(): void { - $steps = 0; - $work = function (ProgressReporter $reporter) use (&$steps): void { - for ($index = 1; $index <= 3; $index++) { - $steps++; - $reporter->advance('packed ' . $index); - } - }; - - // Drill into the panel, then activate the progress row. - $tester = (new TuiTester($this->form($work, 3)))->rows(12); - $answers = $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); - - $this->assertSame(3, $steps); - $this->assertFalse($answers->has('apply')); - // The label the work set through advance() shows beside the bar. - $this->assertStringContainsString('packed 3', $tester->display()); - } - - public function testActivatingIndeterminateRowTicksLikeSpinner(): void { - $ticks = 0; - $work = function (ProgressReporter $reporter) use (&$ticks): void { - for ($index = 0; $index < 4; $index++) { - $ticks++; - $reporter->advance(); - } - }; - - // No steps declared, so the indicator is an indeterminate spinner. - $answers = (new TuiTester($this->form($work)))->rows(12)->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); - - $this->assertSame(4, $ticks); - $this->assertFalse($answers->has('apply')); - } - - public function testActivatingRowWithoutWorkIsNoOp(): void { - $form = Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p): void { - $p->progress('apply', 'Apply')->steps(3); - }); - - $answers = (new TuiTester($form))->rows(12)->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); - - $this->assertFalse($answers->has('apply')); - } - - public function testHeadlessCollectionOmitsProgressRow(): void { - $answers = (new Tui($this->form($this->work(), 3)))->collect('{}'); - - $this->assertFalse($answers->has('apply')); - } - - public function testTheAnswerSchemaOmitsProgressRow(): void { - $form = Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->progress('apply', 'Apply')->steps(1)->run($this->work()); - })->build(); - - $schema = (new AgentHelp($form))->generate(); - - // A real question keeps its schema property; the progress row carries none. - $this->assertStringContainsString('name', $schema); - $this->assertStringNotContainsString('apply', $schema); - } - - /** - * A single-panel form whose only row is a progress row. - * - * @param \Closure $work - * The work the row runs when activated. - * @param int|null $steps - * The step count for a determinate bar, or NULL for a spinner. - * - * @return \DrevOps\Tui\Builder\Form - * The form. - */ - protected function form(\Closure $work, ?int $steps = NULL): Form { - return Form::create('Apply')->panel('prep', 'Prep', function (PanelBuilder $p) use ($work, $steps): void { - $builder = $p->progress('apply', 'Apply')->run($work); - - if ($steps !== NULL) { - $builder->steps($steps); - } - }); - } - - /** - * A single-step work closure, for cases that never actually run it. - * - * @return \Closure - * The work. - */ - protected function work(): \Closure { - return static function (ProgressReporter $reporter): void { - $reporter->advance(); - }; - } - -} diff --git a/tests/phpunit/Unit/ProgressableTest.php b/tests/phpunit/Unit/ProgressableTest.php index f76d8665..d286806f 100644 --- a/tests/phpunit/Unit/ProgressableTest.php +++ b/tests/phpunit/Unit/ProgressableTest.php @@ -7,13 +7,13 @@ use DrevOps\Tui\Builder\FieldBuilder; use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; -use DrevOps\Tui\Engine\Engine; -use DrevOps\Tui\Engine\EngineException; +use DrevOps\Tui\Screen\Collector; +use DrevOps\Tui\CollectException; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Model\Field; -use DrevOps\Tui\Model\Panel; -use DrevOps\Tui\Render\PanelController; +use DrevOps\Tui\Block\Field; +use DrevOps\Tui\Block\Panel; +use DrevOps\Tui\Screen\ScreenController; use DrevOps\Tui\Testing\TuiTester; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Tui; @@ -28,8 +28,8 @@ #[CoversClass(PanelBuilder::class)] #[CoversClass(Field::class)] #[CoversClass(Panel::class)] -#[CoversClass(Engine::class)] -#[CoversClass(PanelController::class)] +#[CoversClass(Collector::class)] +#[CoversClass(ScreenController::class)] #[CoversClass(DefaultTheme::class)] #[Group('tui')] final class ProgressableTest extends TestCase { @@ -47,7 +47,7 @@ public function testHeadlessCollectionResolvesTheLoaderOnceAndValidates(): void public function testHeadlessRejectsValueOutsideTheLoadedOptions(): void { $calls = 0; - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); (new Tui($this->form($calls)))->collect('{"fruit":"grape"}'); } diff --git a/tests/phpunit/Unit/QueryOptionsTest.php b/tests/phpunit/Unit/QueryOptionsTest.php index 6f27007b..4b9bea3d 100644 --- a/tests/phpunit/Unit/QueryOptionsTest.php +++ b/tests/phpunit/Unit/QueryOptionsTest.php @@ -8,21 +8,21 @@ use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Condition\Condition; -use DrevOps\Tui\Engine\Engine; -use DrevOps\Tui\Engine\EngineException; +use DrevOps\Tui\Screen\Collector; +use DrevOps\Tui\CollectException; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Model\Field; +use DrevOps\Tui\Block\Field; use DrevOps\Tui\Model\FieldType; use DrevOps\Tui\Model\FormException; use DrevOps\Tui\Model\Option; -use DrevOps\Tui\Render\PanelController; +use DrevOps\Tui\Screen\ScreenController; use DrevOps\Tui\Testing\TuiTester; use DrevOps\Tui\Tui; -use DrevOps\Tui\Widget\Capability\QueryOptionsCapableTrait; -use DrevOps\Tui\Widget\SearchWidget; -use DrevOps\Tui\Widget\SuggestWidget; -use DrevOps\Tui\Widget\WidgetFactory; +use DrevOps\Tui\Field\Capability\QueryOptionsCapableTrait; +use DrevOps\Tui\Field\Search; +use DrevOps\Tui\Field\Suggest; +use DrevOps\Tui\Field\FieldFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -36,11 +36,11 @@ #[CoversClass(Field::class)] #[CoversClass(FieldType::class)] #[CoversClass(Option::class)] -#[CoversClass(Engine::class)] -#[CoversClass(PanelController::class)] -#[CoversClass(WidgetFactory::class)] -#[CoversClass(SearchWidget::class)] -#[CoversClass(SuggestWidget::class)] +#[CoversClass(Collector::class)] +#[CoversClass(ScreenController::class)] +#[CoversClass(FieldFactory::class)] +#[CoversClass(Search::class)] +#[CoversClass(Suggest::class)] #[CoversTrait(QueryOptionsCapableTrait::class)] #[Group('tui')] final class QueryOptionsTest extends TestCase { @@ -210,7 +210,7 @@ public function testHeadlessLooksTheSuppliedValueUpAsItsQuery(): void { } public function testHeadlessRejectsValueNoQueryProduces(): void { - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/"turnip" was not found/'); (new Tui($this->form()))->collect('{"veg":"turnip"}'); @@ -219,7 +219,7 @@ public function testHeadlessRejectsValueNoQueryProduces(): void { public function testHeadlessRejectsValueTheQueryAnswersWithout(): void { // The lookup returns rows, just not the one asked for, so the message can // name what was allowed. - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/not one of: carrot/'); (new Tui($this->form(static fn(string $query): array => ['carrot' => 'Carrot'])))->collect('{"veg":"turnip"}'); @@ -236,12 +236,12 @@ public function testHeadlessLooksUpEachValueOfMultipleField(): void { $this->assertSame(['carrot', 'onion'], $this->queries); } - public function testHeadlessTurnsSourceFailureIntoEngineError(): void { + public function testHeadlessTurnsSourceFailureIntoCollectError(): void { $form = $this->form(static function (string $query): array { throw new \RuntimeException('The pantry is unreachable.'); }); - $this->expectException(EngineException::class); + $this->expectException(CollectException::class); $this->expectExceptionMessageMatches('/Could not load options for field "veg": The pantry is unreachable\./'); (new Tui($form))->collect('{"veg":"potato"}'); @@ -287,35 +287,35 @@ public function testSourceReturningSomethingElseDegradesToNoOptions(): void { $this->assertStringNotContainsString('Carrot', $tester->display()); } - public function testWidgetWithNoSourceNeverAsksForQuery(): void { - $widget = new SearchWidget(['carrot' => 'Carrot']); - $widget->handle(Key::char('c')); + public function testFieldWithNoSourceNeverAsksForQuery(): void { + $field = new Search(['carrot' => 'Carrot']); + $field->handle(Key::char('c')); - $this->assertFalse($widget->isQueryDriven()); - $this->assertNull($widget->pendingQuery()); + $this->assertFalse($field->isQueryDriven()); + $this->assertNull($field->pendingQuery()); } public function testTheOldestCachedQueryIsDroppedOnceTheCacheIsFull(): void { - $widget = new SearchWidget([]); - $widget->driveByQuery(); + $field = new Search([]); + $field->driveByQuery(); // Each character makes the query one longer, so typing fills the cache with // one more distinct query than it holds. $queries = []; - for ($i = 0; $i <= SearchWidget::QUERY_CACHE_SIZE; $i++) { - $widget->handle(Key::char('a')); - $query = $widget->pendingQuery(); + for ($i = 0; $i <= Search::QUERY_CACHE_SIZE; $i++) { + $field->handle(Key::char('a')); + $query = $field->pendingQuery(); $this->assertIsString($query); $queries[] = $query; - $widget->applyQuery($query, []); + $field->applyQuery($query, []); } // Stepping back lands on cached queries until the very first one, which the // newest insert evicted and which therefore has to be asked for again. for ($i = count($queries) - 1; $i > 0; $i--) { - $widget->handle(Key::named(KeyName::Backspace)); + $field->handle(Key::named(KeyName::Backspace)); $expected = $i === 1 ? $queries[0] : NULL; - $this->assertSame($expected, $widget->pendingQuery()); + $this->assertSame($expected, $field->pendingQuery()); } } @@ -324,7 +324,7 @@ public function testRejectedDeclarationFailsWhenTheFormIsBuilt(\Closure $declare $this->expectException(FormException::class); $this->expectExceptionMessageMatches($message); - Form::create('Order')->panel('order', 'New order', $declare)->build(); + Form::create('Order')->panel('order', 'New order', $declare)->root(); } /** diff --git a/tests/phpunit/Unit/Render/NavigatorTest.php b/tests/phpunit/Unit/Render/NavigatorTest.php deleted file mode 100644 index 44421223..00000000 --- a/tests/phpunit/Unit/Render/NavigatorTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertSame('Hub', $navigator->current()->title); - $this->assertTrue($navigator->isRoot()); - $this->assertSame(['Hub'], $navigator->breadcrumb()); - - $navigator->enter($sub); - $this->assertSame('Advanced', $navigator->current()->title); - $this->assertFalse($navigator->isRoot()); - $this->assertSame(['Hub', 'Advanced'], $navigator->breadcrumb()); - - $this->assertTrue($navigator->pop()); - $this->assertSame('Hub', $navigator->current()->title); - $this->assertFalse($navigator->pop()); - } - - public function testParent(): void { - $sub = new Panel('adv', 'Advanced', ''); - $navigator = new Navigator(new Panel('hub', 'Hub', '', [], [$sub])); - - $this->assertNotInstanceOf(Panel::class, $navigator->parent()); - - $navigator->enter($sub); - $this->assertInstanceOf(Panel::class, $navigator->parent()); - $this->assertSame('Hub', $navigator->parent()->title); - } - -} diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php deleted file mode 100644 index 0d47ccb1..00000000 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ /dev/null @@ -1,1621 +0,0 @@ -controller(); - - $controller->handle(Key::named(KeyName::Enter)); - $this->assertSame('General', $controller->currentPanel()->title); - - $controller->handle(Key::named(KeyName::Escape)); - $this->assertSame('Demo', $controller->currentPanel()->title); - } - - public function testNavigateCursorClamps(): void { - $controller = $this->controller(); - $this->assertSame(0, $controller->cursor()); - - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - - // The root holds 2 panels plus the Submit and Cancel buttons (4 items). - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(3, $controller->cursor()); - - $controller->handle(Key::named(KeyName::Up)); - $this->assertSame(2, $controller->cursor()); - } - - public function testSubmitButton(): void { - $controller = $this->controller(); - - // Move past the 2 panels to Submit (index 2), then activate it. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - } - - public function testCancelButton(): void { - $controller = $this->controller(); - - // Move to Cancel (index 3), then activate it. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->isDone()); - $this->assertTrue($controller->isCancelled()); - } - - public function testButtonsRenderByDefault(): void { - $controller = $this->controller(); - - // Submit and Cancel render inline on one row. - $this->assertStringContainsString('[ Submit ] [ Cancel ]', Ansi::strip($controller->frame(12))); - - // Select a button and re-render (covers the button cursor-line branch). - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertStringContainsString('[ Submit ]', Ansi::strip($controller->frame(12))); - } - - public function testButtonsOptOut(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('a', 'A'); - }); - $controller = new PanelController($builder->build(), $this->plainTheme(), ['a' => 'x']); - - $this->assertStringNotContainsString('Submit', Ansi::strip($controller->frame(12))); - - // With buttons off, the single field is the only item: Down clamps at 0. - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(0, $controller->cursor()); - } - - public function testButtonsOnlyOnRoot(): void { - $controller = $this->controller(); - - // The root panel shows the buttons. - $this->assertStringContainsString('[ Submit ]', Ansi::strip($controller->frame(12))); - - // Drilling into a sub-panel hides them. - $controller->handle(Key::named(KeyName::Enter)); - $sub = Ansi::strip($controller->frame(12)); - $this->assertStringNotContainsString('Submit', $sub); - $this->assertStringNotContainsString('Cancel', $sub); - - // Popping back to the root shows them again. - $controller->handle(Key::named(KeyName::Escape)); - $this->assertStringContainsString('[ Submit ]', Ansi::strip($controller->frame(12))); - } - - public function testInlineEditExpandsWidgetInsideThePanel(): void { - $controller = $this->controller(); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - $frame = Ansi::strip($controller->frame(12)); - - // The editor expands in place: the breadcrumb and the sibling row stay - // visible, the field's view (its caret input) shows under the label, and - // the footer switches to the widget's hints - no full-screen editor header. - $this->assertStringContainsString('General', $frame); - $this->assertStringContainsString('❯ Name', $frame); - $this->assertStringContainsString('Acme', $frame); - $this->assertStringContainsString('Advanced', $frame); - $this->assertStringContainsString('accept', $frame); - $this->assertStringNotContainsString('────', $frame); - } - - public function testInlineEditRendersChoiceListInThePanel(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->select('env', 'Env')->default('dev')->options(['dev' => 'Development', 'prod' => 'Production']); - $p->text('note', 'Note'); - }); - $controller = new PanelController($builder->build(), $this->plainTheme(), ['env' => 'dev', 'note' => 'n']); - - $this->drillAndEdit($controller); - - $frame = Ansi::strip($controller->frame(12)); - - // A multi-line widget view renders inline too: the select's own radio list - // shows in the panel, with the sibling field still visible below it. - $this->assertStringContainsString('Development', $frame); - $this->assertStringContainsString('Production', $frame); - $this->assertStringContainsString('Note', $frame); - } - - public function testInlineEditKeepsTheFieldDescription(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->confirm('cdn', 'Serve via CDN?')->description('Cache assets at the edge.'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(50, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['cdn' => TRUE]); - - $this->drillAndEdit($controller); - - // The field's help text stays visible while its editor is open in the row. - $this->assertStringContainsString('Cache assets at the edge.', Ansi::strip($controller->frame(12))); - } - - public function testStandaloneEditTakesTheFullScreen(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name')->standalone(); - $p->text('other', 'Other'); - }); - $controller = new PanelController($builder->build(), $this->plainTheme(), ['name' => 'Acme', 'other' => 'x']); - - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - $frame = Ansi::strip($controller->frame(12)); - - // A standalone field opens full-screen: the underlined label header and the - // widget's hints, with none of the other panel rows around it. - $this->assertStringContainsString("Name\n────", $frame); - $this->assertStringContainsString('accept', $frame); - $this->assertStringContainsString('esc cancel', $frame); - $this->assertStringNotContainsString('Other', $frame); - } - - public function testButtonsNavigateWithLeftRight(): void { - $controller = $this->controller(); - - // Past the two sub-panels to Submit (index 2). - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(2, $controller->cursor()); - - // Right moves to Cancel, Left moves back to Submit. - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(3, $controller->cursor()); - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(2, $controller->cursor()); - - // Left on the first button clamps. - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(2, $controller->cursor()); - } - - public function testLeftRightIgnoredOffButtons(): void { - $controller = $this->controller(); - - // On a normal item, Left/Right do nothing. - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(0, $controller->cursor()); - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(0, $controller->cursor()); - } - - public function testEditFieldReturnsWithValue(): void { - $controller = $this->controller(); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - $controller->handle(Key::char('!')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($controller->isEditing()); - $this->assertSame('Acme!', $controller->answers()->value('name')); - $this->assertSame(Provenance::Edited, $controller->answers()->provenanceOf('name')); - } - - public function testEditCancelKeepsValue(): void { - $controller = $this->controller(); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - $controller->handle(Key::named(KeyName::Escape)); - - $this->assertFalse($controller->isEditing()); - $this->assertSame('Acme', $controller->answers()->value('name')); - } - - public function testDrillIntoSubPanel(): void { - $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame('Advanced', $controller->currentPanel()->title); - } - - public function testMouseWheelScrollsWithoutMovingCursor(): void { - $controller = $this->controller(); - $before = $controller->cursor(); - - $controller->handle(Key::named(KeyName::MouseWheelDown)); - - $this->assertSame($before, $controller->cursor()); - $this->assertFalse($controller->isEditing()); - $this->assertStringContainsString('Demo', $controller->frame(4)); - } - - public function testMouseWheelUpScrollsBackWithoutMovingCursor(): void { - $controller = $this->controller(); - - $controller->handle(Key::named(KeyName::MouseWheelDown)); - $controller->handle(Key::named(KeyName::MouseWheelUp)); - - $this->assertSame(0, $controller->cursor()); - $this->assertStringContainsString('Demo', $controller->frame(4)); - } - - public function testHubShowsPanelValueSummary(): void { - $controller = $this->controller(); - - // At the root hub (before drilling in), each sub-panel shows a one-line - // summary of its field values - here the "General" panel's name. - $this->assertStringContainsString('Acme', Ansi::strip($controller->frame(20))); - } - - public function testFrameShowsSelectionAndValue(): void { - $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - - $frame = Ansi::strip($controller->frame(12)); - - $this->assertStringContainsString('General', $frame); - $this->assertStringContainsString('❯ Name', $frame); - $this->assertStringContainsString('Acme', $frame); - } - - public function testEditingFrameShowsWidget(): void { - $controller = $this->controller(); - $this->drillAndEdit($controller); - - $frame = $controller->frame(12); - - $this->assertStringContainsString('Name', $frame); - $this->assertStringContainsString('Acme', $frame); - } - - public function testNoteRendersAsCardTheCursorSkips(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->note('mid', 'Middle')->description('Between fields.'); - $p->confirm('agree', 'Agree'); - }); - $theme = new DefaultTheme(60, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - $controller = new PanelController($builder->build(), $theme, ['name' => 'Acme', 'agree' => FALSE]); - - // Drill in: the cursor lands on the first navigable field. - $controller->handle(Key::named(KeyName::Enter)); - $this->assertSame(0, $controller->cursor()); - - $frame = Ansi::strip($controller->frame(16)); - // The note card renders its title and body inline... - $this->assertStringContainsString('Middle', $frame); - $this->assertStringContainsString('Between fields.', $frame); - // ...but the selection marker is on the field, never the note. - $this->assertStringContainsString('❯ Name', $frame); - - // Down moves from the first field straight to the field after the note. - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - - $frame = Ansi::strip($controller->frame(16)); - $this->assertStringContainsString('❯ Agree', $frame); - $this->assertStringNotContainsString('❯ Middle', $frame); - } - - public function testNoteInterpolatesCurrentAnswersAndUpdates(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->note('echo', 'Echo')->description('Hello {{name}}.'); - }); - $theme = new DefaultTheme(60, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - $controller = new PanelController($builder->build(), $theme, ['name' => '']); - - $controller->handle(Key::named(KeyName::Enter)); - - // Edit the name; the note re-interpolates the new answer once it settles. - $controller->handle(Key::named(KeyName::Enter)); - foreach (str_split('Plum') as $char) { - $controller->handle(Key::char($char)); - } - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertStringContainsString('Hello Plum.', Ansi::strip($controller->frame(16))); - } - - public function testBorderedNoteRendersBox(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->note('boxed', 'Boxed')->description('In a box.')->border(); - $p->text('name', 'Name'); - }); - // The frame itself is borderless, so any box glyphs come from the note; an - // opt-in note border falls back to the single-line box here. - $theme = new DefaultTheme(60, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - $controller = new PanelController($builder->build(), $theme, ['name' => 'Acme']); - - $controller->handle(Key::named(KeyName::Enter)); - $frame = Ansi::strip($controller->frame(16)); - - $this->assertStringContainsString('Boxed', $frame); - $this->assertStringContainsString('┌', $frame); - $this->assertStringContainsString('┐', $frame); - $this->assertStringContainsString('└', $frame); - $this->assertStringContainsString('┘', $frame); - } - - public function testNotesOnlySubPanelNavigatesSafely(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->panel('info', 'Info', function (PanelBuilder $sp): void { - $sp->note('a', 'First note')->description('Alpha.'); - $sp->note('b', 'Second note')->description('Beta.'); - }); - }); - $theme = new DefaultTheme(60, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - $controller = new PanelController($builder->build(), $theme, ['name' => 'Acme']); - - // Drill into General, move to the Info sub-panel, then drill into it. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertSame('Info', $controller->currentPanel()->title); - - // The sub-panel has no navigable items: the cursor stays put and pressing - // Enter is inert rather than opening an editor for a note. - $this->assertSame(0, $controller->cursor()); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(0, $controller->cursor()); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertFalse($controller->isEditing()); - - $frame = Ansi::strip($controller->frame(16)); - $this->assertStringContainsString('First note', $frame); - $this->assertStringContainsString('Second note', $frame); - - // Back out returns to the parent panel. - $controller->handle(Key::named(KeyName::Escape)); - $this->assertSame('General', $controller->currentPanel()->title); - } - - public function testModalButtonSelectionSkipsNoteInTheOffset(): void { - $form = Form::create('Demo') - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->panel('edit', 'Quick edit', function (PanelBuilder $m): void { - $m->modal('Apply', 'Discard'); - $m->note('hint', 'Heads up')->description('Pick a nickname.'); - $m->text('nick', 'Nickname'); - }) - ->build(); - // Colour on so the selected button carries the cursor styling. - $theme = new DefaultTheme(50, ['color' => TRUE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - $controller = new PanelController($form, $theme, ['name' => 'Acme', 'nick' => '']); - - // Open the modal, then move onto the Apply button. The note before the - // field must not count toward the button offset, or Apply stays unlit. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - - $this->assertStringContainsString($theme->cursor('[ Apply ]'), $controller->frame(20)); - } - - public function testQuit(): void { - $controller = $this->controller(); - $this->assertFalse($controller->isDone()); - - $controller->handle(Key::char('q')); - - $this->assertTrue($controller->isDone()); - } - - public function testHubFooterShowsQuitAndHelp(): void { - $controller = $this->controller(); - - // The hub footer is complete: it surfaces quit and the help toggle, not - // just the move/select/back subset. - $footer = Ansi::strip($controller->frame(12)); - $this->assertStringContainsString('q quit', $footer); - $this->assertStringContainsString('? help', $footer); - } - - public function testHelpOverlayTogglesAndCloses(): void { - $controller = $this->controller(); - - // '?' opens the overlay; the frame becomes the help screen listing the hub - // and each widget type the form uses (here Text and Confirm). - $controller->handle(Key::char('?')); - $this->assertTrue($controller->isShowingHelp()); - - $help = Ansi::strip($controller->frame(12)); - $this->assertStringContainsString('Keyboard help', $help); - $this->assertStringContainsString('Navigation', $help); - $this->assertStringContainsString('Text', $help); - $this->assertStringContainsString('Confirm', $help); - $this->assertStringContainsString('? close', $help); - - // Any key dismisses it, and that key does nothing else (the cursor stays). - $controller->handle(Key::named(KeyName::Down)); - $this->assertFalse($controller->isShowingHelp()); - $this->assertSame(0, $controller->cursor()); - $this->assertStringContainsString('Demo', Ansi::strip($controller->frame(12))); - } - - public function testFooterHiddenWhenTurnedOff(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('a', 'A'); - }); - $controller = new PanelController($builder->build(), $this->plainTheme(), ['a' => 'x'], footer: FALSE); - - // The hub footer is gone. - $this->assertStringNotContainsString('quit', Ansi::strip($controller->frame(12))); - - // And so is the editor's hint line (drill into the panel, then the field). - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - $this->assertStringNotContainsString('accept', Ansi::strip($controller->frame(12))); - } - - public function testRunSubmitsThroughTheInputPipe(): void { - $controller = $this->controller(); - $terminal = new BufferedTerminal([ - KeyEncoder::encode(Key::named(KeyName::Down)), - KeyEncoder::encode(Key::named(KeyName::Down)), - KeyEncoder::encode(Key::named(KeyName::Enter)), - ]); - - $answers = $controller->run($terminal); - - $this->assertInstanceOf(Answers::class, $answers); - $this->assertTrue($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - // The loop rendered the hub before submitting. - $this->assertStringContainsString('Demo', Ansi::strip($terminal->output())); - } - - public function testRunPaintsTheThemeBackground(): void { - $config = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->build(); - $keys = [KeyEncoder::encode(Key::named(KeyName::Enter))]; - - // The dos theme washes the screen blue: run() hands its background to the - // terminal, which fills every rendered frame with it. - $dos = new PanelController($config, new DosTheme(40), ['name' => 'Acme']); - $painted = new BufferedTerminal($keys); - $dos->run($painted); - $this->assertSame('44', $painted->paintedBackground); - $this->assertStringContainsString("\033[44m", $painted->output()); - - // A theme with no background leaves the terminal's own surface untouched. - $plain = new PanelController($config, new DefaultTheme(40), ['name' => 'Acme']); - $blank = new BufferedTerminal($keys); - $plain->run($blank); - $this->assertNull($blank->paintedBackground); - $this->assertStringNotContainsString("\033[44m", $blank->output()); - } - - public function testRunStopsWhenInputIsExhausted(): void { - $controller = $this->controller(); - // A single navigation key that does not finish the form; input then ends. - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Down))]); - - $controller->run($terminal); - - // The EOF break ends the loop without the form being submitted. - $this->assertFalse($controller->isDone()); - $this->assertSame(1, $controller->cursor()); - } - - public function testRunInterruptStopsBeforeHandlingMoreKeys(): void { - $controller = $this->controller(); - // Ctrl-C arrives first; the Down queued after it must never be handled. - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Interrupt)), KeyEncoder::encode(Key::named(KeyName::Down))]); - - $controller->run($terminal); - - $this->assertTrue($controller->isInterrupted()); - // An interrupt is neither a quit nor a cancel-button finish. - $this->assertFalse($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - // The loop broke on the interrupt, so the trailing Down never - // moved the cursor. - $this->assertSame(0, $controller->cursor()); - } - - public function testRunInterruptClearsEvenWhenClearOnExitOff(): void { - $config = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->build(); - $theme = $this->plainTheme(); - - // An interrupt renders the frame once (one clear) and then forces a second - // clear at teardown despite clearOnExit being off. - $interrupted = new PanelController($config, $theme, ['name' => 'Acme'], clearOnExit: FALSE); - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Interrupt))]); - $interrupted->run($terminal); - $this->assertTrue($interrupted->isInterrupted()); - $this->assertSame(2, substr_count($terminal->output(), TerminalControl::clear())); - - // Exhausting the input renders the same single frame but adds no teardown - // clear - so the interrupt's extra clear is what wipes the screen. - $exhausted = new PanelController($config, $theme, ['name' => 'Acme'], clearOnExit: FALSE); - $quiet = new BufferedTerminal([]); - $exhausted->run($quiet); - $this->assertFalse($exhausted->isInterrupted()); - $this->assertSame(1, substr_count($quiet->output(), TerminalControl::clear())); - } - - public function testRunInterruptAtBannerAbortsBeforeTheForm(): void { - $config = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->build(); - $controller = new PanelController($config, $this->plainTheme(), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); - // Ctrl-C at the "press any key" banner aborts instead of entering the form. - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Interrupt))]); - - $controller->run($terminal); - - $this->assertTrue($controller->isInterrupted()); - $this->assertStringContainsString('Press any key to continue', Ansi::strip($terminal->output())); - // The loop was skipped entirely, so the form body never rendered. - $this->assertStringNotContainsString('Name', Ansi::strip($terminal->output())); - } - - public function testRunRendersBannerThenTheForm(): void { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }); - $controller = new PanelController($builder->build(), $this->plainTheme(), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); - // The first key dismisses the banner; input then ends. - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Enter))]); - - $controller->run($terminal); - - $this->assertStringContainsString('Press any key to continue', Ansi::strip($terminal->output())); - // After the banner is dismissed, the loop renders the form body itself. - $this->assertStringContainsString('General', Ansi::strip($terminal->output())); - } - - public function testTextareaExternalEditCommitsCapturedValue(): void { - $controller = $this->textareaController($this->fixedEditor('FROM EDITOR')); - - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - $controller->handle(Key::char("\x05")); - - $this->assertFalse($controller->isEditing()); - $this->assertSame('FROM EDITOR', $controller->answers()->value('notes')); - $this->assertSame(Provenance::Edited, $controller->answers()->provenanceOf('notes')); - } - - public function testTextareaExternalEditAbortKeepsEditing(): void { - $controller = $this->textareaController($this->fixedEditor(NULL)); - - $this->drillAndEdit($controller); - $controller->handle(Key::char("\x05")); - - // A NULL capture (aborted edit) leaves the field open, value intact. - $this->assertTrue($controller->isEditing()); - $this->assertSame('seeded', $controller->answers()->value('notes')); - } - - #[DataProvider('dataProviderTextareaEditorHintFollowsAvailability')] - public function testTextareaEditorHintFollowsAvailability(bool $available, bool $shown): void { - $controller = $this->textareaController($available ? $this->fixedEditor(NULL) : $this->unavailableEditor()); - - $this->drillAndEdit($controller); - - $frame = Ansi::strip($controller->frame(12)); - - $this->assertSame($shown, str_contains($frame, 'ctrl-e editor'), 'the editor hint follows availability'); - } - - /** - * Data provider for testTextareaEditorHintFollowsAvailability(). - * - * @return \Iterator - * Whether an editor is available and whether the hint offers it. - */ - public static function dataProviderTextareaEditorHintFollowsAvailability(): \Iterator { - yield 'editor available' => [TRUE, TRUE]; - yield 'no editor available' => [FALSE, FALSE]; - } - - public function testTextareaHandoffInertWithoutAnEditor(): void { - $controller = $this->textareaController($this->unavailableEditor()); - - $this->drillAndEdit($controller); - - // With no editor available the trigger is inert - editing continues. - $controller->handle(Key::char("\x05")); - - $this->assertTrue($controller->isEditing()); - $this->assertSame('seeded', $controller->answers()->value('notes')); - } - - /** - * A single-panel controller whose textarea opts into the editor handoff. - * - * @param \DrevOps\Tui\Render\ExternalEditor $editor - * The external-editor service to inject. - */ - protected function textareaController(ExternalEditor $editor): PanelController { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->textarea('notes', 'Notes')->externalEditor(); - }); - - return new PanelController($builder->build(), $this->plainTheme(), ['notes' => 'seeded'], external_editor: $editor); - } - - /** - * An available editor stub returning a fixed capture. - * - * @param string|null $result - * The value edit() returns (NULL simulates an aborted edit). - */ - protected function fixedEditor(?string $result): ExternalEditor { - return new class($result) extends ExternalEditor { - - public function __construct(protected ?string $result) { - } - - #[\Override] - public function isAvailable(): bool { - return TRUE; - } - - #[\Override] - public function edit(string $initial, ?Terminal $terminal = NULL): ?string { - return $this->result; - } - - }; - } - - /** - * An editor stub reporting no editor is available. - */ - protected function unavailableEditor(): ExternalEditor { - return new class extends ExternalEditor { - - #[\Override] - public function isAvailable(): bool { - return FALSE; - } - - #[\Override] - public function edit(string $initial, ?Terminal $terminal = NULL): ?string { - throw new \RuntimeException('the editor must not launch when unavailable'); - } - - }; - } - - public function testModalOpensCenteredOverTheBackdrop(): void { - $controller = $this->modalController(); - - // Move to the modal item and open it. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->currentPanel()->isModal()); - $this->assertSame('Quick edit', $controller->currentPanel()->title); - - $frame = Ansi::strip($controller->frame(14)); - - // The dialog box shows its title, description, field and its own configured - // buttons; the parent panel shows through around it (the backdrop). - $this->assertStringContainsString('Quick edit', $frame); - $this->assertStringContainsString('Adjust the nickname.', $frame); - $this->assertStringContainsString('Nickname', $frame); - $this->assertStringContainsString('[ Apply ]', $frame); - $this->assertStringContainsString('[ Discard ]', $frame); - $this->assertStringContainsString('Main', $frame); - } - - public function testModalSubmitKeepsEditsAndReturnsToParent(): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // Edit the dialog's field, then activate its Submit button. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('!')); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($controller->currentPanel()->isModal()); - $this->assertFalse($controller->isDone()); - $this->assertSame('ace!', $controller->answers()->value('nick')); - // The cursor is restored to the item that opened the dialog. - $this->assertSame(1, $controller->cursor()); - } - - #[DataProvider('dataProviderModalDismissalRestoresTheAnswers')] - public function testModalDismissalRestoresTheAnswers(array $dismissal): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // Edit the field so the dialog has something to discard. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('X')); - $controller->handle(Key::named(KeyName::Enter)); - - foreach ($dismissal as $key) { - $controller->handle($key); - } - - $this->assertFalse($controller->currentPanel()->isModal()); - $this->assertSame('ace', $controller->answers()->value('nick')); - // The cursor is restored to the item that opened the dialog. - $this->assertSame(1, $controller->cursor()); - } - - /** - * Data provider for testModalDismissalRestoresTheAnswers(). - * - * @return \Iterator - * The keys that dismiss an open dialog, discarding its edits. - */ - public static function dataProviderModalDismissalRestoresTheAnswers(): \Iterator { - // Past the field to Cancel, then activate it. - yield 'cancel button' => [ - [Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Enter)], - ]; - - yield 'escape' => [[Key::named(KeyName::Escape)]]; - } - - public function testModalQuitDismissesInsteadOfEndingTheForm(): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertTrue($controller->currentPanel()->isModal()); - - // A modal is blocking: quit closes it rather than finishing the form. - $controller->handle(Key::char('q')); - - $this->assertFalse($controller->isDone()); - $this->assertFalse($controller->currentPanel()->isModal()); - } - - public function testModalButtonsNavigateWithLeftRight(): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // Past the field to Submit (index 1), then Left/Right between buttons. - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(2, $controller->cursor()); - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(1, $controller->cursor()); - } - - public function testModalEditsFieldInlineInsideTheDialog(): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - // Type a character so the live editor value differs from the stored one - - // the dialog must show the live value, proving the editor renders in place. - $controller->handle(Key::char('Z')); - - $frame = Ansi::strip($controller->frame(14)); - - $this->assertStringContainsString('Nickname', $frame); - $this->assertStringContainsString('aceZ', $frame); - // The editor renders inside the dialog box, which still frames it. - $this->assertStringContainsString('[ Apply ]', $frame); - } - - public function testModalKeepsButtonsVisibleWhenTallerThanTheScreen(): void { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->text('a', 'Alpha'); - $p->panel('big', 'Big dialog', function (PanelBuilder $m): void { - $m->modal('Save', 'Discard')->description('Many fields.'); - for ($i = 1; $i <= 12; $i++) { - $m->text('f' . $i, 'Field ' . $i); - } - }); - }); - $values = ['a' => 'x']; - for ($i = 1; $i <= 12; $i++) { - $values['f' . $i] = 'v' . $i; - } - $controller = new PanelController($builder->build(), new DefaultTheme(50, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), $values); - - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // A dialog with more content than the viewport scrolls its body under a - // pinned button footer, so both exits stay reachable rather than clipping - // off the bottom. - $frame = Ansi::strip($controller->frame(12)); - $this->assertStringContainsString('[ Save ]', $frame); - $this->assertStringContainsString('[ Discard ]', $frame); - - // The very short viewport falls back to truncating the body, still pinning - // the buttons. - $squeezed = Ansi::strip($controller->frame(8)); - $this->assertStringContainsString('[ Save ]', $squeezed); - } - - /** - * A controller over a form whose second top-level panel is a modal dialog. - */ - protected function modalController(): PanelController { - $builder = Form::create('Demo') - ->buttons(FALSE) - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->panel('edit', 'Quick edit', function (PanelBuilder $m): void { - $m->modal('Apply', 'Discard')->description('Adjust the nickname.'); - $m->text('nick', 'Nickname'); - }); - - return new PanelController($builder->build(), new DefaultTheme(50, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme', 'nick' => 'ace']); - } - - public function testRunFullscreenFillsTheTerminalExactly(): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE]); - $terminal = new BufferedTerminal([], 14, 40); - - $controller->run($terminal); - - // The stretched frame is exactly the terminal height, footer pinned last. - $lines = explode("\n", Ansi::strip($terminal->output())); - $this->assertCount(14, $lines); - $this->assertStringContainsString('quit', $lines[13]); - } - - public function testRunFullscreenCentersTheBodyBlock(): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE, 'halign' => HAlign::Center]); - $terminal = new BufferedTerminal([], 14, 40); - - $controller->run($terminal); - - // The widest block row is the 24-column button bar, so the block indents - // (40 - 24) / 2 = 8 columns as one unit. - $this->assertStringContainsString(str_repeat(' ', 8) . '> General', Ansi::strip($terminal->output())); - } - - public function testRunFullscreenBottomAlignsTheBodyBlock(): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE, 'valign' => VAlign::Bottom]); - $terminal = new BufferedTerminal([], 14, 40); - - $controller->run($terminal); - - $lines = explode("\n", Ansi::strip($terminal->output())); - - // The body window spans rows 1-11: the block sinks to its bottom. - $this->assertSame('', $lines[1]); - $this->assertStringContainsString('> General', $lines[8]); - $this->assertStringContainsString('[ Submit ]', $lines[11]); - } - - public function testRunFullscreenPositionsTheCappedFrame(): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE, 'max_width' => 30, 'halign' => HAlign::Center, 'border' => Border::Line], 60); - $terminal = new BufferedTerminal([], 12, 60); - - $controller->run($terminal); - - $lines = explode("\n", Ansi::strip($terminal->output())); - - // The capped 30-column box floats centered in the 60-column terminal. - $this->assertCount(12, $lines); - $this->assertSame(str_repeat(' ', 15) . '+' . str_repeat('-', 28) . '+', rtrim($lines[0])); - } - - /** - * Tests the guard screen a terminal below the minimum size falls back to. - * - * @param list $keys - * The encoded keys the guard screen reads. - * @param bool $done - * Whether the keys finish the form. - * @param bool $interrupted - * Whether the keys interrupt it. - */ - #[DataProvider('dataProviderRunFullscreenTooSmallGuard')] - public function testRunFullscreenTooSmallGuard(array $keys, bool $done, bool $interrupted): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE]); - // Six rows are below the ten-row minimum, so the guard screen stands in - // for the frame and swallows everything that is not an exit. - $terminal = new BufferedTerminal($keys, 6, 40); - - $controller->run($terminal); - - $output = Ansi::strip($terminal->output()); - $this->assertStringContainsString('Terminal too small.', $output); - $this->assertStringContainsString('Need at least 24 x 10 - have 40 x 6.', $output); - $this->assertSame($done, $controller->isDone()); - $this->assertSame($interrupted, $controller->isInterrupted()); - $this->assertSame(0, $controller->cursor()); - } - - /** - * Data provider for testRunFullscreenTooSmallGuard(). - * - * @return \Iterator - * The encoded keys the guard screen reads, and whether they leave the - * controller finished and interrupted. - */ - public static function dataProviderRunFullscreenTooSmallGuard(): \Iterator { - // The Down is swallowed by the guard, then quit ends the loop. - yield 'quit after a swallowed key' => [ - [KeyEncoder::encode(Key::named(KeyName::Down)), 'q'], - TRUE, - FALSE, - ]; - - yield 'interrupt' => [[KeyEncoder::encode(Key::named(KeyName::Interrupt))], FALSE, TRUE]; - } - - #[DataProvider('dataProviderRunFullscreenMinimums')] - public function testRunFullscreenMinimums(array $options, int $width, string $label, int $rows, int $cols, bool $too_small): void { - $controller = $this->fullscreenController($options, $width, $label); - $terminal = new BufferedTerminal([], $rows, $cols); - - $controller->run($terminal); - - $output = Ansi::strip($terminal->output()); - - // The guard screen stands in for the frame, so the two are exclusive. - $this->assertSame($too_small, str_contains($output, 'Terminal too small.'), 'the guard screen shows only when the terminal is too small'); - $this->assertSame(!$too_small, str_contains($output, 'General'), 'the frame renders only when the terminal is big enough'); - } - - /** - * Data provider for testRunFullscreenMinimums(). - * - * @return \Iterator, int, string, int, int, bool}> - * The theme options, theme width and field label, the terminal rows and - * columns, and whether the run dead-ends on the too-small guard. - */ - public static function dataProviderRunFullscreenMinimums(): \Iterator { - $long = 'Preferred delivery window of the season'; - - // Thirty columns cannot fit the measured 50-column field row. - yield 'measured min width exceeds the terminal' => [['fullscreen' => TRUE], 30, $long, 24, 30, TRUE]; - - yield 'explicit min width overrides the measure' => [ - ['fullscreen' => TRUE, 'min_width' => 10], - 30, - $long, - 24, - 30, - FALSE, - ]; - - // The content measures ~50 columns, but the 30-column cap is the - // consumer's word that clipping is acceptable: a 40-column terminal must - // render the capped frame, not dead-end on an unsatisfiable notice. - yield 'max width caps the measured min width' => [ - ['fullscreen' => TRUE, 'max_width' => 30], - 40, - $long, - 24, - 40, - FALSE, - ]; - - // The same six-row terminal renders the plain frame when not fullscreen. - yield 'outside fullscreen the minimums do not apply' => [[], 40, 'Name', 6, 40, FALSE]; - } - - public function testRunFullscreenTooSmallQuitDismissesAnOpenModal(): void { - $builder = Form::create('Demo') - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->panel('edit', 'Quick edit', function (PanelBuilder $m): void { - $m->modal('Apply', 'Discard'); - $m->text('nick', 'Nickname'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE, 'fullscreen' => TRUE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme', 'nick' => 'ace']); - - // Open the modal, then run on a terminal below the minimum height. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertTrue($controller->currentPanel()->isModal()); - - $controller->run(new BufferedTerminal(['q'], 6, 40)); - - // Quit on the guard screen dismissed the dialog, not the whole form. - $this->assertFalse($controller->isDone()); - $this->assertFalse($controller->currentPanel()->isModal()); - } - - public function testModalBodyUsesTheFullScreenBudget(): void { - $builder = Form::create('Demo') - ->panel('main', 'Main', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - }) - ->panel('edit', 'Quick edit', function (PanelBuilder $m): void { - $m->modal('Apply', 'Discard'); - $m->text('one', 'First'); - $m->text('two', 'Second'); - $m->text('three', 'Third'); - $m->text('four', 'Fourth'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(50, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal])); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // The screen rows bound the dialog, so its four fields fit a 14-row - // screen; a body-viewport bound would deduct the frame chrome a second - // time and slice the last field away. - $this->assertStringContainsString('Fourth', Ansi::strip($controller->frame(14))); - } - - public function testRunFullscreenMinHeightIsCappedByMaxHeight(): void { - // max_height 8 lowers the default 10-row minimum: a 9-row terminal is - // enough for the 8-row frame, so no notice shows. - $controller = $this->fullscreenController(['fullscreen' => TRUE, 'max_height' => 8]); - $terminal = new BufferedTerminal([], 9, 40); - - $controller->run($terminal); - - $output = Ansi::strip($terminal->output()); - $this->assertStringNotContainsString('Terminal too small.', $output); - $this->assertCount(9, explode("\n", $output)); - } - - public function testGridArrowsMoveSpatially(): void { - $controller = $this->gridController(); - - // layout(1, 2): A alone on row one, B and C beside each other below. - // Right on a one-column row stays put. - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(0, $controller->cursor()); - - // Down lands on the nearest column of the next row (B), Right walks to C. - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(2, $controller->cursor()); - - // The row edge clamps; Up from C lands back on A (its nearest column). - $controller->handle(Key::named(KeyName::Right)); - $this->assertSame(2, $controller->cursor()); - $controller->handle(Key::named(KeyName::Up)); - $this->assertSame(0, $controller->cursor()); - - // Down, Left: back to B; Left clamps at the row's first column. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(1, $controller->cursor()); - $controller->handle(Key::named(KeyName::Left)); - $this->assertSame(1, $controller->cursor()); - } - - public function testGridDownFromTheLastRowReachesTheButtons(): void { - $controller = $this->gridController(); - - // A -> B -> buttons: Down from the last grid row jumps to Submit, and Up - // returns to the last panel. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(3, $controller->cursor()); - - $controller->handle(Key::named(KeyName::Enter)); - $this->assertTrue($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - } - - public function testGridUpFromTheFirstRowReachesTheFieldsAbove(): void { - $builder = Form::create('Demo') - ->panel('mixed', 'Mixed', function (PanelBuilder $p): void { - $p->layout(2); - $p->text('note', 'Note'); - $p->panel('a', 'A', function (PanelBuilder $sp): void { - $sp->text('one', 'One'); - }); - $p->panel('b', 'B', function (PanelBuilder $sp): void { - $sp->text('two', 'Two'); - }); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal])); - - // Drill into the mixed panel: the field sits above the grid. - $controller->handle(Key::named(KeyName::Enter)); - $this->assertSame('Mixed', $controller->currentPanel()->title); - - // Down enters the grid, Up climbs back out onto the field. - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - $controller->handle(Key::named(KeyName::Up)); - $this->assertSame(0, $controller->cursor()); - } - - public function testGridEnterDrillsIntoTheSelectedPanel(): void { - $controller = $this->gridController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Right)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame('C', $controller->currentPanel()->title); - } - - public function testGridFrameRendersPanelsSideBySide(): void { - $controller = $this->gridController(); - - $lines = explode("\n", Ansi::strip($controller->frame(20))); - - // B and C share a line; A has its own row above them. - $side_by_side = array_values(array_filter($lines, static fn(string $line): bool => str_contains($line, 'B >') && str_contains($line, 'C >'))); - $this->assertNotSame([], $side_by_side); - $this->assertStringContainsString('> A', Ansi::strip($controller->frame(20))); - - // The spatial hint advertises all four arrows. - $this->assertStringContainsString('^/v/ move', Ansi::strip($controller->frame(20))); - } - - /** - * A controller over a layout(1, 2) grid of three panels. - * - * @return \DrevOps\Tui\Render\PanelController - * The controller. - */ - protected function gridController(): PanelController { - $builder = Form::create('Demo') - ->layout(1, 2) - ->panel('a', 'A', function (PanelBuilder $p): void { - $p->text('one', 'One'); - }) - ->panel('b', 'B', function (PanelBuilder $p): void { - $p->text('two', 'Two'); - }) - ->panel('c', 'C', function (PanelBuilder $p): void { - $p->text('three', 'Three'); - }); - - return new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal])); - } - - /** - * A controller over a one-panel form with configurable layout options. - * - * @param array $options - * Theme options merged over colourless ASCII defaults. - * @param int $width - * The theme width (the terminal width in fullscreen). - * @param string $label - * The field label, which is what the content measures at its widest. - * - * @return \DrevOps\Tui\Render\PanelController - * The controller. - */ - protected function fullscreenController(array $options, int $width = 40, string $label = 'Name'): PanelController { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p) use ($label): void { - $p->text('name', $label); - }); - - return new PanelController($builder->build(), new DefaultTheme($width, ['color' => FALSE, 'unicode' => FALSE] + $options + ['border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme']); - } - - /** - * A controller over a two-panel form seeded with answers. - */ - public function testEditEnforcesDeclaredValidatorAndTransform(): void { - $form = Form::create('Demo') - ->panel('stall', 'Stall', function (PanelBuilder $p): void { - $p->text('name', 'Name') - ->validate(static fn (mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'A name is required.') - ->transform(static fn (mixed $value): mixed => is_string($value) ? strtolower($value) : $value); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), values: ['name' => '']); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - - // An invalid value is rejected: the editor stays open showing the error. - $controller->handle(Key::named(KeyName::Enter)); - $this->assertTrue($controller->isEditing()); - $this->assertStringContainsString('A name is required.', $controller->frame(24)); - - // A valid value is accepted and stored transformed. - $controller->handle(Key::char('B')); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertFalse($controller->isEditing()); - $this->assertSame('b', $controller->answers()->value('name')); - } - - public function testSubmitRefusedWhileRequiredFieldIsEmpty(): void { - $controller = $this->requiredController(['name' => '', 'note' => '']); - - // The root holds the one panel plus the buttons: Submit is index 1. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($controller->isDone()); - $this->assertStringContainsString('Produce name is required.', Ansi::strip($controller->frame(24))); - - // Drill into the panel, fill the field, and come back out. - $controller->handle(Key::named(KeyName::Up)); - $this->drillAndEdit($controller); - $this->assertTrue($controller->isEditing()); - $controller->handle(Key::char('P')); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Escape)); - - // The accepted edit retired the message and the submit now goes through. - $this->assertStringNotContainsString('Produce name is required.', Ansi::strip($controller->frame(24))); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertTrue($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - } - - public function testSubmitAllowedWhenRequiredFieldsHoldValues(): void { - $controller = $this->requiredController(['name' => 'Pear', 'note' => '']); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - // An empty optional field never blocks the submit. - $this->assertTrue($controller->isDone()); - $this->assertFalse($controller->isCancelled()); - } - - public function testCancelIgnoresAnEmptyRequiredField(): void { - $controller = $this->requiredController(['name' => '', 'note' => '']); - - // Past the panel and Submit to Cancel (index 2). - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->isDone()); - $this->assertTrue($controller->isCancelled()); - } - - public function testSubmitIgnoresAnInactiveRequiredField(): void { - $form = Form::create('Demo') - ->panel('stall', 'Stall', function (PanelBuilder $p): void { - $p->text('mode', 'Mode'); - $p->text('plot', 'Garden plot name')->required()->when(new Condition('mode', eq: 'custom')); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), values: ['mode' => 'standard', 'plot' => '']); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->isDone()); - } - - public function testSubmitIgnoresRequiredNote(): void { - // A note carries no answer, so marking one required can never withhold the - // submit. - $form = Form::create('Demo') - ->panel('stall', 'Stall', function (PanelBuilder $p): void { - $p->note('intro', 'Intro')->required(); - $p->text('name', 'Produce name'); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), values: ['name' => 'Pear']); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($controller->isDone()); - } - - public function testModalKeepsItsEditsAndTheFormSubmitCatchesTheGap(): void { - // A dialog's Apply means "keep my edits and close", so it is not withheld - - // trapping the user would leave Discard, which drops every dialog edit, as - // the only way out. The form submit is the boundary that catches the gap. - $form = Form::create('Demo') - ->panel('edit', 'Quick edit', function (PanelBuilder $m): void { - $m->modal('Apply', 'Discard'); - $m->text('plot', 'Garden plot name')->required(); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), values: ['plot' => '']); - - // Open the dialog, move to Apply, and close it with the field still empty. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertSame('Demo', $controller->currentPanel()->title); - $this->assertFalse($controller->isDone()); - - // The form's own submit refuses, naming the field the dialog left empty. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertFalse($controller->isDone()); - $this->assertStringContainsString('Garden plot name is required.', Ansi::strip($controller->frame(24))); - } - - /** - * A controller over one required and one optional field on a single panel. - * - * @param array $values - * The seeded answer values. - * - * @return \DrevOps\Tui\Render\PanelController - * The controller. - */ - protected function requiredController(array $values): PanelController { - $form = Form::create('Demo') - ->panel('stall', 'Stall', function (PanelBuilder $p): void { - $p->text('name', 'Produce name')->required(); - $p->text('note', 'Delivery note'); - }) - ->build(); - - return new PanelController($form, $this->plainTheme(), values: $values); - } - - public function testEditEnforcesHandlerBehaviour(): void { - $form = Form::create('Demo') - ->panel('stall', 'Stall', function (PanelBuilder $p): void { - $p->text('machine_name', 'Machine name'); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), values: ['machine_name' => 'Seed'], handlers: new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler'])); - $this->drillAndEdit($controller); - $controller->handle(Key::char('X')); - $controller->handle(Key::named(KeyName::Enter)); - - // The handler's static transform() lowercased the accepted value. - $this->assertFalse($controller->isEditing()); - $this->assertSame('seedx', $controller->answers()->value('machine_name')); - } - - public function testConditionalFieldFollowsAnswers(): void { - $form = Form::create('Demo') - ->panel('packing', 'Packing', function (PanelBuilder $p): void { - $p->confirm('extra', 'Extra'); - $p->text('notes', 'Notes')->default('mixed')->when(new Condition('extra')); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), ['extra' => FALSE, 'notes' => 'mixed']); - $controller->handle(Key::named(KeyName::Enter)); - - // The condition fails, so the field neither renders nor answers. - $this->assertStringNotContainsString('Notes', Ansi::strip($controller->frame(12))); - $this->assertFalse($controller->answers()->has('notes')); - - // Flip the gate on: the field appears carrying its settled value. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('y')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertStringContainsString('Notes', Ansi::strip($controller->frame(12))); - $this->assertSame('mixed', $controller->answers()->value('notes')); - - // Flip it back: the field hides again and contributes no answer. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('n')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertStringNotContainsString('Notes', Ansi::strip($controller->frame(12))); - $this->assertFalse($controller->answers()->has('notes')); - } - - public function testCursorClampsWhenFieldHides(): void { - $form = Form::create('Demo') - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('gated', 'Gated')->default('g')->when(new Condition('extra')); - $p->confirm('extra', 'Extra'); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), ['gated' => 'g', 'extra' => TRUE]); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $this->assertSame(1, $controller->cursor()); - - // Hiding the first field shrinks the list; the cursor clamps onto it. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('n')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame(0, $controller->cursor()); - $this->assertSame(['extra'], array_keys($controller->answers()->values)); - } - - public function testEditReSettlesDerivedChain(): void { - $controller = $this->derivedController(); - $controller->handle(Key::named(KeyName::Enter)); - - // The construction settle computed the rule over the seeded source. - $this->assertSame('red_apple', $controller->answers()->value('slug')); - - // Editing the source re-derives the target, keeping its derived badge. - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('x')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame('Red Applex', $controller->answers()->value('name')); - $this->assertSame('red_applex', $controller->answers()->value('slug')); - $this->assertSame(Provenance::Derived, $controller->answers()->provenanceOf('slug')); - } - - public function testEditDerivedFieldPinsOverride(): void { - $controller = $this->derivedController(); - $controller->handle(Key::named(KeyName::Enter)); - - // Editing the derived field itself pins the rule as an override. - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('z')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame('red_applez', $controller->answers()->value('slug')); - $this->assertSame(Provenance::Override, $controller->answers()->provenanceOf('slug')); - - // The pinned value survives edits to the source it derived from. - $controller->handle(Key::named(KeyName::Up)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('x')); - $controller->handle(Key::named(KeyName::Enter)); - - $this->assertSame('Red Applex', $controller->answers()->value('name')); - $this->assertSame('red_applez', $controller->answers()->value('slug')); - } - - public function testEditAppliesFixups(): void { - $form = Form::create('Demo') - ->fixup(new Fixup(set: 'note', to: 'boxed', when: new Condition('tag', eq: 'go'))) - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('tag', 'Tag'); - $p->text('note', 'Note'); - }) - ->build(); - - $controller = new PanelController($form, $this->plainTheme(), ['tag' => '', 'note' => '']); - $controller->handle(Key::named(KeyName::Enter)); - - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('g')); - $controller->handle(Key::char('o')); - $controller->handle(Key::named(KeyName::Enter)); - - // The guard matches the accepted edit, so the fix-up set its target on - // the same settle. - $this->assertSame('boxed', $controller->answers()->value('note')); - } - - /** - * Build a controller over a name field and a slug derived from it. - */ - protected function derivedController(): PanelController { - $form = Form::create('Demo') - ->panel('naming', 'Naming', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->text('slug', 'Slug')->derive(new Derive('{{name}}', 'machine')); - }) - ->build(); - - return new PanelController($form, $this->plainTheme(), ['name' => 'Red Apple'], ['slug' => Provenance::Derived]); - } - - /** - * Enter the panel under the cursor, then open the editor on its field. - * - * @param \DrevOps\Tui\Render\PanelController $controller - * The controller to drive. - */ - protected function drillAndEdit(PanelController $controller): void { - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); - } - - protected function controller(): PanelController { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); - $p->panel('adv', 'Advanced', function (PanelBuilder $sp): void { - $sp->confirm('debug', 'Debug'); - }); - }) - ->panel('drupal', 'Drupal', function (PanelBuilder $p): void { - $p->text('profile', 'Profile'); - }); - $theme = $this->plainTheme(); - - return new PanelController($builder->build(), $theme, ['name' => 'Acme', 'debug' => FALSE, 'profile' => 'standard']); - } - -} diff --git a/tests/phpunit/Unit/Resolver/EnvNameResolverTest.php b/tests/phpunit/Unit/Resolver/EnvNameResolverTest.php index baefb3f1..a5dd811b 100644 --- a/tests/phpunit/Unit/Resolver/EnvNameResolverTest.php +++ b/tests/phpunit/Unit/Resolver/EnvNameResolverTest.php @@ -4,7 +4,7 @@ namespace DrevOps\Tui\Tests\Unit\Resolver; -use DrevOps\Tui\Model\Field; +use DrevOps\Tui\Block\Field; use DrevOps\Tui\Model\FieldType; use DrevOps\Tui\Resolver\EnvNameResolver; use PHPUnit\Framework\Attributes\CoversClass; @@ -21,9 +21,7 @@ final class EnvNameResolverTest extends TestCase { #[DataProvider('dataProviderCanonical')] public function testCanonical(string $prefix, string $env_name, string $expected): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: $env_name); - - $this->assertSame($expected, (new EnvNameResolver($prefix))->canonical($field)); + $this->assertSame($expected, (new EnvNameResolver($prefix))->canonical(self::field($env_name))); } public static function dataProviderCanonical(): \Iterator { @@ -37,15 +35,11 @@ public static function dataProviderCanonical(): \Iterator { } public function testAliasesAreReportedInDeclarationOrder(): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE', 'OLDER_CRATE']); - - $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], (new EnvNameResolver('APP_'))->aliases($field)); + $this->assertSame(['OLD_CRATE', 'OLDER_CRATE'], (new EnvNameResolver('APP_'))->aliases(self::field('', ['OLD_CRATE', 'OLDER_CRATE']))); } public function testAliasesAreEmptyWhenNoneDeclared(): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, ''); - - $this->assertSame([], (new EnvNameResolver('APP_'))->aliases($field)); + $this->assertSame([], (new EnvNameResolver('APP_'))->aliases(self::field())); } /** @@ -60,9 +54,7 @@ public function testAliasesAreEmptyWhenNoneDeclared(): void { */ #[DataProvider('dataProviderAll')] public function testAll(string $env_name, array $aliases, array $expected): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: $env_name, envAliases: $aliases); - - $this->assertSame($expected, (new EnvNameResolver('APP_'))->all($field)); + $this->assertSame($expected, (new EnvNameResolver('APP_'))->all(self::field($env_name, $aliases))); } public static function dataProviderAll(): \Iterator { @@ -73,9 +65,7 @@ public static function dataProviderAll(): \Iterator { #[DataProvider('dataProviderIsAdvertisable')] public function testIsAdvertisable(string $prefix, string $env_name, bool $expected): void { - $field = new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: $env_name); - - $this->assertSame($expected, (new EnvNameResolver($prefix))->isAdvertisable($field)); + $this->assertSame($expected, (new EnvNameResolver($prefix))->isAdvertisable(self::field($env_name))); } public static function dataProviderIsAdvertisable(): \Iterator { @@ -87,4 +77,29 @@ public static function dataProviderIsAdvertisable(): \Iterator { yield 'a bare mechanical name is not advertised' => ['', '', FALSE]; } + /** + * A field declaring the environment names under test. + * + * @param string $env_name + * The declared name, or empty to keep the mechanical one. + * @param list $aliases + * The declared aliases. + * + * @return \DrevOps\Tui\Block\Field + * The field. + */ + protected static function field(string $env_name = '', array $aliases = []): Field { + $field = new Field('crate_size', 'Crate size', FieldType::Text); + + if ($env_name !== '') { + $field->env($env_name); + } + + if ($aliases !== []) { + $field->envAliases($aliases); + } + + return $field; + } + } diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php index 34a2e7e6..7e9e18aa 100644 --- a/tests/phpunit/Unit/Resolver/InputResolverTest.php +++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php @@ -4,9 +4,8 @@ namespace DrevOps\Tui\Tests\Unit\Resolver; -use DrevOps\Tui\Model\Field; +use DrevOps\Tui\Block\Field; use DrevOps\Tui\Model\FieldType; -use DrevOps\Tui\Model\NumberBounds; use DrevOps\Tui\Resolver\InputResolver; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; @@ -44,7 +43,7 @@ public static function dataProviderEnvValueCoercion(): \Iterator { yield 'date passes through' => ['APP_DUE', '2026-07-15', 'due', '2026-07-15']; yield 'number trims and casts' => ['APP_PORT', ' 8080 ', 'port', 8080]; yield 'rating trims and casts' => ['APP_TASTE', ' 4 ', 'taste', 4]; - // Left as typed so the engine rejects it instead of it becoming a 0. + // Left as typed so the collection rejects it instead of it becoming a 0. yield 'non-integral rating stays a string' => ['APP_TASTE', 'great', 'taste', 'great']; yield 'multiple select splits a comma list' => ['APP_MODS', 'a, b ,c', 'mods', ['a', 'b', 'c']]; yield 'empty multiple select' => ['APP_MODS', '', 'mods', []]; @@ -62,49 +61,49 @@ public function testEnvNameResolution(Field $field, array $env, array $expected) /** * Data provider for testEnvNameResolution(). * - * @return \Iterator, array}> + * @return \Iterator, array}> * The field, the environment it is resolved against and the inputs it * contributes. */ public static function dataProviderEnvNameResolution(): \Iterator { yield 'mechanical name is the prefixed field id' => [ - new Field('machine_name', 'Machine', '', FieldType::Text, ''), + new Field('machine_name', 'Machine', FieldType::Text), ['APP_MACHINE_NAME' => 'x'], ['machine_name' => 'x'], ]; yield 'declared name replaces the mechanical one' => [ - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE'), + (new Field('crate_size', 'Crate size', FieldType::Text))->env('LEGACY_CRATE'), ['LEGACY_CRATE' => 'large', 'APP_CRATE_SIZE' => 'small'], ['crate_size' => 'large'], ]; yield 'mechanical name is not read once replaced' => [ - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE'), + (new Field('crate_size', 'Crate size', FieldType::Text))->env('LEGACY_CRATE'), ['APP_CRATE_SIZE' => 'small'], [], ]; yield 'alias answers when the canonical name is unset' => [ - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE']), + (new Field('crate_size', 'Crate size', FieldType::Text))->envAliases(['OLD_CRATE']), ['OLD_CRATE' => 'large'], ['crate_size' => 'large'], ]; yield 'canonical name wins over an alias' => [ - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE']), + (new Field('crate_size', 'Crate size', FieldType::Text))->envAliases(['OLD_CRATE']), ['OLD_CRATE' => 'large', 'APP_CRATE_SIZE' => 'small'], ['crate_size' => 'small'], ]; yield 'earlier alias wins over a later one' => [ - new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE', 'OLDER_CRATE']), + (new Field('crate_size', 'Crate size', FieldType::Text))->envAliases(['OLD_CRATE', 'OLDER_CRATE']), ['OLDER_CRATE' => 'small', 'OLD_CRATE' => 'large'], ['crate_size' => 'large'], ]; yield 'alias value is coerced like the canonical one' => [ - new Field('organic', 'Organic', '', FieldType::Confirm, FALSE, envAliases: ['OLD_ORGANIC']), + (new Field('organic', 'Organic', FieldType::Confirm))->envAliases(['OLD_ORGANIC']), ['OLD_ORGANIC' => 'yes'], ['organic' => TRUE], ]; @@ -142,23 +141,23 @@ public function testPromptsFromFile(): void { /** * Build one field of each coercible type for resolution. * - * @return \DrevOps\Tui\Model\Field[] + * @return list<\DrevOps\Tui\Block\Field> * The fields. */ protected function fields(): array { return [ - new Field('name', 'Name', '', FieldType::Text, ''), - new Field('agree', 'Agree', '', FieldType::Confirm, FALSE), - new Field('mods', 'Mods', '', FieldType::Select, [], multiple: TRUE), - new Field('port', 'Port', '', FieldType::Number, 0), - new Field('taste', 'Taste', '', FieldType::Rating, 1, bounds: new NumberBounds(1, 5)), - new Field('ack', 'Ack', '', FieldType::Pause, TRUE), - new Field('tags', 'Tags', '', FieldType::Search, [], multiple: TRUE), - new Field('rank', 'Rank', '', FieldType::Reorder, []), - new Field('vis', 'Visibility', '', FieldType::Toggle, 'public'), - new Field('paths', 'Paths', '', FieldType::FilePicker, [], multiple: TRUE), - new Field('cfg', 'Config', '', FieldType::FilePicker, ''), - new Field('due', 'Due', '', FieldType::Calendar, ''), + new Field('name', 'Name', FieldType::Text), + new Field('agree', 'Agree', FieldType::Confirm), + (new Field('mods', 'Mods', FieldType::Select))->multiple(), + new Field('port', 'Port', FieldType::Number), + new Field('taste', 'Taste', FieldType::Rating), + new Field('ack', 'Ack', FieldType::Pause), + (new Field('tags', 'Tags', FieldType::Search))->multiple(), + new Field('rank', 'Rank', FieldType::Reorder), + new Field('vis', 'Visibility', FieldType::Toggle), + (new Field('paths', 'Paths', FieldType::FilePicker))->multiple(), + new Field('cfg', 'Config', FieldType::FilePicker), + new Field('due', 'Due', FieldType::Calendar), ]; } diff --git a/tests/phpunit/Unit/Schema/AgentHelpTest.php b/tests/phpunit/Unit/Schema/AgentHelpTest.php index 8890d4a4..047f2225 100644 --- a/tests/phpunit/Unit/Schema/AgentHelpTest.php +++ b/tests/phpunit/Unit/Schema/AgentHelpTest.php @@ -26,7 +26,7 @@ public function testGenerate(): void { $p->text('name', 'Site name')->required(); $p->confirm('agree', 'Agree'); }) - ->build(); + ->root(); $help = (new AgentHelp($form, 'APP_'))->generate(); @@ -43,7 +43,7 @@ public function testGenerate(): void { #[DataProvider('dataProviderDescribesFieldShape')] public function testDescribesFieldShape(\Closure $declare, array $contains, array $absent, array $matches): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $this->assertHelp((new AgentHelp($form))->generate(), $contains, $absent, $matches); } @@ -145,31 +145,31 @@ static function (PanelBuilder $p): void { // Each text keeps its own key, so an agent reads the same three guidance // texts a human does rather than one merged description. - yield 'hint and placeholder travel as extension keys' => [ + yield 'help and placeholder travel as extension keys' => [ static function (PanelBuilder $p): void { $p->text('name', 'Site name') ->description('The public name') - ->hint('Type a few letters to filter.') + ->help('Type a few letters to filter.') ->placeholder('E.g. Golden Beetroot'); }, - ['"description": "The public name"', '"x-hint": "Type a few letters to filter."', '"x-placeholder": "E.g. Golden Beetroot"'], + ['"description": "The public name"', '"x-help": "Type a few letters to filter."', '"x-placeholder": "E.g. Golden Beetroot"'], [], [], ]; - yield 'undeclared hint and placeholder are omitted' => [ + yield 'undeclared help and placeholder are omitted' => [ static function (PanelBuilder $p): void { $p->text('name', 'Site name'); }, [], - ['"x-hint"', '"x-placeholder"'], + ['"x-help"', '"x-placeholder"'], [], ]; } #[DataProvider('dataProviderAdvertisesEnvironmentVariables')] public function testAdvertisesEnvironmentVariables(\Closure $declare, string $prefix, array $contains, array $absent, array $matches): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $this->assertHelp((new AgentHelp($form, $prefix))->generate(), $contains, $absent, $matches); } @@ -250,7 +250,7 @@ static function (PanelBuilder $p): void { #[DataProvider('dataProviderResolvesDefault')] public function testResolvesDefault(\Closure $declare, Context $context, array $contains, array $absent): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $this->assertHelp((new AgentHelp($form, '', $context))->generate(), $contains, $absent, []); } @@ -304,7 +304,7 @@ static function (PanelBuilder $p): void { #[DataProvider('dataProviderSkipsNonAnsweringField')] public function testSkipsNonAnsweringField(\Closure $declare, string $absent): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); // A field that carries no answer is not one an agent is asked to provide. $this->assertHelp((new AgentHelp($form, 'APP_'))->generate(), ['"name"'], [$absent], []); diff --git a/tests/phpunit/Unit/Schema/DefaultResolverTest.php b/tests/phpunit/Unit/Schema/DefaultResolverTest.php index 7e86dcf4..77f1088d 100644 --- a/tests/phpunit/Unit/Schema/DefaultResolverTest.php +++ b/tests/phpunit/Unit/Schema/DefaultResolverTest.php @@ -4,10 +4,11 @@ namespace DrevOps\Tui\Tests\Unit\Schema; +use DrevOps\Tui\Block\Field; +use DrevOps\Tui\Block\Tree; use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Handler\Context; -use DrevOps\Tui\Model\Field; use DrevOps\Tui\Schema\DefaultResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; @@ -100,13 +101,11 @@ public function testDeclaredSchemaDefaultSkipsThrowingClosure(): void { * @param \Closure $configure * The callback declaring one field on the panel builder. * - * @return \DrevOps\Tui\Model\Field - * The built field. + * @return \DrevOps\Tui\Block\Field + * The declared field. */ protected static function field(\Closure $configure): Field { - $form = Form::create('T')->panel('p', 'p', $configure)->build(); - - return array_values($form->fields())[0]; + return Tree::fields(Form::create('T')->panel('p', 'p', $configure)->root())[0]; } } diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index f511d27b..a8a7527a 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -28,11 +28,11 @@ public function testGenerate(): void { ->panel('p', 'p', function (PanelBuilder $p): void { $profile = $p->select('profile', 'Profile')->description('The profile')->default('standard')->required(); $profile->option('standard', 'Standard', 'Std')->option('minimal', 'Minimal'); - $p->text('theme')->hint('Leave empty to follow the profile.')->placeholder('E.g. Golden Beetroot')->derive(new Derive('{{profile}}'))->when(new Condition('profile', eq: 'standard')); + $p->text('theme')->help('Leave empty to follow the profile.')->placeholder('E.g. Golden Beetroot')->derive(new Derive('{{profile}}'))->when(new Condition('profile', eq: 'standard')); $p->number('port', 'Port')->min(1)->max(65535)->step(5); $p->calendar('release', 'Release date')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday); }) - ->build(); + ->root(); // Spelled out in full rather than through prompt(): this is the one place // that documents the complete shape of a generated prompt. @@ -43,7 +43,7 @@ public function testGenerate(): void { 'type' => 'select', 'label' => 'Profile', 'description' => 'The profile', - 'hint' => '', + 'help' => '', 'placeholder' => '', 'options' => [ ['value' => 'standard', 'label' => 'Standard', 'description' => 'Std'], @@ -74,7 +74,7 @@ public function testGenerate(): void { 'type' => 'text', 'label' => 'theme', 'description' => '', - 'hint' => 'Leave empty to follow the profile.', + 'help' => 'Leave empty to follow the profile.', 'placeholder' => 'E.g. Golden Beetroot', 'options' => [], 'options_dynamic' => FALSE, @@ -102,7 +102,7 @@ public function testGenerate(): void { 'type' => 'number', 'label' => 'Port', 'description' => '', - 'hint' => '', + 'help' => '', 'placeholder' => '', 'options' => [], 'options_dynamic' => FALSE, @@ -130,7 +130,7 @@ public function testGenerate(): void { 'type' => 'calendar', 'label' => 'Release date', 'description' => '', - 'hint' => '', + 'help' => '', 'placeholder' => '', 'options' => [], 'options_dynamic' => FALSE, @@ -164,7 +164,7 @@ public function testDescribesTemplateShape(): void { ->panel('p', 'p', function (PanelBuilder $p): void { $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{grade}}')->default('valley-a'); }) - ->build(); + ->root(); // The pattern travels as declared, with its slots named in shape order, so // external tooling can drive the field rather than guess at its shape. @@ -193,7 +193,7 @@ public function testExcludesNonSelectableOptions(): void { ->separator() ->option('demo', 'Demo', disabled: TRUE, disabled_reason: 'nope'); }) - ->build(); + ->root(); $expected = [ 'prompts' => [ @@ -213,7 +213,7 @@ public function testExcludesNonSelectableOptions(): void { #[DataProvider('dataProviderDescribesFieldInJson')] public function testDescribesFieldInJson(\Closure $declare, array $fragments): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $json = (string) json_encode((new SchemaGenerator($form))->generate()); @@ -271,7 +271,7 @@ static function (PanelBuilder $p): void { #[DataProvider('dataProviderResolvesDefault')] public function testResolvesDefault(\Closure $declare, Context $context, mixed $expected): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $prompts = (new SchemaGenerator($form, $context))->generate()['prompts']; $this->assertIsArray($prompts); @@ -325,7 +325,7 @@ static function (PanelBuilder $p): void { #[DataProvider('dataProviderDescribesEnvironmentVariables')] public function testDescribesEnvironmentVariables(\Closure $declare, string $prefix, int $index, ?string $env, array $aliases): void { - $form = Form::create('T')->panel('p', 'p', $declare)->build(); + $form = Form::create('T')->panel('p', 'p', $declare)->root(); $prompts = (new SchemaGenerator($form, new Context(), $prefix))->generate()['prompts']; $this->assertIsArray($prompts); @@ -371,7 +371,7 @@ public function testExcludesPresentationalNote(): void { $p->note('intro', 'Intro')->description('Welcome.'); $p->text('name', 'Name'); }) - ->build(); + ->root(); $schema = (new SchemaGenerator($form))->generate(); @@ -387,7 +387,7 @@ public function testRoundTripsThroughJson(): void { ->panel('p', 'p', function (PanelBuilder $p): void { $p->confirm('x')->default(TRUE); }) - ->build(); + ->root(); $schema = (new SchemaGenerator($form))->generate(); $decoded = json_decode((string) json_encode($schema), TRUE); @@ -413,7 +413,7 @@ protected static function prompt(array $overrides): array { 'type' => '', 'label' => '', 'description' => '', - 'hint' => '', + 'help' => '', 'placeholder' => '', 'options' => [], 'options_dynamic' => FALSE, diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index 573f3c65..c618453d 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -4,11 +4,11 @@ namespace DrevOps\Tui\Tests\Unit\Schema; +use DrevOps\Tui\Block\Panel; use DrevOps\Tui\Builder\FieldBuilder; use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Condition\Condition; -use DrevOps\Tui\Model\FormDefinition; use DrevOps\Tui\Schema\SchemaValidator; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; @@ -113,7 +113,7 @@ public function testRequired(string $id, mixed $value, ?string $expected_error): $p->text('plot', 'Garden plot name')->required(message: 'The garden plot name is required.'); $p->text('note', 'Delivery note'); }) - ->build(); + ->root(); $answers = ['name' => 'Pear', 'crates' => ['a'], 'plot' => 'North bed', 'note' => '']; $answers[$id] = $value; @@ -139,7 +139,7 @@ public static function dataProviderRequired(): \Iterator { public function testNumericStringOptionMembership(): void { $form = Form::create('T') ->panel('p', 'p', fn(PanelBuilder $p): FieldBuilder => $p->toggle('flag')->option('0', 'Off')->option('1', 'On')) - ->build(); + ->root(); $validator = new SchemaValidator($form); // A numeric-string value stays valid: values are compared as strings. @@ -152,7 +152,7 @@ public function testValidatesFilePickerConstraints(): void { $root = vfsStream::url('root'); $form = Form::create('T') ->panel('p', 'p', fn(PanelBuilder $p): FieldBuilder => $p->filePicker('cfg')->filesOnly()->extensions(['yml'])->maxSize(100)) - ->build(); + ->root(); $validator = new SchemaValidator($form); $this->assertSame([], $validator->validate(['cfg' => $root . '/ok.yml'])); @@ -165,7 +165,7 @@ public function testFilePickerConstraintsIgnoredOnNonPickerField(): void { // field's plain string value is not weighed as a filesystem path. $form = Form::create('T') ->panel('p', 'p', fn(PanelBuilder $p): FieldBuilder => $p->text('name')->maxSize(100)) - ->build(); + ->root(); $this->assertSame([], (new SchemaValidator($form))->validate(['name' => 'not-a-real-path'])); } @@ -173,7 +173,7 @@ public function testFilePickerConstraintsIgnoredOnNonPickerField(): void { /** * Build a form exercising every validation branch. */ - protected function form(): FormDefinition { + protected function form(): Panel { return Form::create('T') ->panel('p', 'p', function (PanelBuilder $p): void { $p->note('intro', 'Intro')->description('Welcome.'); @@ -193,7 +193,7 @@ protected function form(): FormDefinition { $p->filePicker('paths')->multiple(); $p->reorder('ranking')->option('x')->option('y')->option('z'); }) - ->build(); + ->root(); } } diff --git a/tests/phpunit/Unit/Screen/BuilderGapsTest.php b/tests/phpunit/Unit/Screen/BuilderGapsTest.php new file mode 100644 index 00000000..73e611eb --- /dev/null +++ b/tests/phpunit/Unit/Screen/BuilderGapsTest.php @@ -0,0 +1,207 @@ +action('submit', 'Submit')->action('cancel', 'Cancel'); + + $first = $actions->render($theme); + $second = $actions->select('cancel')->render($theme); + + $this->assertNotSame($first, $second); + $this->assertSame('[ Submit ] [ Cancel ]', Ansi::strip($second)); + $this->assertStringContainsString($theme->actionSelected('Cancel'), $second); + } + + public function testSelectingAnActionThatWasNeverDeclaredSaysWhichExist(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown action "save". This block declares: submit, cancel.'); + + (new Actions())->action('submit', 'Submit')->action('cancel', 'Cancel')->select('save'); + } + + public function testActionsWithholdTheEndOfTheFormAndSayWhy(): void { + $actions = new Actions(); + + $this->assertNull($actions->refusal()); + $this->assertSame('Basket contents is required.', $actions->refuse('Basket contents is required.')->refusal()); + $this->assertNull($actions->refuse(NULL)->refusal()); + } + + public function testLegendForgetsWhatNoLongerApplies(): void { + $legend = (new Legend())->entry('↵', 'accept'); + + $this->assertSame('', $legend->clear()->render($this->theme())); + } + + public function testEveryBlockThatCarriesAnIdSaysWhatItIs(): void { + $this->assertSame('intro', (new Markup('intro', 'Pick the produce.'))->id()); + $this->assertSame('packing', (new Progress('packing', 'Packing'))->id()); + } + + public function testWorkWithNoStepsCannotReportProgress(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Work with no steps cannot report progress; leave the total unset for a spinner.'); + + (new Progress('packing', 'Packing'))->steps(0); + } + + public function testLeavingPanelMakesItDrawAsRowAgain(): void { + $panel = (new Panel('advanced', 'Advanced'))->layout(new TwoColumnLayout())->enter(); + + $this->assertSame(' Advanced ›', $panel->leave()->render($this->theme())); + } + + public function testBuilderPlacesBlocksInWhicheverRegionWasNamed(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->layout('two-column'); + $p->in('left')->text('courier', 'Courier'); + $p->in('right')->markup('note', 'Weighed at the bench.'); + }); + + $this->assertCount(1, $panel->in('left')->blocks()); + $this->assertCount(1, $panel->in('right')->blocks()); + } + + public function testNamingRegionTheLayoutNeverDeclaredIsCaughtWhereItIsWritten(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown region "sidebar".'); + + (new PanelBuilder('main', 'Delivery'))->layout('two-column')->in('sidebar'); + } + + public function testBuilderTakesBlockItWasHandedReadyMade(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->add(new Markup('intro', 'Pick the produce.')); + }); + + $this->assertCount(1, $panel->in('content')->blocks()); + } + + public function testFieldDeclaresEveryCapabilityItClaimsThroughItsBuilder(): void { + $panel = $this->panel(static function (PanelBuilder $p): void { + $p->text('organic', 'Organic only?')->default('yes'); + $p->select('basket', 'Basket contents') + ->option('apple', 'Apple') + ->default('apple') + ->required() + ->validate(static fn(mixed $value): ?string => $value === 'apple' ? NULL : 'Pick apple.') + ->when(new Condition('organic', eq: 'yes')) + ->help('Every crate is weighed at the packing bench.'); + }); + + $this->assertSame(['organic' => 'yes', 'basket' => 'apple'], (new Collector())->collect($panel)); + } + + public function testColumnsLayoutTrimsWhenItsFixedRegionsCannotAllFit(): void { + $layout = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Columns); + + $this->region('sidebar')->fixed(24); + $this->region('main')->fixed(20); + } + + }; + + // Twelve columns cannot hold a sidebar of twenty-four and a main of any + // width: the fixed region is cut back rather than the sizes overrunning. + $sizes = $layout->arrange(12); + + $this->assertSame(12, array_sum($sizes)); + $this->assertSame(['sidebar' => 12, 'main' => 0], $sizes); + } + + public function testLayoutOfFixedRegionsAloneNeedsNoRemainderDivided(): void { + $layout = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + + $this->region('top')->fixed(2); + $this->region('bottom')->fixed(3); + } + + }; + + $this->assertSame(['top' => 2, 'bottom' => 3], $layout->arrange(40)); + } + + public function testMarkupBlockDrawsItsTitleAboveItsBody(): void { + $this->assertSame("Yields\nTwelve crates.", (new Markup('yields', 'Twelve crates.', 'Yields'))->render($this->theme())); + } + + public function testSpinningWorkAdvancesItsFrameRatherThanCount(): void { + $progress = new Progress('fetching', 'Fetching'); + + $first = $progress->render($this->theme()); + $second = $progress->advance()->render($this->theme()); + + $this->assertNotSame($first, $second); + } + + public function testTheAssemblerOffersTheButtonsThatEndForm(): void { + $this->assertSame(['submit', 'cancel'], (new Assembler())->actions()->names()); + } + + /** + * A theme with colour off, so the assertions read as plain strings. + */ + protected function theme(): DefaultTheme { + return new DefaultTheme(80, ['color' => FALSE]); + } + + /** + * Declare a panel and hand back the block it declared. + * + * @param \Closure $declare + * The declaration, given the panel builder. + * + * @return \DrevOps\Tui\Block\Panel + * The panel block. + */ + protected function panel(\Closure $declare): Panel { + $builder = new PanelBuilder('main', 'Delivery'); + $declare($builder); + $builder->seal(); + + return $builder->block(); + } + +} diff --git a/tests/phpunit/Unit/Screen/BuilderTest.php b/tests/phpunit/Unit/Screen/BuilderTest.php new file mode 100644 index 00000000..af785e6f --- /dev/null +++ b/tests/phpunit/Unit/Screen/BuilderTest.php @@ -0,0 +1,110 @@ +panel(function (PanelBuilder $p): void { + $p->text('courier', 'Courier')->default('Valley Runs'); + $p->number('weight', 'Basket weight')->default(1200); + $p->confirm('organic', 'Organic only?')->default(TRUE); + }); + + $this->assertSame(['courier' => 'Valley Runs', 'weight' => 1200, 'organic' => TRUE], (new Collector())->collect($panel)); + } + + public function testMarkupBetweenTwoFieldsDoesNotChangeTheShapeOfTheCode(): void { + $panel = $this->panel(function (PanelBuilder $p): void { + $p->text('courier', 'Courier')->default('Valley Runs'); + $p->markup('weighing', 'Every crate is weighed at the packing bench.'); + $p->number('weight', 'Basket weight')->default(1200); + }); + + $blocks = $panel->currentLayout()->in('content')->blocks(); + + $this->assertInstanceOf(Field::class, $blocks[0]); + $this->assertInstanceOf(Markup::class, $blocks[1]); + $this->assertInstanceOf(Field::class, $blocks[2]); + } + + public function testTheAssemblerFurnishesTheScreenTheLayoutWouldNot(): void { + $panel = $this->panel(function (PanelBuilder $p): void { + $p->text('courier', 'Courier')->default('Valley Runs'); + }); + + $screen = (new Assembler())->assemble($panel); + + // The layout declared three regions and named no block; the assembler put + // the standard furniture in them. + $this->assertInstanceOf(Breadcrumb::class, $screen->in('header')->blocks()[0]); + $this->assertInstanceOf(Legend::class, $screen->in('footer')->blocks()[0]); + $this->assertSame([$panel], $screen->in('content')->blocks()); + } + + public function testAnAssembledScreenDrawsEndToEnd(): void { + $panel = $this->panel(function (PanelBuilder $p): void { + $p->text('courier', 'Courier')->default('Valley Runs'); + }); + + $screen = (new Assembler())->assemble($panel); + $rendered = (new ScreenRenderer(new DefaultTheme(40, ['color' => FALSE])))->render($screen, 6, 40); + $lines = array_map(rtrim(...), explode("\n", $rendered)); + + $this->assertSame('Delivery', $lines[0]); + $this->assertSame(' Courier Valley Runs', $lines[1]); + $this->assertCount(6, $lines); + $this->assertStringContainsString('to move', $lines[5]); + } + + public function testTheAssembledLegendIsReadOutOfThePanelsOwnBindings(): void { + $legend = (new Assembler())->assemble($this->panel())->in('footer')->blocks()[0]; + + $this->assertInstanceOf(Legend::class, $legend); + $this->assertSame('↑/↓ to move · ↵ to select · ESC to go back', $legend->render(new DefaultTheme(80, ['color' => FALSE]))); + } + + /** + * Declare a panel and hand back the block it declared. + * + * @param \Closure|null $declare + * The declaration, given the panel builder; NULL declares an empty panel. + * + * @return \DrevOps\Tui\Block\Panel + * The panel block. + */ + protected function panel(?\Closure $declare = NULL): Panel { + $builder = new PanelBuilder('main', 'Delivery'); + + if ($declare instanceof \Closure) { + $declare($builder); + } + + $builder->seal(); + + return $builder->block(); + } + +} diff --git a/tests/phpunit/Unit/Screen/CollectorResolutionTest.php b/tests/phpunit/Unit/Screen/CollectorResolutionTest.php new file mode 100644 index 00000000..81b45a75 --- /dev/null +++ b/tests/phpunit/Unit/Screen/CollectorResolutionTest.php @@ -0,0 +1,694 @@ +collect($this->sourcesForm(), ['name' => 'Supplied'], $this->project(TRUE)); + + $this->assertSame('Supplied', $answers->value('name')); + $this->assertSame(Provenance::Edited, $answers->provenanceOf('name')); + } + + public function testDetectedValueWinsOverTheDeclaredDefault(): void { + $answers = $this->collect($this->sourcesForm(), [], $this->project(TRUE)); + + $this->assertSame('Detected Box', $answers->value('name')); + $this->assertSame('summer', $answers->value('season')); + $this->assertSame(Provenance::Detected, $answers->provenanceOf('name')); + } + + public function testNothingIsDetectedOutsideUpdateMode(): void { + $answers = $this->collect($this->sourcesForm(), [], $this->project(FALSE)); + + $this->assertSame('Weekly Box', $answers->value('name')); + $this->assertSame(Provenance::Default, $answers->provenanceOf('name')); + } + + public function testDetectedValueTheFieldWouldRefuseFallsBackToTheDefault(): void { + // The file holds a crate count outside the declared bounds, so the answer + // is the default rather than a detected value nothing would accept. + $answers = $this->collect($this->sourcesForm(), [], $this->project(TRUE)); + + $this->assertSame(2, $answers->value('crates')); + $this->assertSame(Provenance::Default, $answers->provenanceOf('crates')); + } + + public function testDetectorReadsTheRunContextItWasGiven(): void { + $seen = NULL; + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p) use (&$seen): void { + $p->text('version', 'Version')->default('')->discover(function (Context $context) use (&$seen): string { + $seen = $context; + + return $context->version; + }); + }); + + $answers = $this->collect($form, [], new Context('orchard', [], TRUE, '9.9')); + + $this->assertSame('9.9', $answers->value('version')); + $this->assertInstanceOf(Context::class, $seen); + $this->assertSame('orchard', $seen->directory); + } + + public function testClosureDefaultReadsTheContextAndTheAnswersSoFar(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('grower', 'Grower')->default('sunny'); + $p->text('lot', 'Lot')->default(static function (Context $context): string { + $grower = $context->answers['grower'] ?? ''; + + return $context->version . ':' . (is_string($grower) ? $grower : ''); + }); + }); + + $answers = $this->collect($form, [], new Context('', [], FALSE, '2.0')); + + $this->assertSame('2.0:sunny', $answers->value('lot')); + } + + /** + * Every source stamps the provenance the answer set reports. + * + * @param array $supplied + * The values supplied to the collection. + * @param bool $update + * Whether values already outside the form are detected. + * @param array $expected + * The expected provenance of each answer. + */ + #[DataProvider('dataProviderProvenanceFollowsTheSource')] + public function testProvenanceFollowsTheSource(array $supplied, bool $update, array $expected): void { + $answers = $this->collect($this->sourcesForm(), $supplied, $this->project($update)); + + foreach ($expected as $id => $provenance) { + $this->assertSame($provenance, $answers->provenanceOf($id), $id); + } + } + + /** + * Data provider for testProvenanceFollowsTheSource(). + * + * @return \Iterator,bool,array}> + * The supplied values, the update flag and the expected provenance. + */ + public static function dataProviderProvenanceFollowsTheSource(): \Iterator { + yield 'declared defaults' => [[], FALSE, ['name' => Provenance::Default, 'slug' => Provenance::Derived]]; + yield 'supplied value' => [['name' => 'Pear'], FALSE, ['name' => Provenance::Edited, 'slug' => Provenance::Derived]]; + yield 'detected value' => [[], TRUE, ['name' => Provenance::Detected, 'slug' => Provenance::Detected]]; + yield 'supplied over a computed one' => [['slug' => 'kept'], FALSE, ['slug' => Provenance::Override]]; + } + + public function testComputedValueFollowsTheAnswerItReads(): void { + $answers = $this->collect($this->sourcesForm(), ['name' => 'Golden Beetroot'], $this->project(FALSE)); + + $this->assertSame('golden_beetroot', $answers->value('slug')); + } + + public function testSuppliedValuePinsTheComputedOne(): void { + $answers = $this->collect($this->sourcesForm(), ['name' => 'Golden Beetroot', 'slug' => 'kept'], $this->project(FALSE)); + + $this->assertSame('kept', $answers->value('slug')); + } + + public function testDetectedValuePinsTheComputedOne(): void { + $answers = $this->collect($this->sourcesForm(), [], $this->project(TRUE)); + + // The slug was detected, so the rule that would compute it stands down. + $this->assertSame('detected-slug', $answers->value('slug')); + $this->assertSame(Provenance::Detected, $answers->provenanceOf('slug')); + } + + /** + * A supplied value of the wrong shape is refused, naming the shape owed. + * + * @param \Closure $declare + * The panel declaration. + * @param mixed $value + * The value supplied for the field "x". + * @param string $expected + * The expected message. + */ + #[DataProvider('dataProviderValueOfTheWrongShapeIsRefused')] + public function testValueOfTheWrongShapeIsRefused(\Closure $declare, mixed $value, string $expected): void { + $this->expectException(CollectException::class); + $this->expectExceptionMessage($expected); + + $this->collect(Form::create('T')->panel('p', 'p', $declare), ['x' => $value]); + } + + /** + * Data provider for testValueOfTheWrongShapeIsRefused(). + * + * @return \Iterator + * The declaration, the supplied value and the expected message. + */ + public static function dataProviderValueOfTheWrongShapeIsRefused(): \Iterator { + yield 'text takes a string' => [ + static function (PanelBuilder $p): void { + $p->text('x', 'X'); + }, + 42, + 'Invalid value for field "x": must be a string.', + ]; + + yield 'number takes a number' => [ + static function (PanelBuilder $p): void { + $p->number('x', 'X'); + }, + 'many', + 'Invalid value for field "x": must be a number.', + ]; + + yield 'rating takes a whole number' => [ + static function (PanelBuilder $p): void { + $p->rating('x', 'X'); + }, + 2.5, + 'Invalid value for field "x": must be a whole number.', + ]; + + yield 'confirm takes a boolean' => [ + static function (PanelBuilder $p): void { + $p->confirm('x', 'X'); + }, + 'yes', + 'Invalid value for field "x": must be a boolean.', + ]; + + yield 'a multiple field takes a list' => [ + static function (PanelBuilder $p): void { + $p->select('x', 'X')->multiple()->options(['a' => 'Alpha']); + }, + 'a', + 'Invalid value for field "x": must be a list.', + ]; + + yield 'calendar takes an ISO date' => [ + static function (PanelBuilder $p): void { + $p->calendar('x', 'X'); + }, + '2026-7-5', + 'Invalid value for field "x": must be a date (YYYY-MM-DD).', + ]; + } + + public function testEmptinessIsAnsweredBeforeTheShape(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->select('x', 'Picks')->multiple()->required()->options(['a' => 'Alpha']); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "x": Picks is required.'); + + $this->collect($form, ['x' => []]); + } + + public function testShapeIsAnsweredBeforeTheFieldsOwnValidator(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->number('x', 'X')->validate(static fn(): string => 'never reached'); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "x": must be a number.'); + + $this->collect($form, ['x' => 'many']); + } + + public function testShapeOfTemplateAnswerIsMeasuredBeforeItsValidator(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->template('x', 'X')->pattern('{{head}}-{{tail}}'); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "x"'); + + $this->collect($form, ['x' => 'nodash']); + } + + public function testReusableBehaviourStandsInWhereTheFieldDeclaresNone(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('machine_name', 'Machine name'); + }); + + $answers = $this->collect($form, ['machine_name' => 'Golden Beetroot'], NULL, new HandlerRegistry([self::HANDLERS])); + + // The reusable transformer normalized the supplied value. + $this->assertSame('golden beetroot', $answers->value('machine_name')); + } + + public function testReusableBehaviourRefusesWhatItCannotAccept(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('machine_name', 'Machine name'); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "machine_name": A machine name is required.'); + + $this->collect($form, ['machine_name' => ''], NULL, new HandlerRegistry([self::HANDLERS])); + } + + public function testTheFieldsOwnBehaviourWinsOverTheReusableOne(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('machine_name', 'Machine name') + ->validate(static fn(mixed $value): ?string => $value === 'REFUSED' ? 'The field says so.' : NULL) + ->transform(static fn(mixed $value): mixed => is_string($value) ? strtoupper($value) : $value); + }); + + $registry = new HandlerRegistry([self::HANDLERS]); + + $this->assertSame('GOLDEN', $this->collect($form, ['machine_name' => 'Golden'], NULL, $registry)->value('machine_name')); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "machine_name": The field says so.'); + + $this->collect($form, ['machine_name' => 'refused'], NULL, $registry); + } + + public function testOnlySuppliedValuesAreNormalized(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('a', 'A')->default(' kept ')->transform(static fn(mixed $value): mixed => is_string($value) ? trim($value) : $value); + $p->text('b', 'B')->default('')->transform(static fn(mixed $value): mixed => is_string($value) ? trim($value) : $value); + }); + + $answers = $this->collect($form, ['b' => ' trimmed ']); + + $this->assertSame(' kept ', $answers->value('a')); + $this->assertSame('trimmed', $answers->value('b')); + } + + public function testNormalizationHappensBeforeAnythingReadsTheValue(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('name', 'Name')->transform(static fn(mixed $value): mixed => is_string($value) ? trim($value) : $value); + $p->text('echo', 'Echo')->derive(new Derive('{{name}}')); + }); + + $answers = $this->collect($form, ['name' => ' Pear ']); + + $this->assertSame('Pear', $answers->value('echo')); + } + + /** + * A value the resolved rows no longer hold is restated against them. + * + * @param \Closure $declare + * The panel declaration. + * @param mixed $expected + * The expected answer for the field "x". + */ + #[DataProvider('dataProviderValueIsRestatedAgainstTheResolvedRows')] + public function testValueIsRestatedAgainstTheResolvedRows(\Closure $declare, mixed $expected): void { + $answers = $this->collect(Form::create('T')->panel('p', 'p', $declare), []); + + $this->assertSame($expected, $answers->value('x')); + } + + /** + * Data provider for testValueIsRestatedAgainstTheResolvedRows(). + * + * @return \Iterator + * The declaration and the expected answer. + */ + public static function dataProviderValueIsRestatedAgainstTheResolvedRows(): \Iterator { + $rows = static fn(Context $context): array => ['carrot' => 'Carrot', 'potato' => 'Potato']; + + yield 'a choice the set dropped falls away' => [ + static function (PanelBuilder $p) use ($rows): void { + $p->select('x', 'X')->default('apple')->options($rows); + }, + '', + ]; + + yield 'a list keeps only what the set still holds' => [ + static function (PanelBuilder $p) use ($rows): void { + $p->select('x', 'X')->multiple()->default(['apple', 'carrot'])->options($rows); + }, + ['carrot'], + ]; + + yield 'a ranking is completed to the resolved set' => [ + static function (PanelBuilder $p) use ($rows): void { + $p->reorder('x', 'X')->options($rows); + }, + ['carrot', 'potato'], + ]; + + yield 'a toggle falls back to the first resolved state' => [ + static function (PanelBuilder $p) use ($rows): void { + $p->toggle('x', 'X')->options($rows); + }, + 'carrot', + ]; + + yield 'a hint set leaves the value alone' => [ + static function (PanelBuilder $p) use ($rows): void { + $p->suggest('x', 'X')->default('Quince')->options($rows); + }, + 'Quince', + ]; + } + + public function testSuppliedValueIsReportedRatherThanQuietlyDropped(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->select('x', 'X')->options(static fn(Context $context): array => ['carrot' => 'Carrot']); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "x": value "apple" is not one of: carrot'); + + $this->collect($form, ['x' => 'apple']); + } + + public function testRowsOwedOnceAreAskedForOnce(): void { + $calls = 0; + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p) use (&$calls): void { + $p->select('x', 'X')->default('carrot')->options(function () use (&$calls): array { + $calls++; + + return ['carrot' => 'Carrot']; + }); + }); + + $this->assertSame('carrot', $this->collect($form, [])->value('x')); + $this->assertSame(1, $calls); + } + + public function testEachSuppliedItemIsLookedUpThroughTheQuery(): void { + $queries = []; + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p) use (&$queries): void { + $p->search('x', 'X')->multiple()->optionsFrom(static function (string $query) use (&$queries): array { + $queries[] = $query; + + return [$query => ucfirst($query)]; + }); + }); + + $answers = $this->collect($form, ['x' => ['carrot', 'potato', 'carrot']]); + + $this->assertSame(['carrot', 'potato', 'carrot'], $answers->value('x')); + // One lookup per distinct item: the same query twice answers the same way. + $this->assertSame(['carrot', 'potato'], $queries); + } + + public function testSourceThatCannotAnswerFailsTheCollection(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->search('x', 'X')->optionsFrom(static function (): array { + throw new \RuntimeException('The pantry is unreachable.'); + }); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Could not load options for field "x": The pantry is unreachable.'); + + $this->collect($form, ['x' => 'carrot']); + } + + public function testFieldItsConditionHidesCarriesNoAnswerAndNoProvenance(): void { + $answers = $this->collect($this->sourcesForm(), ['name' => 'Pear'], $this->project(FALSE)); + + $this->assertFalse($answers->has('note')); + $this->assertArrayNotHasKey('note', $answers->provenance); + $this->assertArrayNotHasKey('note', $answers->items); + } + + public function testConditionReadsTheSettledAnswersRatherThanTheDeclaredOnes(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('name', 'Name')->default(''); + $p->text('slug', 'Slug')->derive(new Derive('{{name}}', 'machine')); + // The rule reads a computed answer, so it can only hold once that answer + // has been computed rather than when it was declared. + $p->text('note', 'Note')->default('seen')->when(new Condition('slug', eq: 'golden_beetroot')); + }); + + $this->assertTrue($this->collect($form, ['name' => 'Golden Beetroot'])->has('note')); + $this->assertFalse($this->collect($form, ['name' => 'Pear'])->has('note')); + } + + public function testRulesThatWriteValueApplyOnceTheAnswersSettle(): void { + $answers = $this->collect($this->sourcesForm(), ['delivery' => 'doorstep', 'wrap' => TRUE], $this->project(FALSE)); + + $this->assertFalse($answers->value('wrap')); + + $answers = $this->collect($this->sourcesForm(), ['delivery' => 'gift', 'wrap' => TRUE], $this->project(FALSE)); + + $this->assertTrue($answers->value('wrap')); + } + + public function testRuleAimedAtRowThatOnlyShowsIsIgnored(): void { + $form = Form::create('T') + ->fixup(new Fixup(set: 'hint', to: 'written')) + ->fixup(new Fixup(set: 'name', from: 'hint')) + ->panel('p', 'p', function (PanelBuilder $p): void { + $p->markup('hint', 'Read the label.'); + $p->text('name', 'Name')->default('Pear'); + }); + + // Neither rule reaches an answer: the row it names carries none, so the + // settled value stands rather than being overwritten with nothing. + $this->assertSame('Pear', $this->collect($form, [])->value('name')); + } + + public function testAnswersDescribeTheQuestionsTheyAnswer(): void { + $form = Form::create('Orchard') + ->panel('main', 'Main', function (PanelBuilder $p): void { + $p->template('code', 'Code')->pattern('{{head}}-{{tail}}')->default('a-b'); + $p->panel('deep', 'Deep', function (PanelBuilder $q): void { + $q->text('inner', 'Inner')->default('deep'); + }); + }); + + $answers = $this->collect($form, []); + + $code = $answers->item('code'); + $this->assertInstanceOf(Answer::class, $code); + $this->assertSame('Code', $code->label); + $this->assertSame(FieldType::Template, $code->type); + $this->assertSame(['Main'], $code->panels); + $this->assertSame(['head' => 'a', 'tail' => 'b'], $answers->parts('code')); + + $inner = $answers->item('inner'); + $this->assertInstanceOf(Answer::class, $inner); + $this->assertSame(['Main', 'Deep'], $inner->panels); + + $this->assertStringContainsString('Main', $answers->toSummary()); + $this->assertStringContainsString('Deep', $answers->toSummary()); + } + + public function testAnswersFollowTheOrderTheFormDeclaresThem(): void { + $form = Form::create('Orchard') + ->panel('first', 'First', function (PanelBuilder $p): void { + $p->text('a', 'A')->default('a'); + $p->panel('nested', 'Nested', function (PanelBuilder $q): void { + $q->text('b', 'B')->default('b'); + }); + $p->text('c', 'C')->default('c'); + }) + ->panel('second', 'Second', function (PanelBuilder $p): void { + $p->text('d', 'D')->default('d'); + }); + + // A panel asks its own questions before the ones beneath it, whatever order + // the rows were placed in. + $this->assertSame(['a', 'c', 'b', 'd'], array_keys($this->collect($form, [])->values)); + } + + public function testTheTreeIsWalkedForEveryRowItHolds(): void { + $root = Form::create('Orchard') + ->panel('main', 'Main', function (PanelBuilder $p): void { + $p->markup('hint', 'Read the label.'); + $p->text('name', 'Name')->default('Pear'); + $p->progress('packing', 'Packing'); + $p->panel('deep', 'Deep', function (PanelBuilder $q): void { + $q->text('inner', 'Inner')->default('deep'); + }); + }) + ->root(); + + $this->assertSame(['name', 'inner'], array_map(static fn(Field $field): string => $field->id(), Tree::fields($root))); + $this->assertSame(['hint', 'name', 'packing', 'inner'], Tree::ids($root)); + } + + public function testRowThatOnlyShowsContributesNothingWhereverItSits(): void { + $panel = (new Panel('main', 'Delivery'))->layout(new PanelLayout()); + $panel->in('content')->add(new Field('note', 'Note', FieldType::Note)); + $panel->in('content')->add((new Field('courier', 'Courier'))->default('Valley Runs')); + + $answers = (new Collector())->answers($panel); + + $this->assertSame(['courier' => 'Valley Runs'], $answers->values); + $this->assertArrayNotHasKey('note', $answers->provenance); + } + + public function testResolverIsNotAskedAgainWhileItsWholeInputStands(): void { + $calls = 0; + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p) use (&$calls): void { + $p->select('item', 'Item')->default('carrot')->options(function (Context $context) use (&$calls): array { + $calls++; + + return ['carrot' => 'Carrot']; + }); + }); + + $collector = new Collector(); + $root = $form->root(); + + $this->assertSame('carrot', $collector->answers($root)->value('item')); + $this->assertSame('carrot', $collector->answers($root)->value('item')); + $this->assertSame(1, $calls); + + // Another run is another question, so the rows the first one produced are + // not handed back for it. + $collector->answers($root, [], new Context('orchard')); + $this->assertSame(2, $calls); + } + + public function testResolverThatCannotAnswerFailsTheCollection(): void { + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p): void { + $p->select('x', 'X')->options(static function (Context $context): array { + throw new \RuntimeException('The pantry is unreachable.'); + }); + }); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Could not load options for field "x": The pantry is unreachable.'); + + $this->collect($form, []); + } + + public function testQuerySourceIsNotConsultedWhereItConstrainsNothing(): void { + $calls = 0; + $form = Form::create('T')->panel('p', 'p', function (PanelBuilder $p) use (&$calls): void { + $p->text('open', 'Open')->default('no'); + // Hints are never a closed set, so nothing is measured against them. + $p->suggest('hint', 'Hint')->optionsFrom(static function (string $query) use (&$calls): array { + $calls++; + + return []; + }); + // A field its condition hides was never asked for, so nothing is looked + // up for it either. + $p->search('hidden', 'Hidden')->optionsFrom(static function (string $query) use (&$calls): array { + $calls++; + + return []; + })->when(new Condition('open', eq: 'yes')); + }); + + $answers = $this->collect($form, ['hint' => 'Quince', 'hidden' => 'carrot']); + + $this->assertSame('Quince', $answers->value('hint')); + $this->assertFalse($answers->has('hidden')); + $this->assertSame(0, $calls); + } + + /** + * Collect a form's answers with no screen at all. + * + * @param \DrevOps\Tui\Builder\Form $form + * The form to collect. + * @param array $supplied + * The values supplied for its fields. + * @param \DrevOps\Tui\Handler\Context|null $context + * The run the collection belongs to. + * @param \DrevOps\Tui\Handler\HandlerRegistry|null $registry + * The registry of behaviour reused across forms. + * + * @return \DrevOps\Tui\Answers\Answers + * The answers. + */ + protected function collect(Form $form, array $supplied = [], ?Context $context = NULL, ?HandlerRegistry $registry = NULL): Answers { + // The rules that write a value once the answers settle belong to the form + // rather than to any block, so they travel beside the tree. + return (new Collector($registry, $form->currentFixups()))->answers($form->root(), $supplied, $context); + } + + /** + * A form whose answers can arrive from every source there is. + * + * @return \DrevOps\Tui\Builder\Form + * The form. + */ + protected function sourcesForm(): Form { + return Form::create('Orchard') + ->fixup($this->wrapRule()) + ->panel('main', 'Main', function (PanelBuilder $p): void { + $p->text('name', 'Name')->default('Weekly Box')->discover(new JsonValue('box.json', 'name')); + $p->text('slug', 'Slug')->derive(new Derive('{{name}}', 'machine'))->discover(new JsonValue('box.json', 'slug')); + $p->text('season', 'Season')->default('spring')->discover(new Dotenv('SEASON')); + $p->number('crates', 'Crates')->min(1)->max(9)->default(2)->discover(new JsonValue('box.json', 'crates')); + $p->select('delivery', 'Delivery')->options(['doorstep' => 'Doorstep', 'gift' => 'Gift'])->default('doorstep'); + $p->confirm('wrap', 'Wrap?')->default(TRUE); + $p->text('note', 'Note')->default('n/a')->when(new Condition('delivery', eq: 'gift')); + }); + } + + /** + * The rule stripping the wrapping off anything that is not a gift. + * + * @return \DrevOps\Tui\Model\Fixup + * The rule. + */ + protected function wrapRule(): Fixup { + return new Fixup(set: 'wrap', to: FALSE, when: new Condition('delivery', ne: 'gift')); + } + + /** + * A run against a directory holding answers of its own. + * + * @param bool $update + * Whether those answers are detected. + * + * @return \DrevOps\Tui\Handler\Context + * The context. + */ + protected function project(bool $update): Context { + vfsStream::setup('project', NULL, [ + 'box.json' => '{"name": "Detected Box", "slug": "detected-slug", "crates": 99}', + '.env' => 'SEASON=summer', + ]); + + return new Context(vfsStream::url('project'), [], $update); + } + +} diff --git a/tests/phpunit/Unit/Screen/CollectorTest.php b/tests/phpunit/Unit/Screen/CollectorTest.php new file mode 100644 index 00000000..5a47bd1d --- /dev/null +++ b/tests/phpunit/Unit/Screen/CollectorTest.php @@ -0,0 +1,228 @@ +panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('weight', 'Weight'))->default(1200), + ); + + $this->assertSame(['courier' => 'Valley Runs', 'weight' => 1200], (new Collector())->collect($panel)); + } + + public function testNothingThatOnlyShowsReachesTheResult(): void { + $panel = $this->panel( + new Breadcrumb('Orchard'), + new Markup('intro', 'Pick the produce.'), + new Legend(), + (new Field('courier', 'Courier'))->default('Valley Runs'), + ); + + $this->assertSame(['courier' => 'Valley Runs'], (new Collector())->collect($panel)); + } + + public function testWorkThatOnlyActivatesReachesNothingEither(): void { + $panel = $this->panel( + new Progress('packing', 'Packing crates'), + (new Field('courier', 'Courier'))->default('Valley Runs'), + ); + + $this->assertSame(['courier' => 'Valley Runs'], (new Collector())->collect($panel)); + } + + public function testFieldItsConditionHidesIsNeverAskedFor(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(TRUE), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(static fn(): bool => FALSE), + ); + + $this->assertSame(['organic' => TRUE], (new Collector())->collect($panel)); + } + + public function testSuppliedValuesAreOfferedRatherThanTakenOnTrust(): void { + $panel = $this->panel( + (new Field('weight', 'Weight', FieldType::Number)) + ->default(1200) + ->validate(static fn(mixed $value): ?string => is_int($value) && $value >= 200 ? NULL : 'Enter at least 200.'), + ); + + $this->assertSame(['weight' => 4000], (new Collector())->collect($panel, ['weight' => 4000])); + } + + public function testRefusedSuppliedValueSaysWhichFieldAndWhy(): void { + $panel = $this->panel( + (new Field('weight', 'Weight', FieldType::Number)) + ->validate(static fn(mixed $value): ?string => is_int($value) && $value >= 200 ? NULL : 'Enter at least 200.'), + ); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "weight": Enter at least 200.'); + + (new Collector())->collect($panel, ['weight' => 10]); + } + + public function testConditionIsAnsweredAgainstWhatWasCollectedBeforeIt(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(TRUE), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('organic', eq: TRUE)), + ); + + $this->assertSame(['organic' => TRUE, 'certifier' => 'Soil Board'], (new Collector())->collect($panel)); + + // The supplied answer is what the condition then sees, so a field that + // depended on the default is no longer asked for. + $this->assertSame(['organic' => FALSE], (new Collector())->collect($panel, ['organic' => FALSE])); + } + + public function testRequiredFieldRefusesAnEmptyAnswerAndSaysWhichOne(): void { + $panel = $this->panel((new Field('basket', 'Basket contents'))->required()); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "basket": Basket contents is required.'); + + (new Collector())->collect($panel, ['basket' => '']); + } + + public function testSuppliedValueIsMeasuredAgainstTheBoundsItWasGiven(): void { + $panel = $this->panel((new Field('weight', 'Weight', FieldType::Number))->default(1200)->bounds(new NumberBounds(200, 9000))); + + $this->assertSame(['weight' => 4000], (new Collector())->collect($panel, ['weight' => 4000])); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "weight": must be between 200 and 9000.'); + + (new Collector())->collect($panel, ['weight' => 10]); + } + + public function testSuppliedValueIsNormalizedBeforeItIsCollected(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->transform(static fn(mixed $value): mixed => is_string($value) ? trim($value) : $value), + ); + + $this->assertSame(['courier' => 'Valley Runs'], (new Collector())->collect($panel, ['courier' => ' Valley Runs '])); + } + + public function testSuppliedValueIsMeasuredAgainstTheEntriesItMayPickFrom(): void { + $panel = $this->panel( + (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple', 'Apple')->entry('carrot', 'Carrot')->default('apple'), + ); + + $this->assertSame(['basket' => 'carrot'], (new Collector())->collect($panel, ['basket' => 'carrot'])); + + $this->expectException(CollectException::class); + $this->expectExceptionMessage('Invalid value for field "basket": value "plum" is not one of: apple, carrot'); + + (new Collector())->collect($panel, ['basket' => 'plum']); + } + + public function testFieldsInSubPanelsAreCollectedToo(): void { + $advanced = $this->panel((new Field('debug', 'Debug'))->default(FALSE)); + $panel = $this->panel((new Field('courier', 'Courier'))->default('Valley Runs'), $advanced); + + $this->assertSame(['courier' => 'Valley Runs', 'debug' => FALSE], (new Collector())->collect($panel)); + } + + public function testSeedingResolvesEveryValueAndRefusesNone(): void { + $panel = $this->panel( + (new Field('weight', 'Weight', FieldType::Number))->default(1200)->bounds(new NumberBounds(200, 9000)), + ); + + // 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. + [$values] = (new Collector())->seed($panel, ['weight' => 10]); + + $this->assertSame(['weight' => 10], $values); + } + + public function testSeedingSaysHowEachAnswerCameToBeAndWhoIsThere(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(TRUE), + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('organic', eq: FALSE)), + ); + + [$values, $provenance, $active] = (new Collector())->seed($panel, ['courier' => 'Coast Runs']); + + // A field a condition hides keeps the value it settled on, so a condition + // satisfied later surfaces a row that already knows its answer. + $this->assertSame(['organic' => TRUE, 'courier' => 'Coast Runs', 'certifier' => 'Soil Board'], $values); + $this->assertSame(['organic' => Provenance::Default, 'courier' => Provenance::Edited], $provenance); + $this->assertSame(['organic' => TRUE, 'courier' => TRUE, 'certifier' => FALSE], $active); + } + + public function testCollectingBuildsNoScreenAtAll(): void { + // A layout arranges drawing, so headlessly there is nothing for it to do. + // The layout answers by recording the question rather than by staying + // empty: an empty region would read the same whether it was consulted or + // not, and what is being claimed is that it never was. + $layout = new class(Axis::Rows) extends AbstractLayout { + + /** + * How many times the sizes were asked for. + */ + public int $arranged = 0; + + public function __construct(Axis $axis) { + parent::__construct($axis); + $this->region('content')->flex(1); + } + + public function arrange(int $available): array { + $this->arranged++; + + return parent::arrange($available); + } + + }; + + $panel = (new Panel('main', 'Delivery'))->layout($layout); + $panel->in('content')->add((new Field('courier', 'Courier'))->default('Valley Runs')); + + $this->assertSame(['courier' => 'Valley Runs'], (new Collector())->collect($panel)); + $this->assertSame(0, $layout->arranged); + } + + /** + * A panel holding the given blocks in its content region. + */ + protected function panel(object ...$blocks): Panel { + $panel = (new Panel('main', 'Delivery'))->layout(new DefaultLayout()); + + foreach ($blocks as $block) { + /** @var \DrevOps\Tui\Block\BlockInterface $block */ + $panel->in('content')->add($block); + } + + return $panel; + } + +} diff --git a/tests/phpunit/Unit/Screen/KeyRouterTest.php b/tests/phpunit/Unit/Screen/KeyRouterTest.php new file mode 100644 index 00000000..34643548 --- /dev/null +++ b/tests/phpunit/Unit/Screen/KeyRouterTest.php @@ -0,0 +1,281 @@ +router(new Markup('intro', 'Pick the produce.'), $courier); + + $this->assertSame($courier, $router->focused()); + $this->assertTrue($courier->isFocused()); + } + + public function testFocusSkipsEveryBlockThatDoesNotTakeIt(): void { + $courier = new Field('courier', 'Courier'); + $weight = new Field('weight', 'Weight'); + $router = $this->router($courier, new Markup('note', 'Weighed at the bench.'), $weight); + + $router->handle(Key::named(KeyName::Down)); + + $this->assertSame($weight, $router->focused()); + $this->assertTrue($weight->isFocused()); + $this->assertFalse($courier->isFocused()); + } + + public function testFocusStopsAtTheEndsRatherThanWrapping(): void { + $courier = new Field('courier', 'Courier'); + $router = $this->router($courier, new Field('weight', 'Weight')); + + $router->handle(Key::named(KeyName::Up)); + $this->assertSame($courier, $router->focused()); + } + + public function testAnOpenFieldTakesEveryPrintableKeyAsSomethingTyped(): void { + $courier = new Field('courier', 'Courier'); + $router = $this->router($courier); + + $router->handle(Key::named(KeyName::Enter)); + $router->handle(Key::char('?')); + + // The key stopped at the field and became a character, so it never reached + // the panel and never opened help. + $this->assertFalse($router->isShowingHelp()); + $this->assertStringContainsString('?', $courier->render($this->theme())); + } + + public function testClosedFieldBindsNoPrintableKeySoTheSameOneTravelsOutward(): void { + $router = $this->router((new Field('courier', 'Courier'))->help('Every crate is weighed.')); + + $router->handle(Key::char('?')); + + $this->assertTrue($router->isShowingHelp()); + } + + public function testAnyKeyDismissesHelpOnceItIsShowing(): void { + $router = $this->router((new Field('courier', 'Courier'))->help('Every crate is weighed.')); + + $router->handle(Key::char('?')); + $router->handle(Key::named(KeyName::Down)); + + $this->assertFalse($router->isShowingHelp()); + } + + public function testTheHelpShowingIsTheHelpOfTheFieldItWasAskedOf(): void { + $courier = (new Field('courier', 'Courier'))->help('Every crate is weighed.'); + $router = $this->router($courier); + + $this->assertNotInstanceOf(Field::class, $router->helping()); + + $router->handle(Key::char('?')); + + $this->assertSame($courier, $router->helping()); + } + + public function testFieldWithNoHelpAdvertisesNoneAndOpensNone(): void { + $router = $this->router(new Field('courier', 'Courier')); + + $router->handle(Key::char('?')); + + $this->assertFalse($router->isShowingHelp()); + } + + public function testActivatingFieldOpensIt(): void { + $courier = new Field('courier', 'Courier'); + $router = $this->router($courier); + + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame(Mode::Edit, $courier->mode()); + } + + public function testCancellingAnOpenFieldClosesItAndKeepsTheAnswer(): void { + $courier = (new Field('courier', 'Courier'))->default('Valley Runs'); + $router = $this->router($courier); + + $router->handle(Key::named(KeyName::Enter)); + $router->handle(Key::char('X')); + $router->handle(Key::named(KeyName::Escape)); + + $this->assertSame('Valley Runs', $courier->value()); + $this->assertSame(Mode::View, $courier->mode()); + } + + public function testAcceptingAnOpenFieldTakesWhatWasTypedIntoIt(): void { + $courier = new Field('courier', 'Courier'); + $router = $this->router($courier); + + $router->handle(Key::named(KeyName::Enter)); + + foreach (str_split('Coast') as $char) { + $router->handle(Key::char($char)); + } + + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame('Coast', $courier->value()); + } + + public function testSpaceTogglesInsideAnOpenListBecauseTheEditorBindsIt(): void { + $basket = (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot'); + $router = $this->router($basket); + + $router->handle(Key::named(KeyName::Enter)); + $router->handle(Key::named(KeyName::Space)); + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame(['apple'], $basket->value()); + } + + public function testCursorKeysReachAnOpenListRatherThanMovingBetweenFields(): void { + $basket = (new Field('basket', 'Basket contents', FieldType::Select))->entry('apple', 'Apple')->entry('carrot', 'Carrot'); + $router = $this->router($basket, new Field('weight', 'Weight')); + + $router->handle(Key::named(KeyName::Enter)); + $router->handle(Key::named(KeyName::Down)); + $router->handle(Key::named(KeyName::Enter)); + + // The key stopped at the open field, so the cursor never left the row. + $this->assertSame('carrot', $basket->value()); + $this->assertSame($basket, $router->focused()); + } + + public function testPanelWithNothingFocusableFocusesNothing(): void { + $router = $this->router(new Markup('intro', 'Pick the produce.')); + + $this->assertNotInstanceOf(Field::class, $router->focused()); + + // Nothing takes the cursor, so nothing moves it either. + $router->handle(Key::named(KeyName::Down)); + $this->assertNotInstanceOf(Field::class, $router->focused()); + } + + public function testSelectingTheNestedPanelGoesIntoItAndEscapeComesBackOut(): void { + $advanced = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout()); + $certifier = new Field('certifier', 'Certifier'); + $advanced->in('content')->add($certifier); + + $router = $this->router(new Field('courier', 'Courier'), $advanced); + + $router->handle(Key::named(KeyName::Down)); + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame($advanced, $router->current()); + $this->assertTrue($advanced->isEntered()); + $this->assertSame($certifier, $router->focused()); + $this->assertSame(['Delivery', 'Advanced'], $router->trail()); + + $router->handle(Key::named(KeyName::Escape)); + + // Coming back restores the screen, the trail and the row it was left on. + $this->assertSame('main', $router->current()->id()); + $this->assertFalse($advanced->isEntered()); + $this->assertSame($advanced, $router->focused()); + $this->assertSame(['Delivery'], $router->trail()); + } + + public function testGoingBackFromTheOutermostPanelGoesNowhere(): void { + $router = $this->router(new Field('courier', 'Courier')); + + $router->handle(Key::named(KeyName::Escape)); + + $this->assertSame('main', $router->current()->id()); + } + + public function testGoingIntoThePanelPreparesItOnce(): void { + $prepared = 0; + $advanced = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout()); + $advanced->preload(static function () use (&$prepared): void { + $prepared++; + }); + + $router = $this->router($advanced); + + $router->handle(Key::named(KeyName::Enter)); + $router->handle(Key::named(KeyName::Escape)); + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame(1, $prepared); + } + + public function testTheLegendIsRewrittenFromWhicheverBinderIsInnermost(): void { + $router = $this->router(new Field('courier', 'Courier')); + $legend = new Legend(); + + $closed = $router->refresh($legend)->render($this->theme()); + + $router->handle(Key::named(KeyName::Enter)); + $open = $router->refresh($legend)->render($this->theme()); + + $this->assertSame('↑/↓ to move · ↵ to select · ESC to go back', $closed); + $this->assertSame('↵ to accept · ESC to cancel', $open); + } + + public function testEveryBlockInThePanelAnswersToTheBindingsTheRouterSpreads(): void { + $advanced = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout()); + $certifier = new Field('certifier', 'Certifier', FieldType::Select); + $advanced->in('content')->add($certifier); + + $keys = KeyMapManager::create('vim'); + $router = $this->router(new Field('courier', 'Courier'), $advanced); + $router->bind($keys); + + // A preset reaches the panel, the panels beneath it and their fields, so a + // retuned key means the same thing everywhere on the screen. + $router->handle(Key::char('j')); + $this->assertSame($advanced, $router->focused()); + + $router->handle(Key::named(KeyName::Enter)); + + $this->assertSame($keys->forField(FieldType::Select), $certifier->bindings()); + } + + /** + * A key router over a panel holding the given blocks. + */ + protected function router(object ...$blocks): KeyRouter { + $panel = (new Panel('main', 'Delivery'))->layout(new DefaultLayout()); + + foreach ($blocks as $block) { + /** @var \DrevOps\Tui\Block\BlockInterface $block */ + $panel->in('content')->add($block); + } + + return new KeyRouter($panel); + } + + /** + * A theme with colour off, so the assertions read as plain strings. + */ + protected function theme(): DefaultTheme { + return new DefaultTheme(80, ['color' => FALSE]); + } + +} diff --git a/tests/phpunit/Unit/Screen/Layout/LayoutManagerTest.php b/tests/phpunit/Unit/Screen/Layout/LayoutManagerTest.php new file mode 100644 index 00000000..54b21322 --- /dev/null +++ b/tests/phpunit/Unit/Screen/Layout/LayoutManagerTest.php @@ -0,0 +1,119 @@ +assertSame([ + 'default' => DefaultLayout::class, + 'panel' => PanelLayout::class, + 'two-column' => TwoColumnLayout::class, + ], $built); + } + + public function testEachCallHandsBackLayoutOfItsOwn(): void { + // Two forms picking the same layout must not share its regions, or one + // would see the other's blocks. + $this->assertNotSame(LayoutManager::create('default'), LayoutManager::create('default')); + } + + public function testConsumerRegistersItsOwnUnderShortName(): void { + LayoutManager::register('sidebar', SidebarLayoutFixture::class); + + $this->assertInstanceOf(SidebarLayoutFixture::class, LayoutManager::create('sidebar')); + $this->assertSame(['default', 'panel', 'two-column', 'sidebar'], LayoutManager::names()); + } + + public function testClassNameWorksWithoutRegistering(): void { + $this->assertInstanceOf(SidebarLayoutFixture::class, LayoutManager::create(SidebarLayoutFixture::class)); + } + + public function testAnUnknownNameListsTheOnesThereAre(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown layout "sidebar". Registered: default, panel, two-column.'); + + LayoutManager::create('sidebar'); + } + + public function testRegisteringSomethingThatIsNotLayoutIsRefused(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Layout class "stdClass" must implement ' . LayoutInterface::class . '.'); + + LayoutManager::register('bogus', \stdClass::class); + } + + public function testBuildingSomethingThatIsNotLayoutIsRefused(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown layout "stdClass".'); + + LayoutManager::create(\stdClass::class); + } + + public function testArrangementNobodyCanBuildIsRefusedWhereItIsNamed(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Layout class "' . AbstractLayout::class . '" cannot be instantiated.'); + + LayoutManager::register('abstract', AbstractLayout::class); + } + + public function testBuildingArrangementNobodyCanBuildIsRefusedToo(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Layout class "' . AbstractLayout::class . '" cannot be instantiated.'); + + LayoutManager::create(AbstractLayout::class); + } + +} + +/** + * A layout registered by a consumer rather than shipped. + */ +final class SidebarLayoutFixture extends AbstractLayout { + + /** + * Construct the layout. + */ + public function __construct() { + parent::__construct(Axis::Columns); + + $this->region('sidebar')->fixed(24); + $this->region('main')->scrolls(); + } + +} diff --git a/tests/phpunit/Unit/Screen/Layout/LayoutTest.php b/tests/phpunit/Unit/Screen/Layout/LayoutTest.php new file mode 100644 index 00000000..d49767a8 --- /dev/null +++ b/tests/phpunit/Unit/Screen/Layout/LayoutTest.php @@ -0,0 +1,171 @@ +assertInstanceOf(Region::class, $layout->in('content')); + $this->assertSame('content', $layout->in('content')->name()); + } + + public function testReachingForRegionThatWasNeverDeclaredSaysWhichExist(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown region "sidebar". This layout declares: header, content, footer.'); + + (new DefaultLayout())->in('sidebar'); + } + + public function testFixedRegionsComeOffTheTopAndTheRestShareWhatIsLeft(): void { + $layout = new DefaultLayout(); + + // 24 rows, one to the header and one to the footer, 22 to content. + $this->assertSame(['header' => 1, 'content' => 22, 'footer' => 1], $layout->arrange(24)); + } + + public function testSharesDivideTheRemainderInProportion(): void { + $layout = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + + $this->region('top')->flex(30); + $this->region('middle')->flex(40); + $this->region('bottom')->flex(30); + } + + }; + + $this->assertSame(['top' => 30, 'middle' => 40, 'bottom' => 30], $layout->arrange(100)); + } + + public function testThirtyFortyThirtyAndThreeFourThreeMeanTheSameThing(): void { + $percent = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + $this->region('a')->flex(30); + $this->region('b')->flex(40); + $this->region('c')->flex(30); + } + + }; + + $ratio = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + $this->region('a')->flex(3); + $this->region('b')->flex(4); + $this->region('c')->flex(3); + } + + }; + + $this->assertSame($percent->arrange(37), $ratio->arrange(37)); + } + + public function testTheLeftoverCellGoesToTheLastRegionTakingShare(): void { + $layout = new TwoColumnLayout(); + + // 81 does not halve, so one column carries the odd cell rather than the + // frame losing it. + $sizes = $layout->arrange(81); + $this->assertSame(81, array_sum($sizes)); + $this->assertSame(['left' => 40, 'right' => 41], $sizes); + } + + public function testFixedRegionKeepsItsSizeHoweverLargeTheTerminal(): void { + $layout = new DefaultLayout(); + + $this->assertSame(1, $layout->arrange(24)['header']); + $this->assertSame(1, $layout->arrange(120)['header']); + } + + public function testFixedRegionsAreTrimmedWhenTheyCannotAllFit(): void { + $layout = new DefaultLayout(); + + // Two rows cannot hold a header, a footer and any content at all: the + // fixed regions are cut back rather than the layout returning sizes that + // add up to more than there is. + $sizes = $layout->arrange(2); + + $this->assertSame(2, array_sum($sizes)); + $this->assertSame(0, $sizes['content']); + } + + public function testLayoutWithNoRegionsArrangesNothing(): void { + $layout = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + } + + }; + + $this->assertSame([], $layout->arrange(40)); + } + + public function testDeclaringTheSameRegionTwiceIsRefused(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Region "header" is already declared on this layout.'); + + new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + $this->region('header'); + $this->region('header'); + } + + }; + } + + public function testTheDefaultLayoutStacksThreeRegionsAndScrollsTheMiddle(): void { + $layout = new DefaultLayout(); + + $this->assertSame(Axis::Rows, $layout->axis()); + $this->assertSame(['header', 'content', 'footer'], $layout->names()); + $this->assertFalse($layout->in('header')->isScrolling()); + $this->assertTrue($layout->in('content')->isScrolling()); + $this->assertFalse($layout->in('footer')->isScrolling()); + } + + public function testTheTwoColumnLayoutSplitsLeftFromRight(): void { + $layout = new TwoColumnLayout(); + + $this->assertSame(Axis::Columns, $layout->axis()); + $this->assertSame(['left', 'right'], $layout->names()); + } + + public function testLayoutNamesNoBlockItMightHold(): void { + // Reuse is what a layout would lose by knowing its content: it declares + // arrangement and nothing else, so its regions arrive empty. + foreach ([new DefaultLayout(), new TwoColumnLayout()] as $layout) { + foreach ($layout->names() as $name) { + $this->assertSame([], $layout->in($name)->blocks()); + } + } + } + +} diff --git a/tests/phpunit/Unit/Screen/RegionTest.php b/tests/phpunit/Unit/Screen/RegionTest.php new file mode 100644 index 00000000..40118cb3 --- /dev/null +++ b/tests/phpunit/Unit/Screen/RegionTest.php @@ -0,0 +1,100 @@ +assertSame('header', (new Region('header'))->name()); + } + + public function testDeclaringNeitherSizeIsWeightOfOne(): void { + $region = new Region('content'); + + $this->assertNull($region->fixedSize()); + $this->assertSame(1, $region->flexShare()); + } + + public function testFixedTakesCellsAndClearsAnyShare(): void { + $region = (new Region('header'))->flex(4)->fixed(1); + + $this->assertSame(1, $region->fixedSize()); + $this->assertNull($region->flexShare()); + } + + public function testFlexTakesShareAndClearsAnyFixedSize(): void { + $region = (new Region('content'))->fixed(1)->flex(3); + + $this->assertNull($region->fixedSize()); + $this->assertSame(3, $region->flexShare()); + } + + public function testBlocksFlowDownTheRegionUnlessToldOtherwise(): void { + $this->assertSame(Axis::Rows, (new Region('content'))->flowAxis()); + $this->assertSame(Axis::Columns, (new Region('header'))->flow(Axis::Columns)->flowAxis()); + } + + public function testRegionIsPinnedUntilItDeclaresItScrolls(): void { + $this->assertFalse((new Region('header'))->isScrolling()); + $this->assertTrue((new Region('content'))->scrolls()->isScrolling()); + } + + public function testEveryDeclarationChainsBackToTheRegion(): void { + $region = new Region('content'); + + $this->assertSame($region, $region->fixed(1)); + $this->assertSame($region, $region->flex(2)); + $this->assertSame($region, $region->flow(Axis::Columns)); + $this->assertSame($region, $region->scrolls()); + } + + public function testRegionArrivesEmpty(): void { + $this->assertSame([], (new Region('content'))->blocks()); + } + + public function testRegionTakesEveryKindOfBlockTheSameWay(): void { + $region = new Region('content'); + $first = new Markup('intro', 'Pick the produce.'); + $second = new Breadcrumb(); + + $region->add($first)->add($second); + + $this->assertSame([$first, $second], $region->blocks()); + } + + public function testAddingBlockChainsBackToTheRegion(): void { + $region = new Region('content'); + + $this->assertSame($region, $region->add(new Breadcrumb())); + } + + public function testFixedRejectsSizeNoRegionCouldHave(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('A fixed size is a count of cells, so it cannot be 0.'); + + (new Region('header'))->fixed(0); + } + + public function testFlexRejectsShareThatWouldTakeNothing(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('A flex share divides the remainder, so it cannot be 0.'); + + (new Region('content'))->flex(0); + } + +} diff --git a/tests/phpunit/Unit/Screen/ScreenControllerTest.php b/tests/phpunit/Unit/Screen/ScreenControllerTest.php new file mode 100644 index 00000000..09f74b15 --- /dev/null +++ b/tests/phpunit/Unit/Screen/ScreenControllerTest.php @@ -0,0 +1,580 @@ +panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('weight', 'Basket weight', FieldType::Number))->default(1200), + ); + + $tester = $this->tester($panel); + $answers = $tester->run(); + + $this->assertStringContainsString('Courier Valley Runs', $tester->frame(0)); + $this->assertStringContainsString('Basket weight 1200', $tester->frame(0)); + $this->assertSame((new Collector())->collect($this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('weight', 'Basket weight', FieldType::Number))->default(1200), + )), ['courier' => $answers->value('courier'), 'weight' => $answers->value('weight')]); + } + + public function testSuppliedValueIsWhatTheFormOpensOn(): void { + $panel = $this->panel((new Field('courier', 'Courier'))->default('Valley Runs')); + + $tester = $this->tester($panel)->supplied(['courier' => 'Coast Runs']); + $answers = $tester->run(); + + $this->assertStringContainsString('Courier Coast Runs', $tester->frame(0)); + $this->assertSame(Provenance::Edited, $answers->provenanceOf('courier')); + } + + public function testFieldItsConditionHidesContributesNoAnswer(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(FALSE), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('organic', eq: TRUE)), + ); + + $this->assertFalse($this->tester($panel)->run()->has('certifier')); + } + + public function testCursorWalksTheRowsThatTakeItAndSkipsTheRest(): void { + $courier = new Field('courier', 'Courier'); + $weight = new Field('weight', 'Basket weight'); + $panel = $this->panel($courier, new Markup('weighing', 'Weighed at the bench.'), $weight); + + $this->tester($panel)->run(Key::named(KeyName::Down)); + + $this->assertTrue($weight->isFocused()); + $this->assertFalse($courier->isFocused()); + } + + public function testEnterOpensTheFieldAndTheKeysOnOfferBecomeItsOwn(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + $tester->run(Key::named(KeyName::Enter)); + + $this->assertStringContainsString('to select', $tester->frame(0)); + $this->assertStringContainsString('to accept', $tester->frame(1)); + $this->assertStringNotContainsString('to select', $tester->frame(1)); + } + + public function testTypingReachesAnOpenFieldAndAcceptingTakesIt(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + $answers = $tester->run(Key::named(KeyName::Enter), 'Coast', Key::named(KeyName::Enter)); + + $this->assertSame('Coast', $answers->value('courier')); + } + + public function testTheListKeysReachAnOpenListRatherThanMovingBetweenRows(): void { + $basket = (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot'); + + $tester = $this->tester($this->panel($basket, new Field('courier', 'Courier'))); + + $answers = $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Space), + Key::named(KeyName::Down), + Key::named(KeyName::Space), + Key::named(KeyName::Enter), + ); + + $this->assertSame(['apple', 'carrot'], $answers->value('basket')); + } + + public function testRefusedValueLeavesTheFieldOpenWithTheReasonOnIt(): void { + $courier = (new Field('courier', 'Courier')) + ->default('Valley Runs') + ->validate(static fn(mixed $value): ?string => $value === 'Coast' ? NULL : 'Only the coast run is taking crates.'); + + $tester = $this->tester($this->panel($courier)); + $answers = $tester->run(Key::named(KeyName::Enter), 'X', Key::named(KeyName::Enter)); + + // What was offered is still in front of the reader, with the reason under + // it, and the answer stayed where it was. + $this->assertStringContainsString('Only the coast run is taking crates.', $tester->frame()); + $this->assertSame('Only the coast run is taking crates.', $courier->refusal()); + $this->assertSame('Valley Runs', $answers->value('courier')); + } + + public function testRefusedValueIsTakenOnceItIsAcceptable(): void { + $courier = (new Field('courier', 'Courier')) + ->validate(static fn(mixed $value): ?string => $value === 'Coast' ? NULL : 'Only the coast run is taking crates.'); + + $tester = $this->tester($this->panel($courier)); + + $answers = $tester->run( + Key::named(KeyName::Enter), + 'X', + Key::named(KeyName::Enter), + Key::named(KeyName::Backspace), + 'Coast', + Key::named(KeyName::Enter), + ); + + $this->assertSame('Coast', $answers->value('courier')); + $this->assertNull($courier->refusal()); + } + + public function testEditorRefusesWhatItsOwnBoundsRuleOut(): void { + $weight = (new Field('weight', 'Basket weight', FieldType::Number))->default(1200)->bounds(new NumberBounds(200, 9000)); + $tester = $this->tester($this->panel($weight)); + + $answers = $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Backspace), + Key::named(KeyName::Backspace), + Key::named(KeyName::Backspace), + Key::named(KeyName::Backspace), + '5', + Key::named(KeyName::Enter), + ); + + $this->assertStringContainsString('Enter a number between 200 and 9000.', $tester->frame()); + $this->assertSame(1200, $answers->value('weight')); + } + + public function testEscapeClosesAnOpenFieldWithoutCommittingWhatWasTyped(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'))); + + $answers = $tester->run(Key::named(KeyName::Enter), 'X', Key::named(KeyName::Escape)); + + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertStringContainsString('Courier Valley Runs', $tester->frame()); + } + + public function testEnterOnTheNestedPanelGoesIntoItAndTheTrailGrows(): void { + $advanced = $this->nested('advanced', 'Advanced', new Field('certifier', 'Certifier')); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $advanced)); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + $this->assertStringContainsString('Delivery › Advanced', $tester->frame()); + $this->assertStringContainsString('Certifier', $tester->frame()); + $this->assertTrue($advanced->isEntered()); + } + + public function testEscapeComesBackOutToTheRowItWasLeftOn(): void { + $advanced = $this->nested('advanced', 'Advanced', new Field('certifier', 'Certifier')); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $advanced)); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter), Key::named(KeyName::Escape)); + + $this->assertStringNotContainsString('› Advanced', $tester->frame()); + $this->assertTrue($advanced->isFocused()); + } + + public function testRegionScrollsToKeepTheFocusedRowInSight(): void { + $fields = []; + + foreach (range(1, 10) as $index) { + $fields[] = new Field('crate' . $index, 'Crate ' . $index); + } + + $tester = $this->tester($this->panel(...$fields))->rows(8); + $tester->run(...array_fill(0, 8, Key::named(KeyName::Down))); + + // The first frame opens on the top of the list, with a mark saying the + // rows run past the bottom of the region. + $this->assertStringContainsString('Crate 1', $tester->frame(0)); + $this->assertStringContainsString('▼', $tester->frame(0)); + + // The last one has followed the cursor down, so the row it is on shows and + // both edges are marked. + $this->assertStringContainsString('Crate 9', $tester->frame()); + $this->assertStringNotContainsString('Crate 1 ', $tester->frame()); + $this->assertStringContainsString('▲', $tester->frame()); + } + + public function testSubmitIsWithheldWhileTheFieldIsOwedAnAnswer(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->required())); + + $answers = $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + // The button stayed unpressed, so the session ran on to the end of the + // scripted keys rather than ending on it. + $this->assertStringContainsString('Courier is required.', $tester->frame()); + $this->assertNull($answers->value('courier')); + } + + public function testRowThatOnlyShowsIsNeitherCollectedNorOwedAnAnswer(): void { + $panel = $this->panel( + (new Field('intro', 'Pick the produce.', FieldType::Note))->required(), + (new Field('courier', 'Courier'))->default('Valley Runs'), + ); + + $tester = $this->tester($panel); + $answers = $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + // The row carries no answer, so nothing is owed for it and the form ended + // on the button rather than refusing it. + $this->assertFalse($answers->has('intro')); + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertCount(3, $tester->frames()); + } + + public function testFieldItsConditionHidesIsNotOwedAnAnswerEither(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(FALSE), + (new Field('certifier', 'Certifier'))->required()->when(new Condition('organic', eq: TRUE)), + ); + + $tester = $this->tester($panel); + $answers = $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + // A field that is not there is never asked for, so an empty one cannot + // withhold the submit. + $this->assertFalse($answers->has('certifier')); + $this->assertCount(3, $tester->frames()); + } + + public function testOnlyTheRegionHoldingTheCursorFollowsIt(): void { + $panel = (new Panel('main', 'Delivery'))->layout(new TwoScrollingRowsLayoutFixture()); + + foreach (range(1, 4) as $index) { + $panel->in('top')->add(new Field('crate' . $index, 'Crate ' . $index)); + $panel->in('bottom')->add(new Field('case' . $index, 'Case ' . $index)); + } + + $tester = $this->tester($panel)->rows(8); + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Down)); + + // The cursor is on the last row of the top region, so that region followed + // it while the one below stayed where it was left. + $this->assertStringContainsString('Crate 4', $tester->frame()); + $this->assertStringContainsString('Case 1', $tester->frame()); + } + + public function testAnsweringTheFieldRetiresTheRefusalAndLetsTheSubmitThrough(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->required())); + + $answers = $tester->run( + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + Key::named(KeyName::Up), + Key::named(KeyName::Enter), + 'Coast', + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + ); + + $this->assertStringNotContainsString('is required', $tester->frame()); + $this->assertSame('Coast', $answers->value('courier')); + + // The session ended on the button, so the key after it drew no frame. + $this->assertCount(8, $tester->frames()); + } + + public function testTheHorizontalKeysWalkTheButtonsAndCancelAbandonsTheForm(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + $this->expectException(CancelException::class); + $this->expectExceptionMessage('The interactive session was cancelled.'); + + $tester->run( + Key::named(KeyName::Down), + Key::named(KeyName::Right), + Key::named(KeyName::Left), + Key::named(KeyName::Right), + Key::named(KeyName::Enter), + ); + } + + public function testTheCancelledFormLeavesItsLastFrameToBeRead(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + try { + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Right), Key::named(KeyName::Enter)); + } + catch (CancelException) { + // The frames survive the abandonment, which is what makes what was on + // screen at the time assertable. + } + + $this->assertStringContainsString('[ Cancel ]', $tester->frame()); + } + + public function testFormThatHidesItsButtonsDrawsNone(): void { + $panel = $this->panel(new Field('courier', 'Courier'))->buttons(new Buttons(FALSE)); + + $tester = $this->tester($panel); + $tester->run(); + + $this->assertStringNotContainsString('Submit', $tester->frame()); + } + + public function testButtonsAreLabelledAsThePanelDeclaresThem(): void { + $panel = $this->panel(new Field('courier', 'Courier'))->buttons(new Buttons(TRUE, 'Send it', 'Forget it')); + + $tester = $this->tester($panel); + $tester->run(); + + $this->assertStringContainsString('[ Send it ]', $tester->frame()); + $this->assertStringContainsString('[ Forget it ]', $tester->frame()); + } + + public function testProgressRunsItsWorkOnActivationAndDrawsItsIndicator(): void { + $progress = (new Progress('packing', 'Packing crates'))->steps(4)->work(static function (ProgressReporter $reporter): void { + $reporter->advance('the apples'); + $reporter->advance('the carrots'); + }); + + $tester = $this->tester($this->panel($progress)); + $tester->run(Key::named(KeyName::Enter)); + + // A frame per step, so the bar fills in place while the work runs. + $this->assertStringContainsString('0/4', $tester->frame(1)); + $this->assertStringContainsString('1/4 the apples', $tester->frame(2)); + $this->assertStringContainsString('2/4 the carrots', $tester->frame(3)); + } + + public function testProgressWithNoWorkDrawsNothingNew(): void { + $tester = $this->tester($this->panel(new Progress('packing', 'Packing crates'))); + $tester->run(Key::named(KeyName::Enter)); + + // A frame before the key and a frame after it: nothing ran in between, so + // nothing repainted the row. + $this->assertCount(2, $tester->frames()); + $this->assertSame($tester->frame(0), $tester->frame(1)); + } + + public function testHelpKeyOpensTheFocusedFieldsHelpAndAnyKeyDismissesIt(): void { + $courier = (new Field('courier', 'Courier'))->help('Every crate is weighed at the packing bench.'); + $tester = $this->tester($this->panel($courier)); + + $tester->run(Key::char('?'), Key::named(KeyName::Down)); + + $this->assertStringContainsString('Every crate is weighed at the packing bench.', $tester->frame(1)); + $this->assertStringNotContainsString('Every crate is weighed', $tester->frame(2)); + } + + public function testChangedAnswerIsStampedAsEdited(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'))); + + $answers = $tester->run(Key::named(KeyName::Enter), 'X', Key::named(KeyName::Enter)); + + $this->assertSame('Valley RunsX', $answers->value('courier')); + $this->assertSame(Provenance::Edited, $answers->provenanceOf('courier')); + } + + public function testChangingComputedAnswerPinsTheRuleThatComputesIt(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('label', 'Crate label'))->derive(new Derive('courier')), + ); + + $tester = $this->tester($panel); + $answers = $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter), 'X', Key::named(KeyName::Enter)); + + $this->assertSame(Provenance::Override, $answers->provenanceOf('label')); + } + + public function testInterruptKeyAbortsTheSessionFromAnywhere(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + $this->expectException(InterruptException::class); + $this->expectExceptionMessage('The interactive session was interrupted.'); + + $tester->run(Key::named(KeyName::Enter), 'X', Key::named(KeyName::Interrupt)); + } + + public function testExhaustedScriptEndsTheSessionWithTheAnswersAsTheyStand(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'))); + + $this->assertSame('Valley Runs', $tester->run()->value('courier')); + } + + public function testScreenIsClearedAsTheSessionEnds(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + $tester->run(); + + $this->assertStringEndsWith($this->clear(), $tester->output()); + } + + public function testConsumerCanKeepTheLastFrameOnScreen(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->clearOnExit(FALSE); + $tester->run(); + + $this->assertStringEndsNotWith($this->clear(), $tester->output()); + } + + public function testAnAbortAlwaysLeavesTheScreenClean(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->clearOnExit(FALSE); + + try { + $tester->run(Key::named(KeyName::Interrupt)); + } + catch (InterruptException) { + // The clear is what the assertion below is about. + } + + $this->assertStringEndsWith($this->clear(), $tester->output()); + } + + public function testFrameIsDrawnInsideTheBorderItWasGiven(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->rows(6)->cols(30)->border(Border::Rounded); + $tester->run(); + + $lines = explode("\n", $tester->frame()); + + $this->assertStringStartsWith('╭', $lines[0]); + $this->assertStringStartsWith('╰', $lines[5]); + } + + public function testFrameIsNeverLaidOutWiderThanTheTerminal(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->cols(40); + $tester->run(); + + foreach (explode("\n", $tester->frame()) as $line) { + $this->assertSame(40, Ansi::width($line)); + } + } + + public function testPanelThatNamesItsRegionsAnythingElseTakesTheButtonsInTheFirst(): void { + $panel = (new Panel('main', 'Delivery'))->layout(new TwoColumnLayout()); + $panel->in('left')->add(new Field('courier', 'Courier')); + + $tester = $this->tester($panel); + $tester->run(); + + $this->assertStringContainsString('[ Submit ]', $tester->frame()); + } + + public function testWhatPrecedesTheFirstFrameIsDrawnBeforeIt(): void { + $clear = $this->clear(); + $controller = new class($this->panel(new Field('courier', 'Courier')), new DefaultTheme(40, ['color' => FALSE])) extends ScreenController { + + /** + * {@inheritdoc} + */ + #[\Override] + protected function opening(): string { + return 'Orchard deliveries'; + } + + }; + + $terminal = new BufferedTerminal([], 8, 40); + $controller->run($terminal); + + $this->assertStringStartsWith($clear . 'Orchard deliveries', $terminal->output()); + } + + public function testDrivingOneKeyDrawsNothingWithNoTerminal(): void { + $progress = (new Progress('packing', 'Packing crates'))->steps(4)->work(static function (ProgressReporter $reporter): void { + $reporter->advance(); + }); + + $controller = new ScreenController($this->panel($progress), new DefaultTheme(40, ['color' => FALSE])); + $controller->handle(Key::named(KeyName::Enter)); + + // The work still ran; only the drawing needed somewhere to draw. + $this->assertSame(1, $progress->current()); + $this->assertFalse($controller->isCancelled()); + $this->assertFalse($controller->isInterrupted()); + } + + /** + * The sequence every frame is written behind. + * + * @return non-empty-string + * The sequence. + */ + protected function clear(): string { + /** @var non-empty-string $clear */ + $clear = TerminalControl::clear(); + + return $clear; + } + + /** + * A tester over a panel, sized so a frame reads the same everywhere. + */ + protected function tester(Panel $panel): ScreenTester { + return (new ScreenTester($panel))->rows(14)->cols(60); + } + + /** + * The panel a screen starts in, holding the given blocks. + */ + protected function panel(object ...$blocks): Panel { + return $this->nested('main', 'Delivery', ...$blocks); + } + + /** + * A panel holding the given blocks in its content region. + */ + protected function nested(string $id, string $title, object ...$blocks): Panel { + $panel = (new Panel($id, $title))->layout(new PanelLayout()); + + foreach ($blocks as $block) { + /** @var \DrevOps\Tui\Block\BlockInterface $block */ + $panel->in('content')->add($block); + } + + return $panel; + } + +} + +/** + * A layout whose two rows scroll independently of each other. + */ +final class TwoScrollingRowsLayoutFixture extends AbstractLayout { + + /** + * Construct the layout. + */ + public function __construct() { + parent::__construct(Axis::Rows); + + $this->region('top')->flex(1)->scrolls(); + $this->region('bottom')->flex(1)->scrolls(); + } + +} diff --git a/tests/phpunit/Unit/Screen/ScreenParityTest.php b/tests/phpunit/Unit/Screen/ScreenParityTest.php new file mode 100644 index 00000000..a76b2b01 --- /dev/null +++ b/tests/phpunit/Unit/Screen/ScreenParityTest.php @@ -0,0 +1,917 @@ +panel( + new Field('intro', 'Pick the produce.', FieldType::Note), + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(FALSE), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('organic', eq: TRUE)), + ); + + $tester = $this->tester($panel); + $answers = $tester->run(Key::named(KeyName::Enter), Key::char('y'), Key::named(KeyName::Enter)); + + // The row is not there while the answer it depends on says it is not, and + // is there on the very next frame once the answer changes. + $this->assertStringNotContainsString('Certifier', $tester->frame(0)); + $this->assertStringContainsString('Certifier', $tester->frame()); + $this->assertSame('Soil Board', $answers->value('certifier')); + } + + public function testRowThatLeavesTakesTheCursorOffItself(): void { + $panel = $this->panel( + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(TRUE), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('organic', eq: TRUE)), + ); + + $tester = $this->tester($panel); + $answers = $tester->run( + Key::named(KeyName::Down), + Key::named(KeyName::Up), + Key::named(KeyName::Enter), + Key::char('n'), + Key::named(KeyName::Enter), + ); + + $this->assertStringNotContainsString('Certifier', $tester->frame()); + $this->assertFalse($answers->has('certifier')); + } + + public function testComputedAnswerRecomputesAsTheAnswerItReadsChanges(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley'), + (new Field('label', 'Crate label'))->derive(new Derive('{{courier}}')), + ); + + $answers = $this->tester($panel)->run(Key::named(KeyName::Enter), ' Runs', Key::named(KeyName::Enter)); + + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertSame('Valley Runs', $answers->value('label')); + $this->assertSame(Provenance::Derived, $answers->provenanceOf('label')); + } + + public function testRuleThatWritesAnAnswerReAppliesAfterEveryEdit(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('label', 'Crate label'))->default('none'), + ); + + $tester = $this->tester($panel)->collector(new Collector(NULL, [new Fixup(set: 'label', from: 'courier')])); + $answers = $tester->run(Key::named(KeyName::Enter), '!', Key::named(KeyName::Enter)); + + $this->assertSame('Valley Runs!', $answers->value('label')); + } + + public function testRowSetThatFollowsTheAnswersNarrowsBeforeTheNextFrame(): void { + $catalog = [ + 'fruit' => ['apple' => 'Apple', 'pear' => 'Pear'], + 'vegetable' => ['carrot' => 'Carrot', 'tomato' => 'Tomato'], + ]; + + $category = (new Field('category', 'Category', FieldType::Select)) + ->default('fruit') + ->entry('fruit', 'Fruit') + ->entry('vegetable', 'Vegetable'); + + $item = (new Field('item', 'Item', FieldType::Select))->resolve(static function (Context $context) use ($catalog): array { + $category = $context->answers['category'] ?? ''; + + return is_string($category) ? ($catalog[$category] ?? []) : []; + }); + + $tester = $this->tester($this->panel($category, $item)); + + $answers = $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + ); + + // The set the second row offers followed the first row's new answer, so + // what opened under it is what the new category holds. + $this->assertStringContainsString('Carrot', $tester->frame(-2)); + $this->assertSame('vegetable', $answers->value('category')); + $this->assertSame('carrot', $answers->value('item')); + } + + public function testTakingTheAnswerThatWasAlreadyThereStillRecordsIt(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'))); + $answers = $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertSame(Provenance::Edited, $answers->provenanceOf('courier')); + $this->assertStringContainsString('edited', $tester->frame()); + } + + public function testModalPanelOpensAsDialogOverTheScreenItWasOpenedFrom(): void { + $tester = $this->tester($this->order())->rows(16); + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + $frame = $tester->frame(); + + // The screen behind is still there, with the dialog drawn over it: its own + // title, its standing text, its rows and its own way out. + $this->assertStringContainsString('Item Pear', $frame); + $this->assertStringContainsString('│ Gift options', $frame); + $this->assertStringContainsString('│ Wrap this order as a gift.', $frame); + $this->assertStringContainsString('[ Save ] [ Discard ]', $frame); + } + + public function testDialogKeepsWhatItCollectedWhenItsOwnSubmitClosesIt(): void { + $tester = $this->tester($this->order())->rows(16); + $answers = $tester->run(...$this->intoTheDialog(Key::named(KeyName::Down), Key::named(KeyName::Enter))); + + $this->assertSame('Enjoy!', $answers->value('note')); + $this->assertStringNotContainsString('[ Save ]', $tester->frame()); + } + + public function testDialogPutsTheAnswersBackWhenItsOwnCancelClosesIt(): void { + $tester = $this->tester($this->order())->rows(16); + + $answers = $tester->run(...$this->intoTheDialog( + Key::named(KeyName::Down), + Key::named(KeyName::Right), + Key::named(KeyName::Enter), + )); + + $this->assertSame('Enjoy', $answers->value('note')); + } + + public function testDialogPutsTheAnswersBackWhenItIsAbandonedInstead(): void { + $tester = $this->tester($this->order())->rows(16); + + $this->assertSame('Enjoy', $tester->run(...$this->intoTheDialog(Key::named(KeyName::Escape)))->value('note')); + } + + public function testLeavingInsideDialogClosesTheDialogRatherThanTheForm(): void { + $tester = $this->tester($this->order())->rows(16); + $answers = $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter), Key::char('q'), Key::named(KeyName::Up)); + + // The form ran on after the dialog closed, so the key after it drew a + // frame of the panel rather than ending the session. + $this->assertStringNotContainsString('[ Save ]', $tester->frame()); + $this->assertSame('Pear', $answers->value('item')); + } + + public function testTextareaHandsItsBufferToTheEditorOfTheReadersOwn(): void { + $editor = new EditorFixture(TRUE); + $notes = (new Field('notes', 'Packing notes', FieldType::Textarea))->default('Weighed')->externalEditor(); + + $tester = $this->tester($this->panel($notes))->externalEditor($editor); + $answers = $tester->run(Key::named(KeyName::Enter), Key::char("\x05"), Key::named(KeyName::Tab)); + + // The session left the terminal to the editor and took it back, and what + // came back is what the row now holds. + $this->assertTrue($notes->hasHandoff()); + $this->assertSame('Weighed at the bench', $answers->value('notes')); + $this->assertInstanceOf(Terminal::class, $editor->suspended); + } + + public function testFieldOffersNoHandoffWhereThereIsNoEditorToHandOffTo(): void { + $notes = (new Field('notes', 'Packing notes', FieldType::Textarea))->default('Weighed')->externalEditor(); + + $tester = $this->tester($this->panel($notes))->externalEditor(new EditorFixture(FALSE))->cols(90); + $tester->run(Key::named(KeyName::Enter)); + + $this->assertFalse($notes->hasHandoff()); + $this->assertStringContainsString('to accept', $tester->frame()); + $this->assertStringNotContainsString('CTRL', $tester->frame()); + } + + public function testHelpTakesThePageItNeedsAndDrawsWhatItExplains(): void { + $courier = (new Field('courier', 'Courier'))->help("Every crate is weighed at the **packing bench**.\n\n- crates go out at noon"); + + $tester = $this->tester($this->panel($courier))->options(['markdown' => TRUE]); + $tester->run(Key::char('?'), Key::named(KeyName::Down)); + + // The page names the row it belongs to and draws the passage as prose, and + // the next key puts the panel back. + $this->assertStringContainsString('Courier', $tester->frame(1)); + $this->assertStringContainsString('Every crate is weighed at the packing bench.', $tester->frame(1)); + $this->assertStringContainsString('• crates go out at noon', $tester->frame(1)); + $this->assertStringNotContainsString('crates go out at noon', $tester->frame(2)); + } + + public function testStandaloneFieldTakesTheWholeFrameAndComesBackOnCancel(): void { + $harvest = (new Field('harvest', 'Harvest date', FieldType::Calendar))->default('2026-07-15')->standalone(); + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'), $harvest))->rows(16); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter), Key::named(KeyName::Escape)); + + // Nothing but the field is in front of the reader while it is open, and + // the panel is back the moment it closes. + $this->assertStringContainsString('July 2026', $tester->frame(2)); + $this->assertStringNotContainsString('Courier', $tester->frame(2)); + $this->assertStringContainsString('Courier Valley Runs', $tester->frame(3)); + } + + public function testBannerIsShownBeforeTheFormAndAnyKeyDismissesIt(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->banner('ORCHARD', '1.2.3'); + $tester->run(Key::named(KeyName::Enter)); + + $this->assertStringContainsString('ORCHARD', $tester->frame(0)); + $this->assertStringContainsString('Version: 1.2.3', $tester->frame(0)); + $this->assertStringContainsString('Press any key to continue...', $tester->frame(0)); + $this->assertStringContainsString('Courier', $tester->frame(1)); + } + + public function testInterruptAtTheBannerAbortsRatherThanOpeningTheForm(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->banner('ORCHARD'); + + try { + $tester->run(Key::named(KeyName::Interrupt)); + } + catch (InterruptException) { + // The abort is what the assertion below is about. + } + + $this->assertCount(1, $tester->frames()); + $this->assertStringContainsString('ORCHARD', $tester->frame()); + } + + public function testTerminalTooSmallForTheFrameSaysSoAndTakesOnlyTheKeyThatLeaves(): void { + $weight = new Field('weight', 'Basket weight'); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $weight)) + ->rows(6)->cols(24) + ->options(['fullscreen' => TRUE, 'min_width' => 40, 'min_height' => 10]); + + $tester->run(Key::named(KeyName::Down), Key::char('q')); + + $this->assertStringContainsString('Terminal too small.', $tester->frame()); + $this->assertStringContainsString('Need at least 40 x 10 - have 24 x 6.', $tester->frame()); + + // Nothing behind the notice moved, because nothing behind it was reachable. + $this->assertFalse($weight->isFocused()); + } + + public function testTerminalWideEnoughForTheFormItselfPassesTheGuard(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('certifier', 'Certifier'))->default('Soil Board')->when(new Condition('courier', eq: 'Coast Runs')), + $this->nested('advanced', 'Advanced', new Field('grade', 'Grade')), + ); + + // No minimum was stated, so the guard measures the rows the form draws - + // the rows that are not there and the panels it can be walked into aside. + $tester = $this->tester($panel) + ->rows(12)->cols(40) + ->options(['fullscreen' => TRUE]); + + $tester->run(); + + $this->assertStringNotContainsString('Terminal too small.', $tester->frame()); + $this->assertStringContainsString('Courier Valley Runs', $tester->frame()); + } + + public function testFullscreenFrameIsAnchoredWhereTheThemeSaysIt(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))) + ->rows(12)->cols(50) + ->options(['fullscreen' => TRUE, 'halign' => 'center', 'valign' => 'middle', 'max_width' => 20, 'max_height' => 4]); + + $tester->run(); + + $lines = explode("\n", $tester->frame()); + + // The frame is capped to the size the theme allows and floats in the + // middle of the terminal, padded with blank rows on every side. + $this->assertCount(12, $lines); + $this->assertSame('', trim($lines[0])); + $this->assertSame(' Delivery', rtrim($lines[4])); + } + + public function testTerminalIsWashedWithTheBackgroundTheThemeDeclares(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->rows(6)->cols(30)->theme(new DosTheme(30, ['color' => TRUE])); + $tester->run(); + + $this->assertStringContainsString("\033[44m", $tester->output()); + } + + public function testCompactSpacingDropsTheExplanationUnderTheRow(): void { + $courier = (new Field('courier', 'Courier'))->description('The run this basket goes out on.'); + + $padded = $this->tester($this->panel($courier))->options(['spacing' => Spacing::Normal]); + $padded->run(Key::named(KeyName::Enter)); + + $compact = $this->tester($this->panel($courier))->options(['spacing' => Spacing::Compact]); + $compact->run(Key::named(KeyName::Enter)); + + $this->assertStringContainsString('The run this basket goes out on.', $padded->frame()); + $this->assertStringNotContainsString('The run this basket goes out on.', $compact->frame()); + } + + #[DataProvider('dataProviderSpacingDecidesWhatShowsBetweenTheRows')] + public function testSpacingDecidesWhatShowsBetweenTheRows(Spacing $spacing, array $expected): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('weight', 'Basket weight'))->default('1200'), + ); + + $tester = $this->tester($panel)->options(['spacing' => $spacing]); + $tester->run(); + + $rows = array_map(rtrim(...), array_slice(explode("\n", $tester->frame()), 1, count($expected))); + + $this->assertSame($expected, $rows); + } + + public static function dataProviderSpacingDecidesWhatShowsBetweenTheRows(): \Iterator { + // The padded spacing is what a form gets without asking for one, so its + // blank row between two answers is the shape a reader meets by default. + yield 'padded' => [Spacing::Padded, ['❯ Courier Valley Runs', '', ' Basket weight 1200', '', '[ Submit ] [ Cancel ]']]; + yield 'normal' => [Spacing::Normal, ['❯ Courier Valley Runs', ' Basket weight 1200', '[ Submit ] [ Cancel ]']]; + yield 'compact' => [Spacing::Compact, ['❯ Courier Valley Runs', ' Basket weight 1200', '[ Submit ] [ Cancel ]']]; + } + + #[DataProvider('dataProviderSettledRowReadsTheAnswerRatherThanHoldsIt')] + public function testSettledRowReadsTheAnswerRatherThanHoldsIt(Field $field, string $reads): void { + $tester = $this->tester($this->panel($field)); + $tester->run(); + + // The row under the trail is the field's, so what it reads is the whole of + // what the answer says on screen. + $this->assertSame($reads, rtrim(explode("\n", $tester->frame())[1])); + } + + public static function dataProviderSettledRowReadsTheAnswerRatherThanHoldsIt(): \Iterator { + yield 'a decision is a word' => [ + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(TRUE), + '❯ Organic only? yes', + ]; + + yield 'a decision against is one too' => [ + (new Field('organic', 'Organic only?', FieldType::Confirm))->default(FALSE), + '❯ Organic only? no', + ]; + + yield 'a secret never prints' => [ + (new Field('key', 'Orchard key', FieldType::Password))->default('winter-pear'), + '❯ Orchard key ••••••••', + ]; + + yield 'an unanswered secret masks nothing' => [ + (new Field('key', 'Orchard key', FieldType::Password))->default(''), + '❯ Orchard key', + ]; + + yield 'several answers read as one run' => [ + (new Field('basket', 'Basket contents', FieldType::Select)) + ->multiple() + ->entry('apple', 'Apple') + ->entry('carrot', 'Carrot') + ->default(['apple', 'carrot']), + '❯ Basket contents apple, carrot', + ]; + + yield 'a grade reads as its scale' => [ + (new Field('ripeness', 'Ripeness', FieldType::Rating))->bounds(new NumberBounds(1, 5))->captions([3 => 'Ready'])->default(3), + '❯ Ripeness ●●●○○ 3/5 Ready', + ]; + + yield 'a weight is its number' => [ + (new Field('weight', 'Basket weight', FieldType::Number))->default(1200), + '❯ Basket weight 1200', + ]; + + yield 'a date is the day it names' => [ + (new Field('harvest', 'Harvest date', FieldType::Calendar))->default('2026-07-15'), + '❯ Harvest date 2026-07-15', + ]; + + yield 'a filled shape is the shape' => [ + (new Field('crate', 'Crate code', FieldType::Template))->pattern(new Template('{{orchard}}-{{fruit}}'))->default('valley-apple'), + '❯ Crate code valley-apple', + ]; + } + + public function testAnswerThatCarriesLineBreaksTakesOneRowPerLine(): void { + $notes = (new Field('notes', 'Packing notes', FieldType::Textarea))->default("Weighed at the bench\r\nSealed at dawn"); + + $tester = $this->tester($this->panel($notes)); + $tester->run(); + + $rows = array_map(rtrim(...), array_slice(explode("\n", $tester->frame()), 1, 2)); + + // No row ever carries a newline of its own, and the line that follows lines + // up under the value column rather than under the label. + $this->assertSame('❯ Packing notes Weighed at the bench', $rows[0]); + $this->assertSame(' Sealed at dawn', $rows[1]); + } + + public function testMarkdownDrawsTheSubsetInTheRowExplanation(): void { + $courier = (new Field('courier', 'Courier'))->description('Pick what is **ripe** today.'); + + $on = $this->tester($this->panel($courier))->options(['markdown' => TRUE, 'color' => TRUE]); + $on->run(Key::named(KeyName::Enter)); + + $off = $this->tester($this->panel($courier))->options(['color' => TRUE]); + $off->run(Key::named(KeyName::Enter)); + + // The markers are drawn rather than shown where markdown is on, and left + // exactly as they were typed where it is not. + $this->assertStringContainsString('Pick what is ripe today.', $on->display()); + $this->assertStringContainsString("\033[1mripe", $on->output()); + $this->assertStringContainsString('Pick what is **ripe** today.', $off->display()); + } + + public function testMarkdownDrawsTheSubsetInStandingNote(): void { + $note = new Markup('intro', "Pick what is **ripe**:\n- crisp apples"); + + $tester = $this->tester($this->panel($note))->options(['markdown' => TRUE]); + $tester->run(); + + $this->assertStringContainsString('Pick what is ripe:', $tester->frame()); + $this->assertStringContainsString('• crisp apples', $tester->frame()); + } + + public function testLinkResolvesWhetherOrNotMarkdownIsDrawn(): void { + $note = new Markup('intro', 'The [seasonal guide](https://example.com/guide) lists them.'); + + $tester = $this->tester($this->panel($note)); + $tester->run(); + + // Colour is off here, so the address is kept rather than hidden behind a + // label no terminal could open. + $this->assertStringContainsString('The seasonal guide (https://example.com/guide)', $tester->frame()); + } + + public function testRowThatOnlyShowsNeverTakesTheCursor(): void { + $courier = new Field('courier', 'Courier'); + $weight = new Field('weight', 'Basket weight'); + $panel = $this->panel($courier, new Field('intro', 'Pick the produce.', FieldType::Note), $weight); + + $this->tester($panel)->run(Key::named(KeyName::Down)); + + $this->assertTrue($weight->isFocused()); + $this->assertFalse($courier->isFocused()); + } + + public function testLeavingEndsTheSessionWithTheAnswersAsTheyStand(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs'))); + $answers = $tester->run(Key::char('q'), Key::named(KeyName::Down)); + + // Leaving is not abandoning, so the answers stand - and the key after it + // drew no frame, because the session had ended. + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertCount(1, $tester->frames()); + } + + public function testGoingIntoPanelReplacesTheScreenWithItsContents(): void { + $advanced = $this->nested('advanced', 'Advanced', new Field('certifier', 'Certifier'), new Field('grade', 'Grade')); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $advanced))->rows(14); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + $inside = $tester->frame(); + + // What the panel holds is the whole of what is in front of the reader: the + // row it was entered from, the rows beside it and the buttons that end the + // form are all left behind, and only the trail says where the cursor is. + $this->assertStringContainsString('Delivery › Advanced', $inside); + $this->assertStringContainsString('Certifier', $inside); + $this->assertStringContainsString('Grade', $inside); + $this->assertStringNotContainsString('Courier', $inside); + $this->assertStringNotContainsString('[ Submit ]', $inside); + + // Everything fits, so nothing says there is more to reach. + $this->assertStringNotContainsString('▼', $inside); + } + + public function testComingBackOutOfPanelDrawsEverythingItReplaced(): void { + $advanced = $this->nested('advanced', 'Advanced', new Field('certifier', 'Certifier')); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $advanced))->rows(14); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter), Key::named(KeyName::Escape)); + + $back = $tester->frame(); + + $this->assertStringContainsString('Courier', $back); + $this->assertStringContainsString('Advanced', $back); + $this->assertStringContainsString('[ Submit ]', $back); + $this->assertStringNotContainsString('Certifier', $back); + } + + public function testVimPresetDrivesTheWholeSession(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley'), + (new Field('weight', 'Basket weight'))->default('12'), + ); + + $tester = $this->tester($panel)->keys(KeyMapManager::create('vim')); + + $answers = $tester->run( + Key::char('j'), + Key::named(KeyName::Enter), + '0', + Key::named(KeyName::Enter), + Key::char('k'), + Key::named(KeyName::Enter), + ' Runs', + Key::named(KeyName::Enter), + Key::char('j'), + Key::char('j'), + Key::named(KeyName::Enter), + ); + + $this->assertSame('Valley Runs', $answers->value('courier')); + $this->assertSame('120', $answers->value('weight')); + } + + public function testRowSetThatHasToBeFetchedIsFetchedWhenItsPanelOpens(): void { + $calls = 0; + $basket = (new Field('basket', 'Basket contents', FieldType::Select))->load(function () use (&$calls): array { + $calls++; + + return ['apple' => 'Apple', 'carrot' => 'Carrot']; + }); + + $tester = $this->tester($this->nested('main', 'Delivery', $basket)); + $tester->run(Key::named(KeyName::Enter)); + + // Asked once, by the session opening the panel that holds it - and the row + // says the set is still coming rather than reading as empty until it lands. + $this->assertSame(1, $calls); + $this->assertStringContainsString('Apple', $tester->frame()); + } + + public function testRowSetIsFetchedOnlyOnceTheDeeperPanelIsWalkedInto(): void { + $calls = 0; + $basket = (new Field('basket', 'Basket contents', FieldType::Select))->load(function () use (&$calls): array { + $calls++; + + return ['apple' => 'Apple']; + }); + + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $this->nested('advanced', 'Advanced', $basket))); + $tester->run(); + + // Nobody has walked into it, so nothing has paid for it yet. + $this->assertSame(0, $calls); + + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Enter)); + + $this->assertSame(1, $calls); + } + + public function testLegendAdvertisesTheWayOutOfTheFormButNotOutOfAnOpenRow(): void { + $tester = $this->tester($this->panel((new Field('courier', 'Courier'))->default('Valley Runs')))->cols(80); + $tester->run(Key::named(KeyName::Enter)); + + // Leaving is about the session, so it is offered where it acts - and while + // a row is open the same letter is something being typed. + $this->assertStringContainsString('to quit', $tester->frame(0)); + $this->assertStringNotContainsString('to quit', $tester->frame()); + } + + public function testLegendOffersHelpOnlyWhereThereIsHelpToShow(): void { + $courier = (new Field('courier', 'Courier'))->help('Every crate is weighed at the packing bench.'); + $tester = $this->tester($this->panel($courier, new Field('weight', 'Basket weight')))->cols(80); + $tester->run(Key::named(KeyName::Down)); + + $this->assertStringContainsString('to show help', $tester->frame(0)); + $this->assertStringNotContainsString('to show help', $tester->frame()); + } + + public function testNestedPanelsSitSideBySideWhereTheFormArrangesThemThatWay(): void { + $fruit = $this->nested('fruit', 'Fruit', (new Field('fruit', 'Fruit'))->default('Apple')); + $veg = $this->nested('veg', 'Vegetables', (new Field('veg', 'Vegetables'))->default('Carrot')); + $panel = $this->panel((new Field('name', 'Order name'))->default('Weekly Box'), $fruit, $veg); + $panel->grid(2); + + $tester = $this->tester($panel)->cols(60); + $tester->run(); + + $rows = array_map(rtrim(...), explode("\n", $tester->frame())); + + // Each window previews the panel behind it - the way in, then its own rows + // - and the two share a row rather than following one another. + $this->assertContains(' Fruit › Vegetables ›', $rows); + $this->assertContains(' Fruit Apple Vegetables Carrot', $rows); + } + + public function testGridDealsItsPanelsIntoTheVisualRowsTheFormDeclares(): void { + $panel = $this->panel( + $this->nested('summary', 'Summary', (new Field('name', 'Order name'))->default('Weekly Box')), + $this->nested('fruit', 'Fruit', (new Field('fruit', 'Fruit'))->default('Apple')), + $this->nested('veg', 'Vegetables', (new Field('veg', 'Vegetables'))->default('Carrot')), + ); + $panel->grid(1, 2); + + $tester = $this->tester($panel)->rows(16)->cols(60); + $tester->run(); + + $rows = array_map(rtrim(...), explode("\n", $tester->frame())); + + // One full-width window above two sharing the row below it. + $this->assertContains('❯ Summary ›', $rows); + $this->assertContains(' Fruit › Vegetables ›', $rows); + } + + public function testCursorMovesSpatiallyAcrossTheGridOfWindows(): void { + $panel = $this->panel( + $this->nested('summary', 'Summary', (new Field('name', 'Order name'))->default('Weekly Box')), + $this->nested('fruit', 'Fruit', (new Field('fruit', 'Fruit'))->default('Apple')), + $this->nested('veg', 'Vegetables', (new Field('veg', 'Vegetables'))->default('Carrot')), + ); + $panel->grid(1, 2); + + $tester = $this->tester($panel)->rows(16)->cols(60); + + // Down leaves the full-width window for the row beneath it, right walks + // that row, and up comes back to the window above rather than to the + // window the cursor passed on the way down. + $tester->run( + Key::named(KeyName::Down), + Key::named(KeyName::Right), + Key::named(KeyName::Up), + ); + + $frames = array_map(static fn(string $frame): array => array_map(rtrim(...), explode("\n", $frame)), $tester->frames()); + + $this->assertContains(' Fruit › Vegetables ›', $frames[0]); + $this->assertContains('❯ Fruit › Vegetables ›', $frames[1]); + $this->assertContains(' Fruit › ❯ Vegetables ›', $frames[2]); + $this->assertContains('❯ Summary ›', $frames[3]); + + // A grid is moved through in two directions, so the legend says so. + $this->assertStringContainsString('←/→', $tester->frame()); + } + + public function testPanelRowCountsThePicksItHasNoRoomToList(): void { + $form = Form::create('Order') + ->panel('order', 'Produce order', static function (PanelBuilder $p): void { + $p->select('basket', 'Basket')->multiple()->default(['apple', 'beet', 'carrot', 'date']) + ->options(['apple' => 'Apple', 'beet' => 'Beet', 'carrot' => 'Carrot', 'date' => 'Date']); + $p->select('herbs', 'Herbs')->multiple()->default(['basil', 'dill']) + ->options(['basil' => 'Basil', 'dill' => 'Dill']); + }); + + $tester = (new ScreenTester($form->root()))->rows(10)->cols(70); + $tester->run(); + + // The line standing for a whole panel spells out a handful of picks and + // says how many there were once there are more than a handful. + $this->assertStringContainsString('4 items selected', $tester->frame()); + $this->assertStringContainsString('basil, dill', $tester->frame()); + } + + public function testChainOfConditionsStepsInOneStepPerLink(): void { + $form = Form::create('Conditional indentation') + ->panel('order', 'Produce order', static function (PanelBuilder $p): void { + $p->select('category', 'Category')->default('vegetable')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']); + // One condition deep: it hangs off the unconditional category above. + $p->select('basket', 'Basket')->multiple()->default(['carrot'])->options(['carrot' => 'Carrot', 'potato' => 'Potato'])->when(new Condition('category', eq: 'vegetable')); + // Two deep: its rule names a field that is itself conditional. + $p->confirm('weekly', 'Weekly delivery?')->default(TRUE)->when(new Condition('basket', contains: 'carrot')); + // Three deep, and the steps keep going for as long as the chain does. + $p->text('courier', 'Courier note')->default('Leave at the gate')->when(new Condition('weekly', eq: TRUE)); + // Unconditional again, so the row returns to the frame edge. + $p->number('quantity', 'Quantity')->min(1)->max(99)->default(6); + }); + + $stepped = (new ScreenTester($form->root()))->rows(16)->cols(70)->options(['indent_conditional' => TRUE]); + // Into the panel, so its own rows are what is in front of the reader. + $stepped->run(Key::named(KeyName::Enter)); + + $rows = array_map(rtrim(...), explode("\n", $stepped->frame())); + + // One step per link in the chain, and the row after it back at the edge. + $this->assertContains('❯ Category vegetable', $rows); + $this->assertContains(' Basket carrot', $rows); + $this->assertContains(' Weekly delivery? yes', $rows); + $this->assertContains(' Courier note Leave at the gate', $rows); + $this->assertContains(' Quantity 6', $rows); + } + + public function testChainOfConditionsStaysFlushUntilTheThemeIsAskedToStepIt(): void { + $form = Form::create('Conditional indentation') + ->panel('order', 'Produce order', static function (PanelBuilder $p): void { + $p->select('category', 'Category')->default('vegetable')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']); + $p->confirm('weekly', 'Weekly delivery?')->default(TRUE)->when(new Condition('category', eq: 'vegetable')); + }); + + $flush = (new ScreenTester($form->root()))->rows(12)->cols(70); + $flush->run(Key::named(KeyName::Enter)); + + // Off by default: a conditional row renders exactly where every other one + // does, so nothing on screen says which answer brought it into view. + $this->assertContains(' Weekly delivery? yes', array_map(rtrim(...), explode("\n", $flush->frame()))); + } + + public function testFormThatHidesItsLegendAdvertisesNothing(): void { + $shown = $this->tester($this->panel(new Field('courier', 'Courier'))); + $shown->run(); + + $hidden = $this->tester($this->panel(new Field('courier', 'Courier')))->footer(FALSE); + $hidden->run(); + + $this->assertStringContainsString('to select', $shown->frame()); + $this->assertStringNotContainsString('to select', $hidden->frame()); + $this->assertStringContainsString('Courier', $hidden->frame()); + } + + public function testLocalizedSessionDrawsItsChromeInTheActiveLanguage(): void { + Translator::setShared(new Translator('uk')); + + $tester = $this->tester($this->nested('main', 'Постачання', (new Field('courier', 'Кур\'єр'))->default('Coast Runs'))); + $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + // The chrome the library speaks and the words the form declares, both in + // the language the run was given. + $this->assertStringContainsString('Постачання', $tester->frame()); + $this->assertStringContainsString('[ Надіслати ] [ Скасувати ]', $tester->frame()); + $this->assertStringContainsString('перемістити', $tester->frame()); + $this->assertStringContainsString('змінено', $tester->frame()); + } + + public function testUpdateModeBadgesTheAnswersItDetected(): void { + $courier = (new Field('courier', 'Courier'))->discover(static fn(Context $context): string => 'Runs from ' . $context->directory); + + $tester = $this->tester($this->panel($courier))->context(new Context('/orchard', [], TRUE)); + $answers = $tester->run(); + + $this->assertSame(Provenance::Detected, $answers->provenanceOf('courier')); + $this->assertSame('detected', $courier->badgeText()); + $this->assertStringContainsString('Courier Runs from /orchard', $tester->frame()); + $this->assertStringContainsString(' detected ', $tester->frame()); + } + + public function testBadgeSitsInItsOwnColumnAtTheEdgeOfTheFrame(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('grade', 'Grade'))->default('Premium hand-picked'), + ); + + $tester = $this->tester($panel)->supplied(['courier' => 'Coast Runs', 'grade' => 'Standard']); + $tester->run(); + + $rows = array_values(array_filter(explode("\n", $tester->frame()), static fn(string $row): bool => str_contains($row, 'edited'))); + + // Every row of a frame is as wide as the frame, so a badge that ends where + // the row does is one in a column of its own - and two of them there line + // up with each other rather than trailing answers of different lengths. + $this->assertCount(2, $rows); + $this->assertStringEndsWith(' edited ', $rows[0]); + $this->assertStringEndsWith(' edited ', $rows[1]); + } + + /** + * The keys that open the dialog, change its row, then whatever follows. + * + * @return list + * The scripted keys. + */ + protected function intoTheDialog(string|Key ...$then): array { + return array_values([ + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + '!', + Key::named(KeyName::Enter), + ...$then, + ]); + } + + /** + * A form whose second row opens a dialog collecting one answer. + */ + protected function order(): Panel { + $gift = $this->nested('gift', 'Gift options', (new Field('note', 'Gift message'))->default('Enjoy')); + $gift->description('Wrap this order as a gift.')->buttons(new Buttons(TRUE, 'Save', 'Discard'))->modal(); + + return $this->nested('main', 'Basket', (new Field('item', 'Item'))->default('Pear'), $gift); + } + + /** + * A tester over a panel, sized so a frame reads the same everywhere. + */ + protected function tester(Panel $panel): ScreenTester { + return (new ScreenTester($panel))->rows(14)->cols(60); + } + + /** + * The panel a screen starts in, holding the given blocks. + */ + protected function panel(object ...$blocks): Panel { + return $this->nested('main', 'Delivery', ...$blocks); + } + + /** + * A panel holding the given blocks in its content region. + */ + protected function nested(string $id, string $title, object ...$blocks): Panel { + $panel = (new Panel($id, $title))->layout(new PanelLayout()); + + foreach ($blocks as $block) { + /** @var \DrevOps\Tui\Block\BlockInterface $block */ + $panel->in('content')->add($block); + } + + return $panel; + } + +} + +/** + * An editor of the reader's own that answers without launching anything. + */ +final class EditorFixture extends ExternalEditor { + + /** + * The terminal the session left to the editor, once it did. + */ + public ?Terminal $suspended = NULL; + + /** + * Construct the fixture. + * + * @param bool $available + * Whether there is an editor to hand off to at all. + */ + public function __construct(protected bool $available) { + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function isAvailable(): bool { + return $this->available; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function edit(string $initial, ?Terminal $terminal = NULL): string { + $this->suspended = $terminal; + + return $initial . ' at the bench'; + } + +} diff --git a/tests/phpunit/Unit/Screen/ScreenRenderTest.php b/tests/phpunit/Unit/Screen/ScreenRenderTest.php new file mode 100644 index 00000000..fa1526de --- /dev/null +++ b/tests/phpunit/Unit/Screen/ScreenRenderTest.php @@ -0,0 +1,296 @@ +layout(new DefaultLayout()); + $screen->in('header')->add(new Breadcrumb('Orchard', 'Delivery')); + $screen->in('content')->add(new Markup('intro', 'Pick the produce.')); + $screen->in('footer')->add((new Legend())->entry('↵', 'accept')); + + $lines = $this->render($screen, 6, 40); + + $this->assertSame('Orchard › Delivery', $lines[0]); + $this->assertSame('Pick the produce.', $lines[1]); + $this->assertSame('↵ to accept', $lines[5]); + } + + public function testRegionIsGivenExactlyTheRowsItsLayoutAllowed(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add(new Markup('long', implode("\n", array_fill(0, 20, 'row')))); + + // A header and footer of one row each leave four for the content. + $lines = $this->render($screen, 6, 40); + + $this->assertCount(6, $lines); + // The last of those four carries the mark saying there is more below it. + $this->assertSame(['', 'row', 'row', 'row', 'row ▼', ''], $lines); + } + + public function testPinnedRegionClipsWhereScrollingOneWouldNot(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('header')->add(new Markup('over', "one\ntwo\nthree")); + + $lines = $this->render($screen, 6, 40); + + // The header is one row, so only the first line of three survives. + $this->assertSame('one', $lines[0]); + $this->assertSame('', $lines[1]); + } + + public function testScrollingRegionShowsLaterWindowOnceScrolled(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add(new Markup('rows', "one\ntwo\nthree\nfour\nfive\nsix")); + + $screen->in('content')->scrollTo(2); + $lines = $this->render($screen, 6, 40); + + // Four rows of content, starting two in, with the first marked because two + // rows are now out of sight above it. + $this->assertSame(['', 'three ▲', 'four', 'five', 'six', ''], $lines); + } + + public function testPinnedRegionCannotBeScrolled(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Region "header" does not scroll, so it cannot be scrolled to row 2.'); + + (new DefaultLayout())->in('header')->scrollTo(2); + } + + public function testScrollingStopsAtTheLastRowRatherThanRunningPastIt(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add(new Markup('rows', "one\ntwo\nthree\nfour\nfive\nsix")); + + $screen->in('content')->scrollTo(99); + + // Six rows into four leaves two: the window stops there rather than + // scrolling the content off the top of itself. + $this->assertSame(['', 'three ▲', 'four', 'five', 'six', ''], $this->render($screen, 6, 40)); + } + + public function testPinnedRegionSaysNothingAboutWhatItClipped(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('header')->add(new Markup('over', "one\ntwo\nthree")); + + // Only a region you can move through says there is more, because there is + // no way to reach what a pinned one dropped. + $this->assertSame('one', $this->render($screen, 6, 40)[0]); + } + + public function testFramedScreenIsBoxedAndDrawsItsRegionsInside(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('header')->add(new Breadcrumb('Orchard', 'Delivery')); + $screen->in('content')->add(new Markup('intro', 'Pick the produce.')); + + $lines = $this->render($screen, 6, 24, Border::Rounded); + + $this->assertCount(6, $lines); + $this->assertSame('╭──────────────────────╮', $lines[0]); + $this->assertSame('│ Orchard › Delivery │', $lines[1]); + $this->assertSame('│ Pick the produce. │', $lines[2]); + $this->assertSame('╰──────────────────────╯', $lines[5]); + } + + public function testFrameFallsBackToTheGlyphsThatNeedNoUnicode(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add(new Markup('intro', 'Pick the produce.')); + + $theme = new DefaultTheme(20, ['color' => FALSE, 'unicode' => FALSE]); + $lines = explode("\n", (new ScreenRenderer($theme, Border::Rounded))->render($screen, 5, 20)); + + $this->assertSame('+------------------+', $lines[0]); + $this->assertStringStartsWith('| Pick the produce', $lines[2]); + } + + public function testThemeThatCannotDrawTheChromeSaysSo(): void { + // The frame belongs to no block, so the theme is asked for it directly - + // and a theme that declares none of it cannot draw one. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cannot draw the window chrome'); + + (new ScreenRenderer($this->createStub(ThemeInterface::class), Border::Line))->render((new Screen())->layout(new DefaultLayout()), 5, 20); + } + + public function testBlocksInOneRegionStackDownItByDefault(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content') + ->add(new Markup('first', 'First.')) + ->add(new Markup('second', 'Second.')); + + $lines = $this->render($screen, 6, 40); + + // One under the other, in the order they were added, with the air the + // default spacing asks for between them. + $this->assertSame('First.', $lines[1]); + $this->assertSame('', $lines[2]); + $this->assertSame('Second.', $lines[3]); + } + + public function testBlocksStackAgainstEachOtherWhereTheThemeAsksForNoAir(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content') + ->add(new Markup('first', 'First.')) + ->add(new Markup('second', 'Second.')); + + $theme = new DefaultTheme(40, ['color' => FALSE, 'spacing' => Spacing::Normal]); + $lines = array_map(rtrim(...), explode("\n", (new ScreenRenderer($theme))->render($screen, 6, 40))); + + $this->assertSame('First.', $lines[1]); + $this->assertSame('Second.', $lines[2]); + } + + public function testBlockWithNothingToSayCostsNoRowAtAll(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content') + ->add(new Markup('first', 'First.')) + ->add(new Markup('silent', '')) + ->add(new Markup('second', 'Second.')); + + $lines = $this->render($screen, 6, 40); + + // The silent block is not there at all, so the air between the two that + // did draw is the one blank row the spacing asks for rather than three. + $this->assertSame(['', 'First.', '', 'Second.', '', ''], $lines); + } + + public function testBlocksRunAcrossRegionThatFlowsThatWay(): void { + $layout = new DefaultLayout(); + $layout->in('header')->flow(Axis::Columns); + + $screen = (new Screen())->layout($layout); + $screen->in('header') + ->add(new Breadcrumb('Orchard')) + ->add(new Markup('clock', '09:14')); + + $lines = $this->render($screen, 6, 40); + + // Side by side, without nesting a layout to do it. + $this->assertSame('Orchard 09:14', $lines[0]); + } + + public function testColumnsDrawSideBySideOnTheSameRows(): void { + $screen = (new Screen())->layout(new TwoColumnLayout()); + $screen->in('left')->add(new Markup('l', "left one\nleft two")); + $screen->in('right')->add(new Markup('r', 'right one')); + + $lines = $this->render($screen, 2, 24); + + $this->assertSame('left one right one', rtrim($lines[0])); + $this->assertSame('left two', rtrim($lines[1])); + } + + public function testAnEnteredPanelDrawsItsOwnLayoutIntoTheRegion(): void { + $inner = (new Panel('main', 'Delivery'))->layout(new TwoColumnLayout())->enter(); + $inner->in('left')->add(new Markup('l', 'left')); + $inner->in('right')->add(new Markup('r', 'right')); + + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add($inner); + + $lines = $this->render($screen, 4, 20); + + // The panel is where the second layout starts, which is where depth comes + // from rather than a fifth level. + $this->assertSame('left right', $lines[1]); + } + + public function testNestedPanelDrawsRowYouSelect(): void { + $child = (new Panel('advanced', 'Advanced'))->layout(new DefaultLayout()); + + $screen = (new Screen())->layout(new DefaultLayout()); + $screen->in('content')->add($child); + + $this->assertSame(' Advanced ›', $this->render($screen, 4, 20)[1]); + } + + public function testGridIsMeasuredTheWayItIsDrawn(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $region = $screen->in('content')->grid(2, 1); + $region->add(new Markup('above', 'Pick the produce.')); + + $windows = []; + + foreach (['fruit' => 'Fruit', 'veg' => 'Vegetables', 'dairy' => 'Dairy'] as $id => $title) { + $window = (new Panel($id, $title))->layout(new DefaultLayout()); + $window->in('content')->add(new Markup($id . '-note', "one\ntwo")); + $region->add($window); + $windows[$id] = $window; + } + + [$total, $row] = (new ScreenRenderer(new DefaultTheme(40, ['color' => FALSE])))->extent($region, $windows['dairy']); + + // The row above, the air under it, then two windows sharing three rows, a + // rule between the visual rows, and the window below sharing none: two + // windows side by side cost the rows of one rather than of both. + $this->assertSame(9, $total); + // The window below the first visual row starts after it and the rule. + $this->assertSame(6, $row); + + // What was counted is what is drawn: the content region comes to exactly + // those rows, so a region moved against them shows what it says it does. + $drawn = $this->render($screen, $total + 2, 40); + $this->assertSame(' Dairy ›', $drawn[$row + 1]); + $this->assertSame('two', $drawn[$total]); + } + + public function testAnEmptyLayoutDrawsNothingAtAll(): void { + $layout = new class() extends AbstractLayout { + + public function __construct() { + parent::__construct(Axis::Rows); + } + + }; + + $this->assertSame([], $this->render((new Screen())->layout($layout), 6, 40)); + } + + /** + * Draw a screen and return its rows. + * + * @param \DrevOps\Tui\Screen\Screen $screen + * The screen. + * @param int $rows + * The terminal rows. + * @param int $columns + * The terminal columns. + * @param \DrevOps\Tui\Theme\Border $border + * The frame drawn around every region at once. + * + * @return list + * The rows. + */ + protected function render(Screen $screen, int $rows, int $columns, Border $border = Border::None): array { + $rendered = (new ScreenRenderer(new DefaultTheme($columns, ['color' => FALSE]), $border))->render($screen, $rows, $columns); + + return $rendered === '' ? [] : array_map(rtrim(...), explode("\n", $rendered)); + } + +} diff --git a/tests/phpunit/Unit/Screen/ScreenTest.php b/tests/phpunit/Unit/Screen/ScreenTest.php new file mode 100644 index 00000000..d0625b5c --- /dev/null +++ b/tests/phpunit/Unit/Screen/ScreenTest.php @@ -0,0 +1,50 @@ +assertSame($layout, (new Screen())->layout($layout)->currentLayout()); + } + + public function testScreenFitsItsContentsUntilToldToOccupyTheTerminal(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + + $this->assertFalse($screen->isFullscreen()); + $this->assertTrue($screen->fullscreen()->isFullscreen()); + } + + public function testBlockGoesInByRegionName(): void { + $screen = (new Screen())->layout(new DefaultLayout()); + $breadcrumb = new Breadcrumb('Orchard'); + + $screen->in('header')->add($breadcrumb); + + $this->assertSame([$breadcrumb], $screen->currentLayout()->in('header')->blocks()); + } + + public function testScreenWithoutLayoutHasNowhereToPutBlock(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('This screen has no layout, so it has no regions to place a block in.'); + + (new Screen())->in('header'); + } + +} diff --git a/tests/phpunit/Unit/Testing/AllFieldsFormTest.php b/tests/phpunit/Unit/Testing/AllFieldsFormTest.php new file mode 100644 index 00000000..8bccd4ca --- /dev/null +++ b/tests/phpunit/Unit/Testing/AllFieldsFormTest.php @@ -0,0 +1,183 @@ + ['file.txt' => '']]); + $this->pick = vfsStream::url('root/pick'); + } + + public function testEveryFieldTypeIsExercised(): void { + $root = AllFieldsForm::create()->root(); + $present = []; + + foreach (Tree::panels($root) as $panel) { + foreach ($panel->blocks() as $block) { + // A row that only shows and a row that only runs are blocks of their + // own, so each stands for the kind of row it is. + $type = match (TRUE) { + $block instanceof Field => $block->type(), + $block instanceof Markup => FieldType::Note, + $block instanceof Progress => FieldType::Progress, + default => NULL, + }; + + if ($type instanceof FieldType) { + $present[$type->value] = TRUE; + } + } + } + + $actual = array_keys($present); + sort($actual); + + $expected = array_map(static fn(FieldType $type): string => $type->value, FieldType::cases()); + sort($expected); + + $this->assertSame($expected, $actual, 'AllFieldsForm must exercise every FieldType so new fields are not left untested.'); + } + + public function testDrivesEveryField(): void { + $tester = new TuiTester(AllFieldsForm::create($this->pick)); + + $answers = $tester->run(...$this->keystrokes()); + + $this->assertSame('txt', $answers->value('text')); + $this->assertSame('a-b', $answers->value('template')); + $this->assertSame(['head' => 'a', 'tail' => 'b'], $answers->parts('template')); + $this->assertSame(7, $answers->value('number')); + $this->assertSame(4, $answers->value('rating')); + $this->assertSame('2026-07-15', $answers->value('date')); + $this->assertSame('note', $answers->value('textarea')); + $this->assertSame('pw', $answers->value('password')); + $this->assertSame('b', $answers->value('select')); + $this->assertSame(['a'], $answers->value('multiselect')); + $this->assertSame('utc', $answers->value('suggest')); + $this->assertSame('b', $answers->value('search')); + $this->assertSame(['b'], $answers->value('multisearch')); + $this->assertSame(['a', 'b', 'c'], $answers->value('reorder')); + $this->assertTrue($answers->value('confirm')); + $this->assertSame('off', $answers->value('toggle')); + $this->assertSame($this->pick . '/file.txt', $answers->value('filepicker')); + $this->assertSame([$this->pick . '/file.txt'], $answers->value('multifilepicker')); + $this->assertTrue($answers->value('pause')); + $this->assertFalse($tester->isCancelled()); + } + + #[DataProvider('dataProviderRendersEveryFieldAcrossThemes')] + public function testRendersEveryFieldAcrossThemes(string $theme, array $options): void { + $tester = (new TuiTester(AllFieldsForm::create($this->pick)))->theme($theme)->options($options); + + $answers = $tester->run(...$this->keystrokes()); + + // Answers are collected identically regardless of the theme. + $this->assertSame('txt', $answers->value('text')); + $this->assertSame('off', $answers->value('toggle')); + + // Every field's label was rendered somewhere in the session. + $display = $tester->display(); + foreach (['Note', 'Text', 'Template', 'Number', 'Rating', 'Calendar', 'Textarea', 'Password', 'Select', 'MultiSelect', 'Suggest', 'Search', 'MultiSearch', 'Reorder', 'Confirm', 'Toggle', 'FilePicker', 'MultiFilePicker', 'Pause', 'Progress'] as $label) { + $this->assertStringContainsString($label, $display, sprintf('The "%s" label was not rendered.', $label)); + } + } + + public static function dataProviderRendersEveryFieldAcrossThemes(): \Iterator { + yield 'default dark' => ['', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; + yield 'default light' => ['', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; + yield 'ascii no color' => ['', ['mode' => Mode::Dark, 'color' => FALSE, 'unicode' => FALSE]]; + yield 'custom theme class' => [OceanTheme::class, ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; + // Each curated built-in theme renders every field; ember drives the + // no-colour (no-ANSI) path across all of them. + yield 'midnight dark' => ['midnight', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; + yield 'frost light' => ['frost', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; + yield 'ember no color' => ['ember', ['mode' => Mode::Dark, 'color' => FALSE, 'unicode' => FALSE]]; + yield 'mono light' => ['mono', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; + yield 'dos dark' => ['dos', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; + } + + /** + * The scripted keystrokes that open and accept every field, then submit. + * + * @return list + * The keystrokes. + */ + protected function keystrokes(): array { + $enter = Key::named(KeyName::Enter); + $down = Key::named(KeyName::Down); + $tab = Key::named(KeyName::Tab); + $space = Key::named(KeyName::Space); + $escape = Key::named(KeyName::Escape); + + return [ + // Drill into the Fields panel; the cursor lands on the first field. + $enter, + // Each field: open the editor, accept its default, move to the next. + $enter, $enter, $down, + // Template: open, accept the filled-in default. + $enter, $enter, $down, + $enter, $enter, $down, + // Rating: open, accept the point the default sits on. + $enter, $enter, $down, + // Calendar accepts the current day with Enter. + $enter, $enter, $down, + // Textarea accepts with Tab (Enter inserts a newline). + $enter, $tab, $down, + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + // Reorder: open, accept the default (declared) ranking. + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + $enter, $enter, $down, + // Multi file picker: open, toggle the highlighted entry, accept. + $enter, $space, $enter, $down, + // Pause: open and acknowledge, then move to the progress row. + $enter, $enter, $down, + // Progress: activate to run its work (it collects no value). + $enter, + // Back to the root, then activate Submit. + $escape, $down, $enter, + ]; + } + +} diff --git a/tests/phpunit/Unit/Testing/AllWidgetsFormTest.php b/tests/phpunit/Unit/Testing/AllWidgetsFormTest.php deleted file mode 100644 index c026b630..00000000 --- a/tests/phpunit/Unit/Testing/AllWidgetsFormTest.php +++ /dev/null @@ -1,165 +0,0 @@ - ['file.txt' => '']]); - $this->pick = vfsStream::url('root/pick'); - } - - public function testEveryWidgetTypeIsExercised(): void { - $form = AllWidgetsForm::create()->build(); - - $present = []; - foreach ($form->fields() as $field) { - $present[$field->type->value] = TRUE; - } - $actual = array_keys($present); - sort($actual); - - $expected = array_map(static fn(FieldType $type): string => $type->value, FieldType::cases()); - sort($expected); - - $this->assertSame($expected, $actual, 'AllWidgetsForm must exercise every FieldType so new widgets are not left untested.'); - } - - public function testDrivesEveryWidget(): void { - $tester = new TuiTester(AllWidgetsForm::create($this->pick)); - - $answers = $tester->run(...$this->keystrokes()); - - $this->assertSame('txt', $answers->value('text')); - $this->assertSame('a-b', $answers->value('template')); - $this->assertSame(['head' => 'a', 'tail' => 'b'], $answers->parts('template')); - $this->assertSame(7, $answers->value('number')); - $this->assertSame(4, $answers->value('rating')); - $this->assertSame('2026-07-15', $answers->value('date')); - $this->assertSame('note', $answers->value('textarea')); - $this->assertSame('pw', $answers->value('password')); - $this->assertSame('b', $answers->value('select')); - $this->assertSame(['a'], $answers->value('multiselect')); - $this->assertSame('utc', $answers->value('suggest')); - $this->assertSame('b', $answers->value('search')); - $this->assertSame(['b'], $answers->value('multisearch')); - $this->assertSame(['a', 'b', 'c'], $answers->value('reorder')); - $this->assertTrue($answers->value('confirm')); - $this->assertSame('off', $answers->value('toggle')); - $this->assertSame($this->pick . '/file.txt', $answers->value('filepicker')); - $this->assertSame([$this->pick . '/file.txt'], $answers->value('multifilepicker')); - $this->assertTrue($answers->value('pause')); - $this->assertFalse($tester->isCancelled()); - } - - #[DataProvider('dataProviderRendersEveryWidgetAcrossThemes')] - public function testRendersEveryWidgetAcrossThemes(string $theme, array $options): void { - $tester = (new TuiTester(AllWidgetsForm::create($this->pick)))->theme($theme)->options($options); - - $answers = $tester->run(...$this->keystrokes()); - - // Answers are collected identically regardless of the theme. - $this->assertSame('txt', $answers->value('text')); - $this->assertSame('off', $answers->value('toggle')); - - // Every widget's label was rendered somewhere in the session. - $display = $tester->display(); - foreach (['Note', 'Text', 'Template', 'Number', 'Rating', 'Calendar', 'Textarea', 'Password', 'Select', 'MultiSelect', 'Suggest', 'Search', 'MultiSearch', 'Reorder', 'Confirm', 'Toggle', 'FilePicker', 'MultiFilePicker', 'Pause', 'Progress'] as $label) { - $this->assertStringContainsString($label, $display, sprintf('The "%s" label was not rendered.', $label)); - } - } - - public static function dataProviderRendersEveryWidgetAcrossThemes(): \Iterator { - yield 'default dark' => ['', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; - yield 'default light' => ['', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; - yield 'ascii no color' => ['', ['mode' => Mode::Dark, 'color' => FALSE, 'unicode' => FALSE]]; - yield 'custom theme class' => [OceanTheme::class, ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; - // Each curated built-in theme renders every widget; ember drives the - // no-colour (no-ANSI) path across all of them. - yield 'midnight dark' => ['midnight', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; - yield 'frost light' => ['frost', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; - yield 'ember no color' => ['ember', ['mode' => Mode::Dark, 'color' => FALSE, 'unicode' => FALSE]]; - yield 'mono light' => ['mono', ['mode' => Mode::Light, 'color' => TRUE, 'unicode' => TRUE]]; - yield 'dos dark' => ['dos', ['mode' => Mode::Dark, 'color' => TRUE, 'unicode' => TRUE]]; - } - - /** - * The scripted keystrokes that open and accept every field, then submit. - * - * @return list - * The keystrokes. - */ - protected function keystrokes(): array { - $enter = Key::named(KeyName::Enter); - $down = Key::named(KeyName::Down); - $tab = Key::named(KeyName::Tab); - $space = Key::named(KeyName::Space); - $escape = Key::named(KeyName::Escape); - - return [ - // Drill into the Widgets panel; the cursor lands on the first field. - $enter, - // Each field: open the editor, accept its default, move to the next. - $enter, $enter, $down, - // Template: open, accept the filled-in default. - $enter, $enter, $down, - $enter, $enter, $down, - // Rating: open, accept the point the default sits on. - $enter, $enter, $down, - // Calendar accepts the current day with Enter. - $enter, $enter, $down, - // Textarea accepts with Tab (Enter inserts a newline). - $enter, $tab, $down, - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - // Reorder: open, accept the default (declared) ranking. - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - $enter, $enter, $down, - // Multi file picker: open, toggle the highlighted entry, accept. - $enter, $space, $enter, $down, - // Pause: open and acknowledge, then move to the progress row. - $enter, $enter, $down, - // Progress: activate to run its work (it collects no value). - $enter, - // Back to the root, then activate Submit. - $escape, $down, $enter, - ]; - } - -} diff --git a/tests/phpunit/Unit/Testing/ScreenTesterTest.php b/tests/phpunit/Unit/Testing/ScreenTesterTest.php new file mode 100644 index 00000000..49ef737e --- /dev/null +++ b/tests/phpunit/Unit/Testing/ScreenTesterTest.php @@ -0,0 +1,230 @@ +tester($this->panel(new Field('courier', 'Courier'))); + $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Escape)); + + $this->assertCount(3, $tester->frames()); + $this->assertStringContainsString('to accept', $tester->frame(1)); + + // A negative index counts back from the frame the session ended on. + $this->assertSame($tester->frame(2), $tester->frame(-1)); + } + + public function testAskingForFrameThatWasNeverDrawnSaysSo(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + $tester->run(); + + $this->expectException(\OutOfBoundsException::class); + $this->expectExceptionMessage('The session drew 1 frames, so there is none at index 4.'); + + $tester->frame(4); + } + + public function testResultsCannotBeReadBeforeTheSessionRuns(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Call run() before reading the results.'); + + $tester->answers(); + } + + public function testOutputCannotBeReadBeforeTheSessionRuns(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Call run() before reading the results.'); + + $tester->output(); + } + + public function testWhatWasOnScreenSurvivesAnAbandonedSession(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))); + + try { + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Right), Key::named(KeyName::Enter)); + } + catch (CancelException) { + // The session collected nothing, but it drew plenty. + } + + $this->assertStringContainsString('[ Cancel ]', $tester->display()); + + $this->expectException(\LogicException::class); + + $tester->answers(); + } + + public function testDisplayIsTheOutputWithoutItsEscapeSequences(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->options(['color' => TRUE]); + $tester->run(); + + // Colour on, so the raw output carries the sequences the display drops. + $this->assertStringContainsString("\033[", $tester->output()); + $this->assertStringNotContainsString("\033[", $tester->display()); + } + + public function testTheThemeTheBlocksDrawThroughCanBeReplaced(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier'))) + ->theme(new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE])); + + $tester->run(); + + // The ASCII stand-in rather than the glyph, because the theme said so. + $this->assertStringContainsString('> Courier', $tester->frame()); + } + + public function testTheKeysTheScreenAnswersToCanBeRetuned(): void { + $weight = new Field('weight', 'Basket weight'); + $tester = $this->tester($this->panel(new Field('courier', 'Courier'), $weight))->keys(KeyMapManager::create('vim')); + + $tester->run(Key::char('j')); + + $this->assertTrue($weight->isFocused()); + } + + public function testWhatResolvesTheOpeningAnswersCanBeSupplied(): void { + $panel = $this->panel( + (new Field('courier', 'Courier'))->default('Valley Runs'), + (new Field('label', 'Crate label'))->default('none'), + ); + + $tester = $this->tester($panel)->collector(new Collector(NULL, [new Fixup(set: 'label', from: 'courier')])); + + $this->assertSame('Valley Runs', $tester->run()->value('label')); + } + + public function testTheRunTheSessionBelongsToCanBeSet(): void { + $panel = $this->panel((new Field('courier', 'Courier'))->discover(static fn(Context $context): string => 'Runs from ' . $context->directory)); + + $tester = $this->tester($panel)->context(new Context('/orchard', [], TRUE)); + $answers = $tester->run(); + + $this->assertSame('Runs from /orchard', $answers->value('courier')); + $this->assertSame(Provenance::Detected, $answers->provenanceOf('courier')); + } + + public function testValuesCanBeSuppliedAsTheCallerOfTheFormWould(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->supplied(['courier' => 'Coast Runs']); + + $this->assertSame('Coast Runs', $tester->run()->value('courier')); + } + + public function testTheLayoutTheScreenIsArrangedByCanBePicked(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->layout(TallHeaderLayoutFixture::class); + $tester->run(); + + $lines = explode("\n", $tester->frame()); + + // The header takes two rows here, so what follows it starts a row later. + $this->assertSame('Delivery', rtrim($lines[0])); + $this->assertSame('', rtrim($lines[1])); + $this->assertStringContainsString('Courier', $lines[2]); + } + + public function testTheFrameCanBeDrawnInsideBorder(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->border(Border::Line); + $tester->run(); + + $this->assertStringStartsWith('┌', $tester->frame()); + } + + public function testTheLastFrameCanBeLeftOnScreen(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->clearOnExit(FALSE); + $tester->run(); + + // Nothing was written after the frame, so it is still what a reader sees. + $this->assertStringContainsString($tester->frame(), $tester->display()); + $this->assertSame(1, substr_count($tester->display(), 'Delivery')); + } + + public function testTheTerminalSizeDecidesWhatFits(): void { + $tester = $this->tester($this->panel(new Field('courier', 'Courier')))->rows(5)->cols(30); + $tester->run(); + + $lines = explode("\n", $tester->frame()); + + $this->assertCount(5, $lines); + $this->assertSame(30, Ansi::width($lines[0])); + } + + public function testWidthNoFrameCouldBeLaidOutToIsRefused(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The terminal width must be at least 1 column.'); + + $this->tester($this->panel(new Field('courier', 'Courier')))->cols(0); + } + + /** + * A tester over a panel, sized so a frame reads the same everywhere. + */ + protected function tester(Panel $panel): ScreenTester { + return (new ScreenTester($panel))->rows(10)->cols(60); + } + + /** + * A panel holding the given blocks in its content region. + */ + protected function panel(object ...$blocks): Panel { + $panel = (new Panel('main', 'Delivery'))->layout(new PanelLayout()); + + foreach ($blocks as $block) { + /** @var \DrevOps\Tui\Block\BlockInterface $block */ + $panel->in('content')->add($block); + } + + return $panel; + } + +} + +/** + * A layout whose header takes two rows rather than one. + */ +final class TallHeaderLayoutFixture extends AbstractLayout { + + /** + * Construct the layout. + */ + public function __construct() { + parent::__construct(Axis::Rows); + + $this->region('header')->fixed(2); + $this->region('content')->scrolls(); + $this->region('footer')->fixed(1); + } + +} diff --git a/tests/phpunit/Unit/Testing/TuiTesterTest.php b/tests/phpunit/Unit/Testing/TuiTesterTest.php index 0a8b7750..c4fe2c66 100644 --- a/tests/phpunit/Unit/Testing/TuiTesterTest.php +++ b/tests/phpunit/Unit/Testing/TuiTesterTest.php @@ -77,8 +77,9 @@ public function testRunEnforcesHandlerBehaviourWhileEditing(): void { public function testCancelButtonIsReported(): void { $tester = new TuiTester($this->form()); - // Root items: the panel, then Submit, then Cancel. - $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Enter)); + // Root rows: the panel, then the pair of buttons on one row of their own - + // so Down reaches them and Right walks along them to Cancel. + $tester->run(Key::named(KeyName::Down), Key::named(KeyName::Right), Key::named(KeyName::Enter)); $this->assertTrue($tester->isCancelled()); } @@ -93,6 +94,35 @@ public function testInterruptIsReported(): void { $this->assertFalse($tester->isCancelled()); } + public function testAbortedRunStillHandsBackWhatWasAnsweredAndDrawn(): void { + $tester = new TuiTester($this->form()); + + // A session that ends without a submit raises rather than returning, and + // the harness answers the question instead of passing the raise on. + $answers = $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + 'Ada', + Key::named(KeyName::Enter), + Key::named(KeyName::Interrupt), + ); + + $this->assertSame('Ada', $answers->value('name')); + $this->assertStringContainsString('Ada', $tester->display()); + $this->assertTrue($tester->isInterrupted()); + } + + public function testEndingIsForgottenBetweenOneRunAndTheNext(): void { + $tester = new TuiTester($this->form()); + + $tester->run(Key::named(KeyName::Interrupt)); + $this->assertTrue($tester->isInterrupted()); + + $tester->run(); + $this->assertFalse($tester->isInterrupted()); + $this->assertFalse($tester->isCancelled()); + } + public function testEmptyRunCollectsDefaults(): void { $answers = (new TuiTester($this->form()))->run(); @@ -268,8 +298,9 @@ public function testModalSubmitFlowsThroughTheInputPipe(): void { public function testModalDiscardRestoresThroughTheInputPipe(): void { $tester = new TuiTester($this->modalForm()); - // Same as the submit flow but Discard the dialog (Down to Discard, Enter) - // instead of Apply, so the edit is rolled back before the form submits. + // Same as the submit flow but Discard the dialog (Right along its buttons, + // Enter) instead of Apply, so the edit is rolled back before the form + // submits. $answers = $tester->run( Key::named(KeyName::Down), Key::named(KeyName::Enter), @@ -277,7 +308,7 @@ public function testModalDiscardRestoresThroughTheInputPipe(): void { 'Zed', Key::named(KeyName::Enter), Key::named(KeyName::Down), - Key::named(KeyName::Down), + Key::named(KeyName::Right), Key::named(KeyName::Enter), Key::named(KeyName::Down), Key::named(KeyName::Enter), diff --git a/tests/phpunit/Unit/Theme/AbstractThemeTest.php b/tests/phpunit/Unit/Theme/AbstractThemeTest.php new file mode 100644 index 00000000..634a3072 --- /dev/null +++ b/tests/phpunit/Unit/Theme/AbstractThemeTest.php @@ -0,0 +1,167 @@ +assertSame('Orchard', $draw(new FloorTheme())); + } + + public static function dataProviderEveryStyledElementHandsBackTheStringItWasGiven(): \Iterator { + yield 'chrome border' => [static fn(FloorTheme $t): string => $t->chromeBorder('Orchard')]; + yield 'breadcrumb label' => [static fn(FloorTheme $t): string => $t->breadcrumbLabel('Orchard')]; + yield 'legend key' => [static fn(FloorTheme $t): string => $t->legendKey('Orchard')]; + yield 'legend description' => [static fn(FloorTheme $t): string => $t->legendDescription('Orchard')]; + yield 'field label' => [static fn(FloorTheme $t): string => $t->fieldLabel('Orchard')]; + yield 'field value' => [static fn(FloorTheme $t): string => $t->fieldValue('Orchard')]; + yield 'field badge' => [static fn(FloorTheme $t): string => $t->fieldBadge('Orchard')]; + yield 'field description' => [static fn(FloorTheme $t): string => $t->fieldDescription('Orchard')]; + yield 'field entry note' => [static fn(FloorTheme $t): string => $t->fieldEntryNote('Orchard')]; + yield 'field entry description' => [static fn(FloorTheme $t): string => $t->fieldEntryDescription('Orchard')]; + yield 'field error' => [static fn(FloorTheme $t): string => $t->fieldError('Orchard')]; + yield 'field draft' => [static fn(FloorTheme $t): string => $t->fieldDraft('Orchard')]; + yield 'field state' => [static fn(FloorTheme $t): string => $t->fieldState('Orchard')]; + yield 'field caption' => [static fn(FloorTheme $t): string => $t->fieldCaption('Orchard')]; + yield 'panel title' => [static fn(FloorTheme $t): string => $t->panelTitle('Orchard')]; + yield 'markup title' => [static fn(FloorTheme $t): string => $t->markupTitle('Orchard')]; + yield 'markup line' => [static fn(FloorTheme $t): string => $t->markupLine('Orchard')]; + yield 'progress caption' => [static fn(FloorTheme $t): string => $t->progressCaption('Orchard')]; + } + + #[DataProvider('dataProviderEveryGlyphFallsBackToWhatAsciiCanDraw')] + public function testEveryGlyphFallsBackToWhatAsciiCanDraw(\Closure $draw): void { + // Nothing outside ASCII, because the floor declares no Unicode - and every + // mark still reads, because a form has to be usable without one. + $drawn = (string) $draw(new FloorTheme()); + + $this->assertSame($drawn, preg_replace('/[^\x20-\x7E]/', '', $drawn)); + } + + public static function dataProviderEveryGlyphFallsBackToWhatAsciiCanDraw(): \Iterator { + yield 'overflow marker above' => [static fn(FloorTheme $t): string => $t->chromeOverflowMarker(TRUE)]; + yield 'overflow marker below' => [static fn(FloorTheme $t): string => $t->chromeOverflowMarker(FALSE)]; + yield 'breadcrumb separator' => [static fn(FloorTheme $t): string => $t->breadcrumbSeparator()]; + yield 'legend separator' => [static fn(FloorTheme $t): string => $t->legendSeparator()]; + yield 'field selector' => [static fn(FloorTheme $t): string => $t->fieldSelector(TRUE)]; + yield 'field help marker' => [static fn(FloorTheme $t): string => $t->fieldHelpMarker()]; + yield 'field entry selector' => [static fn(FloorTheme $t): string => $t->fieldEntrySelector(TRUE)]; + yield 'field entry marker chosen' => [static fn(FloorTheme $t): string => $t->fieldEntryMarker(TRUE)]; + yield 'field entry marker unchosen' => [static fn(FloorTheme $t): string => $t->fieldEntryMarker(FALSE)]; + yield 'field entry marker exclusive' => [static fn(FloorTheme $t): string => $t->fieldEntryMarker(TRUE, TRUE)]; + yield 'field entry separator' => [static fn(FloorTheme $t): string => $t->fieldEntrySeparator()]; + yield 'field caret' => [static fn(FloorTheme $t): string => $t->fieldCaret()]; + yield 'field mask' => [static fn(FloorTheme $t): string => $t->fieldMask()]; + yield 'field loading' => [static fn(FloorTheme $t): string => $t->fieldLoading()]; + yield 'field scale' => [static fn(FloorTheme $t): string => $t->fieldScale(3, 1, 5, 'Fair')]; + yield 'panel selector' => [static fn(FloorTheme $t): string => $t->panelSelector(TRUE)]; + yield 'panel descend' => [static fn(FloorTheme $t): string => $t->panelDescend()]; + yield 'panel summary separator' => [static fn(FloorTheme $t): string => $t->panelSummarySeparator()]; + yield 'markup bullet' => [static fn(FloorTheme $t): string => $t->markupBullet()]; + yield 'key glyph' => [static fn(FloorTheme $t): string => $t->keyGlyph(Key::named(KeyName::Escape))]; + yield 'progress spinner' => [static fn(FloorTheme $t): string => $t->progressSpinner(0)]; + yield 'progress track' => [static fn(FloorTheme $t): string => $t->progressTrack(4, 10)]; + } + + #[DataProvider('dataProviderEveryPassageSpanHandsBackItsText')] + public function testEveryPassageSpanHandsBackItsText(\Closure $draw): void { + // A passage with nothing to style it is the passage, and a target nothing + // can follow is written out rather than dropped with its styling. + $this->assertSame('Orchard', $draw(new FloorTheme())); + } + + public static function dataProviderEveryPassageSpanHandsBackItsText(): \Iterator { + yield 'strong' => [static fn(FloorTheme $t): string => $t->markupStrong('Orchard')]; + yield 'emphasis' => [static fn(FloorTheme $t): string => $t->markupEmphasis('Orchard')]; + yield 'code' => [static fn(FloorTheme $t): string => $t->markupCode('Orchard')]; + yield 'panel description' => [static fn(FloorTheme $t): string => $t->panelDescription('Orchard')]; + yield 'panel summary' => [static fn(FloorTheme $t): string => $t->panelSummary('Orchard')]; + yield 'entry match' => [static fn(FloorTheme $t): string => $t->fieldEntryMatch('Orchard')]; + } + + public function testFloorWritesOutTargetItCannotFollow(): void { + $this->assertSame('Guide (https://example.com/guide)', (new FloorTheme())->markupLink('Guide', 'https://example.com/guide')); + } + + public function testFloorTypesTheDraftAroundTheCaretAndSuppressesTheCompletion(): void { + // Text nobody typed reads as text somebody did without something to set it + // apart, and the floor has nothing to set it apart with. + $floor = new FloorTheme(); + + $this->assertSame('ab|cd', $floor->fieldInput('ab', 'cd', 'ef')); + $this->assertSame('', $floor->fieldGhost('ef')); + } + + public function testFloorLaysEveryRowOutAgainstTheSameGutter(): void { + // A theme that would rather draw a flat list answers with no gutter at all. + $this->assertSame('', (new FloorTheme())->fieldIndent(2)); + } + + public function testGuidanceOpensWithMarkNothingCanStrip(): void { + // Neither hue nor slant survives here, and a constraint sits directly under + // an entry's own text, so a mark is the one cue left to tell them apart. + $floor = new FloorTheme(); + + $this->assertSame('> Orchard', $floor->fieldConstraint('Orchard')); + $this->assertNotSame($floor->fieldEntryDescription('Orchard'), $floor->fieldConstraint('Orchard')); + } + + public function testMarkOnlyAppearsWhereThereIsSomethingToMark(): void { + $floor = new FloorTheme(); + + $this->assertSame(' ', $floor->fieldSelector(FALSE)); + $this->assertSame(' ', $floor->fieldEntrySelector(FALSE)); + $this->assertNotSame($floor->fieldEntryMarker(TRUE), $floor->fieldEntryMarker(FALSE)); + } + + public function testEntryIsItsOwnTextAndTheMarkBesideItIsNot(): void { + // Selecting and marking come apart at the floor too: the entry says what + // it is, and the mark beside it says whether it was picked. + $floor = new FloorTheme(); + + $this->assertSame('Apple', $floor->fieldEntry('Apple', TRUE)); + $this->assertSame('Apple', $floor->fieldEntry('Apple', FALSE)); + } + + public function testFramingButtonBelongsToTheElementRatherThanTheBlock(): void { + $floor = new FloorTheme(); + + $this->assertSame('[ Submit ]', $floor->actionButton('Submit')); + $this->assertSame('[ Submit ]', $floor->actionSelected('Submit')); + $this->assertSame(' ', $floor->actionSeparator()); + } + + public function testSeparatorsAndTalliesReadWithoutAnythingToDrawThemWith(): void { + $floor = new FloorTheme(); + + $this->assertSame(', ', $floor->fieldValueSeparator()); + $this->assertSame('4/10', $floor->progressCount(4, 10)); + } + + public function testSpinnerCyclesItsOwnFramesAndTrackFillsInProportion(): void { + $floor = new FloorTheme(); + + $this->assertSame($floor->progressSpinner(0), $floor->progressSpinner(4)); + $this->assertSame('[####------]', $floor->progressTrack(4, 10)); + $this->assertSame('[##########]', $floor->progressTrack(99, 10)); + $this->assertSame('[----------]', $floor->progressTrack(-1, 10)); + } + +} diff --git a/tests/phpunit/Unit/Theme/BuiltinThemesTest.php b/tests/phpunit/Unit/Theme/BuiltinThemesTest.php index 4846ea7a..79aae2cd 100644 --- a/tests/phpunit/Unit/Theme/BuiltinThemesTest.php +++ b/tests/phpunit/Unit/Theme/BuiltinThemesTest.php @@ -4,8 +4,9 @@ namespace DrevOps\Tui\Tests\Unit\Theme; +use DrevOps\Tui\Block\Prose; use DrevOps\Tui\Render\Ansi; -use DrevOps\Tui\Render\Viewport; +use DrevOps\Tui\Theme\Border; use DrevOps\Tui\Theme\DosTheme; use DrevOps\Tui\Theme\EmberTheme; use DrevOps\Tui\Theme\FrostTheme; @@ -38,14 +39,14 @@ final class BuiltinThemesTest extends TestCase { public function testPalette(string $name, Mode $mode, array $expected): void { $theme = ThemeManager::create($name, 76, ['mode' => $mode]); - // title, indicator, highlightMatch and border wrap text in the role SGR; - // an unselected value carries no added weight, so it is the value SGR too. - $this->assertSame(Ansi::style('X', $expected['accent']), $theme->title('X')); - $this->assertSame(Ansi::style('X', $expected['accent']), $theme->highlight('X')); - $this->assertSame(Ansi::style('X', $expected['value']), $theme->value('X')); - $this->assertSame(Ansi::style('X', $expected['indicator']), $theme->indicator('X')); - $this->assertSame(Ansi::style('X', $expected['match']), $theme->highlightMatch('X')); - $this->assertSame(Ansi::style('X', $expected['border']), $theme->border('X')); + // A hue is stated once and every element drawn from it follows, so each + // role is read back through an element rather than through the palette. + $this->assertSame(Ansi::style('X', $expected['accent']), $theme->markupTitle('X')); + $this->assertSame(Ansi::style('X', $expected['accent']), $theme->fieldEntry('X', FALSE, TRUE)); + $this->assertSame(Ansi::style('X', $expected['value']), $theme->fieldValue('X')); + $this->assertSame(Ansi::style('▲', $expected['indicator']), $theme->chromeOverflowMarker(TRUE)); + $this->assertSame(Ansi::style('X', $expected['match']), $theme->fieldEntryMatch('X')); + $this->assertSame(Ansi::style('X', $expected['border']), $theme->chromeBorder('X')); } public static function dataProviderPalette(): \Iterator { @@ -62,21 +63,21 @@ public static function dataProviderPalette(): \Iterator { } /** - * A selected value keeps the palette hue and gains bold weight. + * A picked entry keeps the palette hue and gains weight. */ - #[DataProvider('dataProviderSelectedValueIsBold')] - public function testSelectedValueIsBold(string $name, string $expected): void { + #[DataProvider('dataProviderPickedEntryIsBold')] + public function testPickedEntryIsBold(string $name, string $expected): void { $theme = ThemeManager::create($name, 76, ['mode' => Mode::Dark]); - $this->assertSame(Ansi::style('X', $expected), $theme->value('X', TRUE)); + $this->assertSame(Ansi::style('X', $expected), $theme->panelSummary('X')); } - public static function dataProviderSelectedValueIsBold(): \Iterator { - yield 'midnight' => ['midnight', '1;38;5;114']; - yield 'frost' => ['frost', '1;38;5;150']; - yield 'ember' => ['ember', '1;38;5;142']; - yield 'mono' => ['mono', '1;38;5;250']; - yield 'dos' => ['dos', '1;96']; + public static function dataProviderPickedEntryIsBold(): \Iterator { + yield 'midnight' => ['midnight', '38;5;114']; + yield 'frost' => ['frost', '38;5;150']; + yield 'ember' => ['ember', '38;5;142']; + yield 'mono' => ['mono', '38;5;250']; + yield 'dos' => ['dos', '96']; } /** @@ -87,11 +88,11 @@ public function testColourOffStripsPalette(string $name): void { $theme = ThemeManager::create($name, 76, ['color' => FALSE]); $this->assertFalse($theme->hasColor()); - $this->assertSame('Setup', $theme->title('Setup')); - $this->assertSame('X', $theme->value('X', TRUE)); - $this->assertSame('X', $theme->indicator('X')); - $this->assertSame('X', $theme->highlightMatch('X')); - $this->assertSame('X', $theme->border('X')); + $this->assertSame('Setup', $theme->markupTitle('Setup')); + $this->assertSame('X', $theme->fieldValue('X')); + $this->assertSame('▲', $theme->chromeOverflowMarker(TRUE)); + $this->assertSame('X', $theme->fieldEntryMatch('X')); + $this->assertSame('X', $theme->chromeBorder('X')); } public static function dataProviderColourOffStripsPalette(): \Iterator { @@ -106,15 +107,11 @@ public static function dataProviderColourOffStripsPalette(): \Iterator { * The dos theme frames its content in a double-line window by default. */ public function testDosDefaultsToBorderedWindow(): void { - $viewport = new Viewport(0, FALSE, FALSE); - // With no border declared, dos draws its double-line MS-DOS window. - $bordered = ThemeManager::create('dos', 40, ['color' => FALSE])->renderFrame(['Head'], ['Body'], [], $viewport, 1); - $this->assertStringContainsString('═', $bordered); + $this->assertSame(Border::Double, ThemeManager::create('dos', 40, ['color' => FALSE])->borderStyle()); // An explicit border option still wins over the theme's default. - $plain = ThemeManager::create('dos', 40, ['color' => FALSE, 'border' => 'none'])->renderFrame(['Head'], ['Body'], [], $viewport, 1); - $this->assertStringNotContainsString('═', $plain); + $this->assertSame(Border::None, ThemeManager::create('dos', 40, ['color' => FALSE, 'border' => 'none'])->borderStyle()); } /** @@ -133,11 +130,10 @@ public function testDosPaintsBlueBackground(): void { public function testDosSecondaryTextIsLegibleOnBlue(Mode $mode): void { $theme = ThemeManager::create('dos', 76, ['mode' => $mode]); - $this->assertSame(Ansi::style('X', '37'), $theme->breadcrumb('X')); - $this->assertSame(Ansi::style('X', '37'), $theme->footer('X')); - $this->assertSame(Ansi::style('X', '37'), $theme->description('X')); - $this->assertSame(Ansi::style('X', '1;37'), $theme->description('X', TRUE)); - $this->assertSame(Ansi::style('X', '1;37'), $theme->heading('X')); + $this->assertSame(Ansi::style('X', '37'), $theme->breadcrumbLabel('X')); + $this->assertSame(Ansi::style('X', '37'), $theme->fieldState('X')); + $this->assertSame(Ansi::style('X', '37'), $theme->fieldDescription('X')); + $this->assertSame(Ansi::style('X', '37'), $theme->markupLine('X')); } public static function dataProviderDosSecondaryTextIsLegibleOnBlue(): \Iterator { @@ -146,47 +142,101 @@ public static function dataProviderDosSecondaryTextIsLegibleOnBlue(): \Iterator } /** - * CGA has no italic, so the dos hint takes a colour of its own instead. + * A theme's heading hue reaches the pieces its elements are assembled into. + */ + public function testDosHeadingReachesTheGridItHeads(): void { + $theme = ThemeManager::create('dos', 40, ['border' => Border::Line]); + + $this->assertStringContainsString(Ansi::style('Fruit', '1;37'), implode("\n", $theme->renderTable(['Fruit'], [['Apple']]))); + $this->assertStringContainsString(Ansi::style('Yields', '1;37'), implode("\n", $theme->renderCard('Yields', []))); + } + + /** + * CGA has no italic, so the dos constraint takes a colour of its own. */ - #[DataProvider('dataProviderDosHintTakesItsOwnColourRatherThanItalic')] - public function testDosHintTakesItsOwnColourRatherThanItalic(Mode $mode): void { + #[DataProvider('dataProviderDosConstraintTakesItsOwnColour')] + public function testDosConstraintTakesItsOwnColour(Mode $mode): void { $theme = ThemeManager::create('dos', 76, ['mode' => $mode]); - $this->assertSame(Ansi::style('X', '96'), $theme->hint('X')); - $this->assertSame(Ansi::style('X', '1;96'), $theme->hint('X', TRUE)); - $this->assertNotSame($theme->description('X'), $theme->hint('X')); + $this->assertSame(Ansi::style('X', '96'), $theme->fieldConstraint('X')); + $this->assertNotSame($theme->fieldDescription('X'), $theme->fieldConstraint('X')); } - public static function dataProviderDosHintTakesItsOwnColourRatherThanItalic(): \Iterator { + public static function dataProviderDosConstraintTakesItsOwnColour(): \Iterator { yield 'dark' => [Mode::Dark]; yield 'light' => [Mode::Light]; } /** - * A theme's description atom reaches the body text, not only its own rows. + * Every theme separates guidance from description by colour, not italic. + * + * A constraint is drawn directly beneath the highlighted entry's own + * description, so the two need a cue that survives the surface: an SVG + * render carries colour but drops italic entirely. + */ + #[DataProvider('dataProviderGuidanceTakesItsOwnColour')] + public function testGuidanceTakesItsOwnColour(string $name, Mode $mode): void { + $theme = ThemeManager::create($name, 76, ['mode' => $mode]); + + $constraint = $theme->fieldConstraint('X'); + $description = $theme->fieldDescription('X'); + + $this->assertNotSame($description, $constraint); + $this->assertNotSame($description, str_replace(Sgr::Italic->value . ';', '', $constraint)); + } + + public static function dataProviderGuidanceTakesItsOwnColour(): \Iterator { + foreach (['default', 'midnight', 'frost', 'ember', 'mono', 'dos'] as $name) { + yield $name . ' dark' => [$name, Mode::Dark]; + yield $name . ' light' => [$name, Mode::Light]; + } + } + + /** + * Guidance stays apart from a description with the colour switched off. + * + * Strip the surface back and neither hue nor italic survives, so the voice + * has to fall back on something a plain terminal still carries. + */ + #[DataProvider('dataProviderGuidanceSurvivesColourOff')] + public function testGuidanceSurvivesColourOff(string $name, bool $unicode): void { + $theme = ThemeManager::create($name, 76, ['color' => FALSE, 'unicode' => $unicode]); + + $this->assertNotSame($theme->fieldDescription('X'), $theme->fieldConstraint('X')); + } + + public static function dataProviderGuidanceSurvivesColourOff(): \Iterator { + foreach (['default', 'midnight', 'frost', 'ember', 'mono', 'dos'] as $name) { + yield $name . ' unicode' => [$name, TRUE]; + yield $name . ' ascii' => [$name, FALSE]; + } + } + + /** + * A theme's line element reaches the body text, not only its own rows. */ - public function testDosDescriptionBodyCarriesTheThemeAtom(): void { + public function testDosDescriptionBodyCarriesTheThemeElement(): void { $dos = ThemeManager::create('dos', 76); $default = ThemeManager::create('default', 76); - $line = $dos->renderDescriptionBlock('Picked this morning', FALSE)[0]; + $line = Prose::lines('Picked this morning', $dos)[0]; - // The body is styled by the same atom the one-line rows use, so the dos + // The body is styled by the same element the one-line rows use, so the dos // theme's legible white reaches it instead of the dim grey it inherits. - $this->assertSame(' ' . $dos->description('Picked this morning'), $line); - $this->assertNotSame($default->renderDescriptionBlock('Picked this morning', FALSE)[0], $line); + $this->assertSame($dos->markupLine('Picked this morning'), $line); + $this->assertNotSame(Prose::lines('Picked this morning', $default)[0], $line); } /** * The bullet leading a list item is themed with the text beside it. */ - public function testDosBulletCarriesTheThemeAtom(): void { + public function testDosBulletCarriesTheThemeElement(): void { $theme = ThemeManager::create('dos', 76, ['markdown' => TRUE]); - $line = $theme->renderDescriptionBlock('- crisp apples', FALSE)[0]; + $line = Prose::lines('- crisp apples', $theme)[0]; - $this->assertStringContainsString($theme->description($theme->bullet() . ' '), $line); - $this->assertStringContainsString($theme->description('crisp apples'), $line); + $this->assertStringContainsString($theme->markupLine($theme->markupBullet() . ' '), $line); + $this->assertStringContainsString($theme->markupLine('crisp apples'), $line); } } diff --git a/tests/phpunit/Unit/Theme/ElementDelegationTest.php b/tests/phpunit/Unit/Theme/ElementDelegationTest.php new file mode 100644 index 00000000..bd2e639e --- /dev/null +++ b/tests/phpunit/Unit/Theme/ElementDelegationTest.php @@ -0,0 +1,107 @@ +assertSame($other($theme), $one($theme)); + } + + public static function dataProviderElementsSharingOneHueAreDrawnAlike(): \Iterator { + yield 'the two titles' => [ + static fn(DefaultTheme $t): string => $t->panelTitle('Delivery'), + static fn(DefaultTheme $t): string => $t->markupTitle('Delivery'), + ]; + yield 'the three selectors' => [ + static fn(DefaultTheme $t): string => $t->fieldSelector(TRUE), + static fn(DefaultTheme $t): string => $t->panelSelector(TRUE), + ]; + yield 'the field and entry selectors' => [ + static fn(DefaultTheme $t): string => $t->fieldSelector(TRUE), + static fn(DefaultTheme $t): string => $t->fieldEntrySelector(TRUE), + ]; + yield 'a value and a summary of values' => [ + static fn(DefaultTheme $t): string => $t->fieldValue('apple'), + static fn(DefaultTheme $t): string => $t->panelSummary('apple'), + ]; + yield 'a field description and a line of markup' => [ + static fn(DefaultTheme $t): string => $t->fieldDescription('Pick the produce.'), + static fn(DefaultTheme $t): string => $t->markupLine('Pick the produce.'), + ]; + yield 'a panel description and a line of markup' => [ + static fn(DefaultTheme $t): string => $t->panelDescription('Pick the produce.'), + static fn(DefaultTheme $t): string => $t->markupLine('Pick the produce.'), + ]; + yield 'the focused entry and the caret' => [ + static fn(DefaultTheme $t): string => $t->fieldEntry('█', FALSE, TRUE), + static fn(DefaultTheme $t): string => $t->fieldCaret(), + ]; + yield 'the rule and the entry separator' => [ + static fn(DefaultTheme $t): string => $t->renderRule(), + static fn(DefaultTheme $t): string => $t->fieldEntrySeparator(), + ]; + } + + #[DataProvider('dataProviderRepaintingOneHueMovesEveryElementDrawnFromIt')] + public function testRepaintingOneHueMovesEveryElementDrawnFromIt(\Closure $element): void { + // A named theme states a palette and never names an element, so the palette + // is the only thing carrying its colours through. + $default = new DefaultTheme(80); + $mono = new MonoTheme(80); + + $this->assertNotSame($element($default), $element($mono)); + } + + public static function dataProviderRepaintingOneHueMovesEveryElementDrawnFromIt(): \Iterator { + yield 'field selector' => [static fn(DefaultTheme $t): string => $t->fieldSelector(TRUE)]; + yield 'field entry selector' => [static fn(DefaultTheme $t): string => $t->fieldEntrySelector(TRUE)]; + yield 'field value' => [static fn(DefaultTheme $t): string => $t->fieldValue('apple')]; + yield 'field caret' => [static fn(DefaultTheme $t): string => $t->fieldCaret()]; + yield 'field entry marker' => [static fn(DefaultTheme $t): string => $t->fieldEntryMarker(TRUE, TRUE)]; + yield 'chrome border' => [static fn(DefaultTheme $t): string => $t->chromeBorder('----')]; + yield 'chrome overflow marker' => [static fn(DefaultTheme $t): string => $t->chromeOverflowMarker(TRUE)]; + yield 'panel title' => [static fn(DefaultTheme $t): string => $t->panelTitle('Delivery')]; + yield 'progress spinner' => [static fn(DefaultTheme $t): string => $t->progressSpinner(0)]; + } + + public function testThemeSubclassReachesEveryElementThroughOneRepaintedHue(): void { + // The fixture repaints the accent alone, and every element drawn from it + // follows without the theme mentioning any of them. + $ocean = new OceanTheme(80); + $default = new DefaultTheme(80); + + $this->assertSame($ocean->panelTitle('Delivery'), $ocean->markupTitle('Delivery')); + $this->assertNotSame($default->panelTitle('Delivery'), $ocean->panelTitle('Delivery')); + $this->assertNotSame($default->fieldSelector(TRUE), $ocean->fieldSelector(TRUE)); + $this->assertNotSame($default->fieldCaret(), $ocean->fieldCaret()); + + // What the accent does not reach is untouched. + $this->assertSame($default->fieldValue('apple'), $ocean->fieldValue('apple')); + } + +} diff --git a/tests/phpunit/Unit/Theme/OutputRenderTest.php b/tests/phpunit/Unit/Theme/OutputRenderTest.php index a4df21ba..0f8e3e9b 100644 --- a/tests/phpunit/Unit/Theme/OutputRenderTest.php +++ b/tests/phpunit/Unit/Theme/OutputRenderTest.php @@ -162,7 +162,7 @@ public function testTextRendersNothingForAnEmptyString(): void { public function testRuleSpansTheRowWidth(): void { $theme = $this->theme(color: FALSE); - $rule = $theme->divider(); + $rule = $theme->renderRule(); // The row width, not the frame's: a rule sits inside the border like any // other row, so it stops where the border's gutter begins. diff --git a/tests/phpunit/Unit/Theme/ProgressRenderTest.php b/tests/phpunit/Unit/Theme/ProgressRenderTest.php index 4feb0639..5186e20a 100644 --- a/tests/phpunit/Unit/Theme/ProgressRenderTest.php +++ b/tests/phpunit/Unit/Theme/ProgressRenderTest.php @@ -97,20 +97,17 @@ public function testBuiltinThemeRendersProgressInItsOwnAccent(): void { $this->assertStringContainsString("\033[1;38;5;208m", $theme->renderProgressBar(1, 2, 'x', '')); } - public function testLoadingRendersCaptionWithThemedEllipsis(): void { - $line = $this->theme()->renderLoading('Loading fruit'); + public function testLoadingMarkCarriesTheAccent(): void { + $line = $this->theme()->fieldLoading(); - $this->assertStringContainsString('Loading fruit', $line); $this->assertStringContainsString('…', $line); // The ellipsis carries the accent (default dark: bold cyan). $this->assertStringContainsString("\033[1;36m", $line); } - public function testLoadingAsciiFallbackAndEmptyCaption(): void { - $theme = $this->theme(color: FALSE, unicode: FALSE); - - $this->assertSame('...', $theme->renderLoading('')); - $this->assertStringContainsString('Loading ...', $theme->renderLoading('Loading')); + public function testLoadingMarkFallsBackToAscii(): void { + $this->assertSame('...', $this->theme(color: FALSE, unicode: FALSE)->fieldLoading()); + $this->assertSame('…', $this->theme(color: FALSE)->fieldLoading()); } } diff --git a/tests/phpunit/Unit/Theme/ScaleRenderTest.php b/tests/phpunit/Unit/Theme/ScaleRenderTest.php index a73ebb0c..34f8037d 100644 --- a/tests/phpunit/Unit/Theme/ScaleRenderTest.php +++ b/tests/phpunit/Unit/Theme/ScaleRenderTest.php @@ -27,24 +27,24 @@ final class ScaleRenderTest extends TestCase { use BuildsThemesTrait; public function testScaleFillsUpToTheChosenPoint(): void { - $line = $this->theme(color: FALSE)->renderScale(3, 1, 5, 'Fair'); + $line = $this->theme(color: FALSE)->fieldScale(3, 1, 5, 'Fair'); $this->assertSame('●●●○○ 3/5 Fair', $line); } public function testScaleWithoutCaptionIsPointsAndReadout(): void { - $this->assertSame('●●●●● 5/5', $this->theme(color: FALSE)->renderScale(5, 1, 5, '')); + $this->assertSame('●●●●● 5/5', $this->theme(color: FALSE)->fieldScale(5, 1, 5, '')); } public function testScaleAsciiFallback(): void { - $line = $this->theme(color: FALSE, unicode: FALSE)->renderScale(2, 1, 5, ''); + $line = $this->theme(color: FALSE, unicode: FALSE)->fieldScale(2, 1, 5, ''); $this->assertSame('**--- 2/5', $line); $this->assertStringNotContainsString('●', $line); } public function testScaleAppliesTheAccentToTheFilledRun(): void { - $this->assertStringContainsString("\033[1;36m", $this->theme()->renderScale(2, 1, 5, '')); + $this->assertStringContainsString("\033[1;36m", $this->theme()->fieldScale(2, 1, 5, '')); } public function testScaleCarriesTheThemeAccent(): void { @@ -52,11 +52,11 @@ public function testScaleCarriesTheThemeAccent(): void { // no per-theme override, so the accent flows through highlight(). $theme = ThemeManager::create('ember', DefaultTheme::DEFAULT_WIDTH, ['color' => TRUE, 'unicode' => TRUE, 'mode' => Mode::Dark]); - $this->assertStringContainsString("\033[1;38;5;208m", $theme->renderScale(2, 1, 5, '')); + $this->assertStringContainsString("\033[1;38;5;208m", $theme->fieldScale(2, 1, 5, '')); } public function testScaleFoldsCaptionToOneLine(): void { - $line = $this->theme(color: FALSE)->renderScale(1, 1, 3, "Poor\nby any measure"); + $line = $this->theme(color: FALSE)->fieldScale(1, 1, 3, "Poor\nby any measure"); $this->assertSame('●○○ 1/3 Poor by any measure', $line); } @@ -64,7 +64,7 @@ public function testScaleFoldsCaptionToOneLine(): void { #[DataProvider('dataProviderScaleClampsPointOffTheScale')] public function testScaleClampsPointOffTheScale(int $current, int $filled, int $empty): void { // A direct call outside the range must clamp, not crash str_repeat(). - $line = $this->theme(color: FALSE)->renderScale($current, 1, 5, ''); + $line = $this->theme(color: FALSE)->fieldScale($current, 1, 5, ''); $this->assertSame($filled, substr_count($line, '●')); $this->assertSame($empty, substr_count($line, '○')); @@ -83,13 +83,13 @@ public static function dataProviderScaleClampsPointOffTheScale(): \Iterator { public function testSinglePointScaleStillRendersOne(): void { // A collapsed range is rejected at build time, but the renderer is public. - $this->assertSame('● 2/2', $this->theme(color: FALSE)->renderScale(2, 2, 2, '')); + $this->assertSame('● 2/2', $this->theme(color: FALSE)->fieldScale(2, 2, 2, '')); } public function testScaleWiderThanTheFrameIsBoundedByIt(): void { // Nothing wider than the frame can be drawn, so a public call with an // absurd range costs a truncated line rather than the whole heap. - $line = $this->theme(color: FALSE)->renderScale(1, 1, PHP_INT_MAX, ''); + $line = $this->theme(color: FALSE)->fieldScale(1, 1, PHP_INT_MAX, ''); $points = substr_count($line, '●') + substr_count($line, '○'); $this->assertGreaterThan(0, $points); diff --git a/tests/phpunit/Unit/Theme/SupportTest.php b/tests/phpunit/Unit/Theme/SupportTest.php new file mode 100644 index 00000000..0cc940bc --- /dev/null +++ b/tests/phpunit/Unit/Theme/SupportTest.php @@ -0,0 +1,113 @@ + Mode::Dark]); + $light = new DefaultTheme(80, ['mode' => Mode::Light]); + + $this->assertInstanceOf(ColorSchemeCapableInterface::class, $dark); + $this->assertTrue($dark->isDark()); + $this->assertFalse($light->isDark()); + $this->assertTrue((new DefaultTheme(80, ['color' => TRUE]))->hasColor()); + $this->assertFalse((new DefaultTheme(80, ['color' => FALSE]))->hasColor()); + } + + public function testUnicodeIsDeclaredAndCanBeTurnedOff(): void { + $this->assertInstanceOf(UnicodeCapableInterface::class, new DefaultTheme()); + $this->assertTrue((new DefaultTheme(80, ['unicode' => TRUE]))->hasUnicode()); + $this->assertFalse((new DefaultTheme(80, ['unicode' => FALSE]))->hasUnicode()); + } + + public function testThemeDeclaringNothingSupportsNothing(): void { + $declared = class_implements(FloorTheme::class); + + $this->assertNotFalse($declared); + $this->assertArrayNotHasKey(ColorSchemeCapableInterface::class, $declared); + $this->assertArrayNotHasKey(UnicodeCapableInterface::class, $declared); + } + + public function testDeclaredCapabilityPaintsAgainstTheBackgroundItReads(): void { + $dark = new CapableTheme(TRUE, TRUE, TRUE); + $light = new CapableTheme(TRUE, TRUE, FALSE); + + $this->assertSame("\033[36mOrchard\033[0m", $dark->breadcrumbLabel('Orchard')); + $this->assertSame("\033[34mOrchard\033[0m", $light->breadcrumbLabel('Orchard')); + } + + public function testTurningColourOffHandsBackWhatTheElementWasGiven(): void { + $this->assertSame('Orchard', (new CapableTheme(FALSE))->breadcrumbLabel('Orchard')); + } + + public function testSelectingAnItemAddsWeightRatherThanReplacingItsColour(): void { + $theme = new CapableTheme(); + + $this->assertSame("\033[32mApple\033[0m", $theme->fieldEntry('Apple', FALSE)); + $this->assertSame("\033[1;32mApple\033[0m", $theme->fieldEntry('Apple', TRUE)); + } + + public function testAnElementPicksItsGlyphFromWhatTheThemeSupports(): void { + $unicode = new DefaultTheme(80, ['color' => FALSE, 'unicode' => TRUE]); + $ascii = new DefaultTheme(80, ['color' => FALSE, 'unicode' => FALSE]); + + $this->assertSame('›', $unicode->breadcrumbSeparator()); + $this->assertSame('>', $ascii->breadcrumbSeparator()); + $this->assertSame('›', (new CapableTheme(FALSE, TRUE))->breadcrumbSeparator()); + $this->assertSame('>', (new CapableTheme(FALSE, FALSE))->breadcrumbSeparator()); + } + + public function testWithoutColourAnElementHandsBackWhatItWasGiven(): void { + // The floor: a theme that paints nothing still draws, which is why a form + // renders in a terminal that supports nothing. + $plain = new DefaultTheme(80, ['color' => FALSE]); + + $this->assertSame('Orchard', $plain->breadcrumbLabel('Orchard')); + $this->assertSame('4/10', $plain->progressCount(4, 10)); + } + + public function testThemeSpinsThroughItsOwnFramesAndWrapsAtTheEnd(): void { + $unicode = new DefaultTheme(80, ['color' => FALSE, 'unicode' => TRUE]); + $ascii = new DefaultTheme(80, ['color' => FALSE, 'unicode' => FALSE]); + + // Ten frames against four: the element takes a frame number rather than a + // glyph precisely so the theme owns how many there are. + $this->assertSame($unicode->progressSpinner(0), $unicode->progressSpinner(10)); + $this->assertSame($ascii->progressSpinner(0), $ascii->progressSpinner(4)); + $this->assertNotSame($unicode->progressSpinner(4), $ascii->progressSpinner(4)); + } + + public function testBarFillsInProportionAndNeverPastItsWidth(): void { + $theme = new DefaultTheme(80, ['color' => FALSE, 'unicode' => TRUE]); + + $this->assertSame('[████░░░░░░]', $theme->progressTrack(4, 10)); + $this->assertSame('[░░░░░░░░░░]', $theme->progressTrack(0, 10)); + $this->assertSame('[██████████]', $theme->progressTrack(99, 10)); + } + +} diff --git a/tests/phpunit/Unit/Theme/ThemeBuilderTest.php b/tests/phpunit/Unit/Theme/ThemeBuilderTest.php new file mode 100644 index 00000000..acc824f8 --- /dev/null +++ b/tests/phpunit/Unit/Theme/ThemeBuilderTest.php @@ -0,0 +1,220 @@ + $unicode]; + $before = $this->elements(new DefaultTheme(80, $options)); + $after = $this->elements((new DefaultTheme(80, $options))->overrides($overrides)); + + $this->assertNotSame($before[$element], $after[$element]); + + unset($before[$element], $after[$element]); + $this->assertSame($before, $after); + } + + public static function dataProviderOverrideChangesItsElementAndNothingElse(): \Iterator { + $patches = [ + 'breadcrumb separator' => [ + (new ThemeBuilder())->breadcrumb(static fn(BreadcrumbOverrides $b): BreadcrumbOverrides => $b->separator('»', '>>'))->overrides(), + 'breadcrumbSeparator', + ], + 'legend separator' => [ + (new ThemeBuilder())->legend(static fn(LegendOverrides $l): LegendOverrides => $l->separator('•', '+'))->overrides(), + 'legendSeparator', + ], + 'legend key' => [ + (new ThemeBuilder())->legend(static fn(LegendOverrides $l): LegendOverrides => $l->key(Sgr::Bold))->overrides(), + 'legendKey', + ], + 'field selector' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->selector('→', '=>'))->overrides(), + 'fieldSelector', + ], + 'field help marker' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->helpMarker('?', '(?)'))->overrides(), + 'fieldHelpMarker', + ], + 'field value separator' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->valueSeparator(' | '))->overrides(), + 'fieldValueSeparator', + ], + 'field entry selector' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->entrySelector('→', '=>'))->overrides(), + 'fieldEntrySelector', + ], + 'field entry marker' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->entryMarker('★', '(*)'))->overrides(), + 'fieldEntryMarker', + ], + 'field caret' => [ + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->caret('▌', '!'))->overrides(), + 'fieldCaret', + ], + ]; + + foreach ($patches as $name => [$overrides, $element]) { + yield $name . ', unicode' => [$overrides, $element, TRUE]; + yield $name . ', ascii' => [$overrides, $element, FALSE]; + } + } + + public function testGlyphOverrideIsStatedForBothDisplayModesAtOnce(): void { + $overrides = (new ThemeBuilder()) + ->breadcrumb(static fn(BreadcrumbOverrides $b): BreadcrumbOverrides => $b->separator('»', '->')) + ->overrides(); + + $unicode = (new DefaultTheme(80, ['color' => FALSE, 'unicode' => TRUE]))->overrides($overrides); + $ascii = (new DefaultTheme(80, ['color' => FALSE, 'unicode' => FALSE]))->overrides($overrides); + + $this->assertSame('»', $unicode->breadcrumbSeparator()); + $this->assertSame('->', $ascii->breadcrumbSeparator()); + } + + public function testOverrideNamesTheMarkAndTheThemeGoesOnPaintingIt(): void { + $theme = (new DefaultTheme(80))->overrides( + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->selector('→', '=>'))->overrides() + ); + + $this->assertSame($theme->fieldEntry('→', FALSE, TRUE), $theme->fieldSelector(TRUE)); + } + + public function testTwoSelectorsComeApartOnceEitherIsOverridden(): void { + // They share one atom until a consumer says otherwise, which is the whole + // reason each is an element of its own. + $theme = (new DefaultTheme(80, ['color' => FALSE]))->overrides( + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->selector('→', '=>'))->overrides() + ); + + $this->assertSame('→', $theme->fieldSelector(TRUE)); + $this->assertSame('❯', $theme->fieldEntrySelector(TRUE)); + } + + public function testAnUnmarkedStateKeepsWhatTheThemeDrawsForIt(): void { + $plain = new DefaultTheme(80, ['color' => FALSE]); + $theme = (new DefaultTheme(80, ['color' => FALSE]))->overrides( + (new ThemeBuilder())->field(static fn(FieldOverrides $f): FieldOverrides => $f->entryMarker('★', '(*)'))->overrides() + ); + + $this->assertSame('★', $theme->fieldEntryMarker(TRUE)); + $this->assertSame($plain->fieldEntryMarker(FALSE), $theme->fieldEntryMarker(FALSE)); + $this->assertSame(' ', $theme->fieldSelector(FALSE)); + } + + public function testOneGroupCanStateSeveralElementsAtOnce(): void { + $overrides = (new ThemeBuilder()) + ->field(static fn(FieldOverrides $f): FieldOverrides => $f + ->selector('→', '=>') + ->helpMarker('?', '(?)') + ->valueSeparator(' | ') + ->entryMarker('★', '(*)') + ->caret('▌', '!')) + ->overrides(); + + $theme = (new DefaultTheme(80, ['color' => FALSE]))->overrides($overrides); + + $this->assertSame('→', $theme->fieldSelector(TRUE)); + $this->assertSame('?', $theme->fieldHelpMarker()); + $this->assertSame(' | ', $theme->fieldValueSeparator()); + $this->assertSame('★', $theme->fieldEntryMarker(TRUE)); + $this->assertSame('▌', $theme->fieldCaret()); + } + + public function testPatchHoldsOnlyWhatWasStated(): void { + $overrides = (new ThemeBuilder()) + ->legend(static fn(LegendOverrides $l): LegendOverrides => $l->key(Sgr::Bold, Sgr::Cyan)) + ->overrides(); + + $this->assertSame(Sgr::of(Sgr::Bold, Sgr::Cyan), $overrides->style(ThemeElement::LegendKey)); + $this->assertNull($overrides->style(ThemeElement::FieldSelector)); + $this->assertNotInstanceOf(Glyph::class, $overrides->glyph(ThemeElement::BreadcrumbSeparator)); + $this->assertNull($overrides->text(ThemeElement::FieldValueSeparator)); + } + + public function testThemeTakesNoOverridesUntilItIsGivenSome(): void { + $theme = new DefaultTheme(80, ['color' => FALSE]); + + $this->assertSame('›', $theme->breadcrumbSeparator()); + $this->assertSame('❯', $theme->fieldSelector(TRUE)); + } + + /** + * Every element an override can reach, drawn once. + * + * @param \DrevOps\Tui\Theme\DefaultTheme $theme + * The theme. + * + * @return array + * The drawn elements, keyed by the method that drew them. + */ + protected function elements(DefaultTheme $theme): array { + return [ + 'breadcrumbLabel' => $theme->breadcrumbLabel('Orchard'), + 'breadcrumbSeparator' => $theme->breadcrumbSeparator(), + 'legendKey' => $theme->legendKey('esc'), + 'legendDescription' => $theme->legendDescription('to cancel'), + 'legendSeparator' => $theme->legendSeparator(), + 'chromeBorder' => $theme->chromeBorder('----'), + 'chromeOverflowMarker' => $theme->chromeOverflowMarker(TRUE), + 'fieldSelector' => $theme->fieldSelector(TRUE), + 'fieldLabel' => $theme->fieldLabel('Basket'), + 'fieldHelpMarker' => $theme->fieldHelpMarker(), + 'fieldValue' => $theme->fieldValue('apple'), + 'fieldValueSeparator' => $theme->fieldValueSeparator(), + 'fieldBadge' => $theme->fieldBadge('edited'), + 'fieldDescription' => $theme->fieldDescription('Pick the produce.'), + 'fieldEntry' => $theme->fieldEntry('Apple', TRUE), + 'fieldEntrySelector' => $theme->fieldEntrySelector(TRUE), + 'fieldEntryMarker' => $theme->fieldEntryMarker(TRUE), + 'fieldEntryNote' => $theme->fieldEntryNote('out of season'), + 'fieldEntryDescription' => $theme->fieldEntryDescription('Stays crisp.'), + 'fieldConstraint' => $theme->fieldConstraint('Pick two.'), + 'fieldError' => $theme->fieldError('Pick at least two.'), + 'fieldCaret' => $theme->fieldCaret(), + 'fieldDraft' => $theme->fieldDraft('Valley'), + 'fieldState' => $theme->fieldState('Filling fruit'), + 'fieldCaption' => $theme->fieldCaption('orchard/harvest'), + 'panelTitle' => $theme->panelTitle('Delivery'), + 'markupTitle' => $theme->markupTitle('Yields'), + 'markupLine' => $theme->markupLine('Twelve crates.'), + 'actionButton' => $theme->actionButton('Submit'), + 'actionSelected' => $theme->actionSelected('Submit'), + 'actionSeparator' => $theme->actionSeparator(), + 'progressCaption' => $theme->progressCaption('Packing crates'), + 'progressSpinner' => $theme->progressSpinner(0), + 'progressTrack' => $theme->progressTrack(4, 10), + 'progressCount' => $theme->progressCount(4, 10), + ]; + } + +} diff --git a/tests/phpunit/Unit/Theme/ThemeConditionalIndentTest.php b/tests/phpunit/Unit/Theme/ThemeConditionalIndentTest.php deleted file mode 100644 index a017aeae..00000000 --- a/tests/phpunit/Unit/Theme/ThemeConditionalIndentTest.php +++ /dev/null @@ -1,302 +0,0 @@ -chainPanel(); - - [$lines] = $this->indentTheme(FALSE)->renderBody($panel, new Answers(), 0); - - // The cursor row leads with the marker glyph; every other row leads with - // the two blank columns that stand in for it, and nothing more. - $this->assertSame(0, $this->leadingSpaces($lines[0])); - $this->assertSame([2, 2, 2], array_map($this->leadingSpaces(...), array_slice($lines, 1))); - } - - public function testEachConditionStepsTheRowInFurther(): void { - $panel = $this->chainPanel(); - - [$lines] = $this->indentTheme()->renderBody($panel, new Answers(), 0); - - // The cursor sits on the unconditional root, so its marker shows; the - // conditional rows carry the blank marker plus one step per condition. - $this->assertSame('❯ Root ', Ansi::strip($lines[0])); - $this->assertSame([ - 2 + self::STEP, - 2 + self::STEP * 2, - 2 + self::STEP * 3, - ], array_map($this->leadingSpaces(...), array_slice($lines, 1))); - } - - public function testIndentedRowKeepsBadgeAtFrameEdge(): void { - $field = $this->fieldsOf($this->chainPanel())['first']; - $answers = new Answers(['first' => 'Acme'], ['first' => Provenance::Edited]); - - $lines = $this->indentTheme()->renderFieldLine($field, $answers, FALSE); - - $this->assertStringContainsString('First Acme', Ansi::strip($lines[0])); - $this->assertStringContainsString('edited', Ansi::strip($lines[0])); - // The gutter sits inside the row, so the badge still lands on the frame's - // right edge rather than being pushed past it. - $this->assertSame(40, Ansi::width($lines[0])); - } - - public function testValueContinuationLinesFollowTheIndentedColumn(): void { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('notes', 'Notes', '', FieldType::Textarea, '', when: new Condition('root', eq: 'x')), - ]); - $form = new FormDefinition('T', 'S', [$panel]); - $answers = new Answers(['notes' => "Crisp and sweet\nHint of citrus"], []); - - $lines = $this->indentTheme()->renderFieldLine($this->fieldsOf($form->panels[0])['notes'], $answers, FALSE); - - $this->assertSame(' Notes Crisp and sweet', Ansi::strip($lines[0])); - // The second line aligns under the value column, which the gutter moved. - $this->assertSame($this->columnOf($lines[0], 'Crisp'), $this->columnOf($lines[1], 'Hint')); - } - - public function testDescriptionStepsInWithItsField(): void { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', 'root help', FieldType::Text, ''), - new Field('leaf', 'Leaf', 'leaf help', FieldType::Text, '', when: new Condition('root', eq: 'x')), - ]); - new FormDefinition('T', 'S', [$panel]); - - [$lines] = $this->indentTheme()->renderBody($panel, new Answers(), 0); - $rows = array_map(Ansi::strip(...), $lines); - - // A description already sits four columns in; the conditional one carries - // its field's gutter on top of that. - $this->assertSame(4, $this->leadingSpaces($rows[1])); - $this->assertSame(4 + self::STEP, $this->leadingSpaces($rows[3])); - } - - public function testInlineEditorOpensAtTheIndentedColumn(): void { - $field = $this->fieldsOf($this->chainPanel())['second']; - - $lines = $this->indentTheme()->renderInlineEditor($field, "line one\nline two", TRUE); - - $this->assertSame(' ❯ Second line one', Ansi::strip($lines[0])); - $this->assertSame($this->columnOf($lines[0], 'line one'), $this->columnOf($lines[1], 'line two')); - } - - public function testPlainNoteCardStepsInWithItsCondition(): void { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('hint', 'Storage', 'Keep it cool.', FieldType::Note, '', when: new Condition('root', eq: 'x')), - ]); - new FormDefinition('T', 'S', [$panel]); - - $lines = array_map(Ansi::strip(...), $this->indentTheme()->renderNoteLines($this->fieldsOf($panel)['hint'], new Answers())); - - // A card already sits in a two-column gutter of its own. - $this->assertSame(' Storage', $lines[0]); - $this->assertSame(' Keep it cool.', $lines[1]); - } - - public function testBorderedNoteNarrowsToStayInsideTheFrame(): void { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('hint', 'Storage', str_repeat('long ', 30), FieldType::Note, '', when: new Condition('root', eq: 'x'), bordered: TRUE), - ]); - new FormDefinition('T', 'S', [$panel]); - $note = $this->fieldsOf($panel)['hint']; - - $flush = $this->indentTheme(FALSE)->renderNoteLines($note, new Answers()); - $stepped = $this->indentTheme()->renderNoteLines($note, new Answers()); - - // The body wraps to the room the card's own chrome leaves, so an indented - // card narrows by the columns its gutter takes and its right edge still - // lands inside the frame instead of overflowing it. - $this->assertLessThanOrEqual(40, Ansi::width($flush[0])); - $this->assertLessThanOrEqual(40, Ansi::width($stepped[0])); - $this->assertSame(self::STEP, $this->leadingSpaces(Ansi::strip($stepped[0]))); - - // Every row of the wrapped body stays inside the frame too, and the long - // body is carried across rows rather than clipped to one. - $this->assertGreaterThan(3, count($stepped)); - - foreach ($stepped as $line) { - $this->assertLessThanOrEqual(40, Ansi::width(Ansi::strip($line))); - } - } - - public function testMeasuredWidthCoversTheIndent(): void { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('leaf', 'A rather long conditional label', '', FieldType::Text, '', when: new Condition('root', eq: 'x')), - ]); - $form = new FormDefinition('T', 'S', [$panel], buttons: new Buttons(show: FALSE)); - - $flush = $this->indentTheme(FALSE)->measureContentWidth($form, new Answers()); - $stepped = $this->indentTheme()->measureContentWidth($form, new Answers()); - - $this->assertSame($flush + self::STEP, $stepped); - } - - public function testGridColumnPreviewStepsInToo(): void { - $sub = new Panel('sub', 'Sub', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('leaf', 'Leaf', '', FieldType::Text, '', when: new Condition('root', eq: 'x')), - ]); - $panel = new Panel('p', 'P', '', [], [$sub], layout: [1]); - new FormDefinition('T', 'S', [$panel]); - - [$lines] = $this->indentTheme()->renderBody($panel, new Answers(), -1); - $rows = array_map(Ansi::strip(...), $lines); - - $this->assertSame(2, $this->leadingSpaces($rows[1]), 'An unconditional preview row keeps the block gutter.'); - $this->assertSame(2 + self::STEP, $this->leadingSpaces($rows[2])); - } - - /** - * Tests that the option takes a boolean and nothing else. - * - * @param mixed $value - * The value passed as the "indent_conditional" option. - */ - #[DataProvider('dataProviderRejectsNonBooleanOption')] - public function testRejectsNonBooleanOption(mixed $value): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('is not a valid "indent_conditional"'); - - new DefaultTheme(40, ['indent_conditional' => $value]); - } - - public static function dataProviderRejectsNonBooleanOption(): \Iterator { - yield 'a string' => ['yes']; - yield 'a column count' => [2]; - yield 'nothing' => [NULL]; - } - - /** - * A panel whose fields form a three-link condition chain. - * - * Registering it with a form definition is what resolves the depths, so the - * panel is returned already stamped. - * - * @return \DrevOps\Tui\Model\Panel - * The panel. - */ - protected function chainPanel(): Panel { - $panel = new Panel('p', 'P', '', [ - new Field('root', 'Root', '', FieldType::Text, ''), - new Field('first', 'First', '', FieldType::Text, '', when: new Condition('root', eq: 'x')), - new Field('second', 'Second', '', FieldType::Text, '', when: new Condition('first', eq: 'y')), - new Field('third', 'Third', '', FieldType::Text, '', when: new Condition('second', eq: 'z')), - ]); - - new FormDefinition('T', 'S', [$panel]); - - return $panel; - } - - /** - * A panel's fields keyed by id. - * - * @param \DrevOps\Tui\Model\Panel $panel - * The panel. - * - * @return array - * The fields. - */ - protected function fieldsOf(Panel $panel): array { - $fields = []; - - foreach ($panel->fields as $field) { - $fields[$field->id] = $field; - } - - return $fields; - } - - /** - * The number of blank columns a row opens with. - * - * @param string $line - * The rendered row. - * - * @return int - * The blank column count. - */ - protected function leadingSpaces(string $line): int { - $plain = Ansi::strip($line); - - return Ansi::width($plain) - Ansi::width(ltrim($plain)); - } - - /** - * The screen column a fragment starts at within a row. - * - * Byte offsets would disagree between a row carrying the multi-byte cursor - * marker and one padded with plain blanks, so the fragment is located by the - * width of what precedes it. - * - * @param string $line - * The rendered row. - * @param string $needle - * The fragment to locate. - * - * @return int - * The zero-based column. - */ - protected function columnOf(string $line, string $needle): int { - $plain = Ansi::strip($line); - $offset = mb_strpos($plain, $needle); - - $this->assertIsInt($offset, sprintf('"%s" is present in "%s".', $needle, $plain)); - - return Ansi::width(mb_substr($plain, 0, $offset)); - } - - /** - * A colourless borderless theme of fixed width. - * - * @param bool $indent - * Whether conditional fields are indented. - * - * @return \DrevOps\Tui\Theme\DefaultTheme - * The theme. - */ - protected function indentTheme(bool $indent = TRUE): DefaultTheme { - return new DefaultTheme(40, [ - 'color' => FALSE, - 'border' => Border::None, - 'spacing' => Spacing::Normal, - 'indent_conditional' => $indent, - ]); - } - -} diff --git a/tests/phpunit/Unit/Theme/ThemeFullscreenTest.php b/tests/phpunit/Unit/Theme/ThemeFullscreenTest.php deleted file mode 100644 index 27b27309..00000000 --- a/tests/phpunit/Unit/Theme/ThemeFullscreenTest.php +++ /dev/null @@ -1,198 +0,0 @@ -assertSame($expected, (new DefaultTheme(40, $options))->chromeHeight($has_footer)); - } - - public static function dataProviderChromeHeight(): \Iterator { - yield 'borderless normal' => [['border' => Border::None, 'spacing' => Spacing::Normal], TRUE, 3]; - yield 'borderless normal no footer' => [['border' => Border::None, 'spacing' => Spacing::Normal], FALSE, 3]; - yield 'borderless compact' => [['border' => Border::None, 'spacing' => Spacing::Compact], TRUE, 2]; - yield 'boxed no footer' => [['border' => Border::Line, 'spacing' => Spacing::Normal], FALSE, 5]; - yield 'boxed with footer' => [['border' => Border::Line, 'spacing' => Spacing::Normal], TRUE, 6]; - yield 'boxed padded with footer' => [['border' => Border::Line, 'spacing' => Spacing::Padded], TRUE, 8]; - } - - #[DataProvider('dataProviderFullscreenBorderlessAlignsTheBlock')] - public function testFullscreenBorderlessAlignsTheBlock(string $halign, string $valign, array $expected): void { - $theme = new DefaultTheme(10, ['color' => FALSE, 'fullscreen' => TRUE, 'halign' => $halign, 'valign' => $valign, 'border' => Border::None, 'spacing' => Spacing::Normal]); - - $frame = $theme->renderFrame(['H'], ['ab'], ['F'], new Viewport(0, FALSE, FALSE), 4); - - $this->assertSame($expected, explode("\n", Ansi::strip($frame))); - } - - public static function dataProviderFullscreenBorderlessAlignsTheBlock(): \Iterator { - // The body window stretches to its budget (four rows plus the two - // indicator rows), the block anchored per alignment; the header, the - // footer gap and the footer wrap it unchanged. - yield 'top left' => ['left', 'top', ['H', 'ab', '', '', '', '', '', '', 'F']]; - yield 'middle center' => ['center', 'middle', ['H', '', '', ' ab', '', '', '', '', 'F']]; - yield 'bottom right' => ['right', 'bottom', ['H', '', '', '', '', '', ' ab', '', 'F']]; - } - - public function testFullscreenFrameHeightMatchesTheBudget(): void { - $theme = new DefaultTheme(10, ['color' => FALSE, 'fullscreen' => TRUE]); - - $frame = $theme->renderFrame(['H'], ['ab'], ['F'], new Viewport(0, FALSE, FALSE), 4); - - // Header + footer + chrome + viewport height = the exact frame height. - $this->assertCount(1 + 1 + $theme->chromeHeight(TRUE) + 4, explode("\n", $frame)); - } - - public function testNonFullscreenFrameHugsItsContent(): void { - $theme = new DefaultTheme(10, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); - - $frame = $theme->renderFrame(['H'], ['ab'], ['F'], new Viewport(0, FALSE, FALSE), 4); - - $this->assertSame(['H', 'ab', '', 'F'], explode("\n", Ansi::strip($frame))); - } - - public function testFullscreenBoxedStretchesAndCentersInsideTheBorder(): void { - $theme = new DefaultTheme(12, ['color' => FALSE, 'fullscreen' => TRUE, 'halign' => 'center', 'border' => Border::Line]); - - $frame = $theme->renderFrame(['H'], ['ab'], ['F'], new Viewport(0, FALSE, FALSE), 3); - $lines = explode("\n", $frame); - - // 4 rules + header + footer + the stretched window (3 + 2 indicators). - $this->assertCount(1 + 1 + $theme->chromeHeight(TRUE) + 3, $lines); - - foreach ($lines as $line) { - $this->assertSame(12, Ansi::width($line)); - } - - // Inner width is 8, so a 2-column block centered in it indents 3 columns - // past the border gutter. - $this->assertStringContainsString('│ ab │', $frame); - } - - public function testFullscreenEditorStretchesToTheGivenRows(): void { - $theme = new DefaultTheme(20, ['color' => FALSE, 'fullscreen' => TRUE, 'border' => Border::Line]); - - // No hints draw no footer: the box is the title, rules and the stretched - // body window, filling the rows exactly. - $this->assertCount(15, explode("\n", $theme->renderEditor('Name', 'val', [], NULL, 15))); - } - - public function testEditorIgnoresRowsOutsideFullscreen(): void { - $theme = new DefaultTheme(20, ['color' => FALSE, 'border' => Border::Line, 'spacing' => Spacing::Normal]); - - // 3 rules + the title + a one-line body window: content-sized. - $this->assertCount(5, explode("\n", $theme->renderEditor('Name', 'val', [], NULL, 15))); - } - - public function testFullscreenBorderlessEditorStretchesToTheGivenRows(): void { - $theme = new DefaultTheme(20, ['color' => FALSE, 'fullscreen' => TRUE, 'border' => Border::None]); - - $lines = explode("\n", $theme->renderEditor('Name', 'val', [], NULL, 15)); - - // The borderless editor fills the rows too, keeping its label-over-rule - // header at the top of the stretched frame. - $this->assertCount(15, $lines); - $this->assertStringContainsString('Name', Ansi::strip($lines[0])); - $this->assertStringContainsString('val', Ansi::strip(implode("\n", $lines))); - } - - public function testMeasureContentWidthFindsTheWidestRow(): void { - $form = Form::create('Produce stand') - ->panel('stand', 'Stand', function (PanelBuilder $p): void { - $p->text('window', 'Preferred delivery window')->default('Morning'); - }) - ->build(); - - $answers = new Answers(['window' => 'Morning'], []); - - // The widest row is the field: marker gutter (4) + label (25) + value (7). - $this->assertSame(36, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]))->measureContentWidth($form, $answers)); - - // A border adds its two columns and gutters on each side. - $this->assertSame(40, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::Line]))->measureContentWidth($form, $answers)); - - // A provenance badge widens the row by its padded label. - $edited = new Answers(['window' => 'Morning'], ['window' => Provenance::Edited]); - $this->assertSame(45, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]))->measureContentWidth($form, $edited)); - } - - public function testMeasureContentWidthCoversDescriptionsSummariesAndButtons(): void { - $form = Form::create('T') - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('a', 'A')->default('a value under the summary clip')->description('a rather long field description row'); - }) - ->build(); - - $answers = new Answers(['a' => 'a value under the summary clip'], []); - - // The field description row (4 + 35) beats the field row (4 + 1 + 30 + 2 - // spacing = 37) and the hub summary row (4 + 30). - $this->assertSame(39, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]))->measureContentWidth($form, $answers)); - - // Compact spacing drops descriptions and summaries: the field row itself - // (4 + 1 + 30) loses to the button bar (24)... and wins at 35. - $this->assertSame(35, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, $answers)); - } - - public function testMeasureContentWidthCoversHints(): void { - $form = Form::create('T') - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('a', 'A')->hint('a rather long field hint row that outruns every other'); - }) - ->build(); - - // The hint row (4 + 53) is the widest, so the frame fits it unclipped. - $this->assertSame(57, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]))->measureContentWidth($form, new Answers())); - - // Compact spacing drops the hint with the rest of the guidance rows. - $this->assertSame(24, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, new Answers())); - } - - public function testMeasureContentWidthFloorsAtTheButtonBar(): void { - $form = Form::create('T') - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('a', 'A'); - }) - ->build(); - - // Every row is narrower than the button bar: it sets the floor. - $this->assertSame(24, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, new Answers())); - } - - public function testMeasureContentWidthSkipsHiddenButtons(): void { - $form = Form::create('T') - ->buttons(FALSE) - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->text('a', 'A'); - }) - ->build(); - - // With the buttons hidden their bar never renders, so it never measures: - // the widest row is the one-letter field row itself. - $this->assertSame(5, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, new Answers())); - } - -} diff --git a/tests/phpunit/Unit/Theme/ThemeLayoutTest.php b/tests/phpunit/Unit/Theme/ThemeLayoutTest.php deleted file mode 100644 index 10f6f160..00000000 --- a/tests/phpunit/Unit/Theme/ThemeLayoutTest.php +++ /dev/null @@ -1,295 +0,0 @@ - FALSE, 'unicode' => FALSE]); - - [$lines] = $theme->renderBody($this->grid([2]), new Answers(['one' => 'apple', 'two' => 'carrot'], []), 0); - - // Both blocks share the rows: titles beside each other, values beneath. - $this->assertStringContainsString('A >', $lines[0]); - $this->assertStringContainsString('B >', $lines[0]); - $this->assertStringContainsString('One apple', $lines[1]); - $this->assertStringContainsString('Two carrot', $lines[1]); - } - - public function testContentWidthReflectsBorderInset(): void { - // A borderless frame keeps the full width for content. - $this->assertSame(80, (new DefaultTheme(80, ['border' => Border::None]))->contentWidth()); - // A bordered frame reserves the border and gutter columns. - $this->assertSame(76, (new DefaultTheme(80, ['border' => Border::Line]))->contentWidth()); - } - - public function testLayoutStacksRowsWithBlankLineBetween(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE]); - - [$lines] = $theme->renderBody($this->grid([1, 2]), new Answers(), 0); - $body = array_map(Ansi::strip(...), $lines); - - // Row one holds A alone; a blank line separates it from B and C. - $this->assertStringContainsString('A >', $body[0]); - $this->assertStringNotContainsString('B >', $body[0]); - $this->assertSame('', $body[2]); - $this->assertStringContainsString('B >', $body[3]); - $this->assertStringContainsString('C >', $body[3]); - } - - public function testLayoutCursorLineTracksTheSelectedRow(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE]); - $panel = $this->grid([1, 2]); - - // The third panel sits on the second grid row: title (A), value row, - // blank, then the row it starts on. - [, $first] = $theme->renderBody($panel, new Answers(), 0); - [$lines, $third] = $theme->renderBody($panel, new Answers(), 2); - - $this->assertSame(0, $first); - $this->assertSame(3, $third); - // The selected block carries the marker. - $this->assertStringContainsString('> C', Ansi::strip($lines[3])); - } - - public function testLayoutRendersFieldsAboveTheGrid(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE]); - $field = new Field('note', 'Note', '', FieldType::Text, ''); - $panel = new Panel('p', 'P', '', [$field], $this->grid([2])->panels, NULL, [2]); - - [$lines] = $theme->renderBody($panel, new Answers(['note' => 'ripe'], []), 0); - $body = array_map(Ansi::strip(...), $lines); - - // The field keeps its normal row above the grid, separated by a blank. - $this->assertStringContainsString('Note ripe', $body[0]); - $this->assertSame('', $body[1]); - $this->assertStringContainsString('A >', $body[2]); - $this->assertStringContainsString('B >', $body[2]); - } - - public function testLayoutBlockShowsDescriptionAndDrillInRows(): void { - $theme = new DefaultTheme(60, ['color' => FALSE, 'unicode' => FALSE]); - $inner = new Panel('inner', 'Inner', '', [new Field('deep', 'Deep', '', FieldType::Text, '')]); - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A', 'Fresh from the stall.', [], [$inner]), - new Panel('b', 'B', '', [new Field('two', 'Two', '', FieldType::Text, '')]), - ], NULL, [2]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0); - $body = implode("\n", $lines); - - // The description sits under the title and the nested panel shows as a - // drill-in row. - $this->assertStringContainsString('Fresh from the stall.', $body); - $this->assertStringContainsString('Inner >', $body); - } - - public function testLayoutClipsBlocksToTheColumnWidth(): void { - $theme = new DefaultTheme(30, ['color' => FALSE, 'unicode' => FALSE]); - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A rather long panel title indeed', ''), - new Panel('b', 'B', ''), - ], NULL, [2]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0); - - // Two columns in 30 columns leave 14 each: the long title clips, and the - // second column still starts at its own edge. - $this->assertLessThanOrEqual(30, Ansi::width($lines[0])); - $this->assertStringContainsString('B', $lines[0]); - } - - public function testLayoutClampsTheAssembledRowToTinyFrames(): void { - // Three one-column cells plus two gutters outgrow a five-column frame; - // the assembled row is clamped as a whole. - $theme = new DefaultTheme(5, ['color' => FALSE, 'unicode' => FALSE]); - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A', ''), - new Panel('b', 'B', ''), - new Panel('c', 'C', ''), - ], NULL, [3]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0); - - foreach ($lines as $line) { - $this->assertLessThanOrEqual(5, Ansi::width($line)); - } - } - - public function testLayoutPreviewsMultiLineValuesAsTheirFirstLine(): void { - $theme = new DefaultTheme(60, ['color' => FALSE, 'unicode' => FALSE]); - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]), - new Panel('b', 'B', '', [new Field('two', 'Two', '', FieldType::Text, '')]), - ], NULL, [2]); - - [$lines] = $theme->renderBody($panel, new Answers(['notes' => "Crisp and sweet\nHint of citrus", 'two' => 'x'], []), 0); - - // A grid cell is one physical row: the multi-line value previews as its - // first line with a there-is-more marker, and no entry carries an embedded - // newline that would desync the column zip. - $body = implode('|', $lines); - $this->assertStringContainsString('Crisp and sweet...', $body); - $this->assertStringNotContainsString('Hint of citrus', $body); - $this->assertStringNotContainsString("\n", $body); - $this->assertCount(2, $lines); - } - - public function testLayoutPreviewMarkerFollowsTheDisplayMode(): void { - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]), - new Panel('b', 'B', '', [new Field('two', 'Two', '', FieldType::Text, '')]), - ], NULL, [2]); - $answers = new Answers(['notes' => "Crisp and sweet\nHint of citrus", 'two' => 'x'], []); - - [$unicode_lines] = (new DefaultTheme(60, ['color' => FALSE, 'unicode' => TRUE]))->renderBody($panel, $answers, 0); - [$ascii_lines] = (new DefaultTheme(60, ['color' => FALSE, 'unicode' => FALSE]))->renderBody($panel, $answers, 0); - - // A terminal without Unicode must never be handed the glyph. - $this->assertStringContainsString('Crisp and sweet…', implode('|', $unicode_lines)); - $this->assertStringNotContainsString('…', implode('|', $ascii_lines)); - } - - public function testMeasureUsesTheWidestValueLine(): void { - $form = Form::create('T') - ->buttons(FALSE) - ->panel('p', 'P', function (PanelBuilder $p): void { - $p->textarea('notes', 'A')->default("Crisp and sweet\nlonger second line"); - }) - ->build(); - - // The two value lines stack under the value column, so the row needs the - // widest single line (18), never the whole string's length. - $answers = new Answers(['notes' => "Crisp and sweet\nlonger second line"], []); - $this->assertSame(23, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, $answers)); - } - - public function testMeasureContentWidthCoversTheGrid(): void { - $form = Form::create('T') - ->layout(2) - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('one', 'Preferred window')->default('Morning')) - ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'Two')) - ->build(); - - // The widest block is A: field row 4 + 16 + 7 = 27. Two equal columns - // need 27 * 2 + 2 = 56 - wider than any single linear row. - $answers = new Answers(['one' => 'Morning'], []); - $this->assertSame(56, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, $answers)); - } - - public function testMeasureReservesRoomForTheMultiLineMarker(): void { - $form = Form::create('T') - ->buttons(FALSE) - ->layout(2) - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->textarea('notes', 'A')->default("Crisp and sweet\nshort")) - ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'B')) - ->build(); - $answers = new Answers(['notes' => "Crisp and sweet\nshort"], []); - $options = ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]; - - $unicode = (new DefaultTheme(40, $options + ['unicode' => TRUE]))->measureContentWidth($form, $answers); - $ascii = (new DefaultTheme(40, $options + ['unicode' => FALSE]))->measureContentWidth($form, $answers); - - // The first line is the widest, so a grid cell is that line plus its - // marker. Measuring the raw lines alone would size both modes the same and - // clip the marker off the end of the cell. - $this->assertSame(44, $unicode); - $this->assertSame(48, $ascii); - } - - public function testMeasureCountsColumnsNotEscapeSequences(): void { - $grid = Form::create('T') - ->buttons(FALSE) - ->layout(2) - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->rating('grade', 'A')) - ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'B')) - ->build(); - $linear = Form::create('T') - ->buttons(FALSE) - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->rating('grade', 'A')) - ->build(); - $answers = new Answers(['grade' => 3], []); - $options = ['border' => Border::None, 'spacing' => Spacing::Compact]; - - // A rating renders painted, so measuring its raw string would count the - // escape sequences as columns and oversize the frame. Colour is invisible, - // so it must not move the measurement either in a grid cell or in a row. - foreach ([$grid, $linear] as $form) { - $plain = (new DefaultTheme(40, $options + ['color' => FALSE]))->measureContentWidth($form, $answers); - $painted = (new DefaultTheme(40, $options + ['color' => TRUE]))->measureContentWidth($form, $answers); - - $this->assertSame($plain, $painted); - } - } - - public function testLayoutPreviewsNoteAsItsTitle(): void { - $theme = new DefaultTheme(60, ['color' => FALSE, 'unicode' => FALSE]); - $panel = new Panel('p', 'P', '', [], [ - new Panel('a', 'A', '', [new Field('intro', 'Getting started', 'Body text.', FieldType::Note, '')]), - new Panel('b', 'B', '', [new Field('two', 'Two', '', FieldType::Text, '')]), - ], NULL, [2]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0); - - // A note in a grid column previews as its title. - $this->assertStringContainsString('Getting started', implode("\n", $lines)); - } - - public function testMeasureContentWidthCoversNoteInGrid(): void { - $form = Form::create('T') - ->buttons(FALSE) - ->layout(2) - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->note('intro', 'A fairly long note title here')) - ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('two', 'Two')) - ->build(); - - // Block A is driven by the note title: 2 + 29 = 31. Two equal columns need - // 31 * 2 + 2 = 64. Measuring panel A on its own also walks the note row. - $this->assertSame(64, (new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->measureContentWidth($form, new Answers())); - } - - /** - * A panel whose lettered sub-panels are arranged by the given layout. - * - * @param list $layout - * The grid rows. - * - * @return \DrevOps\Tui\Model\Panel - * The panel: sub-panels A, B, C... to fill the layout, one text field - * each. - */ - protected function grid(array $layout): Panel { - $panels = []; - $ids = ['one', 'two', 'three', 'four']; - - for ($index = 0; $index < array_sum($layout); $index++) { - $letter = chr(ord('A') + $index); - $panels[] = new Panel(strtolower($letter), $letter, '', [new Field($ids[$index], ucfirst($ids[$index]), '', FieldType::Text, '')]); - } - - return new Panel('p', 'P', '', [], $panels, NULL, $layout); - } - -} diff --git a/tests/phpunit/Unit/Theme/ThemeManagerTest.php b/tests/phpunit/Unit/Theme/ThemeManagerTest.php index c35774ea..2cf45ba4 100644 --- a/tests/phpunit/Unit/Theme/ThemeManagerTest.php +++ b/tests/phpunit/Unit/Theme/ThemeManagerTest.php @@ -37,17 +37,17 @@ public function testCreate(string $name, array $options, \Closure $styled, strin } public static function dataProviderCreate(): \Iterator { - yield 'default is dark' => ['default', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;36']; - yield 'empty is dark' => ['', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;36']; + yield 'default is dark' => ['default', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;36']; + yield 'empty is dark' => ['', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;36']; // The dark/light palette is a mode option, not a separate theme. - yield 'light mode' => ['default', ['mode' => Mode::Light], static fn(DefaultTheme $t): string => $t->title('X'), '1;34']; - yield 'light mode indicator' => ['default', ['mode' => Mode::Light], static fn(DefaultTheme $t): string => $t->indicator('X'), '35']; + yield 'light mode' => ['default', ['mode' => Mode::Light], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;34']; + yield 'light mode value' => ['default', ['mode' => Mode::Light], static fn(DefaultTheme $t): string => $t->fieldValue('X'), '32']; // Each curated built-in theme resolves by name to its own accent. - yield 'midnight resolves' => ['midnight', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;38;5;141']; - yield 'frost resolves' => ['frost', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;38;5;117']; - yield 'ember resolves' => ['ember', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;38;5;208']; - yield 'mono resolves' => ['mono', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;97']; - yield 'dos resolves' => ['dos', [], static fn(DefaultTheme $t): string => $t->title('X'), '1;97']; + yield 'midnight resolves' => ['midnight', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;38;5;141']; + yield 'frost resolves' => ['frost', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;38;5;117']; + yield 'ember resolves' => ['ember', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;38;5;208']; + yield 'mono resolves' => ['mono', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;97']; + yield 'dos resolves' => ['dos', [], static fn(DefaultTheme $t): string => $t->markupTitle('X'), '1;97']; } public function testCreateUnknownThrows(): void { diff --git a/tests/phpunit/Unit/Theme/ThemeOptionsTest.php b/tests/phpunit/Unit/Theme/ThemeOptionsTest.php index 662ee3dd..7852f6fa 100644 --- a/tests/phpunit/Unit/Theme/ThemeOptionsTest.php +++ b/tests/phpunit/Unit/Theme/ThemeOptionsTest.php @@ -4,15 +4,7 @@ namespace DrevOps\Tui\Tests\Unit\Theme; -use DrevOps\Tui\Answers\Answers; -use DrevOps\Tui\Input\Action; -use DrevOps\Tui\Input\Hint; -use DrevOps\Tui\Input\KeyMapManager; -use DrevOps\Tui\Model\Field; -use DrevOps\Tui\Model\FieldType; -use DrevOps\Tui\Model\Panel; use DrevOps\Tui\Render\Ansi; -use DrevOps\Tui\Render\Viewport; use DrevOps\Tui\Tests\Fixtures\Theme\AccentOptionTheme; use DrevOps\Tui\Theme\Border; use DrevOps\Tui\Theme\DefaultTheme; @@ -33,145 +25,30 @@ #[Group('theme')] final class ThemeOptionsTest extends TestCase { - public function testCompactSpacingDropsDescriptionsAndSummary(): void { - $panel = new Panel('p', 'P', '', [ - new Field('a', 'A', 'field help', FieldType::Text, ''), - ], [ - new Panel('sub', 'Sub', 'panel help', [new Field('b', 'B', '', FieldType::Text, '')]), - ]); - - $theme = new DefaultTheme(40, ['color' => FALSE, 'spacing' => Spacing::Compact]); - [$lines] = $theme->renderBody($panel, new Answers(['b' => 'Beta'], []), 0); - $body = Ansi::strip(implode("\n", $lines)); - - // Compact keeps labels but drops descriptions, the summary and any gaps. - $this->assertStringContainsString('A', $body); - $this->assertStringContainsString('Sub', $body); - $this->assertStringNotContainsString('field help', $body); - $this->assertStringNotContainsString('panel help', $body); - $this->assertStringNotContainsString('Beta', $body); - $this->assertStringNotContainsString("\n\n", implode("\n", $lines)); + #[DataProvider('dataProviderBorderStyleAccessor')] + public function testBorderStyleAccessor(array $options, Border $expected): void { + $this->assertSame($expected, (new DefaultTheme(24, $options))->borderStyle()); } - public function testPaddedSpacingInsertsGapsBetweenItems(): void { - $panel = new Panel('p', 'P', '', [ - new Field('a', 'A', '', FieldType::Text, ''), - new Field('b', 'B', '', FieldType::Text, ''), - ]); - - $theme = new DefaultTheme(40, ['color' => FALSE, 'spacing' => Spacing::Padded]); - [$lines] = $theme->renderBody($panel, new Answers(), 0); - - // A blank line separates the two fields. - $this->assertStringContainsString('A', Ansi::strip($lines[0])); - $this->assertSame('', $lines[1]); - $this->assertStringContainsString('B', Ansi::strip($lines[2])); - } - - #[DataProvider('dataProviderBorderDrawsBox')] - public function testBorderDrawsBox(bool $unicode, Border $border, string $expected): void { - $theme = new DefaultTheme(24, ['color' => FALSE, 'unicode' => $unicode, 'border' => $border]); - $frame = $theme->renderFrame(['HEAD'], ['body'], ['FOOT'], new Viewport(0, FALSE, FALSE), 1); - - $this->assertStringContainsString($expected, $frame); - $this->assertStringContainsString('HEAD', Ansi::strip($frame)); - $this->assertStringContainsString('body', Ansi::strip($frame)); - $this->assertStringContainsString('FOOT', Ansi::strip($frame)); - } - - public static function dataProviderBorderDrawsBox(): \Iterator { - yield 'line' => [TRUE, Border::Line, '┌']; - yield 'rounded' => [TRUE, Border::Rounded, '╭']; - yield 'double' => [TRUE, Border::Double, '╔']; - yield 'ascii line corner' => [FALSE, Border::Line, '+']; - yield 'ascii double fill' => [FALSE, Border::Double, '=']; - } - - public function testBorderedLinesAreExactlyOuterWidthAndClip(): void { - $theme = new DefaultTheme(12, ['color' => FALSE, 'border' => Border::Line]); - - // Inner width is 12 - 4 = 8, so a 20-char body line must clip; every line - // is exactly the outer width. - $frame = $theme->renderFrame(['H'], [str_repeat('x', 20)], ['F'], new Viewport(0, FALSE, FALSE), 1); - - foreach (explode("\n", $frame) as $line) { - $this->assertSame(12, Ansi::width($line)); - } + public static function dataProviderBorderStyleAccessor(): \Iterator { + // Unset draws the rounded box; only an explicit opt-out goes borderless. + yield 'defaults' => [[], Border::Rounded]; + yield 'enum case' => [['border' => Border::Double], Border::Double]; + yield 'string value' => [['border' => 'line'], Border::Line]; + yield 'none' => [['border' => Border::None], Border::None]; } - public function testPaddedBorderAddsInnerPadding(): void { - $padded = new DefaultTheme(20, ['color' => FALSE, 'spacing' => Spacing::Padded, 'border' => Border::Line]); - $plain = new DefaultTheme(20, ['color' => FALSE, 'spacing' => Spacing::Normal, 'border' => Border::Line]); - - $args = [['H'], ['b'], ['F'], new Viewport(0, FALSE, FALSE), 1]; - - // Padded adds a blank boxed line above and below the body. - $this->assertSame(substr_count($plain->renderFrame(...$args), "\n") + 2, substr_count($padded->renderFrame(...$args), "\n")); + public function testBorderCostsTheContentItsChromeColumns(): void { + // A bordered frame spends a border column and a gutter each side, so what + // is left for content is four columns narrower than the frame. + $this->assertSame(20, (new DefaultTheme(24, ['border' => Border::Line]))->contentWidth()); + $this->assertSame(24, (new DefaultTheme(24, ['border' => Border::None]))->contentWidth()); } - public function testBorderlessStatusGapFollowsSpacing(): void { - $args = [['H'], ['b'], ['F'], new Viewport(0, FALSE, FALSE), 1]; - - // Normal detaches the footer with a blank line; compact keeps it attached. - $normal = explode("\n", Ansi::strip((new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]))->renderFrame(...$args))); - $this->assertSame(['H', 'b', '', 'F'], $normal); - - $compact = explode("\n", Ansi::strip((new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]))->renderFrame(...$args))); - $this->assertSame(['H', 'b', 'F'], $compact); - } - - public function testDefaultLookIsBorderedAndPadded(): void { - $args = [['H'], ['b'], ['F'], new Viewport(0, FALSE, FALSE), 1]; - - // Unset options draw the padded rounded box; only an explicit opt-out - // goes borderless. - $default = (new DefaultTheme(24, ['color' => FALSE]))->renderFrame(...$args); - $explicit = (new DefaultTheme(24, ['color' => FALSE, 'border' => Border::Rounded, 'spacing' => Spacing::Padded]))->renderFrame(...$args); - - $this->assertSame($explicit, $default); - $this->assertStringContainsString('╭', $default); - $this->assertStringNotContainsString('╭', (new DefaultTheme(24, ['color' => FALSE, 'border' => Border::None]))->renderFrame(...$args)); - } - - public function testEditorAdoptsBorder(): void { - $plain = (new DefaultTheme(30, ['color' => FALSE, 'border' => Border::None]))->renderEditor('Name', 'Acme'); - $boxed = (new DefaultTheme(30, ['color' => FALSE, 'border' => Border::Line]))->renderEditor('Name', 'Acme'); - - // Borderless keeps today's label-over-rule editor; a border boxes it. - $this->assertStringContainsString("Name\n────", Ansi::strip($plain)); - $this->assertStringNotContainsString('┌', $plain); - - $this->assertStringContainsString('┌', $boxed); - $this->assertStringContainsString('Name', Ansi::strip($boxed)); - $this->assertStringContainsString('Acme', Ansi::strip($boxed)); - } - - public function testEditorDrawsHintsOnlyWhenGiven(): void { - $plain = new DefaultTheme(30, ['color' => FALSE]); - $keys = KeyMapManager::create()->forField(FieldType::Text); - - // An empty hint list draws no footer (the footer can be turned off); a - // non-empty list draws it. - $this->assertStringNotContainsString('accept', $plain->renderEditor('Name', 'body', [], $keys)); - $this->assertStringContainsString('accept', $plain->renderEditor('Name', 'body', [new Hint('accept', Action::Accept)], $keys)); - - // Bordered: an empty hint list still closes the box with a single rule. - $boxed = new DefaultTheme(30, ['color' => FALSE, 'border' => Border::Line]); - $frame = $boxed->renderEditor('Name', 'body', [], $keys); - $this->assertStringNotContainsString('accept', $frame); - $this->assertStringContainsString('body', Ansi::strip($frame)); - } - - public function testBorderColourFollowsMode(): void { - $args = [['H'], ['b'], ['F'], new Viewport(0, FALSE, FALSE), 1]; - - // The border is drawn in the mode's border colour - cyan in dark, blue in - // light - not the editor-rule grey. - $dark = (new DefaultTheme(20, ['border' => Border::Line]))->renderFrame(...$args); - $this->assertStringContainsString("\033[36m", $dark); - - $light = (new DefaultTheme(20, ['border' => Border::Line, 'mode' => Mode::Light]))->renderFrame(...$args); - $this->assertStringContainsString("\033[34m", $light); + public function testSpacingAccessor(): void { + $this->assertSame(Spacing::Padded, (new DefaultTheme(40))->spacing()); + $this->assertSame(Spacing::Compact, (new DefaultTheme(40, ['spacing' => Spacing::Compact]))->spacing()); + $this->assertSame(Spacing::Normal, (new DefaultTheme(40, ['spacing' => 'normal']))->spacing()); } public function testFieldStyleInvalidValueThrows(): void { @@ -182,7 +59,7 @@ public function testFieldStyleInvalidValueThrows(): void { } public function testFieldFlatInputHasPlainCaretNoFill(): void { - $line = (new DefaultTheme(40))->renderInput('ab', 'cd', 'ef'); + $line = (new DefaultTheme(40))->fieldInput('ab', 'cd', 'ef'); // Flat keeps the value with the caret glyph and a dimmed ghost - no fill. $this->assertStringNotContainsString("\033[30;47m", $line); @@ -193,7 +70,7 @@ public function testFieldFlatInputHasPlainCaretNoFill(): void { public function testFieldBoxedInputFillsBehindTheValue(): void { // A string value (not the enum case) exercises the option's string path. - $line = (new DefaultTheme(40, ['field' => 'boxed', 'border' => Border::None]))->renderInput('localhost', ''); + $line = (new DefaultTheme(40, ['field' => 'boxed', 'border' => Border::None]))->fieldInput('localhost', ''); // The fill opens before the value, so the background runs behind the text // itself, and the field is padded to a fixed, visible width. @@ -204,7 +81,7 @@ public function testFieldBoxedInputFillsBehindTheValue(): void { } public function testFieldBoxedEmptyInputIsVisible(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'border' => Border::None]))->renderInput('', ''); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'border' => Border::None]))->fieldInput('', ''); // An empty buffer still renders a full-width filled bar (caret + pad). $this->assertStringStartsWith("\033[30;47m", $line); @@ -212,14 +89,14 @@ public function testFieldBoxedEmptyInputIsVisible(): void { } public function testFieldBoxedInputAdaptsToLightMode(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'mode' => Mode::Light]))->renderInput('x', ''); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'mode' => Mode::Light]))->fieldInput('x', ''); // Light mode fills dark (white on blue) for contrast on a light terminal. $this->assertStringStartsWith("\033[97;44m", $line); } public function testFieldUnderlineInputUnderlinesField(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Underline]))->renderInput('x', 'y'); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Underline]))->fieldInput('x', 'y'); $this->assertStringStartsWith("\033[4;32m", $line); $this->assertStringContainsString('x', Ansi::strip($line)); @@ -227,7 +104,7 @@ public function testFieldUnderlineInputUnderlinesField(): void { } public function testFieldBoxedInputCaretShowsTheLetter(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed]))->renderInput('ab', 'cd'); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed]))->fieldInput('ab', 'cd'); // The caret reverses the character it sits on ('c'), so the letter shows // through the cursor rather than a solid block. @@ -235,7 +112,7 @@ public function testFieldBoxedInputCaretShowsTheLetter(): void { } public function testFieldBoxedInputFillRunsUnbrokenThroughCaretAndGhost(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed]))->renderInput('ab', 'cd', 'xyz'); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed]))->fieldInput('ab', 'cd', 'xyz'); // The caret (reverse) and ghost (dim) toggle off rather than reset, so the // fill is never punctured: exactly one closing reset in the whole line. @@ -244,7 +121,7 @@ public function testFieldBoxedInputFillRunsUnbrokenThroughCaretAndGhost(): void } public function testFieldInputNoColourFallsBackToFlat(): void { - $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'color' => FALSE]))->renderInput('ab', 'cd'); + $line = (new DefaultTheme(40, ['field' => FieldStyle::Boxed, 'color' => FALSE]))->fieldInput('ab', 'cd'); // No colour: no SGR and no padding, just the value with the ascii caret. $this->assertStringNotContainsString("\033[", $line); @@ -346,22 +223,22 @@ public function testSizeOptionAccessorsAndDefaults(): void { public function testFullscreenMaxWidthCapsTheFrame(): void { // The cap narrows a fullscreen frame; uncapped keeps the terminal width. - $this->assertSame(100, (new DefaultTheme(200, ['fullscreen' => TRUE, 'max_width' => 100]))->outerWidth()); - $this->assertSame(200, (new DefaultTheme(200, ['fullscreen' => TRUE]))->outerWidth()); + $this->assertSame(100, (new DefaultTheme(200, ['fullscreen' => TRUE, 'max_width' => 100, 'border' => Border::None]))->contentWidth()); + $this->assertSame(200, (new DefaultTheme(200, ['fullscreen' => TRUE, 'border' => Border::None]))->contentWidth()); // A cap wider than the terminal never widens the frame. - $this->assertSame(80, (new DefaultTheme(80, ['fullscreen' => TRUE, 'max_width' => 100]))->outerWidth()); + $this->assertSame(80, (new DefaultTheme(80, ['fullscreen' => TRUE, 'max_width' => 100, 'border' => Border::None]))->contentWidth()); // Outside fullscreen the cap has no effect on sizing. - $this->assertSame(200, (new DefaultTheme(200, ['max_width' => 100]))->outerWidth()); + $this->assertSame(200, (new DefaultTheme(200, ['max_width' => 100, 'border' => Border::None]))->contentWidth()); } public function testCustomOptionDeclaredBySchema(): void { $theme = $this->accentTheme(['color' => FALSE, 'accent' => 'warm']); - $this->assertSame('warm', $theme->accent()); + $this->assertSame('warm', $theme->accentOption()); // Unset falls back to the theme's default. - $this->assertSame('cool', $this->accentTheme(['color' => FALSE])->accent()); + $this->assertSame('cool', $this->accentTheme(['color' => FALSE])->accentOption()); } public function testCustomOptionInvalidValueThrows(): void { diff --git a/tests/phpunit/Unit/Theme/ThemeRenderTest.php b/tests/phpunit/Unit/Theme/ThemeRenderTest.php index a49c4ccd..96fb332b 100644 --- a/tests/phpunit/Unit/Theme/ThemeRenderTest.php +++ b/tests/phpunit/Unit/Theme/ThemeRenderTest.php @@ -4,398 +4,31 @@ namespace DrevOps\Tui\Tests\Unit\Theme; -use DrevOps\Tui\Answers\Answers; -use DrevOps\Tui\Answers\Provenance; -use DrevOps\Tui\Input\Action; -use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; -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\TableSpec; use DrevOps\Tui\Render\Ansi; -use DrevOps\Tui\Render\HelpSection; -use DrevOps\Tui\Render\Navigator; -use DrevOps\Tui\Render\Viewport; use DrevOps\Tui\Tests\Traits\BuildsThemesTrait; use DrevOps\Tui\Theme\Border; use DrevOps\Tui\Theme\DefaultTheme; -use DrevOps\Tui\Theme\Spacing; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** - * Tests the theme's rendering via headless frame probes. + * Tests the finished pieces the theme composes from its elements. */ #[CoversClass(DefaultTheme::class)] -#[CoversClass(HelpSection::class)] #[Group('theme')] final class ThemeRenderTest extends TestCase { use BuildsThemesTrait; - public function testFieldLineSelectedRightAlignsBadge(): void { - $lines = $this->plainTheme()->renderFieldLine(new Field('name', 'Name', '', FieldType::Text, ''), new Answers(['name' => 'Acme'], ['name' => Provenance::Edited]), TRUE); - - // A single-line value is one row. - $this->assertCount(1, $lines); - $this->assertStringContainsString('❯ Name Acme', Ansi::strip($lines[0])); - $this->assertStringContainsString('edited', Ansi::strip($lines[0])); - $this->assertSame(40, Ansi::width($lines[0])); - } - - public function testFieldLineDefaultHasNoBadge(): void { - $lines = $this->plainTheme()->renderFieldLine(new Field('name', 'Name', '', FieldType::Text, ''), new Answers(['name' => 'Acme'], ['name' => Provenance::Default]), FALSE); - - $this->assertStringNotContainsString('default', $lines[0]); - $this->assertStringContainsString('Name Acme', Ansi::strip($lines[0])); - } - - public function testFieldLineRendersValues(): void { - $theme = $this->plainTheme(); - - $bool = Ansi::strip($theme->renderFieldLine(new Field('b', 'B', '', FieldType::Confirm, FALSE), new Answers(['b' => TRUE], ['b' => Provenance::Default]), FALSE)[0]); - $this->assertStringContainsString('B yes', $bool); - - $list = Ansi::strip($theme->renderFieldLine(new Field('m', 'M', '', FieldType::Select, [], multiple: TRUE), new Answers(['m' => ['a', 'b']], ['m' => Provenance::Default]), FALSE)[0]); - $this->assertStringContainsString('M a, b', $list); - } - - public function testFieldLineMasksPasswordValue(): void { - $field = new Field('token', 'Token', '', FieldType::Password, ''); - - $line = Ansi::strip($this->plainTheme()->renderFieldLine($field, new Answers(['token' => 's3cret-long'], ['token' => Provenance::Edited]), FALSE)[0]); - - $this->assertStringNotContainsString('s3cret-long', $line); - // The mask has a fixed length so it does not leak the value's length. - $this->assertStringContainsString('Token ••••••••', $line); - - $empty = Ansi::strip($this->plainTheme()->renderFieldLine($field, new Answers(['token' => ''], ['token' => Provenance::Default]), FALSE)[0]); - $this->assertStringNotContainsString('•', $empty); - } - - public function testRenderInlineEditorPutsViewInPlaceOfValue(): void { - $field = new Field('cdn', 'CDN', '', FieldType::Confirm, FALSE); - - $lines = $this->plainTheme()->renderInlineEditor($field, "line one\nline two", TRUE); - - // The view's first line sits on the label row where the value would be; a - // further line aligns under that value column. - $this->assertSame('❯ CDN line one', Ansi::strip($lines[0])); - $this->assertMatchesRegularExpression('/^ +line two$/', Ansi::strip($lines[1])); - $this->assertCount(2, $lines); - } - - public function testBodyExpandsMultiLineValueAcrossRows(): void { - $panel = new Panel('p', 'P', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]); - $answers = new Answers(['notes' => "Crisp and sweet\nHint of citrus"], ['notes' => Provenance::Edited]); - - [$lines] = $this->plainTheme()->renderBody($panel, $answers, 0); - - // Each body entry is one physical row: an embedded newline would desync the - // box border, the badge alignment and the scroll maths. - foreach ($lines as $line) { - $this->assertStringNotContainsString("\n", $line); - } - - $stripped = array_map(Ansi::strip(...), $lines); - - // The first value line rides the label row; the rest align under the value - // column. - $this->assertStringContainsString('Notes Crisp and sweet', $stripped[0]); - $this->assertMatchesRegularExpression('/^ +Hint of citrus$/', $stripped[1]); - - // The provenance badge rides the label row only. - $this->assertStringContainsString('edited', $stripped[0]); - $this->assertStringNotContainsString('edited', $stripped[1]); - } - - public function testBodyExpandsInlineEditorMultiLineView(): void { - $field = new Field('notes', 'Notes', '', FieldType::Textarea, ''); - $panel = new Panel('p', 'P', '', [$field]); - - // The editor hands back a multi-line caret view for the field being edited. - [$lines] = $this->plainTheme()->renderBody($panel, new Answers(), 0, $field, "Crisp and sweet\nHint of citrus"); - - foreach ($lines as $line) { - $this->assertStringNotContainsString("\n", $line); - } - - $stripped = array_map(Ansi::strip(...), $lines); - $this->assertStringContainsString('Notes Crisp and sweet', $stripped[0]); - $this->assertMatchesRegularExpression('/^ +Hint of citrus$/', $stripped[1]); - } - - public function testBodyRendersInlineEditorDescriptionAsMarkdown(): void { - $theme = new DefaultTheme(76, ['color' => FALSE, 'border' => Border::None, 'markdown' => TRUE]); - $field = new Field('name', 'Name', 'Use a **short** name', FieldType::Text, ''); - $panel = new Panel('p', 'P', '', [$field]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0, $field, 'Acme'); - $stripped = array_map(Ansi::strip(...), $lines); - - // The description under the inline editor expands its markdown. - $this->assertContains(' Use a short name', $stripped); - } - - public function testBodyRendersHintUnderDescription(): void { - $field = new Field('name', 'Name', 'The grower of record', FieldType::Text, '', hint: 'Type a few letters to filter.'); - $panel = new Panel('p', 'P', '', [$field]); - - [$lines] = $this->plainTheme()->renderBody($panel, new Answers(), 0); - $stripped = array_map(Ansi::strip(...), $lines); - - // Guidance stacks under the row in declaration order: what is being asked, - // then how to answer it. - $this->assertSame(' The grower of record', $stripped[1]); - $this->assertSame(' Type a few letters to filter.', $stripped[2]); - } - - public function testBodyRendersHintWithoutDescription(): void { - $field = new Field('name', 'Name', '', FieldType::Text, '', hint: 'Type a few letters to filter.'); - $panel = new Panel('p', 'P', '', [$field]); - - [$lines] = $this->plainTheme()->renderBody($panel, new Answers(), 0); - $stripped = array_map(Ansi::strip(...), $lines); - - $this->assertSame(' Type a few letters to filter.', $stripped[1]); - $this->assertCount(2, $stripped); - } - - public function testBodyRendersHintUnderInlineEditor(): void { - $field = new Field('name', 'Name', '', FieldType::Text, '', hint: 'Type a few letters to filter.'); - $panel = new Panel('p', 'P', '', [$field]); - - [$lines] = $this->plainTheme()->renderBody($panel, new Answers(), 0, $field, 'Acme'); - $stripped = array_map(Ansi::strip(...), $lines); - - $this->assertStringContainsString('Name Acme', $stripped[0]); - $this->assertSame(' Type a few letters to filter.', $stripped[1]); - } - - public function testBodyDropsHintWhenCompact(): void { - $field = new Field('name', 'Name', '', FieldType::Text, '', hint: 'Type a few letters to filter.'); - $panel = new Panel('p', 'P', '', [$field]); - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Compact]); - - [$lines] = $theme->renderBody($panel, new Answers(), 0); - - $this->assertCount(1, $lines); - } - - public function testFieldHintStylesEachLineApartFromTheDescription(): void { - $theme = new DefaultTheme(40, ['border' => Border::None, 'spacing' => Spacing::Normal]); - - $lines = $theme->renderFieldHint("Pick a date\nin the season", FALSE); - - $this->assertCount(2, $lines); - $this->assertSame(' Pick a date', Ansi::strip($lines[0])); - $this->assertSame(' in the season', Ansi::strip($lines[1])); - $this->assertSame(' ' . $theme->hint('Pick a date'), $lines[0]); - $this->assertNotSame($theme->description('Pick a date'), $theme->hint('Pick a date')); - } - - public function testFieldHintFoldsCarriageReturns(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]); - - // A Windows-authored hint splits on its line breaks like any other, and no - // carriage return survives into a row to reposition the cursor. - $lines = $theme->renderFieldHint("Pick a date\r\nin the season\rthis year", FALSE); - - $this->assertSame([' Pick a date', ' in the season', ' this year'], array_map(Ansi::strip(...), $lines)); - - foreach ($lines as $line) { - $this->assertStringNotContainsString("\r", $line); - } - } - - public function testFieldHintRendersMarkupLiterally(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'markdown' => TRUE]); - - // A hint is one short instruction, so it carries no formatting of its own - - // unlike the description above it, which expands its markdown. - $this->assertSame(' Use a **short** name', Ansi::strip($theme->renderFieldHint('Use a **short** name', FALSE)[0])); - } - - public function testPanelSummaryCollapsesMultiLineValue(): void { - $panel = new Panel('sub', 'Sub', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]); - $answers = new Answers(['notes' => "Crisp and sweet\nHint of citrus"], []); - - $summary = $this->plainTheme()->summarizePanel($panel, $answers); - - // A summary is a single line: newlines collapse so a multi-line value does - // not break the row it sits on. - $this->assertStringNotContainsString("\n", $summary); - $this->assertStringContainsString('Crisp and sweet', $summary); - $this->assertStringContainsString('Hint of citrus', $summary); - } - - #[DataProvider('dataProviderBodyNormalizesLineEndingsInMultiLineValue')] - public function testBodyNormalizesLineEndingsInMultiLineValue(string $value): void { - $panel = new Panel('p', 'P', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]); - $answers = new Answers(['notes' => $value], []); - - [$lines] = $this->plainTheme()->renderBody($panel, $answers, 0); - - // A carriage return would send the terminal cursor back to the row start - // and overprint the row, so every line ending an external editor's save - // can carry in splits into rows the same way a newline does. - foreach ($lines as $line) { - $this->assertStringNotContainsString("\r", $line); - $this->assertStringNotContainsString("\n", $line); - } - - $stripped = array_map(Ansi::strip(...), $lines); - $this->assertStringContainsString('Notes Crisp and sweet', $stripped[0]); - $this->assertMatchesRegularExpression('/^ +Hint of citrus$/', $stripped[1]); - } - - public static function dataProviderBodyNormalizesLineEndingsInMultiLineValue(): \Iterator { - yield 'newline' => ["Crisp and sweet\nHint of citrus"]; - yield 'carriage return and newline' => ["Crisp and sweet\r\nHint of citrus"]; - yield 'carriage return' => ["Crisp and sweet\rHint of citrus"]; - } - - #[DataProvider('dataProviderPanelSummaryNormalizesLineEndings')] - public function testPanelSummaryNormalizesLineEndings(string $value): void { - $panel = new Panel('sub', 'Sub', '', [new Field('notes', 'Notes', '', FieldType::Textarea, '')]); - $answers = new Answers(['notes' => $value], []); - - $summary = $this->plainTheme()->summarizePanel($panel, $answers); - - $this->assertStringNotContainsString("\r", $summary); - $this->assertStringNotContainsString("\n", $summary); - $this->assertStringContainsString('Crisp and sweet Hint of citrus', $summary); - } - - public static function dataProviderPanelSummaryNormalizesLineEndings(): \Iterator { - yield 'newline' => ["Crisp and sweet\nHint of citrus"]; - yield 'carriage return and newline' => ["Crisp and sweet\r\nHint of citrus"]; - yield 'carriage return' => ["Crisp and sweet\rHint of citrus"]; - } - - public function testPanelLineShowsDrillIndicator(): void { - $line = Ansi::strip($this->plainTheme()->renderPanelLine(new Panel('adv', 'Advanced', ''), TRUE)); - - $this->assertStringContainsString('❯ Advanced', $line); - $this->assertStringContainsString('›', $line); - } - - public function testBodyReportsCursorLine(): void { - $panel = new Panel('p', 'P', '', [ - new Field('a', 'A', 'desc a', FieldType::Text, ''), - new Field('b', 'B', '', FieldType::Text, ''), - ]); - - [$lines, $cursor_line] = $this->plainTheme()->renderBody($panel, new Answers(), 1); - - $this->assertSame(2, $cursor_line); - $this->assertStringContainsString('❯ B', Ansi::strip($lines[2])); - } - - public function testBodyRendersNoteCardAndSkipsItInTheCursorCount(): void { - $panel = new Panel('p', 'P', '', [ - new Field('name', 'Name', '', FieldType::Text, ''), - new Field('intro', 'Getting started', "First line.\nSecond line.", FieldType::Note, ''), - new Field('agree', 'Agree', '', FieldType::Confirm, FALSE), - ]); - - // Cursor index 1 is the second navigable field; the note is not counted. - [$lines, $cursor_line] = $this->plainTheme()->renderBody($panel, new Answers(['name' => 'Acme', 'agree' => FALSE], []), 1); - - $body = Ansi::strip(implode("\n", $lines)); - $this->assertStringContainsString('Getting started', $body); - $this->assertStringContainsString('First line.', $body); - $this->assertStringContainsString('Second line.', $body); - // The cursor lands on the field after the note, never the note itself. - $this->assertStringContainsString('❯ Agree', Ansi::strip($lines[$cursor_line])); - $this->assertStringNotContainsString('❯ Getting started', $body); - } - - public function testBodySkipsEmptyNote(): void { - $panel = new Panel('p', 'P', '', [ - new Field('name', 'Name', '', FieldType::Text, ''), - new Field('blank', '', '', FieldType::Note, ''), - ]); - - [$lines] = $this->plainTheme()->renderBody($panel, new Answers(['name' => 'Acme'], []), 0); - - // A note with neither title nor body contributes no lines. - $this->assertStringContainsString('Name', Ansi::strip(implode("\n", $lines))); - $this->assertSame([], $this->plainTheme()->renderNoteLines(new Field('blank', '', '', FieldType::Note, ''), new Answers())); - } - - public function testNoteInterpolatesAnswersInTitleAndBody(): void { - $note = new Field('echo', 'Hello {{name}}', 'You picked {{fruit}}.', FieldType::Note, ''); - - $lines = Ansi::strip(implode("\n", $this->plainTheme()->renderNoteLines($note, new Answers(['name' => 'Ada', 'fruit' => 'pear'], [])))); - - $this->assertStringContainsString('Hello Ada', $lines); - $this->assertStringContainsString('You picked pear.', $lines); - } - - public function testNoteBodyRendersMarkdownWhenEnabled(): void { - $theme = new DefaultTheme(76, ['markdown' => TRUE]); - $note = new Field('md', 'Order', "Pick **ripe** fruit:\n- apples\n- pears", FieldType::Note, ''); - - $lines = $theme->renderNoteLines($note, new Answers()); - $joined = implode("\n", $lines); - - $this->assertStringContainsString('Pick ripe fruit:', Ansi::strip($joined)); - $this->assertStringContainsString('• apples', Ansi::strip($joined)); - $this->assertStringContainsString('• pears', Ansi::strip($joined)); - // The bold word carries the bold SGR. - $this->assertStringContainsString("\033[1mripe\033[0m", $joined); - } - - public function testNoteResolvesLinksInTitleAndBody(): void { - $theme = new DefaultTheme(76, ['border' => Border::None]); - $note = new Field('linked', 'See [Orchard](https://example.com/orchard)', 'Order from [Basket](https://example.com/basket).', FieldType::Note, ''); - - $lines = $theme->renderNoteLines($note, new Answers()); - $joined = implode("\n", $lines); - - $this->assertStringContainsString('See Orchard', Ansi::strip($joined)); - $this->assertStringContainsString('Order from Basket.', Ansi::strip($joined)); - $this->assertStringContainsString(Ansi::link('Orchard', 'https://example.com/orchard'), $joined); - $this->assertStringContainsString(Ansi::link('Basket', 'https://example.com/basket'), $joined); - } - - public function testPaddedSpacingSeparatesNoteFromTheFieldAbove(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Padded]); - $panel = new Panel('p', 'P', '', [ - new Field('name', 'Name', '', FieldType::Text, ''), - new Field('intro', 'Intro', 'Body.', FieldType::Note, ''), - ]); - - [$lines] = $theme->renderBody($panel, new Answers(['name' => 'Acme'], []), 0); - $stripped = array_map(Ansi::strip(...), $lines); - - // Padded spacing inserts a blank line before the note card. - $index = array_search(' Intro', $stripped, TRUE); - $this->assertIsInt($index); - $this->assertGreaterThan(0, $index); - $this->assertSame('', $stripped[$index - 1]); - } - - public function testRenderNoteLinesBoxesBorderedNote(): void { - // The theme frame is borderless, so an opt-in note border falls back to the - // single-line box; its glyphs come only from the note. - $lines = $this->plainTheme()->renderNoteLines(new Field('boxed', 'Boxed', 'In a box.', FieldType::Note, '', bordered: TRUE), new Answers()); - $joined = Ansi::strip(implode("\n", $lines)); - - $this->assertStringContainsString('Boxed', $joined); - $this->assertStringContainsString('In a box.', $joined); - $this->assertStringContainsString('┌', $joined); - $this->assertStringContainsString('┐', $joined); - $this->assertStringContainsString('└', $joined); - $this->assertStringContainsString('┘', $joined); + public function testHelpMarkerFallsBackToTextWithoutUnicode(): void { + // The glyph is narrow by design so a fixed-advance surface cannot squeeze + // it, but a surface with no Unicode at all still needs a mark. + $this->assertSame('ⁱ', Ansi::strip((new DefaultTheme(40, ['color' => FALSE]))->fieldHelpMarker())); + $this->assertSame('[?]', Ansi::strip((new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE]))->fieldHelpMarker())); } public function testRenderTableDrawsAlignedGrid(): void { @@ -442,141 +75,6 @@ public function testRenderTableCapsAtFrameWidth(): void { $this->assertStringContainsString('…', implode("\n", $lines)); } - public function testNoteRendersTableBeneathTitleAndBody(): void { - $field = new Field('stock', 'Stock', 'Current basket:', FieldType::Note, '', table: new TableSpec(['Fruit', 'Qty'], [['Apple', '3']])); - $joined = Ansi::strip(implode("\n", $this->plainTheme()->renderNoteLines($field, new Answers()))); - - $this->assertStringContainsString('Stock', $joined); - $this->assertStringContainsString('Current basket:', $joined); - $this->assertStringContainsString('│ Fruit │ Qty │', $joined); - $this->assertStringContainsString('Apple', $joined); - } - - public function testNoteInterpolatesTableCells(): void { - $field = new Field('echo', '', '', FieldType::Note, '', table: new TableSpec(['Item'], [['{{fruit}}']])); - $joined = Ansi::strip(implode("\n", $this->plainTheme()->renderNoteLines($field, new Answers(['fruit' => 'pear'], [])))); - - $this->assertStringContainsString('pear', $joined); - $this->assertStringNotContainsString('{{fruit}}', $joined); - } - - public function testNoteTableFoldsInterpolatedNewlines(): void { - // An answer carrying newlines - a textarea value - interpolated into a cell - // folds to one row so it never splits the grid. - $field = new Field('memo', '', '', FieldType::Note, '', table: new TableSpec(['Memo'], [['{{memo}}']])); - $lines = $this->plainTheme()->renderNoteLines($field, new Answers(['memo' => "first\nsecond"], [])); - - foreach ($lines as $line) { - $this->assertStringNotContainsString("\n", $line); - } - - $this->assertStringContainsString('first second', Ansi::strip(implode(' ', $lines))); - } - - public function testBorderedNoteBoxesItsTable(): void { - $field = new Field('stock', 'Stock', '', FieldType::Note, '', bordered: TRUE, table: new TableSpec(['Fruit'], [['Apple']])); - $joined = Ansi::strip(implode("\n", $this->plainTheme()->renderNoteLines($field, new Answers()))); - - $this->assertStringContainsString('Stock', $joined); - $this->assertStringContainsString('Apple', $joined); - // Two nested boxes: the note frame around the whole card, and the grid. - $this->assertGreaterThanOrEqual(2, substr_count($joined, '┌')); - } - - public function testBodyIncludesSubPanels(): void { - $panel = new Panel('p', 'P', '', [new Field('a', 'A', '', FieldType::Text, '')], [ - new Panel('sub', 'Sub', 'sub desc'), - ]); - - [$lines, $cursor_line] = $this->plainTheme()->renderBody($panel, new Answers(), 1); - - // The cursor is on the sub-panel (index 1, after the single field). - $this->assertSame(1, $cursor_line); - $this->assertStringContainsString('❯ Sub', Ansi::strip($lines[1])); - $this->assertStringContainsString('sub desc', Ansi::strip($lines[2])); - } - - public function testBodyIncludesPanelSummary(): void { - $hub = new Panel('hub', 'Hub', '', [], [ - new Panel('general', 'General', 'the general panel', [new Field('name', 'Name', '', FieldType::Text, '')]), - ]); - - [$lines] = $this->plainTheme()->renderBody($hub, new Answers(['name' => 'Acme'], []), 0); - - // The hub shows the sub-panel's title, description and value summary. - $body = Ansi::strip(implode("\n", $lines)); - $this->assertStringContainsString('General', $body); - $this->assertStringContainsString('the general panel', $body); - $this->assertStringContainsString('Acme', $body); - } - - public function testPanelSummaryJoinsActiveValues(): void { - $panel = new Panel('p', 'P', '', [ - new Field('a', 'A', '', FieldType::Text, ''), - new Field('b', 'B', '', FieldType::Text, ''), - new Field('gated', 'Gated', '', FieldType::Text, ''), - new Field('m', 'M', '', FieldType::Select, [], multiple: TRUE), - new Field('c', 'C', '', FieldType::Text, ''), - new Field('d', 'D', '', FieldType::Text, ''), - ]); - $answers = new Answers(['a' => 'Acme', 'b' => 'Beta', 'm' => ['w', 'x', 'y', 'z'], 'c' => 'Gamma', 'd' => 'Delta'], []); - - // "gated" is skipped (no answer), the multiselect condenses to a pluralized - // count, and only the first four active values appear ("Delta" is dropped). - $this->assertSame('Acme · Beta · 4 items selected · Gamma', $this->plainTheme()->summarizePanel($panel, $answers)); - } - - public function testSummaryLineClipsToWidth(): void { - $line = Ansi::strip($this->plainTheme()->renderSummaryLine(str_repeat('x', 100), FALSE)); - - $this->assertLessThanOrEqual(40, mb_strlen($line)); - $this->assertStringContainsString('…', $line); - } - - public function testSummaryLineClipsWithoutTheGlyphInAsciiMode(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE]); - - $line = Ansi::strip($theme->renderSummaryLine(str_repeat('x', 100), FALSE)); - - // ASCII spends no column on a marker, so the clip still fits the width. - $this->assertLessThanOrEqual(40, mb_strlen($line)); - $this->assertStringNotContainsString('…', $line); - } - - public function testSelectedItemIsBold(): void { - $theme = new DefaultTheme(40); - $field = new Field('name', 'Name', '', FieldType::Text, ''); - $answers = new Answers(['name' => 'Acme'], ['name' => Provenance::Default]); - - // The selected row is bold (SGR 1); a non-selected row is not. - $this->assertStringContainsString("\033[1", $theme->renderFieldLine($field, $answers, TRUE)[0]); - $this->assertStringNotContainsString("\033[1", $theme->renderFieldLine($field, $answers, FALSE)[0]); - - // The selected item's description and summary rows are bold too. - $this->assertStringContainsString("\033[1", $theme->renderDescriptionLine('help', TRUE)); - $this->assertStringNotContainsString("\033[1", $theme->renderDescriptionLine('help', FALSE)); - $this->assertStringContainsString("\033[1", $theme->renderSummaryLine('sum', TRUE)); - } - - public function testFrameShowsIndicatorsAndWindow(): void { - $body = array_map(static fn(int $i): string => 'line' . $i, range(0, 9)); - - $frame = $this->plainTheme()->renderFrame(['HEAD'], $body, ['FOOT'], new Viewport(3, TRUE, TRUE), 4); - - $this->assertStringContainsString('▲', $frame); - $this->assertStringContainsString('▼', $frame); - $this->assertStringContainsString('HEAD', $frame); - $this->assertStringContainsString('FOOT', $frame); - $this->assertStringContainsString('line3', $frame); - $this->assertStringNotContainsString('line0', $frame); - } - - public function testBreadcrumbLine(): void { - $navigator = new Navigator(new Panel('hub', 'Hub', '', [], [new Panel('d', 'Drupal', '')])); - - $this->assertSame('Hub', Ansi::strip($this->plainTheme()->renderBreadcrumbLine($navigator))); - } - public function testBanner(): void { $banner = Ansi::strip($this->plainTheme()->renderBanner("LOGO\nline", '1.2.3')); @@ -586,60 +84,13 @@ public function testBanner(): void { $this->assertStringNotContainsString('Version', Ansi::strip($this->plainTheme()->renderBanner('LOGO', ''))); } - public function testHintsLineIsThemed(): void { - $line = (new DefaultTheme())->renderHints(KeyMapManager::create()->navigation(), new Hint('move', Action::MoveUp, Action::MoveDown)); - - // Themed with the footer role (dim gray) and composed from arrow glyphs. - $this->assertStringContainsString("\033[90m", $line); - $this->assertStringContainsString('↑/↓ move', Ansi::strip($line)); - } - - public function testRenderHintsJoinsFragmentsInBothModes(): void { - $keys = KeyMapManager::create()->forField(FieldType::Select, TRUE); - $hints = [new Hint('select', Action::Toggle), new Hint('none/all', Action::SelectNone, Action::SelectAll)]; - - $unicode = Ansi::strip((new DefaultTheme())->renderHints($keys, ...$hints)); - $this->assertSame('space select · ←/→ none/all', $unicode); - - // The glyphs degrade with the theme's Unicode mode. - $ascii = Ansi::strip((new DefaultTheme(76, ['unicode' => FALSE]))->renderHints($keys, ...$hints)); - $this->assertStringContainsString(' none/all', $ascii); - } - - public function testRenderHintsEmptyWhenNothingBound(): void { - $nav = KeyMapManager::create()->navigation(); - - // Newline is not a navigation action, so the whole line collapses to empty. - $this->assertSame('', (new DefaultTheme())->renderHints($nav, new Hint('newline', Action::NewLine))); - } - - public function testRenderHelpListsSectionsAndCloseHint(): void { - $nav = KeyMapManager::create()->navigation(); - $text = KeyMapManager::create()->forField(FieldType::Text); - - $help = Ansi::strip((new DefaultTheme())->renderHelp( - $nav, - new HelpSection('Navigation', $nav, new Hint('move', Action::MoveUp, Action::MoveDown)), - new HelpSection('Text', $text, new Hint('accept', Action::Accept)), - // A section whose hints resolve to nothing lists its heading only. - new HelpSection('Empty', $nav, new Hint('newline', Action::NewLine)), - )); - - $this->assertStringContainsString('Keyboard help', $help); - $this->assertStringContainsString('Navigation', $help); - $this->assertStringContainsString('↑/↓ move', $help); - $this->assertStringContainsString('Text', $help); - $this->assertStringContainsString('Empty', $help); - $this->assertStringContainsString('? close', $help); + #[DataProvider('dataProviderKeyGlyph')] + public function testKeyGlyph(Key $key, string $unicode, string $ascii): void { + $this->assertSame($unicode, (new DefaultTheme())->keyGlyph($key)); + $this->assertSame($ascii, (new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))->keyGlyph($key)); } - #[DataProvider('dataProviderKeyHint')] - public function testKeyHint(Key $key, string $unicode, string $ascii): void { - $this->assertSame($unicode, (new DefaultTheme())->keyHint($key)); - $this->assertSame($ascii, (new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))->keyHint($key)); - } - - public static function dataProviderKeyHint(): \Iterator { + public static function dataProviderKeyGlyph(): \Iterator { yield 'up' => [Key::named(KeyName::Up), '↑', '^']; yield 'down' => [Key::named(KeyName::Down), '↓', 'v']; yield 'left' => [Key::named(KeyName::Left), '←', '<']; @@ -661,157 +112,10 @@ public static function dataProviderKeyHint(): \Iterator { yield 'control character spelled out' => [Key::char("\x05"), 'ctrl-e', 'ctrl-e']; } - public function testKeysHintDropsUnboundActions(): void { - $nav = KeyMapManager::create()->navigation(); - $theme = new DefaultTheme(); - - $this->assertSame('↑/↓ move', $theme->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown)); - // Newline is not bound in the navigation scope, so the fragment is empty. - $this->assertSame('', $theme->keysHint($nav, 'newline', Action::NewLine)); - } - - public function testRenderEditorDerivesHintFromKeys(): void { - $keys = KeyMapManager::create()->forField(FieldType::Text); - $hints = [new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; - $editor = Ansi::strip((new DefaultTheme())->renderEditor('Name', 'value', $hints, $keys)); - - // The hint reflects the active bindings. - $this->assertStringContainsString('↵ accept', $editor); - $this->assertStringContainsString('esc cancel', $editor); - } - - public function testHorizontalArrowGlyphs(): void { - $unicode = new DefaultTheme(); - $this->assertSame('←', $unicode->arrowLeft()); - $this->assertSame('→', $unicode->arrowRight()); - - $ascii = new DefaultTheme(76, ['unicode' => FALSE]); - $this->assertSame('<', $ascii->arrowLeft()); - $this->assertSame('>', $ascii->arrowRight()); - } - - public function testHintLineJoinsWithDotGlyph(): void { - $line = (new DefaultTheme())->renderHintLine('enter accept', 'esc cancel'); - - $this->assertSame('enter accept · esc cancel', Ansi::strip($line)); - $this->assertStringContainsString("\033[90m", $line); - - $ascii = (new DefaultTheme(76, ['unicode' => FALSE]))->renderHintLine('a', 'b'); - $this->assertSame('a * b', Ansi::strip($ascii)); - } - - public function testEditorHeaderUnderlinesLabel(): void { - $header = (new DefaultTheme())->renderEditorHeader('Site name'); - - // The label styled as a title, over a rule of the same visible width. - $this->assertSame("Site name\n" . str_repeat('─', 9), Ansi::strip($header)); - $this->assertStringContainsString("\033[1;36mSite name\033[0m", $header); - $this->assertStringContainsString("\033[90m", $header); - - $ascii = (new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))->renderEditorHeader('Site name'); - $this->assertSame("Site name\n---------", $ascii); - - // An empty label still yields a visible rule. - $this->assertSame("\n─", Ansi::strip((new DefaultTheme())->renderEditorHeader(''))); - } - - public function testButtonBar(): void { - $bar = (new DefaultTheme())->renderButtonBar(['Submit', 'Cancel'], 0); - - // Both buttons render inline on one row. - $this->assertStringContainsString('[ Submit ]', Ansi::strip($bar)); - $this->assertStringContainsString('[ Cancel ]', Ansi::strip($bar)); - // The selected button (index 0) uses the cursor style (bold reverse). - $this->assertStringContainsString("\033[1;7m[ Submit ]", $bar); - - // With none selected, nothing is cursor-styled. - $this->assertStringNotContainsString("\033[1;7m", (new DefaultTheme())->renderButtonBar(['Submit', 'Cancel'], -1)); - } - - public function testPanelError(): void { - $row = (new DefaultTheme())->renderPanelError('Item is required.'); - - // The row is indented like the button bar it sits above, and painted red. - $this->assertSame(' Item is required.', Ansi::strip($row)); - $this->assertStringContainsString("\033[31m", $row); - - // A declared message carrying line breaks still occupies one row. - $folded = (new DefaultTheme())->renderPanelError("Item is required.\r\nPick one.\rOr two.\nOr three."); - $this->assertSame(' Item is required. Pick one. Or two. Or three.', Ansi::strip($folded)); - } - public function testDimRecedesText(): void { // With colour, dim wraps the text; with colour off, it is left untouched. $this->assertSame("\033[2mx\033[0m", (new DefaultTheme(40))->dim('x')); $this->assertSame('x', $this->plainTheme()->dim('x')); } - public function testRenderModalCentersDialogOverBackdrop(): void { - $theme = $this->plainTheme(); - $modal = new Panel('c', 'Confirm', 'Proceed with care.', [ - new Field('opt', 'Option', '', FieldType::Text, 'val'), - ], [], new Modal(new Buttons(TRUE, 'Yes', 'No'))); - // A backdrop taller than the dialog, so the dialog centres within it. - $backdrop = $theme->renderFrame(['Demo'], ['Alpha 1', 'Beta 2', 'Gamma 3', 'Delta 4', 'Epsilon 5', 'Zeta 6', 'Eta 7', 'Theta 8'], [], new Viewport(0, FALSE, FALSE), 8); - - $out = Ansi::strip($theme->renderModal($modal, new Answers(['opt' => 'val'], ['opt' => Provenance::Default]), 0, NULL, '', 0, $backdrop, 10)); - - $this->assertStringContainsString('Confirm', $out); - $this->assertStringContainsString('Proceed with care.', $out); - $this->assertStringContainsString('Option', $out); - $this->assertStringContainsString('[ Yes ]', $out); - $this->assertStringContainsString('[ No ]', $out); - // Compositing preserves the backdrop's height: the dialog floats within it. - $this->assertCount(count(explode("\n", Ansi::strip($backdrop))), explode("\n", $out)); - } - - public function testRenderModalWithoutFields(): void { - $theme = $this->plainTheme(); - $modal = new Panel('n', 'Notice', 'Saved successfully.', [], [], new Modal()); - $backdrop = "aaaaaa\nbbbbbb\ncccccc\ndddddd\neeeeee\nffffff\ngggggg\nhhhhhh"; - - $out = Ansi::strip($theme->renderModal($modal, new Answers([], []), -1, NULL, '', -1, $backdrop, 8)); - - // A text-only dialog still shows its message and its default buttons. - $this->assertStringContainsString('Notice', $out); - $this->assertStringContainsString('Saved successfully.', $out); - $this->assertStringContainsString('[ Submit ]', $out); - $this->assertStringContainsString('[ Cancel ]', $out); - } - - public function testRenderModalForcesBorderOnBorderlessTheme(): void { - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None]); - $modal = new Panel('e', 'Empty', '', [], [], new Modal()); - $backdrop = "aaaaaa\nbbbbbb\ncccccc\ndddddd\neeeeee\nffffff"; - - $out = Ansi::strip($theme->renderModal($modal, new Answers([], []), -1, NULL, '', -1, $backdrop, 6)); - - // Even a borderless theme boxes the dialog so it reads as floating above. - $this->assertStringContainsString('┌', $out); - $this->assertStringContainsString('Empty', $out); - $this->assertStringContainsString('[ Submit ]', $out); - } - - public function testRenderModalScrollsBodyAndPinsButtonsWhenTall(): void { - $theme = $this->plainTheme(); - $fields = []; - $values = []; - for ($i = 1; $i <= 10; $i++) { - $fields[] = new Field('f' . $i, 'Field ' . $i, '', FieldType::Text, 'v' . $i); - $values['f' . $i] = 'v' . $i; - } - $modal = new Panel('big', 'Big', 'Many fields.', $fields, [], new Modal(new Buttons(TRUE, 'Save', 'Discard'))); - // A short backdrop forces the padding; a small height forces the body to - // scroll under the pinned button footer rather than clipping it. - $backdrop = "aaaa\nbbbb\ncccc"; - - $out = Ansi::strip($theme->renderModal($modal, new Answers($values, []), 0, NULL, '', -1, $backdrop, 12)); - - $this->assertStringContainsString('Field 1', $out); - $this->assertStringContainsString('[ Save ]', $out); - $this->assertStringContainsString('[ Discard ]', $out); - // The last field scrolls out of view, but the buttons never do. - $this->assertStringNotContainsString('Field 10', $out); - } - } diff --git a/tests/phpunit/Unit/Theme/ThemeTest.php b/tests/phpunit/Unit/Theme/ThemeTest.php index f1b7f9b5..e412861f 100644 --- a/tests/phpunit/Unit/Theme/ThemeTest.php +++ b/tests/phpunit/Unit/Theme/ThemeTest.php @@ -4,6 +4,9 @@ namespace DrevOps\Tui\Tests\Unit\Theme; +use DrevOps\Tui\Block\Prose; +use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Theme\Border; use DrevOps\Tui\Theme\DefaultTheme; @@ -14,68 +17,73 @@ use PHPUnit\Framework\TestCase; /** - * Tests the theme's semantic styler and symbol methods. + * Tests what each of the theme's elements paints and which glyph it draws. */ #[CoversClass(DefaultTheme::class)] +#[CoversClass(Prose::class)] #[Group('theme')] final class ThemeTest extends TestCase { - #[DataProvider('dataProviderStyler')] - public function testStyler(\Closure $styled, string $code): void { + #[DataProvider('dataProviderElementPaint')] + public function testElementPaint(\Closure $styled, string $code): void { $this->assertSame(Ansi::style('X', $code), $styled()); } - public static function dataProviderStyler(): \Iterator { + public static function dataProviderElementPaint(): \Iterator { // The default theme colours these per mode. - yield 'dark title' => [static fn(): string => (new DefaultTheme())->title('X'), '1;36']; - yield 'dark value' => [static fn(): string => (new DefaultTheme())->value('X'), '32']; - yield 'dark indicator' => [static fn(): string => (new DefaultTheme())->indicator('X'), '1;33']; - yield 'dark border' => [static fn(): string => (new DefaultTheme())->border('X'), '36']; - yield 'dark match highlight' => [static fn(): string => (new DefaultTheme())->highlightMatch('X'), '1;33']; - yield 'light title' => [static fn(): string => self::light()->title('X'), '1;34']; - yield 'light indicator' => [static fn(): string => self::light()->indicator('X'), '35']; - yield 'light border' => [static fn(): string => self::light()->border('X'), '34']; - yield 'light match highlight' => [static fn(): string => self::light()->highlightMatch('X'), '1;35']; + yield 'dark title' => [static fn(): string => (new DefaultTheme())->markupTitle('X'), '1;36']; + yield 'dark value' => [static fn(): string => (new DefaultTheme())->fieldValue('X'), '32']; + yield 'dark border' => [static fn(): string => (new DefaultTheme())->chromeBorder('X'), '36']; + yield 'dark match' => [static fn(): string => (new DefaultTheme())->fieldEntryMatch('X'), '1;33']; + yield 'light title' => [static fn(): string => self::light()->markupTitle('X'), '1;34']; + yield 'light border' => [static fn(): string => self::light()->chromeBorder('X'), '34']; + yield 'light match' => [static fn(): string => self::light()->fieldEntryMatch('X'), '1;35']; // These roles are mode-independent: dimmed chrome and the red error. - yield 'description' => [static fn(): string => (new DefaultTheme())->description('X'), '90']; - // A hint is the description's grey, italicized so guidance on how to answer - // reads apart from the question itself. - yield 'hint' => [static fn(): string => (new DefaultTheme())->hint('X'), '3;90']; - yield 'error' => [static fn(): string => (new DefaultTheme())->error('X'), '31']; - yield 'breadcrumb' => [static fn(): string => self::light()->breadcrumb('X'), '90']; - // Option-list roles: a bold-gray heading and a gray disabled option. - yield 'heading' => [static fn(): string => (new DefaultTheme())->heading('X'), '1;90']; - yield 'disabled' => [static fn(): string => (new DefaultTheme())->disabled('X'), '90']; + yield 'description' => [static fn(): string => (new DefaultTheme())->fieldDescription('X'), '90']; + // Guidance on how to answer steps along the grey ramp rather than taking a + // hue: it is drawn beside a description and must not be mistaken for one, + // but a coloured guidance line would read as output rather than as chrome. + yield 'constraint' => [static fn(): string => (new DefaultTheme())->fieldConstraint('X'), '3;38;5;246']; + yield 'error' => [static fn(): string => (new DefaultTheme())->fieldError('X'), '31']; + yield 'breadcrumb' => [static fn(): string => self::light()->breadcrumbLabel('X'), '90']; + yield 'entry note' => [static fn(): string => (new DefaultTheme())->fieldEntryNote('X'), '90']; + yield 'state' => [static fn(): string => (new DefaultTheme())->fieldState('X'), '90']; + yield 'caption' => [static fn(): string => (new DefaultTheme())->fieldCaption('X'), '1;38;5;109']; // Inline ghost-text is dimmed gray, the same as the other dimmed chrome. - yield 'ghost' => [static fn(): string => (new DefaultTheme())->ghost('X'), '90']; + yield 'ghost' => [static fn(): string => (new DefaultTheme())->fieldGhost('X'), '90']; // Markdown spans map to bold, italic and a mode-specific code colour. - yield 'strong' => [static fn(): string => (new DefaultTheme())->strong('X'), '1']; - yield 'emphasis' => [static fn(): string => (new DefaultTheme())->emphasis('X'), '3']; - yield 'dark code' => [static fn(): string => (new DefaultTheme())->code('X'), '93']; - yield 'light code' => [static fn(): string => self::light()->code('X'), '35']; + yield 'strong' => [static fn(): string => (new DefaultTheme())->markupStrong('X'), '1']; + yield 'emphasis' => [static fn(): string => (new DefaultTheme())->markupEmphasis('X'), '3']; + yield 'dark code' => [static fn(): string => (new DefaultTheme())->markupCode('X'), '93']; + yield 'light code' => [static fn(): string => self::light()->markupCode('X'), '35']; + } + + public function testOverflowMarkerCarriesTheAttentionHue(): void { + $this->assertSame(Ansi::style('▲', '1;33'), (new DefaultTheme())->chromeOverflowMarker(TRUE)); + $this->assertSame(Ansi::style('▼', '35'), self::light()->chromeOverflowMarker(FALSE)); } public function testBulletGlyph(): void { - $this->assertSame('•', (new DefaultTheme())->bullet()); - $this->assertSame('-', (new DefaultTheme(76, ['unicode' => FALSE]))->bullet()); + $this->assertSame('•', (new DefaultTheme())->markupBullet()); + $this->assertSame('-', (new DefaultTheme(76, ['unicode' => FALSE]))->markupBullet()); } - public function testLinkAtomEmitsHyperlinkWithColour(): void { - $link = (new DefaultTheme())->link('Orchard', 'https://example.com/orchard'); + public function testLinkElementEmitsHyperlinkWithColour(): void { + $link = (new DefaultTheme())->markupLink('Orchard', 'https://example.com/orchard'); $this->assertSame(Ansi::link('Orchard', 'https://example.com/orchard'), $link); $this->assertSame('Orchard', Ansi::strip($link)); } - public function testLinkAtomDegradesWithoutColour(): void { + public function testLinkElementDegradesWithoutColour(): void { $theme = new DefaultTheme(76, ['color' => FALSE]); - $this->assertSame('Orchard (https://example.com/orchard)', $theme->link('Orchard', 'https://example.com/orchard')); + $this->assertSame('Orchard (https://example.com/orchard)', $theme->markupLink('Orchard', 'https://example.com/orchard')); } public function testLabelResolvesLinks(): void { $theme = new DefaultTheme(); - $label = $theme->label('open [Basket](https://example.com/basket)'); + $label = $theme->fieldLabel('open [Basket](https://example.com/basket)'); // The label keeps its styling and the link is clickable inside it. $this->assertStringContainsString(Ansi::link('Basket', 'https://example.com/basket'), $label); @@ -85,97 +93,99 @@ public function testLabelResolvesLinks(): void { public function testLabelLinkDegradesWithoutColour(): void { $theme = new DefaultTheme(76, ['color' => FALSE]); - $this->assertSame('open Basket (https://example.com/basket)', $theme->label('open [Basket](https://example.com/basket)')); + $this->assertSame('open Basket (https://example.com/basket)', $theme->fieldLabel('open [Basket](https://example.com/basket)')); } - public function testDescriptionBlockRendersMarkdownWhenEnabled(): void { + public function testProseRendersMarkdownWhenEnabled(): void { $theme = new DefaultTheme(76, ['markdown' => TRUE]); - $lines = $theme->renderDescriptionBlock('pack **ripe** *sweet* `pears`', FALSE); + $lines = Prose::lines('pack **ripe** *sweet* `pears`', $theme); $this->assertCount(1, $lines); - $this->assertSame(' pack ripe sweet pears', Ansi::strip($lines[0])); + $this->assertSame('pack ripe sweet pears', Ansi::strip($lines[0])); // Bold, italic and the code colour each carry their own SGR. $this->assertStringContainsString("\033[1mripe\033[0m", $lines[0]); $this->assertStringContainsString("\033[3msweet\033[0m", $lines[0]); $this->assertStringContainsString("\033[93mpears\033[0m", $lines[0]); } - public function testDescriptionBlockRendersBulletList(): void { + public function testProseRendersBulletList(): void { $theme = new DefaultTheme(76, ['markdown' => TRUE]); - $lines = $theme->renderDescriptionBlock("- apples\n- pears", FALSE); + $lines = Prose::lines("- apples\n- pears", $theme); $this->assertCount(2, $lines); - $this->assertSame(' • apples', Ansi::strip($lines[0])); - $this->assertSame(' • pears', Ansi::strip($lines[1])); + $this->assertSame('• apples', Ansi::strip($lines[0])); + $this->assertSame('• pears', Ansi::strip($lines[1])); } - public function testDescriptionBlockLeavesMarkdownLiteralWhenDisabled(): void { + public function testProseLeavesMarkdownLiteralWhenDisabled(): void { $theme = new DefaultTheme(); - $lines = $theme->renderDescriptionBlock('pack **ripe** pears', FALSE); + $lines = Prose::lines('pack **ripe** pears', $theme); // With markdown off the markers stay literal, but links still resolve. $this->assertCount(1, $lines); - $this->assertSame(' pack **ripe** pears', Ansi::strip($lines[0])); + $this->assertSame('pack **ripe** pears', Ansi::strip($lines[0])); } - public function testDescriptionBlockStripsToCleanTextWithoutColour(): void { + public function testProseStripsToCleanTextWithoutColour(): void { $theme = new DefaultTheme(76, ['markdown' => TRUE, 'color' => FALSE]); - $lines = $theme->renderDescriptionBlock("Pick **ripe** `pears` [here](https://example.com/here):\n- gala\n- bosc", FALSE); + $lines = Prose::lines("Pick **ripe** `pears` [here](https://example.com/here):\n- gala\n- bosc", $theme); // Markdown enabled but no colour: markers drop, links degrade, bullets show // as plain glyphs, and not a single escape sequence survives. $joined = implode("\n", $lines); $this->assertSame($joined, Ansi::strip($joined)); $this->assertStringContainsString('Pick ripe pears here (https://example.com/here):', $joined); - $this->assertStringContainsString(' • gala', $joined); - $this->assertStringContainsString(' • bosc', $joined); + $this->assertStringContainsString('• gala', $joined); + $this->assertStringContainsString('• bosc', $joined); } - public function testDescriptionBlockNormalizesLineEndings(): void { + public function testProseNormalizesLineEndings(): void { $theme = new DefaultTheme(76, ['color' => FALSE, 'markdown' => TRUE]); - $lines = $theme->renderDescriptionBlock("- apples\r\n- pears\r- plums", FALSE); + $lines = Prose::lines("- apples\r\n- pears\r- plums", $theme); // CRLF and lone CR both split into their own physical lines, with no stray // carriage return left to overprint the row. - $this->assertSame([' • apples', ' • pears', ' • plums'], $lines); + $this->assertSame(['• apples', '• pears', '• plums'], $lines); } - public function testDescriptionBlockResolvesLinksWithoutMarkdown(): void { + public function testProseResolvesLinksWithoutMarkdown(): void { $theme = new DefaultTheme(); - $lines = $theme->renderDescriptionBlock('see [Guide](https://example.com/guide)', FALSE); + $lines = Prose::lines('see [Guide](https://example.com/guide)', $theme); - $this->assertSame(' see Guide', Ansi::strip($lines[0])); + $this->assertSame('see Guide', Ansi::strip($lines[0])); $this->assertStringContainsString(Ansi::link('Guide', 'https://example.com/guide'), $lines[0]); } public function testGhostSuppressedWithoutColour(): void { // Ghost-text cannot be dimmed without ANSI, so it is suppressed entirely // rather than rendered as indistinguishable plain text. - $this->assertSame('', (new DefaultTheme(76, ['color' => FALSE]))->ghost('X')); - $this->assertStringContainsString("\033[90m", (new DefaultTheme())->ghost('X')); + $this->assertSame('', (new DefaultTheme(76, ['color' => FALSE]))->fieldGhost('X')); + $this->assertStringContainsString("\033[90m", (new DefaultTheme())->fieldGhost('X')); } - public function testDivider(): void { - $this->assertSame('──────────', (new DefaultTheme(10, ['color' => FALSE, 'border' => Border::None]))->divider()); - $this->assertSame('----------', (new DefaultTheme(10, ['unicode' => FALSE, 'color' => FALSE, 'border' => Border::None]))->divider()); - // The divider is dimmed when colour is on. - $this->assertStringContainsString("\033[90m", (new DefaultTheme(10))->divider()); + public function testRule(): void { + $this->assertSame('──────────', (new DefaultTheme(10, ['color' => FALSE, 'border' => Border::None]))->renderRule()); + $this->assertSame('----------', (new DefaultTheme(10, ['unicode' => FALSE, 'color' => FALSE, 'border' => Border::None]))->renderRule()); + // The rule is dimmed when colour is on. + $this->assertStringContainsString("\033[90m", (new DefaultTheme(10))->renderRule()); + // One rule wherever it appears: what stands between two runs of entries is + // what stands between two blocks of standalone output. + $this->assertSame((new DefaultTheme(10))->renderRule(), (new DefaultTheme(10))->fieldEntrySeparator()); } - public function testSelectedRowIsBold(): void { + public function testPickedEntryTakesWeightAndFocusTakesTheAccent(): void { $theme = new DefaultTheme(); - // A row styler bolds its text when selected. - $this->assertSame(Ansi::style('X', '1;32'), $theme->value('X', TRUE)); - $this->assertStringContainsString("\033[1", $theme->label('X', TRUE)); - $this->assertStringNotContainsString("\033[1", $theme->label('X', FALSE)); + $this->assertStringContainsString("\033[1", $theme->fieldEntry('X', TRUE)); + $this->assertStringNotContainsString("\033[1", $theme->fieldEntry('X', FALSE)); + $this->assertSame(Ansi::style('X', '1;36'), $theme->fieldEntry('X', FALSE, TRUE)); } public function testColourOffLeavesTextPlain(): void { $theme = new DefaultTheme(76, ['color' => FALSE]); - $this->assertSame('Setup', $theme->title('Setup')); - $this->assertSame('X', $theme->value('X', TRUE)); + $this->assertSame('Setup', $theme->markupTitle('Setup')); + $this->assertSame('X', $theme->fieldValue('X')); $this->assertFalse($theme->hasColor()); } @@ -187,53 +197,57 @@ public function testGlyph(bool $unicode, \Closure $glyph, string $expected): voi } public static function dataProviderGlyph(): \Iterator { - yield 'unicode arrow' => [TRUE, static fn(DefaultTheme $t): string => $t->arrow(), '›']; - yield 'ascii arrow' => [FALSE, static fn(DefaultTheme $t): string => $t->arrow(), '>']; - yield 'unicode enter' => [TRUE, static fn(DefaultTheme $t): string => $t->enter(), '↵']; - yield 'unicode dot' => [TRUE, static fn(DefaultTheme $t): string => $t->dot(), '·']; - yield 'unicode caret' => [TRUE, static fn(DefaultTheme $t): string => $t->caret(), '█']; - yield 'ascii caret' => [FALSE, static fn(DefaultTheme $t): string => $t->caret(), '|']; - yield 'unicode mask' => [TRUE, static fn(DefaultTheme $t): string => $t->mask(), '•']; - yield 'unicode indicator up' => [TRUE, static fn(DefaultTheme $t): string => $t->indicatorUp(), '▲']; + yield 'unicode descend' => [TRUE, static fn(DefaultTheme $t): string => $t->panelDescend(), '›']; + yield 'ascii descend' => [FALSE, static fn(DefaultTheme $t): string => $t->panelDescend(), '>']; + yield 'unicode breadcrumb separator' => [TRUE, static fn(DefaultTheme $t): string => $t->breadcrumbSeparator(), '›']; + yield 'unicode enter' => [TRUE, static fn(DefaultTheme $t): string => $t->keyGlyph(Key::named(KeyName::Enter)), '↵']; + yield 'unicode summary separator' => [TRUE, static fn(DefaultTheme $t): string => $t->panelSummarySeparator(), '·']; + yield 'unicode caret' => [TRUE, static fn(DefaultTheme $t): string => $t->fieldCaret(), '█']; + yield 'ascii caret' => [FALSE, static fn(DefaultTheme $t): string => $t->fieldCaret(), '|']; + yield 'unicode mask' => [TRUE, static fn(DefaultTheme $t): string => $t->fieldMask(), '•']; + yield 'unicode overflow marker' => [TRUE, static fn(DefaultTheme $t): string => $t->chromeOverflowMarker(TRUE), '▲']; } - public function testMarkerRadioCheck(): void { + public function testSelectorAndMarkerGlyphs(): void { $theme = new DefaultTheme(76, ['color' => FALSE]); - $this->assertSame('❯', $theme->marker(TRUE)); - $this->assertSame(' ', $theme->marker(FALSE)); - $this->assertSame('●', $theme->radio(TRUE)); - $this->assertSame('○', $theme->radio(FALSE)); - $this->assertSame('◼', $theme->check(TRUE)); - $this->assertSame('◻', $theme->check(FALSE)); + $this->assertSame('❯', $theme->fieldSelector(TRUE)); + $this->assertSame(' ', $theme->fieldSelector(FALSE)); + // A round mark for a question that takes one answer, a square one for a + // question that takes several. + $this->assertSame('●', $theme->fieldEntryMarker(TRUE, TRUE)); + $this->assertSame('○', $theme->fieldEntryMarker(FALSE, TRUE)); + $this->assertSame('◼', $theme->fieldEntryMarker(TRUE)); + $this->assertSame('◻', $theme->fieldEntryMarker(FALSE)); $ascii = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - $this->assertSame('>', $ascii->marker(TRUE)); - $this->assertSame('(*)', $ascii->radio(TRUE)); - $this->assertSame('[ ]', $ascii->check(FALSE)); + $this->assertSame('>', $ascii->fieldSelector(TRUE)); + $this->assertSame('(*)', $ascii->fieldEntryMarker(TRUE, TRUE)); + $this->assertSame('[ ]', $ascii->fieldEntryMarker(FALSE)); } public function testCursorAccentIsShared(): void { - // The marker, caret and radio all carry the cursor accent, per mode. - $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->marker(TRUE)); - $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->caret()); - $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->radio(TRUE)); - $this->assertStringContainsString("\033[1;34m", self::light()->marker(TRUE)); + // The selector, caret and exclusive mark all carry the cursor accent. + $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->fieldSelector(TRUE)); + $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->fieldCaret()); + $this->assertStringContainsString("\033[1;36m", (new DefaultTheme())->fieldEntryMarker(TRUE, TRUE)); + $this->assertStringContainsString("\033[1;34m", self::light()->fieldSelector(TRUE)); } - public function testCustomThemeOverridesOneElement(): void { + public function testCustomThemeRepaintsOneHue(): void { $theme = new class() extends DefaultTheme { #[\Override] - public function title(string $text): string { - return $this->paint('1;95', $text); + protected function accent(): string { + return '1;95'; } }; - // The overridden element changes; everything else stays the default. - $this->assertSame(Ansi::style('X', '1;95'), $theme->title('X')); - $this->assertSame(Ansi::style('X', '32'), $theme->value('X')); + // Everything drawn from the accent follows; everything else stays default. + $this->assertSame(Ansi::style('X', '1;95'), $theme->markupTitle('X')); + $this->assertSame(Ansi::style('X', '1;95'), $theme->fieldEntry('X', FALSE, TRUE)); + $this->assertSame(Ansi::style('X', '32'), $theme->fieldValue('X')); } public function testHasUnicode(): void { diff --git a/tests/phpunit/Unit/Translation/ChromeCatalogTest.php b/tests/phpunit/Unit/Translation/ChromeCatalogTest.php index b784b59c..3d892765 100644 --- a/tests/phpunit/Unit/Translation/ChromeCatalogTest.php +++ b/tests/phpunit/Unit/Translation/ChromeCatalogTest.php @@ -4,24 +4,31 @@ namespace DrevOps\Tui\Tests\Unit\Translation; +use DrevOps\Tui\Model\Buttons; +use DrevOps\Tui\Translation\Translator; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** - * Guards translations/en.php against drift from the source. + * Guards the bundled catalogs against drift from the source and each other. * * Every literal chrome string the library emits - a `Translator::t('...')` * call, both forms of a `Translator::formatPlural(..., '...', '...')` call, or * a `new Hint('...')` label - must have a matching template key, and the * template must carry no orphan key, so the canonical list of translatable * chrome stays complete and honest. + * + * Every locale catalog shipped beside the template must then carry exactly the + * template's keys: a key it lacks renders in English in the middle of a + * translated screen, and a key the template has dropped is dead weight nobody + * will ever remove. */ #[CoversNothing] #[Group('translation')] final class ChromeCatalogTest extends TestCase { - public function testTemplateMatchesSourceLiterals(): void { + public function testTemplateMatchesTheStringsTheSourceEmits(): void { $root = dirname(__DIR__, 4); $catalog = require $root . '/translations/en.php'; @@ -29,10 +36,10 @@ public function testTemplateMatchesSourceLiterals(): void { $template = array_keys($catalog); sort($template); - $literals = $this->literals($root . '/src'); - sort($literals); + $emitted = array_values(array_unique([...$this->emitted($root . '/src')['keys'], ...$this->computed()])); + sort($emitted); - $this->assertSame($template, $literals, 'translations/en.php is out of sync with the chrome literals in src/. Regenerate it.'); + $this->assertSame($template, $emitted, 'translations/en.php is out of sync with the chrome strings src/ emits. Regenerate it.'); // The template is a self-describing English catalog: value equals key. foreach ($catalog as $key => $value) { @@ -40,17 +47,164 @@ public function testTemplateMatchesSourceLiterals(): void { } } + public function testLocaleCatalogsCarryExactlyTheTemplatesKeys(): void { + $root = dirname(__DIR__, 4); + + $template = require $root . '/translations/en.php'; + $this->assertIsArray($template); + $expected = array_keys($template); + sort($expected); + + $locales = $this->localeCatalogs($root . '/translations'); + $this->assertNotSame([], $locales, 'No locale catalog ships beside the template, so nothing proves the template is translatable.'); + + $plurals = $this->emitted($root . '/src')['plurals']; + + foreach ($locales as $language => $catalog) { + $rule = $catalog[Translator::PLURAL_RULE] ?? NULL; + unset($catalog[Translator::PLURAL_RULE]); + + $keys = array_keys($catalog); + sort($keys); + + $this->assertSame($expected, $keys, sprintf('translations/%s.php does not carry exactly the keys of translations/en.php.', $language)); + + foreach ($catalog as $key => $value) { + $this->assertTranslation($language, (string) $key, $value, $rule instanceof \Closure ? $rule : NULL); + } + + foreach ($plurals as $source) { + $this->assertIsArray($catalog[$source] ?? NULL, sprintf('translations/%s.php does not give the count phrase "%s" a list of forms, so every count would fall back to English.', $language, $source)); + } + } + } + /** - * The distinct literal chrome keys emitted across a directory. + * Assert one catalog entry is a usable translation of its source key. + * + * @param string $language + * The catalog's language, for the failure message. + * @param string $key + * The English source string the entry translates. + * @param mixed $value + * The entry: a translation, or the grammatical forms of a count phrase. + * @param \Closure|null $rule + * The catalog's plural rule, when it supplies one. + */ + protected function assertTranslation(string $language, string $key, mixed $value, ?\Closure $rule): void { + $forms = is_array($value) ? $value : [$value]; + + foreach ($forms as $form) { + $this->assertIsString($form, sprintf('translations/%s.php translates "%s" as something other than a string.', $language, $key)); + $this->assertNotSame('', trim($form), sprintf('translations/%s.php leaves "%s" untranslated.', $language, $key)); + + // A dropped placeholder renders the name of a value instead of the value + // itself, which no reader can recover from. + foreach ($this->placeholders($key) as $placeholder) { + $this->assertStringContainsString($placeholder, $form, sprintf('translations/%s.php drops the "%s" placeholder from "%s".', $language, $placeholder, $key)); + } + } + + if (!is_array($value) || !$rule instanceof \Closure) { + return; + } + + // Forms are read by position, so a list keyed any other way is skipped + // whole and the phrase renders in English at every count. + $this->assertTrue(array_is_list($value), sprintf('translations/%s.php keys the forms of "%s" itself, so the library cannot read them.', $language, $key)); + + $furthest = 0; + + // Past a hundred the rule repeats the boundaries it has already crossed, so + // this reaches every form it can ask for. + for ($count = 0; $count <= 200; $count++) { + $furthest = max($furthest, (int) $rule($count)); + } + + // A form the rule asks for but the list does not hold falls back to the + // English plural, so the language reaches through its own translation. + $this->assertArrayHasKey($furthest, $value, sprintf('translations/%s.php gives "%s" fewer forms than its own plural rule asks for.', $language, $key)); + } + + /** + * The locale catalogs shipped beside the template, keyed by language. * * @param string $directory - * The directory to scan. + * The bundled catalog directory. + * + * @return array> + * The loaded catalogs, the English template excluded. + */ + protected function localeCatalogs(string $directory): array { + $catalogs = []; + + foreach ((array) glob($directory . '/*.php') as $file) { + $language = pathinfo((string) $file, PATHINFO_FILENAME); + + if ($language === 'en') { + continue; + } + + $catalog = require $file; + $this->assertIsArray($catalog); + $catalogs[$language] = $catalog; + } + + return $catalogs; + } + + /** + * The @name placeholders a message carries. + * + * @param string $message + * The message. * * @return list - * The unique literal keys from Translator::t() calls and Hint labels. + * The placeholders, e.g. ["@min", "@max"]. + */ + protected function placeholders(string $message): array { + preg_match_all('/@\w+/', $message, $matches); + + return array_values(array_unique($matches[0])); + } + + /** + * The chrome keys that reach the translator as a value, not as a literal. + * + * A scanner reads what the source spells out, so a string computed on the + * way to the translator is invisible to it. Each one is derived here from + * the same place the library derives it, so a change to either side is + * caught rather than hard-coded twice. + * + * @return list + * The month names a calendar heading formats, and the button labels a form + * ends on unless it renames them. + */ + protected function computed(): array { + $months = []; + + for ($month = 1; $month <= 12; $month++) { + $months[] = (new \DateTimeImmutable(sprintf('2000-%02d-01', $month)))->format('F'); + } + + $buttons = new Buttons(); + + return [...$months, $buttons->submitLabel, $buttons->cancelLabel]; + } + + /** + * What the source under a directory emits, by the shape it emits it in. + * + * @param string $directory + * The directory to scan. + * + * @return array{keys:list,plurals:list} + * Every distinct literal chrome key, and the count-phrase sources among + * them - the keys a translation hangs its grammatical forms from. */ - protected function literals(string $directory): array { + protected function emitted(string $directory): array { $keys = []; + $plurals = []; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); foreach ($iterator as $entry) { @@ -66,10 +220,14 @@ protected function literals(string $directory): array { foreach ($this->literalKeys($tokens, $i) as $key) { $keys[$key] = TRUE; } + + foreach ($this->pluralSource($tokens, $i) as $key) { + $plurals[$key] = TRUE; + } } } - return array_keys($keys); + return ['keys' => array_keys($keys), 'plurals' => array_keys($plurals)]; } /** @@ -110,6 +268,32 @@ protected function literalKeys(array $tokens, int $index): array { return []; } + /** + * The count-phrase source a token sequence introduces at an index. + * + * @param array $tokens + * The token stream. + * @param int $index + * The index to test. + * + * @return list + * The plural argument of a Translator::formatPlural() call at this index - + * the key its forms hang from; empty when this is not such a call. + */ + protected function pluralSource(array $tokens, int $index): array { + $token = $tokens[$index]; + + if (!is_array($token) || $token[0] !== T_STRING || $token[1] !== 'Translator') { + return []; + } + + if (!$this->isStaticCall($tokens, $index, 'formatPlural')) { + return []; + } + + return array_slice($this->argumentStrings($tokens, $index + 3, 2), 1, 1); + } + /** * Whether a `Class::method(` static call opens at an index. * diff --git a/tests/phpunit/Unit/Translation/TranslationRenderTest.php b/tests/phpunit/Unit/Translation/TranslationRenderTest.php index 632afbab..25854fa5 100644 --- a/tests/phpunit/Unit/Translation/TranslationRenderTest.php +++ b/tests/phpunit/Unit/Translation/TranslationRenderTest.php @@ -7,30 +7,37 @@ use DrevOps\Tui\Answers\Answers; use DrevOps\Tui\Answers\Provenance; use DrevOps\Tui\Answers\SummaryFormatter; +use DrevOps\Tui\Block\Legend; +use DrevOps\Tui\Block\Panel; use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; +use DrevOps\Tui\Field\FieldFactory; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Model\Field; -use DrevOps\Tui\Model\FormDefinition; -use DrevOps\Tui\Render\Ansi; -use DrevOps\Tui\Render\PanelController; use DrevOps\Tui\Schema\AgentHelp; use DrevOps\Tui\Schema\SchemaValidator; +use DrevOps\Tui\Testing\ScreenTester; use DrevOps\Tui\Tests\Traits\ResetsTranslatorTrait; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Translation\Translator; -use DrevOps\Tui\Widget\WidgetFactory; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** * Tests that chrome and questions render in the active language end to end. + * + * Two languages, for two different questions. A fixture catalog answers + * whether a string reaches the translator at all, and is deliberately partial + * so an untranslated one still shows. The bundled Ukrainian catalog answers + * whether the package alone, with nothing configured, puts a whole session in + * front of a reader in their language. */ -#[CoversClass(PanelController::class)] #[CoversClass(DefaultTheme::class)] -#[CoversClass(WidgetFactory::class)] +#[CoversClass(FieldFactory::class)] +#[CoversClass(Legend::class)] +#[CoversClass(Provenance::class)] #[CoversClass(SummaryFormatter::class)] #[CoversClass(SchemaValidator::class)] #[CoversClass(AgentHelp::class)] @@ -44,7 +51,13 @@ protected function setUp(): void { Translator::setShared(new Translator('es', [dirname(__DIR__, 2) . '/Fixtures/translations-render'])); } - protected function form(): FormDefinition { + /** + * The declared tree every scenario is driven against. + * + * @return \DrevOps\Tui\Block\Panel + * The panel every declared panel hangs from. + */ + protected function form(): Panel { return Form::create('Demo') ->panel('general', 'General', function (PanelBuilder $panel): void { $panel->text('name', 'Site name')->description('The name.'); @@ -52,13 +65,17 @@ protected function form(): FormDefinition { $panel->rating('grade', 'Grade')->default(5)->captions([5 => 'Excellent']); $panel->confirm('agree', 'Agree'); }) - ->build(); + ->root(); } public function testInteractiveChromeAndQuestionsTranslated(): void { - $controller = new PanelController($this->form(), new DefaultTheme(60, ['color' => FALSE])); + $tester = (new ScreenTester($this->form()))->rows(16)->cols(60); - $root = Ansi::strip($controller->frame(16)); + // Going into the panel is what puts its rows in front of the reader, so + // one run covers the form's chrome and the questions it asks. + $tester->run(Key::named(KeyName::Enter)); + + $root = $tester->frame(0); // The breadcrumb (form title) and the drill-in panel row (panel title). $this->assertStringContainsString('Demostracion', $root); $this->assertStringContainsString('General ES', $root); @@ -67,38 +84,42 @@ public function testInteractiveChromeAndQuestionsTranslated(): void { $this->assertStringContainsString('[ Cancelar ]', $root); $this->assertStringContainsString('mover', $root); - // Drilling into the panel shows the field label and description translated. - $controller->handle(Key::named(KeyName::Enter)); - $panel = Ansi::strip($controller->frame(16)); + $panel = $tester->frame(); $this->assertStringContainsString('Nombre del sitio', $panel); $this->assertStringContainsString('El nombre.', $panel); } public function testOptionLabelsTranslated(): void { - $field = $this->form()->field('plan'); - $this->assertInstanceOf(Field::class, $field); + $tester = (new ScreenTester($this->form()))->rows(16)->cols(60); - $widget = (new WidgetFactory())->create($field, 'basic'); + // Into the panel, down to the choice, and open it. + $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + ); - $this->assertStringContainsString('Nivel basico', Ansi::strip($widget->view(new DefaultTheme(60, ['color' => FALSE])))); + $this->assertStringContainsString('Nivel basico', $tester->frame()); } public function testRatingCaptionsTranslated(): void { - $field = $this->form()->field('grade'); - $this->assertInstanceOf(Field::class, $field); - $theme = new DefaultTheme(60, ['color' => FALSE]); + $tester = (new ScreenTester($this->form()))->rows(16)->cols(60); - // The caption localizes in the editor and in the collapsed panel row alike. - $widget = (new WidgetFactory())->create($field, 5); - $this->assertStringContainsString('Excelente', Ansi::strip($widget->view($theme))); + // The caption localizes in the editor and in the settled row alike, so the + // frame the scale is opened on and the one it closes back to both say it. + $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Down), + Key::named(KeyName::Down), + Key::named(KeyName::Enter), + ); - $controller = new PanelController($this->form(), $theme, ['grade' => 5]); - $controller->handle(Key::named(KeyName::Enter)); - $this->assertStringContainsString('Excelente', Ansi::strip($controller->frame(16))); + $this->assertStringContainsString('Excelente', $tester->frame()); + $this->assertStringContainsString('Excelente', $tester->frame(-2)); } public function testSummaryTranslated(): void { - $answers = Answers::forForm($this->form(), ['agree' => TRUE], ['agree' => Provenance::Edited]); + $answers = Answers::forTree($this->form(), ['agree' => TRUE], ['agree' => Provenance::Edited]); $summary = (new SummaryFormatter())->format($answers); @@ -113,11 +134,159 @@ public function testHeadlessMessagesTranslated(): void { ->panel('general', 'General', function (PanelBuilder $panel): void { $panel->text('name', 'Site name')->required(); }) - ->build(); + ->root(); // A headless validation error and the agent help both localize. $this->assertContains('Falta la pregunta obligatoria "name".', (new SchemaValidator($form))->validate([])); $this->assertStringContainsString('Nombre del sitio', (new AgentHelp($form, 'TUI_'))->generate()); } + public function testUkrainianLegendNamesEveryKeyItAdvertises(): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel): void { + $panel->select('basket', 'Basket')->multiple()->options(['apple' => 'Apple', 'beet' => 'Beet']); + $panel->text('courier', 'Courier')->help('Weighed at the packing bench.'); + }) + ->root(); + + $tester = (new ScreenTester($form))->rows(16)->cols(80); + $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + Key::named(KeyName::Escape), + Key::named(KeyName::Down), + ); + + // Closed over the panel: moving, opening a row, stepping back out, and the + // way out of the session itself. + $closed = $tester->frame(1); + $this->assertStringContainsString('↑/↓ перемістити', $closed); + $this->assertStringContainsString('↵ вибрати', $closed); + $this->assertStringContainsString('ESC назад', $closed); + $this->assertStringContainsString('Q вийти', $closed); + + // Open over a multi-select: the fragments only an open list advertises. + $open = $tester->frame(2); + $this->assertStringContainsString('ПРОБІЛ вибрати', $open); + $this->assertStringContainsString('нічого/усе', $open); + $this->assertStringContainsString('↵ прийняти', $open); + + // Help is offered on the row that has some, so the fragment arrives last. + $this->assertStringContainsString('? довідка', $tester->frame()); + } + + public function testUkrainianRowStatesItsRefusalAndWhereItsValueCameFrom(): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel): void { + $panel->text('courier', 'Courier')->required(); + $panel->text('crate', 'Crate')->default('Valley Runs'); + }) + ->root(); + + $tester = (new ScreenTester($form))->rows(14)->cols(70)->supplied(['crate' => 'Ridge Runs']); + $tester->run( + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + Key::named(KeyName::Enter), + ); + + // The refusal names the field and says, in Ukrainian, what it is owed; the + // badge beside the row it did not refuse says where that value came from. + $frame = $tester->frame(); + $this->assertStringContainsString("є обов'язковим полем.", $frame); + $this->assertStringContainsString('змінено', $frame); + } + + #[DataProvider('dataProviderUkrainianCountPhraseTakesTheFormTheCountCallsFor')] + public function testUkrainianCountPhraseTakesTheFormTheCountCallsFor(int $minimum, string $expected): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel) use ($minimum): void { + $panel->select('basket', 'Basket')->multiple()->minSelections($minimum) + ->options(['apple' => 'Apple', 'beet' => 'Beet', 'carrot' => 'Carrot', 'date' => 'Date', 'endive' => 'Endive', 'fennel' => 'Fennel']); + }) + ->root(); + + $tester = (new ScreenTester($form))->rows(16)->cols(70); + $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + $this->assertStringContainsString($expected, $tester->frame()); + } + + public static function dataProviderUkrainianCountPhraseTakesTheFormTheCountCallsFor(): \Iterator { + // Ukrainian's three forms, each reached through the count a bound states. + yield 'one' => [1, 'Виберіть щонайменше 1 елемент.']; + yield 'few' => [3, 'Виберіть щонайменше 3 елементи.']; + yield 'many' => [5, 'Виберіть щонайменше 5 елементів.']; + } + + public function testUkrainianPanelRowCountsThePicksItHasNoRoomToList(): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel): void { + $panel->select('basket', 'Basket')->multiple()->default(['apple', 'beet', 'carrot', 'date']) + ->options(['apple' => 'Apple', 'beet' => 'Beet', 'carrot' => 'Carrot', 'date' => 'Date']); + }) + ->root(); + + $tester = (new ScreenTester($form))->rows(12)->cols(70); + $tester->run(); + + // Past a handful the row says how many were picked rather than listing + // them, which is the one count phrase a panel of its own renders. + $this->assertStringContainsString('4 елементи вибрано', $tester->frame()); + } + + public function testUkrainianCalendarNamesTheMonthItOpensOn(): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel): void { + $panel->calendar('due', 'Due date')->default('2026-03-15'); + }) + ->root(); + + $tester = (new ScreenTester($form))->rows(16)->cols(70); + $tester->run(Key::named(KeyName::Enter), Key::named(KeyName::Enter)); + + // The heading and the weekday row are formatted from the date, not written + // out in the source, and both still arrive in Ukrainian. + $frame = $tester->frame(); + $this->assertStringContainsString('Березень 2026', $frame); + $this->assertStringContainsString('Пн', $frame); + $this->assertStringContainsString('←/→ на день', $frame); + $this->assertStringContainsString('ESC скасувати', $frame); + } + + public function testUkrainianSummaryAndHeadlessMessagesLocalize(): void { + $this->ukrainian(); + + $form = Form::create('Produce order') + ->panel('order', 'Weekly box', static function (PanelBuilder $panel): void { + $panel->text('courier', 'Courier')->required(); + }) + ->root(); + + $answers = Answers::forTree($form, ['courier' => 'Valley Runs'], ['courier' => Provenance::Edited]); + + $this->assertStringContainsString('(змінено)', (new SummaryFormatter())->format($answers)); + $this->assertContains('Пропущено потрібне питання "courier".', (new SchemaValidator($form))->validate([])); + } + + /** + * Put the session into Ukrainian, on the package's own catalogs alone. + * + * No source is passed: what the assertions read is what a consumer gets from + * the package with nothing but a language named. + */ + protected function ukrainian(): void { + Translator::setShared(new Translator('uk')); + } + } diff --git a/tests/phpunit/Unit/TuiTest.php b/tests/phpunit/Unit/TuiTest.php index 77903955..62f45b1f 100644 --- a/tests/phpunit/Unit/TuiTest.php +++ b/tests/phpunit/Unit/TuiTest.php @@ -12,7 +12,8 @@ use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\Dotenv; use DrevOps\Tui\Discovery\JsonValue; -use DrevOps\Tui\Engine\Engine; +use DrevOps\Tui\Block\Actions; +use DrevOps\Tui\Block\Panel; use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Handler\HandlerRegistry; use DrevOps\Tui\Input\Action; @@ -24,14 +25,18 @@ use DrevOps\Tui\InterruptException; use DrevOps\Tui\Primitive\Progress; use DrevOps\Tui\Render\Ansi; -use DrevOps\Tui\Render\PanelController; use DrevOps\Tui\Render\Terminal; +use DrevOps\Tui\Screen\ScreenController; use DrevOps\Tui\Testing\BufferedTerminal; use DrevOps\Tui\Testing\KeyEncoder; +use DrevOps\Tui\Testing\TuiTester; use DrevOps\Tui\Tests\Traits\IsolatesEnvTrait; use DrevOps\Tui\Tests\Traits\ResetsTranslatorTrait; use DrevOps\Tui\Theme\Border; use DrevOps\Tui\Theme\Mode; +use DrevOps\Tui\Theme\Override\BreadcrumbOverrides; +use DrevOps\Tui\Theme\Override\FieldOverrides; +use DrevOps\Tui\Theme\ThemeBuilder; use DrevOps\Tui\Translation\Translator; use DrevOps\Tui\Tui; use org\bovigo\vfs\vfsStream; @@ -158,8 +163,7 @@ public function testSchemaResolvesClosureDefaultWithContext(): void { $form = Form::create('T') ->panel('p', 'p', function (PanelBuilder $panel): void { $panel->text('version', 'Version')->default(fn (Context $context): string => $context->version); - }) - ->build(); + }); $prompts = (new Tui($form))->schema(new Context(version: '4.5.6'))['prompts']; $this->assertIsArray($prompts); @@ -172,8 +176,7 @@ public function testAgentHelpResolvesClosureDefaultWithContext(): void { $form = Form::create('T') ->panel('p', 'p', function (PanelBuilder $panel): void { $panel->text('version', 'Version')->default(fn (Context $context): string => $context->version); - }) - ->build(); + }); $help = (new Tui($form))->agentHelp(new Context(version: '4.5.6')); @@ -184,8 +187,7 @@ public function testEnvPrefix(): void { $form = Form::create('Demo') ->panel('p', 'p', function (PanelBuilder $panel): void { $panel->text('name'); - }) - ->build(); + }); // No prefix anywhere falls back to the package default. $this->assertStringContainsString('TUI_NAME', (new Tui($form))->agentHelp()); @@ -196,8 +198,7 @@ public function testEnvPrefix(): void { ->envPrefix('FORM_') ->panel('p', 'p', function (PanelBuilder $panel): void { $panel->text('name'); - }) - ->build(); + }); // The form-declared prefix is used unless the constructor overrides it. $this->assertStringContainsString('FORM_NAME', (new Tui($form))->agentHelp()); @@ -212,19 +213,73 @@ public function testValidate(): void { public function testAccessors(): void { $tui = $this->tui(); - $this->assertSame('Demo', $tui->form()->title); - $this->assertInstanceOf(Engine::class, $tui->engine()); + $this->assertInstanceOf(Panel::class, $tui->root()); + $this->assertSame('Demo', $tui->root()->title()); $this->assertInstanceOf(HandlerRegistry::class, $tui->registry()); } public function testController(): void { $controller = $this->tui()->controller(['color' => FALSE, 'unicode' => TRUE, 'mode' => Mode::Dark]); - $this->assertInstanceOf(PanelController::class, $controller); - // The engine's resolved answers seed the controller. + $this->assertInstanceOf(ScreenController::class, $controller); + // The answers the form opens on seed the session. $this->assertSame('', $controller->answers()->value('name')); } + public function testLayoutArrangesTheSessionItNames(): void { + $form = Form::create('Orchard')->panel('main', 'Delivery', function (PanelBuilder $p): void { + $p->text('courier', 'Courier')->default('Valley Runs'); + }); + + $tester = (new TuiTester($form))->layout('two-column'); + $tester->run("\n"); + + // A two-column screen has no header region, so the trail that the default + // layout pins up top is simply not drawn - which is only true when the + // named layout actually reached the session. + $this->assertStringNotContainsString('Orchard', $tester->output()); + } + + public function testLayoutRefusesNameNothingShipsOrRegisters(): void { + $this->expectException(\InvalidArgumentException::class); + + $this->tui()->layout('orchard-grid'); + } + + public function testEverySessionDrivesTheOneDeclaredTree(): void { + $tui = $this->tui(); + + // The declaration is the tree, so a second session drives the very blocks + // the first one did rather than a copy that could disagree with it. + $first = $tui->controller(['color' => FALSE, 'unicode' => TRUE, 'mode' => Mode::Dark]); + $second = $tui->controller(['color' => FALSE, 'unicode' => TRUE, 'mode' => Mode::Dark]); + + $panel = static fn(ScreenController $controller): mixed => (new \ReflectionProperty($controller, 'panel'))->getValue($controller); + + $this->assertSame($tui->root(), $panel($first)); + $this->assertSame($panel($first), $panel($second)); + + // Driving it twice leaves one way out of the form rather than two. + $actions = array_filter($tui->root()->place()->blocks(), static fn(object $block): bool => $block instanceof Actions); + $this->assertCount(1, $actions); + } + + #[DataProvider('dataProviderControllerCarriesTheThemeBorderIntoTheFrame')] + public function testControllerCarriesTheThemeBorderIntoTheFrame(array $options, Border $expected): void { + $controller = $this->tui()->controller($options + ['color' => FALSE, 'unicode' => TRUE, 'mode' => Mode::Dark]); + + // The frame the theme lays its rows out to is the frame drawn around them, + // so the border reaches the session rather than the theme alone. + $this->assertSame($expected, (new \ReflectionProperty($controller, 'border'))->getValue($controller)); + } + + public static function dataProviderControllerCarriesTheThemeBorderIntoTheFrame(): \Iterator { + yield 'declared' => [['border' => Border::Double], Border::Double]; + yield 'declared as its name' => [['border' => 'none'], Border::None]; + // A form is framed unless it asks not to be, and the theme is what says so. + yield 'undeclared' => [[], Border::Rounded]; + } + public function testInteractDrivesScriptedTerminal(): void { // The Demo hub lists the panel, then Submit and Cancel: Down reaches // Submit, Enter activates it. @@ -250,9 +305,11 @@ public function testInteractThrowsOnCancel(): void { // rather than returning the answers exactly like a submitted form. $this->expectException(CancelException::class); - // Down twice reaches Cancel past the panel and Submit; Enter activates it. + // Down reaches the row the buttons share and Right walks along it to + // Cancel; Enter activates it. $down = KeyEncoder::encode(Key::named(KeyName::Down)); - $this->tui()->interact(terminal: new BufferedTerminal([$down, $down, KeyEncoder::encode(Key::named(KeyName::Enter))])); + $right = KeyEncoder::encode(Key::named(KeyName::Right)); + $this->tui()->interact(terminal: new BufferedTerminal([$down, $right, KeyEncoder::encode(Key::named(KeyName::Enter))])); } public function testInteractUpdateModePreFillsDetectedValues(): void { @@ -337,6 +394,51 @@ public function testInteractFullscreenFillsTheScriptedTerminal(): void { } } + #[DataProvider('dataProviderThemeClosureStatesWhatElementsDraw')] + public function testThemeClosureStatesWhatElementsDraw(bool $unicode, string $separator, string $selector): void { + // One Enter descends into the panel, so the trail gains a segment and the + // rows the field selector marks are on screen. + $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Enter))], 20, 60); + + $this->tui() + ->color(FALSE) + ->unicode($unicode) + ->theme(static fn(ThemeBuilder $builder): ThemeBuilder => $builder + ->breadcrumb(static fn(BreadcrumbOverrides $group): BreadcrumbOverrides => $group->separator('»', '::')) + ->field(static fn(FieldOverrides $group): FieldOverrides => $group->selector('→', '=>'))) + ->interact(terminal: $terminal); + + $screen = Ansi::strip($terminal->output()); + + $this->assertStringContainsString($separator, $screen); + $this->assertStringContainsString($selector, $screen); + // Both display modes are stated together, so neither can be set and the + // other silently left broken. + $this->assertStringNotContainsString($unicode ? '::' : '»', $screen); + } + + public static function dataProviderThemeClosureStatesWhatElementsDraw(): \Iterator { + yield 'unicode' => [TRUE, '»', '→']; + yield 'ascii' => [FALSE, '::', '=>']; + } + + public function testThemeClosurePatchesWhicheverThemeIsSelected(): void { + $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Enter))], 20, 60); + + // A name picks the theme and a closure patches it, in either order: the + // patch is not a second theme. + $this->tui() + ->theme(static fn(ThemeBuilder $builder): ThemeBuilder => $builder->field(static fn(FieldOverrides $group): FieldOverrides => $group->selector('→', '=>'))) + ->theme('mono', ['color' => TRUE, 'unicode' => TRUE]) + ->interact(terminal: $terminal); + + $output = $terminal->output(); + + // The glyph is the consumer's and the hue is the theme's, so an override + // names the mark without taking the palette with it. + $this->assertStringContainsString("\033[1;97m→", $output); + } + #[DataProvider('dataProviderResolveTheme')] public function testResolveTheme(string $facade_theme, string $theme, string $expected): void { $tui = $this->themedTui($facade_theme); diff --git a/tests/phpunit/Unit/Widget/CalendarWidgetTest.php b/tests/phpunit/Unit/Widget/CalendarWidgetTest.php deleted file mode 100644 index 1f75ae1f..00000000 --- a/tests/phpunit/Unit/Widget/CalendarWidgetTest.php +++ /dev/null @@ -1,248 +0,0 @@ -assertSame('2026-07-15', $widget->value()); - } - - public function testOpensOnTodayWhenEmpty(): void { - $widget = new CalendarWidget(); - - $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); - } - - public function testInvalidSeedFallsBackToToday(): void { - $widget = new CalendarWidget('not-a-date'); - - $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); - } - - #[DataProvider('dataProviderNavigation')] - public function testNavigation(Key $key, string $expected): void { - $widget = new CalendarWidget('2026-07-15'); - - $widget->handle($key); - - $this->assertSame($expected, $widget->value()); - } - - public static function dataProviderNavigation(): \Iterator { - yield 'left is previous day' => [Key::named(KeyName::Left), '2026-07-14']; - yield 'right is next day' => [Key::named(KeyName::Right), '2026-07-16']; - yield 'up is previous week' => [Key::named(KeyName::Up), '2026-07-08']; - yield 'down is next week' => [Key::named(KeyName::Down), '2026-07-22']; - yield 'page up is previous month' => [Key::named(KeyName::PageUp), '2026-06-15']; - yield 'page down is next month' => [Key::named(KeyName::PageDown), '2026-08-15']; - yield 'home is first of month' => [Key::named(KeyName::Home), '2026-07-01']; - yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31']; - } - - #[DataProvider('dataProviderVimNavigation')] - public function testVimNavigation(Key $key, string $expected): void { - // Injecting the vim scope map proves day and week movement resolve through - // the key bindings: the vim preset reaches the same moves via h/j/k/l. - $widget = (new CalendarWidget('2026-07-15'))->setKeys(KeyMapManager::create('vim')->forField(FieldType::Calendar)); - - $widget->handle($key); - - $this->assertSame($expected, $widget->value()); - } - - public static function dataProviderVimNavigation(): \Iterator { - yield 'h is previous day' => [Key::char('h'), '2026-07-14']; - yield 'l is next day' => [Key::char('l'), '2026-07-16']; - yield 'k is previous week' => [Key::char('k'), '2026-07-08']; - yield 'j is next week' => [Key::char('j'), '2026-07-22']; - } - - #[DataProvider('dataProviderPageMonthClampsToShortMonth')] - public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void { - $widget = new CalendarWidget($seed); - - $widget->handle($key); - - $this->assertSame($expected, $widget->value()); - } - - public static function dataProviderPageMonthClampsToShortMonth(): \Iterator { - // Jan 31 has no counterpart in the shorter month, so the day caps to - // that month's end. - yield 'jan 31 to non-leap feb' => ['2026-01-31', Key::named(KeyName::PageDown), '2026-02-28']; - yield 'jan 31 to leap feb' => ['2024-01-31', Key::named(KeyName::PageDown), '2024-02-29']; - yield 'mar 31 back to feb' => ['2026-03-31', Key::named(KeyName::PageUp), '2026-02-28']; - yield 'oct 31 to nov' => ['2026-10-31', Key::named(KeyName::PageDown), '2026-11-30']; - } - - public function testUnhandledKeysAreNoOps(): void { - $widget = new CalendarWidget('2026-07-15'); - - // An unmapped character and an unmapped named key both leave the cursor - // in place. - $widget->handle(Key::char('z')); - $widget->handle(Key::named(KeyName::Tab)); - - $this->assertSame('2026-07-15', $widget->value()); - } - - public function testNavigationClampsWithinBounds(): void { - $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); - $widget = new CalendarWidget('2026-07-11', bounds: $bounds); - - // A week back would land before the minimum, so it clamps to the minimum. - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame('2026-07-10', $widget->value()); - - // Already on the minimum, a further step left stays on it. - $widget->handle(Key::named(KeyName::Left)); - $this->assertSame('2026-07-10', $widget->value()); - - // The end of the month is past the maximum, so it clamps to the maximum. - $widget->handle(Key::named(KeyName::End)); - $this->assertSame('2026-07-20', $widget->value()); - } - - public function testStepByMovesByDaysClampedToBounds(): void { - $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); - $widget = new CalendarWidget('2026-07-15', bounds: $bounds); - - $widget->stepBy(3); - $this->assertSame('2026-07-18', $widget->value()); - - $widget->stepBy(-30); - $this->assertSame('2026-07-10', $widget->value()); - } - - public function testConstructionClampsSeedIntoBounds(): void { - $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); - - $this->assertSame('2026-07-10', (new CalendarWidget('2026-07-01', bounds: $bounds))->value()); - $this->assertSame('2026-07-20', (new CalendarWidget('2026-07-31', bounds: $bounds))->value()); - } - - public function testAcceptReturnsIsoDate(): void { - $widget = new CalendarWidget('2026-07-15'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter))); - - $this->assertSame('2026-07-16', $value); - $this->assertTrue($widget->isComplete()); - } - - public function testCancel(): void { - $widget = new CalendarWidget('2026-07-15'); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testValidatorErrorIsShown(): void { - $widget = (new CalendarWidget('2026-07-15'))->setHandlers(validate: static fn(mixed $value): string => 'No dates allowed.'); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('No dates allowed.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testRendersCalendar(): void { - $widget = new CalendarWidget('2026-07-15'); - - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString('July 2026', $view); - // The cursor day is bracketed. - $this->assertStringContainsString('[15]', $view); - // The weekday header defaults to a Monday-first week. - $this->assertMatchesRegularExpression('/Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa\s+Su/', $view); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new CalendarWidget('2026-07-15'))->hints()); - - $this->assertSame(['day', 'week', 'accept', 'cancel'], $labels); - } - - public function testWeekStartRotatesHeaderAndLayout(): void { - $sunday = Ansi::strip((new CalendarWidget('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)))->view(new DefaultTheme())); - - // A Sunday-first week reorders the weekday header. - $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $sunday); - - // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the - // month one column right, so the first row holds only days 1-4 (through - // Saturday) and day 5 (Sunday) starts the next row. The default - // Monday-first week fits days 1-5 in the first row. The first grid row is - // the third rendered line. - $monday = Ansi::strip((new CalendarWidget('2026-07-15'))->view(new DefaultTheme())); - $this->assertStringContainsString('5', explode("\n", $monday)[2]); - $this->assertStringNotContainsString('5', explode("\n", $sunday)[2]); - } - - public function testAsciiRendering(): void { - $widget = new CalendarWidget('2026-07-15'); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - $view = $widget->view($theme); - - $this->assertStringContainsString('July 2026', $view); - // The bracket keeps the cursor day distinguishable without colour. - $this->assertStringContainsString('[15]', $view); - } - - public function testDimsOutOfRangeDays(): void { - $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10')); - $widget = new CalendarWidget('2026-07-15', bounds: $bounds); - $theme = new DefaultTheme(); - - $view = $widget->view($theme); - - // A day before the minimum is rendered dimmed, not plain. - $this->assertStringContainsString($theme->description(sprintf(' %2d ', 5)), $view); - // The cursor day stays bracketed and highlighted. - $this->assertStringContainsString($theme->highlight('[15]'), $view); - } - - public function testDimsDaysPastMaximum(): void { - $bounds = new DateBounds(max: new \DateTimeImmutable('2026-07-20')); - $widget = new CalendarWidget('2026-07-15', bounds: $bounds); - $theme = new DefaultTheme(); - - $view = $widget->view($theme); - - // A day after the maximum is dimmed too, guarding the upper bound. - $this->assertStringContainsString($theme->description(sprintf(' %2d ', 25)), $view); - } - -} diff --git a/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php b/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php deleted file mode 100644 index 98d917d3..00000000 --- a/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php +++ /dev/null @@ -1,97 +0,0 @@ -assertFalse($widget->value()); - $this->assertStringContainsString('● No', Ansi::strip($widget->view(new DefaultTheme()))); - - $widget->handle(Key::named(KeyName::Space)); - $this->assertTrue($widget->value()); - $this->assertStringContainsString('● Yes', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testValidatorErrorShownInView(): void { - $widget = (new ConfirmWidget(FALSE))->setHandlers(validate: static fn (mixed $value): string => 'Not allowed.'); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Not allowed.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testCharYesNo(): void { - $widget = new ConfirmWidget(FALSE); - - $widget->handle(Key::char('y')); - $this->assertTrue($widget->value()); - - $widget->handle(Key::char('n')); - $this->assertFalse($widget->value()); - - $widget->handle(Key::char('z')); - $this->assertFalse($widget->value()); - } - - public function testStepByFlipsOnOddSteps(): void { - $widget = new ConfirmWidget(); - - $widget->stepBy(1); - $this->assertTrue($widget->value()); - - // An even step lands back on the same value. - $widget->stepBy(2); - $this->assertTrue($widget->value()); - - $widget->stepBy(-1); - $this->assertFalse($widget->value()); - } - - public function testAccept(): void { - $widget = new ConfirmWidget(TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertTrue($value); - $this->assertTrue($widget->isComplete()); - } - - public function testCancel(): void { - $widget = new ConfirmWidget(FALSE); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new ConfirmWidget(FALSE))->hints()); - - $this->assertSame(['yes/no', 'toggle', 'accept', 'cancel'], $labels); - } - -} diff --git a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php deleted file mode 100644 index 9d7b801c..00000000 --- a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php +++ /dev/null @@ -1,546 +0,0 @@ - ['guide.md' => '', 'intro.txt' => ''], - 'src' => [ - 'Theme' => ['Ocean.php' => ''], - 'Widget' => ['Foo.php' => '', 'Bar.php' => ''], - 'readme.md' => '', - 'util.php' => '', - ], - 'empty' => [], - '.hidden' => ['secret.txt' => ''], - '.env' => '', - 'README.md' => '', - 'composer.json' => '', - ]); - $this->root = vfsStream::url('root'); - } - - public function testOpensAtStartDirectoriesFirst(): void { - $widget = new FilePickerWidget($this->root); - - // The first entry is the first directory, sorted case-insensitively. - $this->assertSame($this->root . '/docs', $widget->value()); - - $view = $this->render($widget); - $this->assertStringContainsString('docs/', $view); - $this->assertStringContainsString('README.md', $view); - // Hidden entries stay out of sight until revealed. - $this->assertStringNotContainsString('.env', $view); - $this->assertStringNotContainsString('.hidden', $view); - } - - public function testDescendAndAscend(): void { - $widget = new FilePickerWidget($this->root); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame($this->root . '/src', $widget->value()); - - $widget->handle(Key::named(KeyName::Right)); - // Inside src the first entry is the Theme directory. - $this->assertSame($this->root . '/src/Theme', $widget->value()); - - // Ascending returns to the parent with the directory just left highlighted. - $widget->handle(Key::named(KeyName::Left)); - $this->assertSame($this->root . '/src', $widget->value()); - } - - public function testCannotAscendAboveStart(): void { - $widget = new FilePickerWidget($this->root); - - $widget->handle(Key::named(KeyName::Left)); - $widget->handle(Key::named(KeyName::Left)); - - $this->assertSame($this->root . '/docs', $widget->value()); - } - - public function testRightOnFileDoesNotDescend(): void { - // README.md is the first file; highlight it, then Right is a no-op. - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::File)); - - // Files-only lists directories (navigable) then files. - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame($this->root . '/composer.json', $widget->value()); - - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame($this->root . '/composer.json', $widget->value()); - } - - public function testAnyModeEnterOnDirectorySelectsIt(): void { - $widget = new FilePickerWidget($this->root); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame($this->root . '/docs', $value); - $this->assertTrue($widget->isComplete()); - } - - public function testAnyModeSelectFileAfterDescending(): void { - $widget = new FilePickerWidget($this->root); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Right), - Key::named(KeyName::Enter), - )); - - // Right descends into docs; Enter accepts its first file. - $this->assertSame($this->root . '/docs/guide.md', $value); - } - - public function testFileModeEnterOnDirectoryDescends(): void { - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::File)); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Enter), - Key::named(KeyName::Enter), - )); - - // The first Enter descends into docs (a directory is not selectable); - // the second accepts its first file. - $this->assertSame($this->root . '/docs/guide.md', $value); - } - - public function testDirectoryModeHidesFilesAndSelectsDirectory(): void { - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::Directory)); - - $view = $this->render($widget); - $this->assertStringContainsString('docs/', $view); - // Files are hidden entirely in directory mode. - $this->assertStringNotContainsString('README.md', $view); - $this->assertStringNotContainsString('composer.json', $view); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - $this->assertSame($this->root . '/docs', $value); - } - - public function testExtensionFilterLimitsFiles(): void { - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::File, ['MD'])); - - // Descend into src (docs, empty, src -> src is third). - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Right)); - - $view = $this->render($widget); - // Directories stay navigable; only .md files pass the (case-insensitive) - // extension filter, so util.php is filtered out. - $this->assertStringContainsString('Theme/', $view); - $this->assertStringContainsString('readme.md', $view); - $this->assertStringNotContainsString('util.php', $view); - } - - public function testTabTogglesHiddenEntries(): void { - $widget = new FilePickerWidget($this->root); - - $this->assertStringNotContainsString('.env', $this->render($widget)); - - $widget->handle(Key::named(KeyName::Tab)); - - $view = $this->render($widget); - $this->assertStringContainsString('.env', $view); - $this->assertStringContainsString('.hidden/', $view); - } - - public function testTypeToFilterNarrowsEntries(): void { - $widget = new FilePickerWidget($this->root); - - foreach (str_split('read') as $char) { - $widget->handle(Key::char($char)); - } - - // Only README.md contains "read". - $this->assertSame('read', $widget->filter()); - $this->assertSame($this->root . '/README.md', $widget->value()); - $this->assertStringContainsString('README.md', $this->render($widget)); - - // Clearing the filter restores the full listing. - foreach (range(1, 4) as $ignored) { - $widget->handle(Key::named(KeyName::Backspace)); - } - $this->assertSame($this->root . '/docs', $widget->value()); - } - - public function testTypeToFilterFoldsCaseBeyondAscii(): void { - vfsStream::setup('accents', NULL, ['Äpfel.md' => '', 'pears.md' => '']); - $widget = new FilePickerWidget(vfsStream::url('accents')); - - $widget->handle(Key::char('ä')); - - // A lowercase non-ASCII query matches its uppercase entry, which a - // byte-level fold would miss. - $this->assertSame(vfsStream::url('accents') . '/Äpfel.md', $widget->value()); - $this->assertStringNotContainsString('pears.md', $this->render($widget)); - } - - public function testBackspaceAscendsWhenFilterEmpty(): void { - $widget = new FilePickerWidget($this->root); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame($this->root . '/src/Theme', $widget->value()); - - $widget->handle(Key::named(KeyName::Backspace)); - $this->assertSame($this->root . '/src', $widget->value()); - } - - public function testMultipleTogglesAndAccepts(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE); - - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame([$this->root . '/docs'], $widget->value()); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame([$this->root . '/docs', $this->root . '/src'], $widget->value()); - - // Toggling an already-selected entry removes it. - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame([$this->root . '/docs'], $widget->value()); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertSame([$this->root . '/docs'], $widget->value()); - } - - public function testMultipleAccumulatesAcrossDirectories(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE); - - // Select the docs directory, then descend into src and select Theme. - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Right)); - $widget->handle(Key::named(KeyName::Space)); - - $this->assertSame([$this->root . '/docs', $this->root . '/src/Theme'], $widget->value()); - } - - public function testMultipleSpaceIgnoresNonSelectableDirectory(): void { - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::File), multiple: TRUE); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - // The first entry is a directory, which files-only mode cannot select. - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame([], $widget->value()); - - // Selectable files carry a checkbox; navigable directories carry a spacer. - $view = $widget->view($theme); - $this->assertStringContainsString('[ ] README.md', $view); - $this->assertStringContainsString('docs/', $view); - } - - public function testMultipleSpaceInEmptyDirectoryIsSafe(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Right)); - $widget->handle(Key::named(KeyName::Space)); - - $this->assertSame([], $widget->value()); - } - - public function testSeedWithMissingBasenameHighlightsTop(): void { - // A default under the start whose entry does not exist opens at the start - // directory with the top entry highlighted. - $widget = new FilePickerWidget($this->root, $this->root . '/nope.txt'); - - $this->assertSame($this->root . '/docs', $widget->value()); - } - - public function testRootBreadcrumb(): void { - $widget = new FilePickerWidget('/'); - - $lines = explode("\n", Ansi::strip($widget->view(new DefaultTheme()))); - $this->assertSame('/', $lines[0]); - } - - public function testNonexistentStartIsEmpty(): void { - $widget = new FilePickerWidget($this->root . '/missing'); - - $this->assertSame('', $widget->value()); - $this->assertStringContainsString('(empty)', $this->render($widget)); - } - - public function testMultipleSeedsSelectionFromDefault(): void { - $widget = new FilePickerWidget($this->root, [$this->root . '/README.md'], multiple: TRUE); - - $this->assertSame([$this->root . '/README.md'], $widget->value()); - // The browser opens at the seeded path's directory with it highlighted. - $this->assertStringContainsString('README.md', $this->render($widget)); - } - - public function testSingleSeededDefaultOpensAtItsDirectory(): void { - $widget = new FilePickerWidget($this->root, $this->root . '/src/readme.md'); - - $this->assertSame($this->root . '/src/readme.md', $widget->value()); - // The breadcrumb reflects the opened sub-directory. - $this->assertStringContainsString('root/src', $this->render($widget)); - } - - public function testSeedIgnoredWhenOutsideStart(): void { - $widget = new FilePickerWidget($this->root, '/somewhere/else.txt'); - - // A default outside the start directory is ignored; the browser opens at - // the start. - $this->assertSame($this->root . '/docs', $widget->value()); - } - - public function testEmptyDirectory(): void { - $widget = new FilePickerWidget($this->root); - - // Highlight and descend into the empty directory. - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame($this->root . '/empty', $widget->value()); - - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame('', $widget->value()); - $this->assertStringContainsString('(empty)', $this->render($widget)); - - // Moving, descending and accepting in an empty directory are all no-ops. - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Right)); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('', $widget->value()); - } - - public function testCancel(): void { - $widget = new FilePickerWidget($this->root); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertNull($value); - $this->assertTrue($widget->isCancelled()); - } - - public function testAsciiRendering(): void { - $widget = new FilePickerWidget($this->root); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - $view = $widget->view($theme); - - // The cursor row carries the ASCII marker; directories carry a slash. - $this->assertStringContainsString('> docs/', $view); - $this->assertStringContainsString('src/', $view); - } - - public function testMultipleAsciiCheckboxes(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - $this->assertStringContainsString('[ ] docs/', $widget->view($theme)); - - $widget->handle(Key::named(KeyName::Space)); - $this->assertStringContainsString('[x] docs/', $widget->view($theme)); - } - - public function testHintsRenderPerMode(): void { - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - // A single picker binds no toggle key, so that fragment drops and Accept - // reads "select"; the browse and hidden fragments are always present. - $single = Ansi::strip($theme->renderHints(KeyMapManager::create()->forField(FieldType::FilePicker), ...(new FilePickerWidget($this->root))->hints())); - $this->assertStringNotContainsString('space select', $single); - $this->assertStringContainsString('open', $single); - $this->assertStringContainsString('tab hidden', $single); - - // Multiple mode leads with the toggle key and Accept reads "accept". - $multiple = Ansi::strip($theme->renderHints(KeyMapManager::create()->forField(FieldType::FilePicker, TRUE), ...(new FilePickerWidget($this->root, multiple: TRUE))->hints())); - $this->assertStringContainsString('space select', $multiple); - $this->assertStringContainsString('accept', $multiple); - } - - public function testScrollsLargeDirectory(): void { - $files = []; - foreach (range(0, 29) as $index) { - $files[sprintf('file%02d.txt', $index)] = ''; - } - vfsStream::setup('big', NULL, $files); - $widget = new FilePickerWidget(vfsStream::url('big')); - $theme = new DefaultTheme(76, ['color' => FALSE]); - - $top = $widget->view($theme); - $this->assertStringContainsString('file00.txt', $top); - $this->assertStringNotContainsString('file29.txt', $top); - // A window that clips below shows the down indicator only. - $this->assertStringContainsString('▼', $top); - $this->assertStringNotContainsString('▲', $top); - - foreach (range(1, 29) as $ignored) { - $widget->handle(Key::named(KeyName::Down)); - } - - $bottom = $widget->view($theme); - $this->assertStringContainsString('file29.txt', $bottom); - $this->assertStringNotContainsString('file00.txt', $bottom); - $this->assertStringContainsString('▲', $bottom); - } - - public function testValueReflectsHighlightBeforeAccept(): void { - $widget = new FilePickerWidget($this->root); - - // Before acceptance the value tracks the highlighted entry. - $this->assertSame($this->root . '/docs', $widget->value()); - - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame($this->root . '/empty', $widget->value()); - - // Moving back up restores the earlier highlight. - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame($this->root . '/docs', $widget->value()); - } - - public function testDefaultsToWorkingDirectoryWhenStartEmpty(): void { - $widget = new class($this->root . '/docs') extends FilePickerWidget { - - public function __construct(protected string $directory) { - parent::__construct(''); - } - - #[\Override] - protected function currentDirectory(): string { - return $this->directory; - } - - }; - - // With no start the browser roots at the current working directory, so - // the breadcrumb is its basename and its entries are listed. - $view = $this->render($widget); - $this->assertStringContainsString('docs', $view); - $this->assertStringContainsString('guide.md', $view); - } - - public function testMultipleRejectsBelowMinWithInlineError(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(2)); - - // Selecting one entry is below the minimum of two. - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Select at least 2 items.', $this->render($widget)); - } - - public function testMultipleAcceptsWithinBounds(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(1, 2)); - - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($widget->isComplete()); - $this->assertSame([$this->root . '/docs'], $widget->value()); - } - - public function testMultipleSelectionHintShownBelowEntries(): void { - $widget = new FilePickerWidget($this->root, multiple: TRUE, selection_bounds: new SelectionBounds(2, 3)); - - // The active limit is surfaced, capitalized, below the entries. - $this->assertStringContainsString('Select between 2 and 3 items.', $this->render($widget)); - } - - public function testRejectsOversizeFileWithInlineError(): void { - vfsStream::setup('sized', NULL, ['big.txt' => str_repeat('a', 200), 'tiny.txt' => str_repeat('a', 10)]); - $root = vfsStream::url('sized'); - $widget = new FilePickerWidget($root, constraints: new FilePickerConstraints(maxSize: 100)); - - // big.txt (200 bytes) is highlighted first and exceeds the 100-byte limit. - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Choose a file no larger than 100 B.', $this->render($widget)); - } - - public function testAcceptsFileWithinSizeLimit(): void { - vfsStream::setup('sized', NULL, ['tiny.txt' => str_repeat('a', 10)]); - $root = vfsStream::url('sized'); - $widget = new FilePickerWidget($root, constraints: new FilePickerConstraints(maxSize: 100)); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame($root . '/tiny.txt', $value); - $this->assertTrue($widget->isComplete()); - } - - public function testConstraintHintShownBelowEntries(): void { - $widget = new FilePickerWidget($this->root, constraints: new FilePickerConstraints(FilePickerMode::File, ['md'], 2097152)); - - // The active limits are surfaced below the entries as a hint. - $this->assertStringContainsString('Files only. Extensions: md. Max 2 MB.', $this->render($widget)); - } - - public function testConstraintHintGivesWayToInlineError(): void { - vfsStream::setup('sized', NULL, ['big.txt' => str_repeat('a', 200)]); - $root = vfsStream::url('sized'); - $widget = new FilePickerWidget($root, constraints: new FilePickerConstraints(maxSize: 100)); - - $widget->handle(Key::named(KeyName::Enter)); - - $view = $this->render($widget); - // The inline error replaces the persistent hint so the two never stack. - $this->assertStringContainsString('Choose a file no larger than 100 B.', $view); - $this->assertStringNotContainsString('Max 100 B.', $view); - } - - /** - * Render a widget's view with the default theme, stripped of ANSI codes. - * - * @param \DrevOps\Tui\Widget\FilePickerWidget $widget - * The widget. - * - * @return string - * The plain-text view. - */ - protected function render(FilePickerWidget $widget): string { - return Ansi::strip($widget->view(new DefaultTheme())); - } - -} diff --git a/tests/phpunit/Unit/Widget/NumberWidgetTest.php b/tests/phpunit/Unit/Widget/NumberWidgetTest.php deleted file mode 100644 index 8f370ad6..00000000 --- a/tests/phpunit/Unit/Widget/NumberWidgetTest.php +++ /dev/null @@ -1,201 +0,0 @@ -assertSame(8080, $value); - $this->assertTrue($widget->isComplete()); - } - - public function testRejectsNonDigits(): void { - $widget = new NumberWidget(); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('4a2 x!', Key::named(KeyName::Enter))); - - $this->assertSame(42, $value); - } - - public function testLeadingMinusOnly(): void { - $widget = new NumberWidget(); - - $widget->handle(Key::char('-')); - $widget->handle(Key::char('7')); - // A second minus, no longer at the start, is ignored. - $widget->handle(Key::char('-')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertSame(-7, $widget->value()); - } - - public function testMinusRejectedMidBuffer(): void { - $widget = new NumberWidget('12'); - - $widget->handle(Key::named(KeyName::Left)); - $widget->handle(Key::named(KeyName::Left)); - // The cursor is at the start, but a minus cannot join an existing one. - $widget->handle(Key::char('-')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertSame(-12, $widget->value()); - } - - public function testEmptyBufferAcceptsZero(): void { - $widget = new NumberWidget(); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(0, $value); - } - - public function testSeededFromCurrentAndRendersCaret(): void { - $widget = new NumberWidget('42'); - - $this->assertStringContainsString('42', $widget->view(new DefaultTheme())); - $this->assertStringContainsString('█', $widget->view(new DefaultTheme())); - } - - public function testArrowsInertAndUnhintedWithoutBounds(): void { - $widget = new NumberWidget('5'); - - // With no bounds the arrows fall through to the inert text handling. - $widget->handle(Key::named(KeyName::Up)); - $widget->handle(Key::named(KeyName::Down)); - - $this->assertSame(5, $widget->value()); - - // Without bounds it contributes only the shared accept/cancel hints. - $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); - $this->assertSame(['accept', 'cancel'], $labels); - } - - public function testStepByInertWithoutBounds(): void { - $widget = new NumberWidget('5'); - - $widget->stepBy(1); - - $this->assertSame(5, $widget->value()); - } - - public function testCancel(): void { - $widget = new NumberWidget('5'); - - WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - } - - public function testUpDownStepByOneWithinBounds(): void { - $widget = new NumberWidget('5', bounds: new NumberBounds(0, 10)); - - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame(6, $widget->value()); - - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame(5, $widget->value()); - } - - public function testStepClampsToMax(): void { - $widget = new NumberWidget('9', bounds: new NumberBounds(0, 10, 3)); - - $widget->handle(Key::named(KeyName::Up)); - - $this->assertSame(10, $widget->value()); - } - - public function testStepClampsToMin(): void { - $widget = new NumberWidget('1', bounds: new NumberBounds(0, 10, 3)); - - $widget->handle(Key::named(KeyName::Down)); - - $this->assertSame(0, $widget->value()); - } - - public function testAcceptsInRangeValue(): void { - $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('5', Key::named(KeyName::Enter))); - - $this->assertSame(5, $value); - $this->assertTrue($widget->isComplete()); - } - - public function testRejectsOutOfRangeInline(): void { - $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); - - $widget->handle(Key::char('5')); - $widget->handle(Key::char('0')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Enter a number between 1 and 10.', $widget->view(new DefaultTheme())); - } - - public function testSteppingClearsStaleError(): void { - $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); - - $widget->handle(Key::char('5')); - $widget->handle(Key::char('0')); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertStringContainsString('Enter a number', $widget->view(new DefaultTheme())); - - // Stepping produces a clamped, in-range value, so the error clears. - $widget->handle(Key::named(KeyName::Up)); - - $this->assertSame(10, $widget->value()); - $this->assertStringNotContainsString('Enter a number', $widget->view(new DefaultTheme())); - } - - public function testHintsWhenBounded(): void { - $widget = new NumberWidget('5', bounds: new NumberBounds(0, 10)); - - $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], $widget->hints()); - - $this->assertSame([ - ['adjust', [Action::Increment, Action::Decrement]], - ['accept', [Action::Accept]], - ['cancel', [Action::Cancel]], - ], $hints); - } - - public function testPlaceholderGhostsAnEmptyBufferOnly(): void { - $widget = (new NumberWidget())->setPlaceholder('E.g. 1200'); - - $this->assertStringContainsString('E.g. 1200', $widget->view(new DefaultTheme())); - - $widget->handle(Key::char('4')); - - $this->assertStringNotContainsString('E.g. 1200', $widget->view(new DefaultTheme())); - } - -} diff --git a/tests/phpunit/Unit/Widget/PasswordWidgetTest.php b/tests/phpunit/Unit/Widget/PasswordWidgetTest.php deleted file mode 100644 index ec312066..00000000 --- a/tests/phpunit/Unit/Widget/PasswordWidgetTest.php +++ /dev/null @@ -1,223 +0,0 @@ -assertSame('s3cret', $value); - } - - public function testMaskedViewCountsCharactersNotBytes(): void { - $widget = new PasswordWidget('éé'); - - // Two characters mask as exactly two glyphs, whatever their byte length. - $this->assertSame('**|', $widget->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))); - } - - public function testViewMasksEveryCharacter(): void { - $widget = new PasswordWidget('abc'); - - $view = $widget->view(new DefaultTheme()); - - $this->assertStringNotContainsString('abc', $view); - $this->assertStringNotContainsString('a', $view); - $this->assertSame(3, substr_count($view, '•')); - $this->assertStringContainsString('█', $view); - } - - public function testValidationErrorShownUnderMask(): void { - $widget = (new PasswordWidget(''))->setHandlers(validate: fn(mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'Required.'); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Required.', $widget->view(new DefaultTheme())); - } - - public function testRevealToggleCyclesDisplayModes(): void { - $widget = new PasswordWidget('abc', revealable: TRUE); - $theme = new DefaultTheme(); - - // Masked by default: one glyph per character, the value never shown. - $this->assertSame(3, substr_count($widget->view($theme), '•')); - $this->assertStringNotContainsString('abc', $widget->view($theme)); - - // Tab reveals the plaintext. - $widget->handle(Key::named(KeyName::Tab)); - $this->assertStringContainsString('abc', $widget->view($theme)); - - // Tab again hides it entirely: neither the value nor its length shows. - $widget->handle(Key::named(KeyName::Tab)); - $hidden = $widget->view($theme); - $this->assertStringNotContainsString('abc', $hidden); - $this->assertStringNotContainsString('•', $hidden); - - // Tab a third time returns to the masked default. - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame(3, substr_count($widget->view($theme), '•')); - } - - public function testToggleIgnoredWhenNotRevealable(): void { - $widget = new PasswordWidget('abc'); - $theme = new DefaultTheme(); - - $widget->handle(Key::named(KeyName::Tab)); - - // Tab neither revealed the value nor was inserted as a character. - $this->assertSame(3, substr_count($widget->view($theme), '•')); - $this->assertStringNotContainsString('abc', $widget->view($theme)); - } - - public function testCancel(): void { - $widget = new PasswordWidget('x'); - - WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - } - - public function testToggleRevealInertWhenNotRevealable(): void { - $widget = new PasswordWidget('secret'); - - $widget->toggleReveal(); - - $this->assertStringNotContainsString('secret', $widget->view(new DefaultTheme())); - } - - public function testRevealDoesNotChangeAcceptedValue(): void { - $widget = new PasswordWidget('', revealable: TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('sekret', Key::named(KeyName::Tab), Key::named(KeyName::Enter))); - - $this->assertSame('sekret', $value); - } - - public function testHintShownOnlyWhenRevealable(): void { - $revealable = array_map(static fn(Hint $hint): string => $hint->label, (new PasswordWidget('x', revealable: TRUE))->hints()); - $this->assertContains('reveal', $revealable); - - $plain = array_map(static fn(Hint $hint): string => $hint->label, (new PasswordWidget('x'))->hints()); - $this->assertNotContains('reveal', $plain); - } - - public function testConfirmAcceptsMatchingEntries(): void { - $theme = new DefaultTheme(); - $widget = new PasswordWidget('', confirm: TRUE); - - // The first Enter stashes the entry and prompts for a second pass. - $this->type($widget, 'pw'); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('re-enter to confirm', $widget->view($theme)); - - // A matching second entry accepts, with the plain value preserved. - $this->type($widget, 'pw'); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertSame('pw', $widget->value()); - } - - public function testConfirmRejectsMismatchAndRestarts(): void { - $theme = new DefaultTheme(); - $widget = new PasswordWidget('', confirm: TRUE); - - $this->type($widget, 'pw'); - $widget->handle(Key::named(KeyName::Enter)); - $this->type($widget, 'zz'); - $widget->handle(Key::named(KeyName::Enter)); - - // The mismatch is rejected with a clear message and both entries cleared. - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Passwords do not match.', $widget->view($theme)); - $this->assertStringNotContainsString('re-enter to confirm', $widget->view($theme)); - - // A fresh matching pair now accepts. - $this->type($widget, 'pw'); - $widget->handle(Key::named(KeyName::Enter)); - $this->type($widget, 'pw'); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertSame('pw', $widget->value()); - } - - public function testConfirmRevalidatesMatchedValue(): void { - $theme = new DefaultTheme(); - $widget = (new PasswordWidget('', confirm: TRUE))->setHandlers(validate: fn(mixed $value): string => 'Too weak.'); - - $this->type($widget, 'x'); - $widget->handle(Key::named(KeyName::Enter)); - $this->type($widget, 'x'); - $widget->handle(Key::named(KeyName::Enter)); - - // Entries match, but the validator still rejects the value and restarts. - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Too weak.', $widget->view($theme)); - $this->assertStringNotContainsString('re-enter to confirm', $widget->view($theme)); - } - - public function testPlaceholderGhostsAnEmptyBufferInEveryDisplayMode(): void { - $widget = (new PasswordWidget('', revealable: TRUE))->setPlaceholder('At least 12 characters'); - $theme = new DefaultTheme(); - - // An empty buffer hides nothing, so the prompt shows masked, plaintext and - // hidden alike. - $this->assertStringContainsString('At least 12 characters', $widget->view($theme)); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertStringContainsString('At least 12 characters', $widget->view($theme)); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertStringContainsString('At least 12 characters', $widget->view($theme)); - } - - public function testPlaceholderClearsOnceTheEntryIsMasked(): void { - $widget = (new PasswordWidget())->setPlaceholder('At least 12 characters'); - - $this->type($widget, 's3cret'); - - $this->assertStringNotContainsString('At least 12 characters', $widget->view(new DefaultTheme())); - } - - /** - * Type a run of printable characters into a widget. - * - * @param \DrevOps\Tui\Widget\PasswordWidget $widget - * The widget. - * @param string $text - * The characters to type. - */ - protected function type(PasswordWidget $widget, string $text): void { - foreach (str_split($text) as $char) { - $widget->handle(Key::char($char)); - } - } - -} diff --git a/tests/phpunit/Unit/Widget/PauseWidgetTest.php b/tests/phpunit/Unit/Widget/PauseWidgetTest.php deleted file mode 100644 index cea4bde9..00000000 --- a/tests/phpunit/Unit/Widget/PauseWidgetTest.php +++ /dev/null @@ -1,73 +0,0 @@ -assertTrue($value); - $this->assertTrue($widget->isComplete()); - } - - public function testSpaceAcknowledges(): void { - $widget = new PauseWidget(); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Space))); - - $this->assertTrue($value); - } - - public function testOtherKeysIgnored(): void { - $widget = new PauseWidget(); - - $widget->handle(Key::char('x')); - $widget->handle(Key::named(KeyName::Down)); - - $this->assertFalse($widget->isComplete()); - $this->assertFalse($widget->value()); - } - - public function testCancelAndView(): void { - $widget = new PauseWidget(); - - // The prompt key glyph is drawn from the live binding (Enter by default). - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('Press ', $view); - $this->assertStringContainsString('to continue', $view); - $this->assertStringContainsString('↵', $view); - - $widget->handle(Key::named(KeyName::Escape)); - $this->assertTrue($widget->isCancelled()); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new PauseWidget())->hints()); - - $this->assertSame(['continue', 'cancel'], $labels); - } - -} diff --git a/tests/phpunit/Unit/Widget/RatingWidgetTest.php b/tests/phpunit/Unit/Widget/RatingWidgetTest.php deleted file mode 100644 index 10613014..00000000 --- a/tests/phpunit/Unit/Widget/RatingWidgetTest.php +++ /dev/null @@ -1,184 +0,0 @@ -assertSame(3, $widget->value()); - $this->assertStringContainsString('●●●○○ 3/5', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testStepsAlongTheScale(): void { - $widget = new RatingWidget(3); - - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame(4, $widget->value()); - - $widget->handle(Key::named(KeyName::Left)); - $this->assertSame(3, $widget->value()); - } - - #[DataProvider('dataProviderStepKeys')] - public function testStepKeys(Key $key, int $expected): void { - $widget = new RatingWidget(3); - - $widget->handle($key); - - $this->assertSame($expected, $widget->value()); - } - - /** - * Data provider for testStepKeys(). - * - * @return \Iterator - * Each stepping key and the point it moves to from three. - */ - public static function dataProviderStepKeys(): \Iterator { - yield 'right' => [Key::named(KeyName::Right), 4]; - yield 'up' => [Key::named(KeyName::Up), 4]; - yield 'left' => [Key::named(KeyName::Left), 2]; - yield 'down' => [Key::named(KeyName::Down), 2]; - } - - #[DataProvider('dataProviderClampsAtEnds')] - public function testClampsAtEnds(int $start, int $delta, int $expected): void { - $widget = new RatingWidget($start); - - $widget->stepBy($delta); - - $this->assertSame($expected, $widget->value()); - } - - /** - * Data provider for testClampsAtEnds(). - * - * @return \Iterator - * The starting point, the step and the point it settles on. - */ - public static function dataProviderClampsAtEnds(): \Iterator { - yield 'stops at the top' => [5, 1, 5]; - yield 'stops at the bottom' => [1, -1, 1]; - yield 'a long step lands on the end' => [3, 99, 5]; - yield 'a long backward step lands on the end' => [3, -99, 1]; - } - - #[DataProvider('dataProviderSeedIsClamped')] - public function testSeedIsClamped(int $seed, int $expected): void { - $this->assertSame($expected, (new RatingWidget($seed))->value()); - } - - /** - * Data provider for testSeedIsClamped(). - * - * @return \Iterator - * The seed value and the point it is moved onto. - */ - public static function dataProviderSeedIsClamped(): \Iterator { - yield 'below the scale' => [-4, 1]; - yield 'above the scale' => [99, 5]; - yield 'on the scale' => [2, 2]; - } - - public function testDigitJumpsToPoint(): void { - $widget = new RatingWidget(1); - - $widget->handle(Key::char('4')); - $this->assertSame(4, $widget->value()); - - // A digit the scale does not reach leaves the choice alone. - $widget->handle(Key::char('9')); - $this->assertSame(4, $widget->value()); - - // Nor does a non-digit character move it. - $widget->handle(Key::char('x')); - $this->assertSame(4, $widget->value()); - } - - public function testDigitBelowScaleIsIgnored(): void { - $widget = new RatingWidget(5, 3, 8); - - $widget->handle(Key::char('1')); - - $this->assertSame(5, $widget->value()); - } - - public function testCaptionOfTheChosenPoint(): void { - $widget = new RatingWidget(1, 1, 5, [1 => 'Poor', 5 => 'Excellent']); - $theme = new DefaultTheme(); - - $this->assertStringContainsString('●○○○○ 1/5 Poor', Ansi::strip($widget->view($theme))); - - // An uncaptioned point renders the scale alone. - $widget->stepBy(1); - $this->assertStringContainsString('●●○○○ 2/5', Ansi::strip($widget->view($theme))); - $this->assertStringNotContainsString('Poor', Ansi::strip($widget->view($theme))); - } - - public function testCustomScale(): void { - $widget = new RatingWidget(3, 0, 10); - - $this->assertStringContainsString('●●●●○○○○○○○ 3/10', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testAsciiRendering(): void { - $widget = new RatingWidget(2); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - $this->assertStringContainsString('**--- 2/5', $widget->view($theme)); - } - - public function testCaptionFoldsToOneLine(): void { - $widget = new RatingWidget(1, 1, 5, [1 => "Poor\nby any measure"]); - - $this->assertStringContainsString('1/5 Poor by any measure', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testAccept(): void { - $widget = new RatingWidget(3); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter))); - - $this->assertSame(4, $value); - $this->assertTrue($widget->isComplete()); - } - - public function testCancel(): void { - $widget = new RatingWidget(3); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new RatingWidget(1))->hints()); - - $this->assertSame(['adjust', 'accept', 'cancel'], $labels); - } - -} diff --git a/tests/phpunit/Unit/Widget/ReorderWidgetTest.php b/tests/phpunit/Unit/Widget/ReorderWidgetTest.php deleted file mode 100644 index ceaa9ecc..00000000 --- a/tests/phpunit/Unit/Widget/ReorderWidgetTest.php +++ /dev/null @@ -1,284 +0,0 @@ -assertStringContainsString('Crisp and sweet.', Ansi::strip($widget->view(new DefaultTheme()))); - - $widget->handle(Key::named(KeyName::Down)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Stays crisp when kept cold.', $view); - $this->assertStringNotContainsString('Crisp and sweet.', $view); - } - - public function testNonSelectableItemShowsNoDescription(): void { - // The cursor starts on the non-selectable heading, so its description - // never renders beneath the list. - $widget = new ReorderWidget([ - new Option('', 'Group', 'group note', OptionKind::Heading), - new Option('a', 'Apple', 'Crisp and sweet.'), - ]); - - $this->assertStringNotContainsString('group note', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testEmptyReorderRendersNoDescription(): void { - // A reorder with no items must render without touching a highlighted row. - $widget = new ReorderWidget([]); - - $this->assertSame('', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testGrabAndMoveDownAccepts(): void { - $widget = new ReorderWidget(self::options()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['b', 'c', 'a'], $value); - } - - public function testNavigateThenGrabMoveUp(): void { - $widget = new ReorderWidget(self::options()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Up), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'c', 'b'], $value); - } - - public function testDefaultOrder(): void { - $widget = new ReorderWidget(self::options(), ['c', 'a']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['c', 'a', 'b'], $value); - } - - public function testDefaultCompletesAndCleans(): void { - // A partial default with an unknown ("x") and a duplicate ("b") still - // resolves to a full ranking: known values first, remainder appended. - $widget = new ReorderWidget(self::options(), ['b', 'x', 'b']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['b', 'a', 'c'], $value); - } - - public function testCancelReturnsNull(): void { - $widget = new ReorderWidget(self::options()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertNull($value); - $this->assertTrue($widget->isCancelled()); - } - - public function testGrabbedClampsAtTop(): void { - $widget = new ReorderWidget(self::options()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Up), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b', 'c'], $value); - } - - public function testGrabbedClampsAtBottom(): void { - $widget = new ReorderWidget(self::options()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b', 'c'], $value); - } - - public function testNavigationClampsAtTop(): void { - $widget = new ReorderWidget(self::options()); - - // Up at the top is a no-op; grabbing then moving down still works. - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Up), - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['b', 'a', 'c'], $value); - } - - public function testNavigationClampsAtBottom(): void { - $widget = new ReorderWidget(self::options()); - - // A third Down stays on the last row; grabbing then moving up still works. - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Up), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'c', 'b'], $value); - } - - public function testGrabTogglesOffThenNavigates(): void { - $widget = new ReorderWidget(self::options()); - - // Grab then drop: the following Down navigates rather than moving the item. - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b', 'c'], $value); - } - - public function testLiveValueReflectsMovesBeforeAccept(): void { - $widget = new ReorderWidget(self::options()); - - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Down)); - - $this->assertSame(['b', 'a', 'c'], $widget->value()); - } - - public function testViewMarkersDegradeWithUnicodeMode(): void { - $widget = new ReorderWidget(self::options()); - - $before = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('❯', $before); - $this->assertStringNotContainsString('↑↓', $before); - - $widget->handle(Key::named(KeyName::Space)); - - $grabbed = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('↑↓', $grabbed); - - $ascii = Ansi::strip($widget->view(new DefaultTheme(76, ['unicode' => FALSE]))); - $this->assertStringContainsString('^v', $ascii); - } - - public function testHints(): void { - $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], (new ReorderWidget(self::options()))->hints()); - - $this->assertSame([ - ['move', [Action::MoveUp, Action::MoveDown]], - ['grab', [Action::Grab]], - ['accept', [Action::Accept]], - ['cancel', [Action::Cancel]], - ], $hints); - } - - public function testHintsWhileHoldingItem(): void { - $widget = new ReorderWidget(self::options()); - $widget->handle(Key::named(KeyName::Space)); - - $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], $widget->hints()); - - // Holding an item swaps to reorder/drop labels and drops the accept hint - - // the form cannot be accepted while an item is held. - $this->assertSame([ - ['reorder', [Action::MoveUp, Action::MoveDown]], - ['drop', [Action::Grab]], - ['cancel', [Action::Cancel]], - ], $hints); - } - - public function testEnterDropsHeldItemInsteadOfAccepting(): void { - $widget = new ReorderWidget(self::options()); - - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Enter)); - - // Enter dropped the held item rather than accepting the form. - $this->assertFalse($widget->isComplete()); - $this->assertSame(['b', 'a', 'c'], $widget->value()); - - // A second Enter, with nothing held, accepts. - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - } - - public function testRejectsNonPositivePageSize(): void { - $this->assertRejectsNonPositivePageSize(static fn(int $size): ReorderWidget => new ReorderWidget(self::options(), page_size: $size), -3); - } - - public function testPagesLongList(): void { - $this->assertPagesAndFollowsCursor(static fn(int $size): ReorderWidget => new ReorderWidget(self::pagingOptions(), page_size: $size)); - } - - /** - * The three-item fixture used across most cases. - * - * @return array - * The value => label option map. - */ - protected static function options(): array { - return ['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry']; - } - -} diff --git a/tests/phpunit/Unit/Widget/SearchWidgetTest.php b/tests/phpunit/Unit/Widget/SearchWidgetTest.php deleted file mode 100644 index 42c4d7dd..00000000 --- a/tests/phpunit/Unit/Widget/SearchWidgetTest.php +++ /dev/null @@ -1,423 +0,0 @@ - - */ - protected array $labels = ['gha' => 'GitHub Actions', 'circleci' => 'CircleCI', 'none' => 'None']; - - /** - * The options used across the multiple-choice tests. - * - * @var array - */ - protected array $services = ['clamav' => 'ClamAV', 'redis' => 'Redis', 'solr' => 'Solr']; - - public function testShowsHighlightedOptionDescription(): void { - $widget = new SearchWidget([ - new Option('apple', 'Apple', 'Crisp and sweet.'), - new Option('banana', 'Banana', 'Rich in potassium.'), - ], 'apple'); - - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Crisp and sweet.', $view); - $this->assertStringNotContainsString('Rich in potassium.', $view); - - $widget->handle(Key::named(KeyName::Down)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Rich in potassium.', $view); - $this->assertStringNotContainsString('Crisp and sweet.', $view); - } - - public function testNoDescriptionWhenFilterMatchesNothing(): void { - $widget = new SearchWidget([new Option('apple', 'Apple', 'Crisp and sweet.')]); - - // A query that matches nothing leaves no highlighted option, so no - // description line is appended. - $widget->handle(Key::char('z')); - - $this->assertStringNotContainsString('Crisp', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testDescriptionFollowsFilteredHighlight(): void { - $widget = new SearchWidget([ - new Option('apple', 'Apple', 'Crisp and sweet.'), - new Option('banana', 'Banana', 'Rich in potassium.'), - ]); - - $widget->handle(Key::char('b')); - $widget->handle(Key::char('a')); - $widget->handle(Key::char('n')); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString('Rich in potassium.', $view); - $this->assertStringNotContainsString('Crisp and sweet.', $view); - } - - public function testFilterNarrowsAndEnterAcceptsValue(): void { - $widget = new SearchWidget($this->labels); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('circle', Key::named(KeyName::Enter))); - - $this->assertSame('circleci', $value); - } - - public function testDefaultSeedsHighlight(): void { - $widget = new SearchWidget($this->labels, 'none'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame('none', $value); - } - - public function testArrowsMoveHighlight(): void { - $widget = new SearchWidget($this->labels); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Down), Key::named(KeyName::Enter))); - - $this->assertSame('circleci', $value); - } - - public function testEnterIgnoredWhenNothingMatches(): void { - $widget = new SearchWidget($this->labels); - - $widget->handle(Key::char('z')); - $widget->handle(Key::char('z')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - - $widget->handle(Key::named(KeyName::Backspace)); - $widget->handle(Key::named(KeyName::Backspace)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($widget->isComplete()); - $this->assertSame('gha', $widget->value()); - } - - public function testBackspaceRemovesWholeMultibyteCharacter(): void { - $widget = new SearchWidget($this->labels); - - // One backspace removes the whole multibyte character, not one byte, so - // the cleared filter shows every option again instead of matching nothing. - $widget->handle(Key::char('é')); - $widget->handle(Key::named(KeyName::Backspace)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($widget->isComplete()); - $this->assertSame('gha', $widget->value()); - } - - public function testSpaceIsPartOfTheQuery(): void { - $widget = new SearchWidget($this->labels); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('hub', Key::named(KeyName::Space), Key::named(KeyName::Backspace), Key::named(KeyName::Enter))); - - $this->assertSame('gha', $value); - } - - public function testViewShowsQueryAndVisibleOptions(): void { - $widget = new SearchWidget($this->labels); - - $widget->handle(Key::char('c')); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString('c█', $view); - $this->assertStringContainsString('CircleCI', $view); - $this->assertStringNotContainsString('None', $view); - $this->assertSame('c', $widget->filter()); - } - - public function testCancel(): void { - $widget = new SearchWidget($this->labels); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - $this->assertNull($value); - } - - public function testNavigationSkipsNonSelectable(): void { - $widget = new SearchWidget($this->mixedOptions()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Enter), - )); - - $this->assertSame('d', $value); - } - - public function testUpSkipsBackOverNonSelectable(): void { - $widget = new SearchWidget($this->mixedOptions()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Up), - Key::named(KeyName::Enter), - )); - - $this->assertSame('b', $value); - } - - public function testDefaultOnDisabledFallsBackToFirstSelectable(): void { - $widget = new SearchWidget($this->mixedOptions(), 'c'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame('a', $value); - } - - public function testFilterDropsHeadingsAndSeparators(): void { - $widget = new SearchWidget($this->mixedOptions()); - - $widget->handle(Key::char('b')); - $widget->handle(Key::char('a')); - $widget->handle(Key::char('n')); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString('Banana', $view); - $this->assertStringNotContainsString('Fruits', $view); - $this->assertStringNotContainsString('Apple', $view); - $this->assertStringNotContainsString('──', $view); - } - - public function testDisabledMatchingFilterNotAccepted(): void { - $widget = new SearchWidget($this->mixedOptions()); - - $widget->handle(Key::char('e')); - $widget->handle(Key::char('r')); - $widget->handle(Key::char('r')); - $this->assertStringContainsString('Cherry (out of stock)', Ansi::strip($widget->view(new DefaultTheme()))); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - } - - public function testRendersHeadingSeparatorAndDisabled(): void { - $view = Ansi::strip((new SearchWidget($this->mixedOptions()))->view(new DefaultTheme())); - - $this->assertStringContainsString('Fruits', $view); - $this->assertStringContainsString('Cherry (out of stock)', $view); - $this->assertStringContainsString('──', $view); - } - - public function testFuzzyMatchesNonContiguousSubsequence(): void { - $widget = new SearchWidget(['gha' => 'GitHub Actions', 'gitlab' => 'GitLab CI', 'circle' => 'CircleCI']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('gha', Key::named(KeyName::Enter))); - - $this->assertSame('gha', $value); - } - - public function testRanksPrefixAheadOfLooserSubsequence(): void { - $widget = new SearchWidget(['alpha' => 'Alpha', 'beta' => 'Beta', 'palace' => 'Palace']); - - // "pa" prefixes Palace but only scatters through Alpha, so Palace ranks - // first and the cursor lands on it even though Alpha is declared earlier. - $value = WidgetRunner::run($widget, ArrayKeyStream::of('pa', Key::named(KeyName::Enter))); - - $this->assertSame('palace', $value); - } - - public function testHighlightsMatchedCharacters(): void { - $theme = new DefaultTheme(); - $widget = new SearchWidget(['palace' => 'Palace', 'alpha' => 'Alpha']); - - $widget->handle(Key::char('p')); - $widget->handle(Key::char('a')); - $view = $widget->view($theme); - - $this->assertStringContainsString($theme->highlightMatch('Pa'), $view); - $this->assertStringContainsString('Palace', Ansi::strip($view)); - } - - public function testRejectsNonPositivePageSize(): void { - $this->assertRejectsNonPositivePageSize(static fn(int $size): SearchWidget => new SearchWidget(['a' => 'A'], page_size: $size), -2); - } - - public function testPagesLongOptionList(): void { - $this->assertPagesAndFollowsCursor(static fn(int $size): SearchWidget => new SearchWidget(self::pagingOptions(), page_size: $size)); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SearchWidget($this->labels))->hints()); - - $this->assertSame(['move', 'accept', 'cancel'], $labels); - } - - public function testMultipleFilterToggleAndAccept(): void { - $widget = new SearchWidget($this->services, [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('sol', Key::named(KeyName::Space), Key::named(KeyName::Enter))); - - $this->assertSame(['solr'], $value); - } - - public function testMultipleSeededSelectionKept(): void { - $widget = new SearchWidget($this->services, ['redis'], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['redis'], $value); - } - - public function testMultipleViewShowsQueryLineAboveOptions(): void { - $widget = new SearchWidget($this->services, [], TRUE); - - $widget->handle(Key::char('r')); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString("r█\n", $view); - $this->assertStringContainsString('Redis', $view); - $this->assertStringNotContainsString('ClamAV', $view); - } - - public function testMultipleHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SearchWidget($this->services, [], TRUE))->hints()); - - $this->assertSame(['select', 'move', 'none/all', 'accept', 'cancel'], $labels); - } - - public function testMultipleSkipsNonSelectableWhenToggling(): void { - $widget = new SearchWidget($this->mixedOptions(), [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b', 'd'], $value); - } - - public function testMultipleRendersKindsBelowQueryLine(): void { - $view = Ansi::strip((new SearchWidget($this->mixedOptions(), [], TRUE))->view(new DefaultTheme())); - - $this->assertStringContainsString("█\n", $view); - $this->assertStringContainsString('Fruits', $view); - $this->assertStringContainsString('Cherry (out of stock)', $view); - $this->assertStringContainsString('──', $view); - } - - public function testMultipleFuzzyMatchesNonContiguousSubsequence(): void { - $widget = new SearchWidget(['banana' => 'Banana', 'apple' => 'Apple', 'cherry' => 'Cherry'], [], TRUE); - - // "bn" is not a substring of any label but is a subsequence of "Banana". - $value = WidgetRunner::run($widget, ArrayKeyStream::of('bn', Key::named(KeyName::Space), Key::named(KeyName::Enter))); - - $this->assertSame(['banana'], $value); - } - - public function testMultipleHighlightsMatchedCharacters(): void { - $theme = new DefaultTheme(); - $widget = new SearchWidget(['banana' => 'Banana'], [], TRUE); - - $widget->handle(Key::char('b')); - $widget->handle(Key::char('n')); - $view = $widget->view($theme); - - // The non-contiguous match highlights each hit character on its own, - // leaving the intervening characters unstyled. - $this->assertStringContainsString($theme->highlightMatch('B'), $view); - $this->assertStringContainsString($theme->highlightMatch('n'), $view); - $this->assertStringContainsString('Banana', Ansi::strip($view)); - } - - public function testMultiplePagesLongOptionList(): void { - $this->assertPagesAndFollowsCursor(static fn(int $size): SearchWidget => new SearchWidget(self::pagingOptions(), [], TRUE, page_size: $size)); - } - - public function testMultipleRejectsBelowMinWithInlineError(): void { - $widget = new SearchWidget($this->services, [], TRUE, selection_bounds: new SelectionBounds(2)); - - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Select at least 2 items.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testMultipleAcceptsWithinBounds(): void { - $widget = new SearchWidget($this->services, [], TRUE, selection_bounds: new SelectionBounds(1, 2)); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['clamav'], $value); - $this->assertTrue($widget->isComplete()); - } - - public function testMultipleSelectionHintShownBelowQueryLine(): void { - $widget = new SearchWidget($this->services, [], TRUE, selection_bounds: new SelectionBounds(2, 3)); - - // The active limit is surfaced before it is reached. - $this->assertStringContainsString('Select between 2 and 3 items.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testPlaceholderGhostsAnEmptyQueryOnly(): void { - $widget = (new SearchWidget($this->services))->setPlaceholder('Type to filter'); - $theme = new DefaultTheme(); - - $this->assertStringContainsString('Type to filter', Ansi::strip($widget->view($theme))); - - $widget->handle(Key::char('c')); - - $this->assertStringNotContainsString('Type to filter', Ansi::strip($widget->view($theme))); - } - -} diff --git a/tests/phpunit/Unit/Widget/SelectWidgetTest.php b/tests/phpunit/Unit/Widget/SelectWidgetTest.php deleted file mode 100644 index b1062aed..00000000 --- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php +++ /dev/null @@ -1,489 +0,0 @@ - 'Apple', 'b' => 'Banana', 'c' => 'Cherry'], 'a'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Up), - Key::named(KeyName::Enter), - )); - - $this->assertSame('b', $value); - $this->assertStringContainsString('●', $widget->view(new DefaultTheme())); - } - - public function testDefaultHighlight(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], 'b'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame('b', $value); - } - - public function testBoundsClamp(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B']); - - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame('a', $widget->value()); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame('b', $widget->value()); - } - - public function testValidatorErrorShownInView(): void { - $widget = (new SelectWidget(['a' => 'A', 'b' => 'B'], 'a'))->setHandlers(validate: static fn (mixed $value): string => 'Not allowed.'); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Not allowed.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testCancel(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B']); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testSetKeysInjectsBindings(): void { - // An injected scope map takes over from the lazy default: the vim select - // scope binds j to move-down, which the default preset does not. - $widget = (new SelectWidget(['a' => 'A', 'b' => 'B'], 'a')) - ->setKeys(KeyMapManager::create('vim')->forField(FieldType::Select)); - - $widget->handle(Key::char('j')); - - $this->assertSame('b', $widget->value()); - } - - public function testNavigationSkipsHeadingsSeparatorsAndDisabled(): void { - $widget = new SelectWidget($this->mixedOptions()); - - // From Apple (0): Down skips the heading to Banana (2); Down skips the - // separator and the disabled Cherry to Date (5). - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Enter), - )); - - $this->assertSame('d', $value); - } - - public function testUpSkipsBackOverDisabled(): void { - $widget = new SelectWidget($this->mixedOptions()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Down), - Key::named(KeyName::Up), - Key::named(KeyName::Enter), - )); - - $this->assertSame('b', $value); - } - - public function testDefaultOnDisabledFallsBackToFirstSelectable(): void { - $widget = new SelectWidget($this->mixedOptions(), 'c'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame('a', $value); - } - - public function testRendersHeadingSeparatorAndDisabledReason(): void { - $view = Ansi::strip((new SelectWidget($this->mixedOptions()))->view(new DefaultTheme())); - - $this->assertStringContainsString('Fruits', $view); - $this->assertStringContainsString('Cherry (out of stock)', $view); - $this->assertStringContainsString('──', $view); - } - - public function testShowsHighlightedOptionDescription(): void { - $widget = new SelectWidget([ - new Option('apple', 'Apple', 'Crisp and sweet.'), - new Option('banana', 'Banana', 'Rich in potassium.'), - ], 'apple'); - - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Crisp and sweet.', $view); - $this->assertStringNotContainsString('Rich in potassium.', $view); - - $widget->handle(Key::named(KeyName::Down)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Rich in potassium.', $view); - $this->assertStringNotContainsString('Crisp and sweet.', $view); - } - - public function testOmitsDescriptionWhenHighlightedOptionHasNone(): void { - $widget = new SelectWidget([ - new Option('apple', 'Apple', 'Crisp and sweet.'), - new Option('banana', 'Banana'), - ], 'banana'); - - // The highlighted Banana has no description, and Apple's never leaks in. - $this->assertSame("○ Apple\n● Banana", Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testMultipleShowsCursorOptionDescription(): void { - $widget = new SelectWidget([ - new Option('apple', 'Apple', 'Crisp and sweet.'), - new Option('banana', 'Banana', 'Rich in potassium.'), - ], [], TRUE); - - $this->assertStringContainsString('Crisp and sweet.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testWrapsDescriptionToContentWidth(): void { - $widget = new SelectWidget([ - new Option('apple', 'Apple', 'Crisp and sweet and best eaten fresh from the tree.'), - ], 'apple'); - - $theme = new DefaultTheme(24); - $lines = explode("\n", Ansi::strip($widget->view($theme))); - - // The option row plus at least two wrapped description lines, each fitting. - $this->assertGreaterThan(2, count($lines)); - foreach (array_slice($lines, 1) as $line) { - $this->assertLessThanOrEqual($theme->contentWidth(), mb_strlen($line)); - } - } - - public function testOmitsDescriptionWhenPanelTooNarrow(): void { - $widget = new SelectWidget([new Option('apple', 'Apple', 'Crisp and sweet.')], 'apple'); - - $this->assertStringNotContainsString('Crisp', Ansi::strip($widget->view(new DefaultTheme(6)))); - } - - public function testNonSelectableRowDescriptionNeverShows(): void { - // With no selectable option the cursor parks on the heading; its - // description must not render as an option description. - $widget = new SelectWidget([new Option('', 'Fruit', 'group note', OptionKind::Heading)]); - - $this->assertStringNotContainsString('group note', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testNoSelectableRowYieldsNoValue(): void { - $widget = new SelectWidget([new Option('', 'Group', '', OptionKind::Heading)]); - - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertSame('', $widget->value()); - } - - public function testRejectsNonPositivePageSize(): void { - $this->assertRejectsNonPositivePageSize(static fn(int $size): SelectWidget => new SelectWidget(['a' => 'A'], page_size: $size), 0); - } - - public function testPagesLongOptionList(): void { - $this->assertPagesAndFollowsCursor(static fn(int $size): SelectWidget => new SelectWidget(self::pagingOptions(), page_size: $size)); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SelectWidget(['a' => 'A']))->hints()); - - $this->assertSame(['move', 'accept', 'cancel'], $labels); - } - - public function testMultipleToggleAndAccept(): void { - $widget = new SelectWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry'], [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b'], $value); - } - - public function testMultipleDefaultSelected(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], ['b'], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['b'], $value); - } - - public function testMultipleFilterNarrowsThenToggles(): void { - $widget = new SelectWidget(['apple' => 'Apple', 'apricot' => 'Apricot', 'banana' => 'Banana'], [], TRUE); - - $widget->handle(Key::char('b')); - $widget->handle(Key::char('a')); - $widget->handle(Key::char('n')); - $this->assertStringContainsString('Banana', $widget->view(new DefaultTheme())); - $this->assertStringNotContainsString('Apple', $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Space)); - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['banana'], $value); - } - - public function testMultipleFilterBackspaceRestoresList(): void { - $widget = new SelectWidget(['apple' => 'Apple', 'banana' => 'Banana'], [], TRUE); - - $widget->handle(Key::char('b')); - $this->assertStringNotContainsString('Apple', $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Backspace)); - $this->assertStringContainsString('Apple', $widget->view(new DefaultTheme())); - } - - public function testMultipleSelectAllAndNone(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE); - - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame(['a', 'b', 'c'], $widget->value()); - - $widget->handle(Key::named(KeyName::Left)); - $this->assertSame([], $widget->value()); - } - - public function testMultipleCancel(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], [], TRUE); - - WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - } - - public function testMultipleUpMovesCursorBack(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Up), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a'], $value); - } - - public function testMultipleToggleOffDeselects(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], ['b'], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame([], $value); - } - - public function testMultipleToggleWithNoMatchesIsNoop(): void { - $widget = new SelectWidget(['a' => 'Apple'], [], TRUE); - - $widget->handle(Key::char('z')); - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame([], $value); - } - - public function testMultipleHints(): void { - $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], (new SelectWidget(['a' => 'A'], [], TRUE))->hints()); - - $this->assertSame([ - ['select', [Action::Toggle]], - ['move', [Action::MoveUp, Action::MoveDown]], - ['none/all', [Action::SelectNone, Action::SelectAll]], - ['accept', [Action::Accept]], - ['cancel', [Action::Cancel]], - ], $hints); - } - - public function testMultipleSpaceSkipsDisabledAndTogglesSelectable(): void { - $widget = new SelectWidget($this->mixedOptions(), [], TRUE); - - // Toggle Apple, skip the heading to Banana and toggle it, skip the - // separator and the disabled Cherry to Date and toggle it. - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Down), - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a', 'b', 'd'], $value); - } - - public function testMultipleSelectAllSkipsDisabled(): void { - $widget = new SelectWidget($this->mixedOptions(), [], TRUE); - - $widget->handle(Key::named(KeyName::Right)); - - $this->assertSame(['a', 'b', 'd'], $widget->value()); - } - - public function testMultipleDefaultExcludesDisabled(): void { - $widget = new SelectWidget($this->mixedOptions(), ['c', 'a'], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame(['a'], $value); - } - - public function testMultipleFilterDropsHeadingsAndSeparators(): void { - $widget = new SelectWidget($this->mixedOptions(), [], TRUE); - - $widget->handle(Key::char('b')); - $widget->handle(Key::char('a')); - $widget->handle(Key::char('n')); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - $this->assertStringContainsString('Banana', $view); - $this->assertStringNotContainsString('Fruits', $view); - $this->assertStringNotContainsString('Apple', $view); - $this->assertStringNotContainsString('──', $view); - } - - public function testMultipleDisabledMatchingFilterIsShownButNotToggleable(): void { - $widget = new SelectWidget($this->mixedOptions(), [], TRUE); - - $widget->handle(Key::char('e')); - $widget->handle(Key::char('r')); - $widget->handle(Key::char('r')); - $this->assertStringContainsString('Cherry (out of stock)', Ansi::strip($widget->view(new DefaultTheme()))); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame([], $value); - } - - public function testMultipleRendersHeadingSeparatorAndDisabled(): void { - $view = Ansi::strip((new SelectWidget($this->mixedOptions(), [], TRUE))->view(new DefaultTheme())); - - $this->assertStringContainsString('Fruits', $view); - $this->assertStringContainsString('Cherry (out of stock)', $view); - $this->assertStringContainsString('──', $view); - } - - public function testMultipleFilterStaysSubstringNotFuzzy(): void { - $widget = new SelectWidget(['banana' => 'Banana', 'apple' => 'Apple'], [], TRUE); - - // "bn" is a subsequence of "Banana" but not a substring, so the checkbox - // list - which stays substring-only - narrows it away. - $widget->handle(Key::char('b')); - $widget->handle(Key::char('n')); - - $this->assertStringNotContainsString('Banana', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testMultipleRejectsNonPositivePageSize(): void { - $this->assertRejectsNonPositivePageSize(static fn(int $size): SelectWidget => new SelectWidget(['a' => 'A'], [], TRUE, page_size: $size), -3); - } - - public function testMultiplePagesLongOptionList(): void { - $this->assertPagesAndFollowsCursor(static fn(int $size): SelectWidget => new SelectWidget(self::pagingOptions(), [], TRUE, page_size: $size)); - } - - public function testMultipleRejectsBelowMinWithInlineError(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE, selection_bounds: new SelectionBounds(2)); - - // One selection is below the minimum of two, so the accept is rejected. - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Select at least 2 items.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testMultipleRejectsAboveMaxWithInlineError(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], [], TRUE, selection_bounds: new SelectionBounds(NULL, 1)); - - // Two selections exceed the maximum of one. - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Select at most 1 item.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testMultipleAcceptsWithinBounds(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B', 'c' => 'C'], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of( - Key::named(KeyName::Space), - Key::named(KeyName::Enter), - )); - - $this->assertSame(['a'], $value); - $this->assertTrue($widget->isComplete()); - } - - public function testMultipleSelectionHintShownInView(): void { - $widget = new SelectWidget(['a' => 'A', 'b' => 'B'], [], TRUE, selection_bounds: new SelectionBounds(1, 2)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - - // The active limit is surfaced, capitalized, below the option list. - $this->assertStringContainsString('Select between 1 and 2 items.', $view); - $this->assertGreaterThan(strpos($view, 'B'), strpos($view, 'Select between 1 and 2 items.')); - } - -} diff --git a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php deleted file mode 100644 index 70d56c6d..00000000 --- a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php +++ /dev/null @@ -1,397 +0,0 @@ -assertSame('UTC', $value); - } - - public function testNarrowsAndSelectsSuggestion(): void { - $widget = new SuggestWidget(['UTC', 'Europe/London', 'Australia/Sydney']); - - $widget->handle(Key::char('l')); - $widget->handle(Key::char('o')); - $widget->handle(Key::char('n')); - $this->assertStringContainsString('Europe/London', Ansi::strip($widget->view(new DefaultTheme()))); - $this->assertStringNotContainsString('Australia/Sydney', Ansi::strip($widget->view(new DefaultTheme()))); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Down), Key::named(KeyName::Enter))); - - $this->assertSame('Europe/London', $value); - } - - public function testEmptyBufferListsAll(): void { - $widget = new SuggestWidget(['x', 'y']); - - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame('x', $widget->value()); - $this->assertStringContainsString('y', $widget->view(new DefaultTheme())); - } - - public function testBackspaceAndUpResetHighlight(): void { - $widget = new SuggestWidget(['abc', 'abd']); - - $widget->handle(Key::char('a')); - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame('abc', $widget->value()); - - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame('a', $widget->value()); - - $widget->handle(Key::char('b')); - $widget->handle(Key::named(KeyName::Backspace)); - $this->assertSame('a', $widget->value()); - } - - public function testBufferExposesTheLiveQuery(): void { - $widget = new SuggestWidget(['alpha']); - - $widget->handle(Key::char('a')); - - $this->assertSame('a', $widget->buffer()); - } - - public function testCancel(): void { - $widget = new SuggestWidget(['x', 'y']); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testSpaceAppendsToBuffer(): void { - $widget = new SuggestWidget(['x', 'y']); - - $widget->handle(Key::char('a')); - $widget->handle(Key::named(KeyName::Space)); - - $this->assertSame('a ', $widget->value()); - } - - public function testFuzzyMatchesNonContiguousSubsequence(): void { - $widget = new SuggestWidget(['GitHub Actions', 'GitLab CI', 'CircleCI']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('gha', Key::named(KeyName::Down), Key::named(KeyName::Enter))); - - $this->assertSame('GitHub Actions', $value); - } - - public function testRanksPrefixAheadOfLooserSubsequence(): void { - $widget = new SuggestWidget(['Alpha', 'Beta', 'Palace']); - - // "pa" is a prefix of Palace but only a scattered subsequence of Alpha, so - // Palace ranks first and the first Down lands on it. - $widget->handle(Key::char('p')); - $widget->handle(Key::char('a')); - $widget->handle(Key::named(KeyName::Down)); - - $this->assertSame('Palace', $widget->value()); - } - - public function testHighlightsMatchedCharacters(): void { - $theme = new DefaultTheme(); - $widget = new SuggestWidget(['Alpha', 'Beta', 'Palace']); - - $widget->handle(Key::char('p')); - $widget->handle(Key::char('a')); - $view = $widget->view($theme); - - // The matched "Pa" prefix is themed as a match run; the label is intact - // once the styling is stripped. - $this->assertStringContainsString($theme->highlightMatch('Pa'), $view); - $this->assertStringContainsString('Palace', Ansi::strip($view)); - } - - public function testShowsHighlightedSuggestionDescription(): void { - $widget = new SuggestWidget(['apple', 'apricot'], '', NULL, ['apple' => 'Crisp and sweet.', 'apricot' => 'Small and tart.']); - - // With nothing highlighted yet (cursor detached), no description shows. - $this->assertStringNotContainsString('Crisp and sweet.', Ansi::strip($widget->view(new DefaultTheme()))); - - $widget->handle(Key::named(KeyName::Down)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Crisp and sweet.', $view); - $this->assertStringNotContainsString('Small and tart.', $view); - - $widget->handle(Key::named(KeyName::Down)); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Small and tart.', $view); - $this->assertStringNotContainsString('Crisp and sweet.', $view); - } - - public function testOmitsDescriptionForSuggestionWithoutEntry(): void { - $widget = new SuggestWidget(['apple', 'pear'], '', NULL, ['apple' => 'Crisp and sweet.']); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - - // The highlighted Pear has no description entry, so nothing is appended. - $this->assertStringNotContainsString('Crisp', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testRejectsNonPositivePageSize(): void { - $this->assertRejectsNonPositivePageSize(static fn(int $size): SuggestWidget => new SuggestWidget(['x'], page_size: $size), 0); - } - - public function testPagesLongSuggestionList(): void { - // The highlight starts detached (-1), so three Downs reach the third item. - $this->assertPagesAndFollowsCursor(static fn(int $size): SuggestWidget => new SuggestWidget(array_values(self::pagingOptions()), page_size: $size), 3); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SuggestWidget(['UTC', 'GMT']))->hints()); - - $this->assertSame(['move', 'accept', 'cancel'], $labels); - } - - public function testPlaceholderGhostsAnEmptyQueryOnly(): void { - $widget = (new SuggestWidget(['Pear', 'Plum']))->setPlaceholder('Type to filter'); - $theme = new DefaultTheme(); - - $this->assertStringContainsString('Type to filter', Ansi::strip($widget->view($theme))); - - $widget->handle(Key::char('P')); - - $this->assertStringNotContainsString('Type to filter', Ansi::strip($widget->view($theme))); - } - - public function testPlaceholderNeverCompetesWithGhostText(): void { - $widget = (new SuggestWidget(['Apple'], '', NULL, [], TRUE))->setPlaceholder('Type to filter'); - $theme = new DefaultTheme(); - - // Both occupy the one slot after the caret, but a completion needs a typed - // query and a placeholder an empty one, so the slot is never contested. - $this->assertStringContainsString('Type to filter', Ansi::strip($widget->queryLine($theme))); - - $widget->handle(Key::char('a')); - $line = Ansi::strip($widget->queryLine($theme)); - $this->assertStringContainsString('pple', $line); - $this->assertStringNotContainsString('Type to filter', $line); - } - - public function testGhostTextIsOptIn(): void { - $widget = new SuggestWidget(['Apple', 'Apricot']); - - $widget->handle(Key::char('a')); - - // Without the opt-in the query line carries no dimmed suffix, and the keys - // that would accept one are inert. - $view = $widget->view(new DefaultTheme()); - $this->assertStringNotContainsString("\033[90m", $view); - - $widget->handle(Key::named(KeyName::Tab)); - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame('a', $widget->value()); - } - - public function testGhostTextRendersDimmedSuffix(): void { - $widget = new SuggestWidget(['Apple', 'Apricot'], '', NULL, [], TRUE); - - $widget->handle(Key::char('a')); - $widget->handle(Key::char('p')); - - // The leading candidate's remainder is previewed dimmed (SGR 90) after the - // caret, while the value stays the typed query until it is accepted. - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('ple', $view); - $this->assertStringContainsString("\033[90m", $view); - $this->assertSame('ap', $widget->value()); - } - - public function testTabAcceptsGhostText(): void { - $widget = new SuggestWidget(['Apple', 'Apricot'], '', NULL, [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('ap', Key::named(KeyName::Tab), Key::named(KeyName::Enter))); - - // Accepting adopts the candidate's own casing. - $this->assertSame('Apple', $value); - } - - public function testRightAcceptsGhostText(): void { - $widget = new SuggestWidget(['Apple', 'Apricot'], '', NULL, [], TRUE); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('ap', Key::named(KeyName::Right), Key::named(KeyName::Enter))); - - $this->assertSame('Apple', $value); - } - - public function testAcceptingGhostTextKeepsTheListAvailable(): void { - $widget = new SuggestWidget(['Apple', 'Apple pie', 'Apricot'], '', NULL, [], TRUE); - - $widget->handle(Key::char('a')); - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('Apple', $widget->value()); - - // The completion re-queries rather than selecting: the narrowed list is - // still open and still arrows into. - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('Apple pie', $view); - $this->assertStringNotContainsString('Apricot', $view); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Down)); - $this->assertSame('Apple pie', $widget->value()); - } - - public function testGhostTextSuppressedWhileSuggestionHighlighted(): void { - $widget = new SuggestWidget(['Apple', 'Apricot'], '', NULL, [], TRUE); - - $widget->handle(Key::char('a')); - $this->assertStringContainsString("\033[90m", $widget->view(new DefaultTheme())); - - // Arrowing into the list makes the highlighted row the live value, so a - // preview of the typed query would contradict it. - $widget->handle(Key::named(KeyName::Down)); - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - - // Tab and Right stay inert while a row is highlighted. - $widget->handle(Key::named(KeyName::Tab)); - $widget->handle(Key::named(KeyName::Right)); - $this->assertSame('Apple', $widget->value()); - } - - public function testGhostTextSuppressedWithoutColour(): void { - $theme = new DefaultTheme(76, ['color' => FALSE]); - $widget = new SuggestWidget(['Apricot'], '', NULL, [], TRUE); - - $widget->handle(Key::char('a')); - - // Without colour the preview cannot be dimmed, so it is dropped rather than - // rendered as plain text indistinguishable from the typed query. The - // suggestion itself still lists below, and no escapes leak into the line. - $this->assertSame('a' . $theme->caret(), $widget->queryLine($theme)); - $this->assertStringNotContainsString("\033", $widget->view($theme)); - } - - public function testGhostTextCompletesPrefixesNotFuzzyMatches(): void { - $widget = new SuggestWidget(['Green apple'], '', NULL, [], TRUE); - - $widget->handle(Key::char('g')); - $widget->handle(Key::char('a')); - - // "ga" is a scattered subsequence, so the row is listed but there is no - // suffix to draw after the caret. - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('Green apple', Ansi::strip($view)); - $this->assertStringNotContainsString("\033[90m", $view); - } - - public function testFullyTypedSuggestionHasNoGhostText(): void { - $widget = new SuggestWidget(['Fig'], '', NULL, [], TRUE); - - $widget->handle(Key::char('f')); - $widget->handle(Key::char('i')); - $widget->handle(Key::char('g')); - - // The query already equals the only candidate; nothing is left to preview. - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - } - - public function testEmptyQueryShowsNoGhostText(): void { - // With nothing typed there is no prefix to complete. - $widget = new SuggestWidget(['Apple'], '', NULL, [], TRUE); - - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - } - - public function testGhostTextIsUnicodeAware(): void { - // Folding is per code point, so a non-ASCII prefix matches and the suffix - // renders whole rather than splitting mid-character. - $widget = new SuggestWidget(['Éclair'], '', NULL, [], TRUE); - - $widget->handle(Key::char('é')); - $this->assertStringContainsString('clair', $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('Éclair', $widget->value()); - } - - public function testGhostTextCompletesQuerySourcedRows(): void { - $widget = new SuggestWidget([], '', NULL, [], TRUE); - $widget->driveByQuery(); - - $widget->handle(Key::char('p')); - $widget->applyQuery('p', Option::list(['Pepper' => 'Pepper', 'Potato' => 'Potato'])); - - // A query source's rows are already the answer and are never ranked again - // locally, so the preview is simply their first prefix match. - $this->assertStringContainsString('epper', $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('Pepper', $widget->value()); - } - - public function testGhostTextSuppressedWhileQueryIsInFlight(): void { - $theme = new DefaultTheme(); - $widget = new SuggestWidget([], '', NULL, [], TRUE); - $widget->driveByQuery(); - $widget->applyQuery('', Option::list(['Apricot' => 'Apricot'])); - - $widget->handle(Key::char('a')); - $this->assertStringContainsString("\033[90m", $widget->queryLine($theme)); - - // The rows still held answer the previous query, and the list showing them - // has already given way to the loading indicator; previewing one of them - // would put back the answer being withdrawn. - $widget->beginQuery(); - $this->assertSame('a' . $theme->caret(), $widget->queryLine($theme)); - - // Once the new rows settle the preview returns, drawn from them. - $widget->applyQuery('a', Option::list(['Apple' => 'Apple'])); - $this->assertStringContainsString('pple', $widget->queryLine($theme)); - } - - public function testGhostTextBacksOffWhenTheQueryStopsMatching(): void { - $widget = new SuggestWidget(['Apple'], '', NULL, [], TRUE); - - $widget->handle(Key::char('a')); - $this->assertStringContainsString("\033[90m", $widget->view(new DefaultTheme())); - - // A typo drops every prefix candidate, so the preview disappears and Tab - // leaves the query untouched. - $widget->handle(Key::char('z')); - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('az', $widget->value()); - } - -} diff --git a/tests/phpunit/Unit/Widget/TemplateWidgetTest.php b/tests/phpunit/Unit/Widget/TemplateWidgetTest.php deleted file mode 100644 index 36e68a5e..00000000 --- a/tests/phpunit/Unit/Widget/TemplateWidgetTest.php +++ /dev/null @@ -1,244 +0,0 @@ -assertSame('one-two', $widget->value()); - } - - public function testSeedsEmptyWhenTheValueDoesNotMatch(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}'), 'nope'); - - $this->assertSame('-', $widget->value()); - } - - public function testTypingFillsTheSlotHoldingTheCaret(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}')); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('one', Key::named(KeyName::Enter))); - - $this->assertSame('one-', $value); - } - - #[DataProvider('dataProviderMovesBetweenSlots')] - public function testMovesBetweenSlots(array $keys, string $expected): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}-{{c}}')); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(...[...$keys, Key::named(KeyName::Enter)])); - - $this->assertSame($expected, $value); - } - - public static function dataProviderMovesBetweenSlots(): \Iterator { - $tab = Key::named(KeyName::Tab); - $down = Key::named(KeyName::Down); - $up = Key::named(KeyName::Up); - - yield 'tab advances' => [['x', $tab, 'y', $tab, 'z'], 'x-y-z']; - yield 'down advances like tab' => [['x', $down, 'y', $down, 'z'], 'x-y-z']; - yield 'up goes back' => [['x', $tab, 'y', $up, 'z'], 'xz-y-']; - yield 'forward wraps to the first slot' => [[$tab, $tab, $tab, 'x'], 'x--']; - yield 'back wraps to the last slot' => [[$up, 'x'], '--x']; - } - - public function testEditsTheSlotItReturnsTo(): void { - $tab = Key::named(KeyName::Tab); - $widget = new TemplateWidget(new Template('{{a}}-{{b}}'), 'one-two'); - - // Tab away and back, then delete a character: the value comes back with - // the buffer, so the edit lands on the original text and not on an empty - // slot. - $value = WidgetRunner::run($widget, ArrayKeyStream::of($tab, $tab, Key::named(KeyName::Backspace), Key::named(KeyName::Enter))); - - $this->assertSame('on-two', $value); - } - - public function testMovesTheCaretInsideTheActiveSlot(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}'), 'one-two'); - - // Left steps back inside the slot, so the inserted character lands before - // the last one rather than at the end. - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Left), 'X', Key::named(KeyName::Enter))); - - $this->assertSame('onXe-two', $value); - } - - public function testValidatesTheSlotBeingLeftWithoutHoldingTheCaret(): void { - $widget = new TemplateWidget($this->gradedTemplate()); - $theme = new DefaultTheme(); - - $widget->handle(Key::char('z')); - $widget->handle(Key::named(KeyName::Tab)); - - // The rejected slot reports its error, but the caret has still moved on. - $this->assertStringContainsString('Grade: use a single letter a-c', $widget->view($theme)); - $this->assertStringContainsString('filling in Crate', $widget->view($theme)); - } - - public function testClearsTheErrorWhenTheSlotBecomesValid(): void { - $widget = new TemplateWidget($this->gradedTemplate()); - $theme = new DefaultTheme(); - - $widget->handle(Key::char('z')); - $widget->handle(Key::named(KeyName::Tab)); - $widget->handle(Key::named(KeyName::Tab)); - $widget->handle(Key::named(KeyName::Backspace)); - $widget->handle(Key::char('b')); - $widget->handle(Key::named(KeyName::Tab)); - - $this->assertStringNotContainsString('use a single letter a-c', $widget->view($theme)); - } - - public function testAcceptRejectsAnInvalidSlotAndTakesTheCaretToIt(): void { - $widget = new TemplateWidget($this->gradedTemplate()); - $theme = new DefaultTheme(); - - // Fill the second slot, then accept while the first is still invalid. - $widget->handle(Key::named(KeyName::Tab)); - $widget->handle(Key::char('9')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Grade: use a single letter a-c', $widget->view($theme)); - $this->assertStringContainsString('filling in Grade', $widget->view($theme)); - } - - public function testAcceptsOnceEverySlotIsValid(): void { - $widget = new TemplateWidget($this->gradedTemplate()); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('a', Key::named(KeyName::Tab), '9', Key::named(KeyName::Enter))); - - $this->assertSame('a-9', $value); - } - - public function testAcceptRejectsSlotHoldingTheSeparator(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}', ['a' => 'Head'])); - $theme = new DefaultTheme(); - - // "one-x" would move the boundary, so the answer would read back as - // a="one", b="x-two" - nothing like what was typed. - WidgetRunner::run($widget, ArrayKeyStream::of('one-x', Key::named(KeyName::Tab), 'two', Key::named(KeyName::Enter))); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Head: must not contain "-".', $widget->view($theme)); - $this->assertStringContainsString('filling in Head', $widget->view($theme)); - } - - public function testAcceptAllowsTheSeparatorInTheLastSlot(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}')); - - // The last slot runs to the end of the string, so it can hold the - // separator without moving any boundary. - $value = WidgetRunner::run($widget, ArrayKeyStream::of('one', Key::named(KeyName::Tab), 'two-x', Key::named(KeyName::Enter))); - - $this->assertSame('one-two-x', $value); - } - - public function testFieldValidatorRunsAgainstTheAssembledValue(): void { - $widget = (new TemplateWidget(new Template('{{a}}-{{b}}'))) - ->setHandlers(validate: static fn(mixed $value): ?string => $value === 'one-two' ? NULL : 'Unknown crate.'); - $theme = new DefaultTheme(); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Unknown crate.', $widget->view($theme)); - } - - public function testTransformerAppliesToTheAssembledValue(): void { - $widget = (new TemplateWidget(new Template('{{a}}-{{b}}'), 'one-two')) - ->setHandlers(transform: static fn(mixed $value): string => is_string($value) ? strtoupper($value) : ''); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Enter))); - - $this->assertSame('ONE-TWO', $value); - } - - public function testCancel(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}'), 'one-two'); - - WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - } - - #[DataProvider('dataProviderRendersTheShape')] - public function testRendersTheShape(bool $unicode, string $caret): void { - $widget = new TemplateWidget(new Template('crate {{a}}-{{b}} ready', ['b' => 'Tail']), 'crate one-two ready'); - - $view = $widget->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => $unicode])); - - // The fixed text frames the filled slots, and the caret marks the live one. - $this->assertStringContainsString('crate one' . $caret . '-two ready', $view); - $this->assertStringContainsString('filling in a', $view); - } - - public static function dataProviderRendersTheShape(): \Iterator { - yield 'unicode' => [TRUE, '█']; - yield 'ascii' => [FALSE, '|']; - } - - public function testEmptySlotShowsItsLabelAsHint(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}', ['b' => 'Tail'])); - - $view = $widget->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE])); - - // The caret sits on the first slot; the empty second one names itself so - // the shape does not collapse to its fixed text alone. - $this->assertStringContainsString('|-Tail', $view); - } - - public function testFilledSlotShowsItsValueNotItsLabel(): void { - $widget = new TemplateWidget(new Template('{{a}}-{{b}}', ['b' => 'Tail']), 'one-two'); - - $view = $widget->view(new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE])); - - $this->assertStringContainsString('-two', $view); - $this->assertStringNotContainsString('Tail', $view); - } - - public function testHintLeadsWithSlotNavigation(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new TemplateWidget(new Template('{{a}}-{{b}}')))->hints()); - - $this->assertSame(['next/previous', 'accept', 'cancel'], $labels); - } - - /** - * A two-slot template whose first slot takes a single letter a-c. - * - * @return \DrevOps\Tui\Model\Template - * The template. - */ - protected function gradedTemplate(): Template { - return new Template('{{grade}}-{{crate}}', ['grade' => 'Grade', 'crate' => 'Crate'], [ - 'grade' => static fn(string $value): ?string => preg_match('/^[a-c]$/', $value) === 1 ? NULL : 'use a single letter a-c', - ]); - } - -} diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php deleted file mode 100644 index b1ee1edf..00000000 --- a/tests/phpunit/Unit/Widget/TextWidgetTest.php +++ /dev/null @@ -1,285 +0,0 @@ -assertSame('Acme', $value); - $this->assertTrue($widget->isComplete()); - } - - public function testTransformApplied(): void { - $widget = (new TextWidget(''))->setHandlers(transform: fn(mixed $value): string => is_string($value) ? strtoupper($value) : ''); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of('acme', Key::named(KeyName::Enter))); - - $this->assertSame('ACME', $value); - } - - public function testValidationBlocksThenAccepts(): void { - $validate = fn(mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'Required.'; - $widget = (new TextWidget(''))->setHandlers($validate); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('Required.', $widget->error()); - $this->assertStringContainsString('Required.', $widget->view(new DefaultTheme())); - - $widget->handle(Key::char('a')); - $widget->handle(Key::char('b')); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertTrue($widget->isComplete()); - $this->assertNull($widget->error()); - $this->assertSame('ab', $widget->value()); - } - - public function testCursorEditingAndBackspace(): void { - $widget = new TextWidget('ac'); - - $widget->handle(Key::named(KeyName::Left)); - $widget->handle(Key::char('b')); - $this->assertSame('abc', $widget->value()); - - $widget->handle(Key::named(KeyName::Backspace)); - $this->assertSame('ac', $widget->value()); - - $widget->handle(Key::named(KeyName::Right)); - $this->assertStringContainsString('█', $widget->view(new DefaultTheme())); - } - - public function testMultibyteEditingKeepsCharacterBoundaries(): void { - $widget = new TextWidget(); - - // One Backspace removes a whole multi-byte character, not one byte. - $widget->handle(Key::char('é')); - $widget->handle(Key::char('x')); - $widget->handle(Key::named(KeyName::Backspace)); - $widget->handle(Key::named(KeyName::Backspace)); - $this->assertSame('', $widget->value()); - - // Left moves over a whole character, so an insertion cannot split it. - $widget->handle(Key::char('é')); - $widget->handle(Key::named(KeyName::Left)); - $widget->handle(Key::char('a')); - $this->assertSame('aé', $widget->value()); - } - - public function testBufferExposesTheLiveInput(): void { - $widget = new TextWidget('ab'); - - $widget->handle(Key::char('c')); - - $this->assertSame('abc', $widget->buffer()); - } - - public function testCancel(): void { - $widget = new TextWidget('x'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - $this->assertNull($value); - } - - public function testSpaceInsertsSpace(): void { - $widget = new TextWidget(); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Space), Key::char('b'), Key::named(KeyName::Enter))); - - $this->assertSame('a b', $value); - } - - public function testHints(): void { - // A plain widget contributes the shared accept/cancel hints. - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new TextWidget())->hints()); - - $this->assertSame(['accept', 'cancel'], $labels); - } - - public function testGhostTextRendersDimmedSuffix(): void { - // The first candidate is skipped (no prefix match); the second completes. - $widget = new TextWidget('', ['other', 'acme-site']); - - $widget->handle(Key::char('a')); - $widget->handle(Key::char('c')); - - // The typed prefix stays put and the remaining suffix is dimmed (SGR 90). - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('me-site', $view); - $this->assertStringContainsString("\033[90m", $view); - - // The ghost is a preview: the value stays the typed text until accepted. - $this->assertSame('ac', $widget->value()); - } - - public function testTabAcceptsCompletion(): void { - $widget = new TextWidget('', ['acme-site']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Tab), Key::named(KeyName::Enter))); - - $this->assertSame('acme-site', $value); - } - - public function testRightAtEndAcceptsCompletion(): void { - $widget = new TextWidget('', ['acme-site']); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Right), Key::named(KeyName::Enter))); - - $this->assertSame('acme-site', $value); - } - - public function testRightMidBufferMovesCaretWithoutCompleting(): void { - $widget = new TextWidget('ab', ['abcdef']); - - // With the caret off the end there is no ghost, so Right advances the caret - // rather than accepting a completion. - $widget->handle(Key::named(KeyName::Left)); - $widget->handle(Key::named(KeyName::Right)); - - $this->assertSame('ab', $widget->value()); - } - - public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void { - $widget = new TextWidget('', ['GitHub']); - - $widget->handle(Key::char('g')); - $widget->handle(Key::char('i')); - $widget->handle(Key::named(KeyName::Tab)); - - // A lower-case prefix matches and accepting adopts the candidate's case. - $this->assertSame('GitHub', $widget->value()); - } - - public function testGhostTextIsUnicodeAware(): void { - // strtolower() folds only ASCII, so a non-ASCII prefix must fold with - // mbstring; the multibyte suffix must render whole, not split mid-byte. - $widget = new TextWidget('', ['Éclair']); - - $widget->handle(Key::char('é')); - $this->assertStringContainsString('clair', $widget->view(new DefaultTheme())); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('Éclair', $widget->value()); - } - - public function testNoMatchLeavesPlainField(): void { - $widget = new TextWidget('', ['acme-site']); - - $widget->handle(Key::char('z')); - - // No candidate starts with "z": no dimmed ghost, and Tab is inert. - $view = $widget->view(new DefaultTheme()); - $this->assertStringNotContainsString("\033[90m", $view); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertSame('z', $widget->value()); - } - - public function testFullyTypedCandidateHasNoGhost(): void { - $widget = new TextWidget('', ['php']); - - $widget->handle(Key::char('p')); - $widget->handle(Key::char('h')); - $widget->handle(Key::char('p')); - - // The buffer already equals the only candidate; nothing is left to ghost. - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - } - - public function testEmptyBufferShowsNoGhost(): void { - // With nothing typed there is no prefix to complete, so no ghost renders. - $widget = new TextWidget('', ['acme-site']); - - $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); - } - - public function testGhostSuppressedInNoAnsiMode(): void { - $widget = new TextWidget('', ['acme-site']); - - $widget->handle(Key::char('a')); - $widget->handle(Key::char('c')); - - // Without colour the ghost cannot be dimmed, so it is suppressed and no - // escape sequences leak into the plain-text line. - $view = $widget->view(new DefaultTheme(76, ['color' => FALSE])); - $this->assertStringNotContainsString('me-site', $view); - $this->assertStringNotContainsString("\033", $view); - } - - public function testPlaceholderGhostsAnEmptyBuffer(): void { - $widget = (new TextWidget())->setPlaceholder('E.g. Golden Beetroot'); - - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('E.g. Golden Beetroot', $view); - $this->assertStringContainsString("\033[90m", $view); - - // The placeholder is not a value: the field still reads as unanswered. - $this->assertSame('', $widget->value()); - } - - public function testPlaceholderClearsOnFirstKeystroke(): void { - $widget = (new TextWidget())->setPlaceholder('E.g. Golden Beetroot'); - - $widget->handle(Key::char('a')); - - $this->assertStringNotContainsString('E.g. Golden Beetroot', $widget->view(new DefaultTheme())); - } - - public function testPlaceholderNeverCompetesWithCompletion(): void { - $widget = (new TextWidget('', ['acme-site']))->setPlaceholder('E.g. Golden Beetroot'); - - // A completion needs a typed prefix and a placeholder needs an empty - // buffer, so the one ghost slot is never contested. - $widget->handle(Key::char('a')); - - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('cme-site', $view); - $this->assertStringNotContainsString('E.g. Golden Beetroot', $view); - } - - public function testPlaceholderSuppressedInNoAnsiMode(): void { - $widget = (new TextWidget())->setPlaceholder('E.g. Golden Beetroot'); - - // Without colour it would read as a typed value rather than as a prompt. - $this->assertStringNotContainsString('E.g. Golden Beetroot', $widget->view(new DefaultTheme(76, ['color' => FALSE]))); - } - - public function testUndeclaredPlaceholderGhostsNothing(): void { - $this->assertStringNotContainsString("\033[90m", (new TextWidget())->view(new DefaultTheme())); - } - -} diff --git a/tests/phpunit/Unit/Widget/TextareaWidgetTest.php b/tests/phpunit/Unit/Widget/TextareaWidgetTest.php deleted file mode 100644 index fd026d20..00000000 --- a/tests/phpunit/Unit/Widget/TextareaWidgetTest.php +++ /dev/null @@ -1,165 +0,0 @@ -assertSame("one\ntwo", $value); - $this->assertTrue($widget->isComplete()); - } - - public function testUpAndDownMoveAcrossLines(): void { - $widget = new TextareaWidget("ab\ncd"); - - // The cursor starts at the end of "cd"; Up keeps the column on "ab". - $widget->handle(Key::named(KeyName::Up)); - $widget->handle(Key::char('X')); - - $this->assertSame("abX\ncd", $widget->value()); - - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::char('Y')); - - $this->assertSame("abX\ncdY", $widget->value()); - } - - public function testUpClampsAtFirstLineAndDownAtLast(): void { - $widget = new TextareaWidget('solo'); - - $widget->handle(Key::named(KeyName::Up)); - $widget->handle(Key::named(KeyName::Down)); - $widget->handle(Key::named(KeyName::Tab)); - - $this->assertSame('solo', $widget->value()); - } - - public function testUpFromLongerLineClampsColumn(): void { - $widget = new TextareaWidget("a\nlonger"); - - $widget->handle(Key::named(KeyName::Up)); - $widget->handle(Key::char('Z')); - - $this->assertSame("aZ\nlonger", $widget->value()); - } - - public function testViewShowsError(): void { - $widget = (new TextareaWidget('x'))->setHandlers(validate: fn(mixed $value): string => 'Nope.'); - - $widget->handle(Key::named(KeyName::Tab)); - $this->assertStringContainsString('Nope.', $widget->view(new DefaultTheme())); - } - - public function testCancel(): void { - $widget = new TextareaWidget('x'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Escape))); - - $this->assertTrue($widget->isCancelled()); - $this->assertNull($value); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x'))->hints()); - - $this->assertSame(['newline', 'accept', 'cancel'], $labels); - } - - public function testEditorKeyRequestsHandoffWhenEnabled(): void { - $widget = new TextareaWidget('draft', externalEdit: TRUE); - - $widget->handle(Key::char("\x05")); - - $this->assertTrue($widget->wantsExternalEdit()); - // The buffer is untouched until the captured value is applied. - $this->assertSame('draft', $widget->value()); - $this->assertFalse($widget->isComplete()); - } - - public function testEditorKeySwallowedWhenDisabled(): void { - $widget = new TextareaWidget('draft'); - - $widget->handle(Key::char("\x05")); - - $this->assertFalse($widget->wantsExternalEdit()); - // The control key is swallowed, never inserted as a raw byte. - $this->assertSame('draft', $widget->value()); - } - - public function testApplyExternalEditReplacesBufferAndAccepts(): void { - $widget = new TextareaWidget('old', externalEdit: TRUE); - $widget->handle(Key::char("\x05")); - - $widget->applyExternalEdit("new\ntext"); - - $this->assertSame("new\ntext", $widget->value()); - $this->assertTrue($widget->isComplete()); - $this->assertFalse($widget->wantsExternalEdit()); - } - - public function testApplyExternalEditNullKeepsBufferAndStaysEditing(): void { - $widget = new TextareaWidget('keep', externalEdit: TRUE); - $widget->handle(Key::char("\x05")); - - $widget->applyExternalEdit(NULL); - - $this->assertSame('keep', $widget->value()); - $this->assertFalse($widget->isComplete()); - $this->assertFalse($widget->wantsExternalEdit()); - } - - public function testApplyExternalEditRunsValidator(): void { - $widget = (new TextareaWidget('x', externalEdit: TRUE))->setHandlers(validate: fn(mixed $value): string => 'Nope.'); - - $widget->applyExternalEdit('bad'); - - $this->assertFalse($widget->isComplete()); - $this->assertStringContainsString('Nope.', $widget->view(new DefaultTheme())); - } - - public function testEditorHintOnlyWhenEnabled(): void { - $enabled = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x', externalEdit: TRUE))->hints()); - $this->assertContains('editor', $enabled); - - $disabled = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x'))->hints()); - $this->assertNotContains('editor', $disabled); - } - - public function testPlaceholderGhostsAnEmptyBufferOnly(): void { - $widget = (new TextareaWidget())->setPlaceholder('E.g. Crisp and sweet'); - - $this->assertStringContainsString('E.g. Crisp and sweet', $widget->view(new DefaultTheme())); - - $widget->handle(Key::char('C')); - - $this->assertStringNotContainsString('E.g. Crisp and sweet', $widget->view(new DefaultTheme())); - } - -} diff --git a/tests/phpunit/Unit/Widget/ToggleWidgetTest.php b/tests/phpunit/Unit/Widget/ToggleWidgetTest.php deleted file mode 100644 index b00ed30e..00000000 --- a/tests/phpunit/Unit/Widget/ToggleWidgetTest.php +++ /dev/null @@ -1,146 +0,0 @@ - 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - $this->assertSame('enabled', $widget->value()); - $this->assertStringContainsString('● Enabled', Ansi::strip($widget->view(new DefaultTheme()))); - - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame('disabled', $widget->value()); - $this->assertStringContainsString('● Disabled', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testHonoursExplicitDefault(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'disabled'); - - $this->assertSame('disabled', $widget->value()); - $this->assertStringContainsString('● Disabled', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testUnknownDefaultFallsBackToFirst(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'nope'); - - $this->assertSame('enabled', $widget->value()); - } - - #[DataProvider('dataProviderFlipKeys')] - public function testFlipKeys(Key $key): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - - $widget->handle($key); - - $this->assertSame('disabled', $widget->value()); - } - - /** - * Data provider for testFlipKeys(). - * - * @return \Iterator - * Each key that flips the switch. - */ - public static function dataProviderFlipKeys(): \Iterator { - yield 'space' => [Key::named(KeyName::Space)]; - yield 'left' => [Key::named(KeyName::Left)]; - yield 'right' => [Key::named(KeyName::Right)]; - yield 'up' => [Key::named(KeyName::Up)]; - yield 'down' => [Key::named(KeyName::Down)]; - } - - public function testDirectSelectionByFirstLetter(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - - $widget->handle(Key::char('d')); - $this->assertSame('disabled', $widget->value()); - - $widget->handle(Key::char('e')); - $this->assertSame('enabled', $widget->value()); - - // Selection is case-insensitive. - $widget->handle(Key::char('D')); - $this->assertSame('disabled', $widget->value()); - - // A letter matching neither label is a no-op. - $widget->handle(Key::char('z')); - $this->assertSame('disabled', $widget->value()); - } - - public function testFirstLetterCollisionSelectsFirstLabel(): void { - $widget = new ToggleWidget(['public' => 'Public', 'private' => 'Private'], 'private'); - - // Both labels start with "p"; the first-declared label wins. - $widget->handle(Key::char('p')); - $this->assertSame('public', $widget->value()); - - // The colliding label stays reachable by flipping. - $widget->handle(Key::named(KeyName::Space)); - $this->assertSame('private', $widget->value()); - } - - public function testAccept(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - - $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Space), Key::named(KeyName::Enter))); - - $this->assertSame('disabled', $value); - $this->assertTrue($widget->isComplete()); - } - - public function testCancel(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - - $widget->handle(Key::named(KeyName::Escape)); - - $this->assertTrue($widget->isCancelled()); - } - - public function testAsciiRendering(): void { - $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'); - $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); - - $view = $widget->view($theme); - - $this->assertStringContainsString('(*) Enabled', $view); - $this->assertStringContainsString('( ) Disabled', $view); - } - - public function testFlipWithoutOptionsIsSafe(): void { - $widget = new ToggleWidget([]); - - $widget->handle(Key::named(KeyName::Space)); - - $this->assertSame('', $widget->value()); - } - - public function testHints(): void { - $labels = array_map(static fn(Hint $hint): string => $hint->label, (new ToggleWidget(['on' => 'On', 'off' => 'Off']))->hints()); - - $this->assertSame(['toggle', 'accept', 'cancel'], $labels); - } - -} diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php deleted file mode 100644 index b3f3f2e9..00000000 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ /dev/null @@ -1,519 +0,0 @@ -assertInstanceOf($expected, (new WidgetFactory())->create($field, $current)); - } - - /** - * Data provider for testCreatesByType(). - * - * @return \Iterator - * The field, the value it opens on and the widget class it builds. - */ - public static function dataProviderCreatesByType(): \Iterator { - yield 'text' => [self::field(FieldType::Text), 'x', TextWidget::class]; - yield 'confirm' => [self::field(FieldType::Confirm), TRUE, ConfirmWidget::class]; - yield 'toggle' => [self::fieldWithOptions(FieldType::Toggle), 'a', ToggleWidget::class]; - yield 'select' => [self::fieldWithOptions(FieldType::Select), 'a', SelectWidget::class]; - yield 'multiple select' => [self::multiFieldWithOptions(FieldType::Select), ['a'], SelectWidget::class]; - yield 'suggest' => [self::fieldWithOptions(FieldType::Suggest), 'a', SuggestWidget::class]; - yield 'number' => [self::field(FieldType::Number), 42, NumberWidget::class]; - yield 'rating' => [self::ratingField(), 3, RatingWidget::class]; - yield 'calendar' => [self::field(FieldType::Calendar), '2026-07-15', CalendarWidget::class]; - yield 'textarea' => [self::field(FieldType::Textarea), 'x', TextareaWidget::class]; - yield 'password' => [self::field(FieldType::Password), 'x', PasswordWidget::class]; - yield 'search' => [self::fieldWithOptions(FieldType::Search), 'a', SearchWidget::class]; - yield 'multiple search' => [self::multiFieldWithOptions(FieldType::Search), ['a'], SearchWidget::class]; - yield 'reorder' => [self::fieldWithOptions(FieldType::Reorder), ['a'], ReorderWidget::class]; - yield 'file picker' => [self::field(FieldType::FilePicker), '/nonexistent', FilePickerWidget::class]; - yield 'pause' => [self::field(FieldType::Pause), TRUE, PauseWidget::class]; - yield 'template' => [self::templateField(), 'one-two', TemplateWidget::class]; - } - - #[DataProvider('dataProviderSeedsValueFromCurrent')] - public function testSeedsValueFromCurrent(Field $field, mixed $current, mixed $expected): void { - $this->assertSame($expected, (new WidgetFactory())->create($field, $current)->value()); - } - - /** - * Data provider for testSeedsValueFromCurrent(). - * - * @return \Iterator - * The field, the value it is handed and the value the widget opens on. - */ - public static function dataProviderSeedsValueFromCurrent(): \Iterator { - yield 'text' => [self::field(FieldType::Text), 'Acme', 'Acme']; - yield 'number from an integer' => [self::field(FieldType::Number), 8080, 8080]; - yield 'number from a non-numeric' => [self::field(FieldType::Number), 'oops', 0]; - yield 'rating from a non-numeric' => [self::ratingField(), 'oops', 1]; - yield 'template from the assembled value' => [self::templateField(), 'one-two', 'one-two']; - yield 'template from a non-string' => [self::templateField(), 42, '-']; - // The seed order flows through: the given value first, the remaining - // option appended to complete the ranking. - yield 'reorder completes the ranking' => [self::fieldWithOptions(FieldType::Reorder), ['b'], ['b', 'a']]; - yield 'multiple from a non-list' => [self::multiFieldWithOptions(FieldType::Select), 'notalist', []]; - // A multiple choice field seeds from the list value, proving the multiple - // flag reaches the select and search widgets. - yield 'multiple select' => [self::multiFieldWithOptions(FieldType::Select), ['a', 'b'], ['a', 'b']]; - yield 'multiple search' => [self::multiFieldWithOptions(FieldType::Search), ['a'], ['a']]; - } - - public function testFilePickerSeedsValueFromCurrent(): void { - // Kept out of the seeding provider: the picker reads a directory, and a - // virtual one lists nothing without depending on what the host happens to - // have on disk. - vfsStream::setup('crates'); - $start = vfsStream::url('crates'); - - // A current path outside the start is ignored and the empty directory - // lists nothing, so the value is empty. - $single = new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: $start); - $this->assertSame('', (new WidgetFactory())->create($single, 'x')->value()); - - // The multiple picker yields a list seeded from the current value, proving - // the multiple flag is threaded through. - $multi = new Field('g', 'G', '', FieldType::FilePicker, [], pickerStart: $start, multiple: TRUE); - $this->assertSame(['/a', '/b'], (new WidgetFactory())->create($multi, ['/a', '/b'])->value()); - } - - public function testDateWithNonStringCurrentOpensOnToday(): void { - // Kept out of the seeding provider: a static provider would fix "today" at - // collection time. Bracketing the call keeps a midnight rollover between - // the two reads from failing the run. - $before = (new \DateTimeImmutable('today'))->format('Y-m-d'); - $widget = (new WidgetFactory())->create(self::field(FieldType::Calendar), 42); - $after = (new \DateTimeImmutable('today'))->format('Y-m-d'); - - $this->assertContains($widget->value(), [$before, $after]); - } - - public function testNoteHasNoEditorWidget(): void { - // A note is presentational: the theme renders it and the cursor skips - // it, so asking the factory to build an editor for one is a mistake. - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Note fields are presentational and have no editor widget.'); - - (new WidgetFactory())->create(self::field(FieldType::Note), NULL); - } - - public function testPasswordFlagsPassedThrough(): void { - $field = new Field('f', 'F', '', FieldType::Password, '', revealable: TRUE, confirm: TRUE); - - $widget = (new WidgetFactory())->create($field, 'secret'); - - // Revealable shows through the reveal hint the widget contributes. - $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); - $this->assertContains('reveal', $labels); - - // Confirm shows through the two-step flow: the first Enter does not accept. - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - } - - public function testNumberBoundsPassedThrough(): void { - $field = new Field('f', 'F', '', FieldType::Number, 0, bounds: new NumberBounds(0, 10)); - - $widget = (new WidgetFactory())->create($field, 5); - - // Bounds show through the adjust hint the widget contributes and stepping. - $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); - $this->assertContains('adjust', $labels); - $widget->handle(Key::named(KeyName::Up)); - $this->assertSame(6, $widget->value()); - } - - public function testRatingScaleAndCaptionsPassedThrough(): void { - $widget = (new WidgetFactory())->create(self::ratingField(), 3); - - $this->assertStringContainsString('●●●○○ 3/5 Fair', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testDateBoundsPassedThrough(): void { - $field = new Field('f', 'F', '', FieldType::Calendar, '', dateBounds: new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'))); - - $widget = (new WidgetFactory())->create($field, '2026-07-01'); - - // The seed is clamped into the field's declared range. - $this->assertSame('2026-07-10', $widget->value()); - } - - public function testCreatingProgressWidgetThrows(): void { - // A progress row runs its work on activation; it has no editor to build. - $field = new Field('p', 'P', '', FieldType::Progress, NULL); - - $this->expectException(\LogicException::class); - - (new WidgetFactory())->create($field, NULL); - } - - #[DataProvider('dataProviderTextareaExternalEditorHandoff')] - public function testTextareaExternalEditorHandoff(bool $opted_in, bool $available, bool $expected): void { - $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: $opted_in); - - $widget = (new WidgetFactory(externalEditorAvailable: $available))->create($field, 'x'); - $this->assertInstanceOf(TextareaWidget::class, $widget); - - $widget->handle(Key::char("\x05")); - $this->assertSame($expected, $widget->wantsExternalEdit()); - } - - /** - * Data provider for testTextareaExternalEditorHandoff(). - * - * @return \Iterator - * Whether the field opted in, whether an editor is available, and whether - * the handoff is offered. - */ - public static function dataProviderTextareaExternalEditorHandoff(): \Iterator { - yield 'opted in and available' => [TRUE, TRUE, TRUE]; - yield 'opted in but unavailable' => [TRUE, FALSE, FALSE]; - yield 'available but not opted in' => [FALSE, TRUE, FALSE]; - } - - public function testInjectsScopedKeymapIntoWidget(): void { - // The vim preset binds j to move-down in the select scope, so the injected - // widget responds to j where a default-preset widget would not. - $widget = (new WidgetFactory(KeyMapManager::create('vim')))->create(self::fieldWithOptions(FieldType::Select), 'a'); - - $widget->handle(Key::char('j')); - - $this->assertSame('b', $widget->value()); - } - - public function testPageSizePassedThrough(): void { - $options = ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B'), 'c' => new Option('c', 'C')]; - $field = new Field('f', 'F', '', FieldType::Select, '', $options, pageSize: 2); - - $view = (new WidgetFactory())->create($field, 'a')->view(new DefaultTheme()); - - // A page size of 2 over three options hides the last one and shows the - // "more below" indicator, proving the field's page size reached the widget. - $this->assertStringContainsString('▼', $view); - $this->assertStringNotContainsString('C', Ansi::strip($view)); - } - - public function testSuggestReceivesSelectableValuesOnly(): void { - $field = new Field('tz', 'TZ', '', FieldType::Suggest, '', [ - new Option('utc', 'UTC'), - new Option('gmt', 'GMT', '', OptionKind::Option, TRUE), - new Option('', '', '', OptionKind::Separator), - ]); - - $widget = (new WidgetFactory())->create($field, ''); - $view = $widget->view(new DefaultTheme()); - - $this->assertStringContainsString('utc', $view); - $this->assertStringNotContainsString('gmt', $view); - } - - public function testPerOptionDescriptionReachesChoiceWidget(): void { - $field = new Field('f', 'F', '', FieldType::Select, 'a', [ - new Option('a', 'Apple', 'Crisp and sweet.'), - new Option('b', 'Banana', 'Rich in potassium.'), - ]); - - $view = Ansi::strip((new WidgetFactory())->create($field, 'a')->view(new DefaultTheme())); - - $this->assertStringContainsString('Crisp and sweet.', $view); - } - - public function testPerOptionDescriptionReachesSuggest(): void { - $field = new Field('f', 'F', '', FieldType::Suggest, '', [new Option('apple', 'Apple', 'Crisp and sweet.')]); - - $widget = (new WidgetFactory())->create($field, ''); - $widget->handle(Key::named(KeyName::Down)); - - $this->assertStringContainsString('Crisp and sweet.', Ansi::strip($widget->view(new DefaultTheme()))); - } - - public function testTextCompletionStaticListReachesWidget(): void { - $field = new Field('name', 'Name', '', FieldType::Text, '', completion: ['acme-site']); - - $view = (new WidgetFactory())->create($field, 'ac')->view(new DefaultTheme()); - - // The matching candidate's remaining suffix shows as dimmed ghost-text. - $this->assertStringContainsString('me-site', $view); - } - - public function testSuggestGhostFlagReachesWidget(): void { - $options = ['Apple' => 'Apple', 'Apricot' => 'Apricot']; - - $off = new Field('fruit', 'Fruit', '', FieldType::Suggest, '', $options); - $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($off, 'ap')->view(new DefaultTheme())); - - $on = new Field('fruit', 'Fruit', '', FieldType::Suggest, '', $options, ghost: TRUE); - $view = (new WidgetFactory())->create($on, 'ap')->view(new DefaultTheme()); - - // The opted-in field previews the leading candidate's remaining suffix. - $this->assertStringContainsString('ple', $view); - $this->assertStringContainsString("\033[90m", $view); - } - - public function testTextCompletionClosureReceivesAnswers(): void { - $seen = []; - $field = new Field('repo', 'Repo', '', FieldType::Text, '', completion: function (array $answers) use (&$seen): array { - $seen = $answers; - - return ['acme-site']; - }); - - $view = (new WidgetFactory())->create($field, 'ac', ['owner' => 'acme'])->view(new DefaultTheme()); - - // The closure is handed the answers collected so far and its result reaches - // the widget as ghost-text. - $this->assertSame(['owner' => 'acme'], $seen); - $this->assertStringContainsString('me-site', $view); - } - - public function testTextCompletionCoercesInvalidResult(): void { - // A mistyped source degrades to no completion rather than erroring: a list - // with non-strings is filtered, and a non-list result is ignored. - $items = new Field('a', 'A', '', FieldType::Text, '', completion: fn (array $answers): array => [123, NULL]); - $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($items, 'ac')->view(new DefaultTheme())); - - $scalar = new Field('b', 'B', '', FieldType::Text, '', completion: fn (array $answers): string => 'oops'); - $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($scalar, 'ac')->view(new DefaultTheme())); - } - - #[DataProvider('dataProviderPlaceholderReachesEveryCapableWidget')] - public function testPlaceholderReachesEveryCapableWidget(FieldType $type): void { - $field = new Field('f', 'F', '', $type, '', placeholder: 'E.g. Golden Beetroot'); - - $view = Ansi::strip((new WidgetFactory())->create($field, '')->view(new DefaultTheme())); - - $this->assertStringContainsString('E.g. Golden Beetroot', $view); - } - - public static function dataProviderPlaceholderReachesEveryCapableWidget(): \Iterator { - foreach (FieldType::cases() as $type) { - if ($type->supportsPlaceholder()) { - yield $type->value => [$type]; - } - } - } - - public function testFieldWithoutPlaceholderGhostsNothing(): void { - $field = new Field('f', 'F', '', FieldType::Text, ''); - - $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($field, '')->view(new DefaultTheme())); - } - - public function testDeclaredValidatorBlocksAcceptUntilItPasses(): void { - $field = new Field('name', 'Name', '', FieldType::Text, '', validate: static fn (mixed $value): ?string => is_string($value) && $value !== '' ? NULL : 'A name is required.'); - - $widget = (new WidgetFactory())->create($field, ''); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('A name is required.', $widget->error()); - - $widget->handle(Key::char('x')); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertNull($widget->error()); - } - - public function testRequiredBlocksAcceptWithNoDeclaredValidator(): void { - $field = new Field('name', 'Produce name', '', FieldType::Text, '', required: TRUE); - - $widget = (new WidgetFactory())->create($field, ''); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('Produce name is required.', $widget->error()); - - $widget->handle(Key::char('P')); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertNull($widget->error()); - } - - public function testRequiredMessageOverrideReachesWidget(): void { - $field = new Field('plot', 'Garden plot name', '', FieldType::Text, '', required: TRUE, requiredMessage: 'The garden plot name is required.'); - - $widget = (new WidgetFactory())->create($field, ''); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertSame('The garden plot name is required.', $widget->error()); - } - - public function testRequiredRunsBeforeTheDeclaredValidator(): void { - $field = new Field('name', 'Produce name', '', FieldType::Text, '', required: TRUE, validate: static fn (mixed $value): ?string => $value === 'Pear' ? NULL : 'Only pears keep.'); - - $widget = (new WidgetFactory())->create($field, ''); - - // The empty value never reaches the declared validator... - $widget->handle(Key::named(KeyName::Enter)); - $this->assertSame('Produce name is required.', $widget->error()); - - // ...which still governs a non-empty one. - $widget->handle(Key::char('F')); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('Only pears keep.', $widget->error()); - } - - public function testRequiredGuardsMultipleSelection(): void { - $field = new Field('crates', 'Crates', '', FieldType::Select, [], ['a' => 'Apples', 'b' => 'Beans'], required: TRUE, multiple: TRUE); - - $widget = (new WidgetFactory())->create($field, []); - - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('Crates is required.', $widget->error()); - - $widget->handle(Key::named(KeyName::Space)); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertSame(['a'], $widget->value()); - } - - public function testDeclaredTransformAppliesOnAccept(): void { - $field = new Field('variety', 'Variety', '', FieldType::Text, '', transform: static fn (mixed $value): mixed => is_string($value) ? strtolower(trim($value)) : $value); - - $widget = (new WidgetFactory())->create($field, ' Golden '); - $widget->handle(Key::named(KeyName::Enter)); - - $this->assertSame('golden', $widget->value()); - } - - public function testHandlerBehaviourReachesWidget(): void { - $handlers = new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler']); - $field = new Field('machine_name', 'Machine name', '', FieldType::Text, ''); - - $widget = (new WidgetFactory(handlers: $handlers))->create($field, ''); - - // The registry's static validate() blocks the empty value... - $widget->handle(Key::named(KeyName::Enter)); - $this->assertFalse($widget->isComplete()); - $this->assertSame('A machine name is required.', $widget->error()); - - // ...and its static transform() lowercases the accepted one. - $widget->handle(Key::char('A')); - $widget->handle(Key::named(KeyName::Enter)); - $this->assertTrue($widget->isComplete()); - $this->assertSame('a', $widget->value()); - } - - public function testDeclaredClosuresWinOverHandlerBehaviour(): void { - $handlers = new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler']); - $field = new Field('machine_name', 'Machine name', '', FieldType::Text, '', validate: static fn (mixed $value): ?string => NULL, transform: static fn (mixed $value): mixed => is_string($value) ? strtoupper($value) : $value); - - $widget = (new WidgetFactory(handlers: $handlers))->create($field, 'a'); - $widget->handle(Key::named(KeyName::Enter)); - - // The declared closures replace the handler's: the accept is not blocked - // and the value uppercases rather than lowercasing. - $this->assertTrue($widget->isComplete()); - $this->assertSame('A', $widget->value()); - } - - /** - * A field of the given type. - * - * @param \DrevOps\Tui\Model\FieldType $type - * The field type. - */ - protected static function field(FieldType $type): Field { - return new Field('f', 'F', '', $type, ''); - } - - /** - * A template field with a two-slot shape. - * - * @return \DrevOps\Tui\Model\Field - * The field. - */ - protected static function templateField(): Field { - return new Field('f', 'F', '', FieldType::Template, '', template: new Template('{{a}}-{{b}}')); - } - - /** - * A rating field over a one-to-five scale with one captioned point. - * - * @return \DrevOps\Tui\Model\Field - * The field. - */ - protected static function ratingField(): Field { - return new Field('f', 'F', '', FieldType::Rating, 1, bounds: new NumberBounds(1, 5), ratingCaptions: [3 => 'Fair']); - } - - /** - * A choice field of the given type with two options. - * - * @param \DrevOps\Tui\Model\FieldType $type - * The field type. - */ - protected static function fieldWithOptions(FieldType $type): Field { - return new Field('f', 'F', '', $type, '', ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B')]); - } - - /** - * A multiple-choice field of the given type with two options. - * - * @param \DrevOps\Tui\Model\FieldType $type - * The field type. - */ - protected static function multiFieldWithOptions(FieldType $type): Field { - return new Field('f', 'F', '', $type, [], ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B')], multiple: TRUE); - } - -} diff --git a/translations/en.php b/translations/en.php index 9192fcba..b48a50f6 100644 --- a/translations/en.php +++ b/translations/en.php @@ -8,6 +8,10 @@ * target language (e.g. "uk.php") and translate the values. The keys are the * English source strings the library looks up; placeholders such as "@count" * must be kept verbatim in a translation. + * + * A few strings reach the translator as a value rather than as a literal - the + * month name a calendar heading formats, and the default button labels - so + * they are listed here even though no `t('...')` call spells them out. */ declare(strict_types=1); @@ -23,27 +27,37 @@ '@label: must not contain "@text".' => '@label: must not contain "@text".', '@value is not a valid "@key". Allowed: @allowed.' => '@value is not a valid "@key". Allowed: @allowed.', '@value is not a valid "@key". Use a non-negative integer.' => '@value is not a valid "@key". Use a non-negative integer.', + 'April' => 'April', + 'August' => 'August', 'Calendar' => 'Calendar', + 'Cancel' => 'Cancel', 'Choose @constraint.' => 'Choose @constraint.', 'Confirm' => 'Confirm', 'Could not load options for field "@id": @error' => 'Could not load options for field "@id": @error', 'Could not load options.' => 'Could not load options.', + 'December' => 'December', 'Directories only' => 'Directories only', 'Enter a number @constraint.' => 'Enter a number @constraint.', 'Extensions: @extensions' => 'Extensions: @extensions', + 'February' => 'February', 'File picker' => 'File picker', 'Files only' => 'Files only', 'Fr' => 'Fr', 'Invalid value for field "@id": @error' => 'Invalid value for field "@id": @error', - 'Keyboard help' => 'Keyboard help', + 'January' => 'January', + 'July' => 'July', + 'June' => 'June', + 'March' => 'March', 'Max @size' => 'Max @size', + 'May' => 'May', 'Missing required question "@id".' => 'Missing required question "@id".', 'Mo' => 'Mo', - 'Navigation' => 'Navigation', 'Need at least @width x @height - have @w x @h.' => 'Need at least @width x @height - have @w x @h.', 'No' => 'No', 'Note' => 'Note', + 'November' => 'November', 'Number' => 'Number', + 'October' => 'October', 'Page size must be a positive integer, @size given.' => 'Page size must be a positive integer, @size given.', 'Password' => 'Password', 'Passwords do not match.' => 'Passwords do not match.', @@ -60,7 +74,9 @@ 'Search' => 'Search', 'Select' => 'Select', 'Select @constraint.' => 'Select @constraint.', + 'September' => 'September', 'Su' => 'Su', + 'Submit' => 'Submit', 'Suggest' => 'Suggest', 'Template' => 'Template', 'Terminal too small.' => 'Terminal too small.', @@ -83,54 +99,53 @@ 'a file with a permitted extension (@extensions)' => 'a file with a permitted extension (@extensions)', 'a list' => 'a list', 'a number' => 'a number', - 'a whole number' => 'a whole number', 'a string' => 'a string', + 'a whole number' => 'a whole number', 'accept' => 'accept', 'adjust' => 'adjust', 'an existing directory' => 'an existing directory', 'an existing file' => 'an existing file', 'an existing path' => 'an existing path', + 'answer yes or no' => 'answer yes or no', 'at least 1 item' => 'at least 1 item', 'at least @count items' => 'at least @count items', 'at least @min' => 'at least @min', 'at most 1 item' => 'at most 1 item', 'at most @count items' => 'at most @count items', 'at most @max' => 'at most @max', - 'back' => 'back', 'between @min and @max' => 'between @min and @max', 'between @min and @max items' => 'between @min and @max items', 'bksp' => 'bksp', 'cancel' => 'cancel', - 'close' => 'close', 'continue' => 'continue', 'ctrl-c' => 'ctrl-c', - 'day' => 'day', 'default' => 'default', 'del' => 'del', 'derived' => 'derived', 'detected' => 'detected', 'drop' => 'drop', 'edited' => 'edited', - 'editor' => 'editor', 'end' => 'end', 'esc' => 'esc', 'exactly 1 item' => 'exactly 1 item', 'exactly @count items' => 'exactly @count items', 'filling in @label' => 'filling in @label', + 'go back' => 'go back', + 'go up' => 'go up', 'grab' => 'grab', - 'help' => 'help', - 'hidden' => 'hidden', 'home' => 'home', + 'insert a newline' => 'insert a newline', 'move' => 'move', + 'move between parts' => 'move between parts', + 'move by day' => 'move by day', + 'move by week' => 'move by week', 'must be @constraint.' => 'must be @constraint.', 'must rank every option exactly once (@options)' => 'must rank every option exactly once (@options)', - 'newline' => 'newline', - 'next/previous' => 'next/previous', 'no' => 'no', - 'none/all' => 'none/all', 'on or after @min' => 'on or after @min', 'on or before @max' => 'on or before @max', 'open' => 'open', + 'open the editor' => 'open the editor', 'option "@value" is disabled' => 'option "@value" is disabled', 'option "@value" is disabled: @reason' => 'option "@value" is disabled: @reason', 'override' => 'override', @@ -141,14 +156,15 @@ 'reorder' => 'reorder', 'reveal' => 'reveal', 'select' => 'select', + 'select none or all' => 'select none or all', + 'show help' => 'show help', + 'show hidden' => 'show hidden', 'space' => 'space', 'tab' => 'tab', + 'to @action' => 'to @action', 'toggle' => 'toggle', - 'up' => 'up', 'value "@value" is not one of: @options' => 'value "@value" is not one of: @options', 'value "@value" was not found' => 'value "@value" was not found', 'value must be a list' => 'value must be a list', - 'week' => 'week', 'yes' => 'yes', - 'yes/no' => 'yes/no', ]; diff --git a/translations/uk.php b/translations/uk.php index c322f08d..9561f4e3 100644 --- a/translations/uk.php +++ b/translations/uk.php @@ -11,9 +11,9 @@ * @see https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes * * Ukrainian has three plural forms, so the catalog supplies its own rule under - * the reserved key; "@count items selected" lists its one/few/many forms and so - * needs no separate singular entry. The wording is a first pass - a native - * review is welcome. + * the reserved key and a count phrase lists its forms as one/few/many, in that + * order. Key caps a keyboard prints in Latin (bksp, esc, tab) stay as they are, + * because that is what the reader sees on the key. */ declare(strict_types=1); @@ -36,59 +36,58 @@ return 2; }, - // The plural forms, in rule order: one, few, many. + '"@min" must not exceed "@max".' => '"@min" не може перевищувати "@max".', + '"@value" does not match the template "@pattern".' => '"@value" не відповідає шаблону "@pattern".', + '(empty)' => '(порожньо)', + '1 item selected' => '1 елемент вибрано', '@count items selected' => [ '@count елемент вибрано', '@count елементи вибрано', '@count елементів вибрано', ], - 'at least @count items' => [ - 'щонайменше @count елемент', - 'щонайменше @count елементи', - 'щонайменше @count елементів', - ], - 'at most @count items' => [ - 'щонайбільше @count елемент', - 'щонайбільше @count елементи', - 'щонайбільше @count елементів', - ], - 'exactly @count items' => [ - 'рівно @count елемент', - 'рівно @count елементи', - 'рівно @count елементів', - ], - // The default button labels: the Buttons model defaults render through t(), - // so they localize here even though they are not in en.php's scanned key - // list. - 'Submit' => 'Надіслати', - 'Cancel' => 'Скасувати', - '"@min" must not exceed "@max".' => '"@min" не може перевищувати "@max".', - '"@value" does not match the template "@pattern".' => '"@value" не відповідає шаблону "@pattern".', - '(empty)' => '(порожньо)', '@label is required.' => "@label є обов'язковим полем.", '@label: @error' => '@label: @error', '@label: must not contain "@text".' => '@label: не може містити "@text".', '@value is not a valid "@key". Allowed: @allowed.' => '@value не є припустимим "@key". Дозволено: @allowed.', '@value is not a valid "@key". Use a non-negative integer.' => '@value не є припустимим "@key". Використайте ціле число не менше нуля.', + 'April' => 'Квітень', + 'August' => 'Серпень', 'Calendar' => 'Календар', + 'Cancel' => 'Скасувати', + 'Choose @constraint.' => 'Оберіть @constraint.', 'Confirm' => 'Підтвердити', + 'Could not load options for field "@id": @error' => 'Не вдалося завантажити варіанти для поля "@id": @error', + 'Could not load options.' => 'Не вдалося завантажити варіанти.', + 'December' => 'Грудень', + 'Directories only' => 'Лише каталоги', 'Enter a number @constraint.' => 'Введіть число @constraint.', + 'Extensions: @extensions' => 'Розширення: @extensions', + 'February' => 'Лютий', 'File picker' => 'Вибір файлу', + 'Files only' => 'Лише файли', 'Fr' => 'Пт', 'Invalid value for field "@id": @error' => 'Неприпустиме значення поля "@id": @error', - 'Keyboard help' => 'Довідка з клавіатури', + 'January' => 'Січень', + 'July' => 'Липень', + 'June' => 'Червень', + 'March' => 'Березень', + 'Max @size' => 'Максимум @size', + 'May' => 'Травень', 'Missing required question "@id".' => 'Пропущено потрібне питання "@id".', 'Mo' => 'Пн', - 'Navigation' => 'Навігація', 'Need at least @width x @height - have @w x @h.' => 'Потрібно щонайменше @width x @height - є @w x @h.', 'No' => 'Ні', + 'Note' => 'Примітка', + 'November' => 'Листопад', 'Number' => 'Число', + 'October' => 'Жовтень', 'Page size must be a positive integer, @size given.' => 'Розмір сторінки має бути додатним цілим числом, задано @size.', 'Password' => 'Пароль', 'Passwords do not match.' => 'Паролі не збігаються.', 'Pause' => 'Пауза', 'Press @key to continue' => 'Натисніть @key, щоб продовжити', 'Press any key to continue...' => 'Натисніть будь-яку клавішу, щоб продовжити...', + 'Progress' => 'Поступ', 'Question "@id" must be @constraint.' => 'Питання "@id" має бути @constraint.', 'Question "@id": @error' => 'Питання "@id": @error', 'Question "@id": @error.' => 'Питання "@id": @error.', @@ -98,7 +97,9 @@ 'Search' => 'Пошук', 'Select' => 'Вибрати', 'Select @constraint.' => 'Виберіть @constraint.', + 'September' => 'Вересень', 'Su' => 'Нд', + 'Submit' => 'Надіслати', 'Suggest' => 'Підказка', 'Template' => 'Шаблон', 'Terminal too small.' => 'Термінал замалий.', @@ -108,6 +109,12 @@ 'The --prompts value is neither a JSON object nor a path to one.' => 'Значення --prompts не є документом JSON або шляхом до нього.', 'Toggle' => 'Перемкнути', 'Tu' => 'Вт', + 'Type 1 character to search.' => 'Введіть 1 символ для пошуку.', + 'Type @count characters to search.' => [ + 'Введіть @count символ для пошуку.', + 'Введіть @count символи для пошуку.', + 'Введіть @count символів для пошуку.', + ], 'Unknown question "@id".' => 'Невідоме питання "@id".', 'Unknown theme option "@key". Known: @known.' => 'Невідомий параметр теми "@key". Відомі: @known.', 'Version: @version' => 'Версія: @version', @@ -115,47 +122,69 @@ 'Yes' => 'Так', 'a boolean' => 'логічне значення', 'a date (YYYY-MM-DD)' => 'дата (РРРР-ММ-ДД)', + 'a file no larger than @size' => 'файл не більший за @size', + 'a file with a permitted extension (@extensions)' => 'файл із дозволеним розширенням (@extensions)', 'a list' => 'список', 'a number' => 'число', - 'a whole number' => 'ціле число', 'a string' => 'рядок', + 'a whole number' => 'ціле число', 'accept' => 'прийняти', 'adjust' => 'налаштувати', + 'an existing directory' => 'наявний каталог', + 'an existing file' => 'наявний файл', + 'an existing path' => 'наявний шлях', + 'answer yes or no' => 'так/ні', + 'at least 1 item' => 'щонайменше 1 елемент', + 'at least @count items' => [ + 'щонайменше @count елемент', + 'щонайменше @count елементи', + 'щонайменше @count елементів', + ], 'at least @min' => 'щонайменше @min', + 'at most 1 item' => 'щонайбільше 1 елемент', + 'at most @count items' => [ + 'щонайбільше @count елемент', + 'щонайбільше @count елементи', + 'щонайбільше @count елементів', + ], 'at most @max' => 'щонайбільше @max', - 'back' => 'назад', 'between @min and @max' => 'від @min до @max', 'between @min and @max items' => 'від @min до @max елементів', 'bksp' => 'bksp', 'cancel' => 'скасувати', - 'close' => 'закрити', 'continue' => 'продовжити', 'ctrl-c' => 'ctrl-c', - 'day' => 'день', 'default' => 'типове', 'del' => 'del', 'derived' => 'похідне', 'detected' => 'виявлено', 'drop' => 'покласти', 'edited' => 'змінено', - 'editor' => 'редактор', 'end' => 'end', 'esc' => 'esc', + 'exactly 1 item' => 'рівно 1 елемент', + 'exactly @count items' => [ + 'рівно @count елемент', + 'рівно @count елементи', + 'рівно @count елементів', + ], 'filling in @label' => 'заповнюється @label', + 'go back' => 'назад', + 'go up' => 'на рівень вище', 'grab' => 'взяти', - 'help' => 'довідка', - 'hidden' => 'приховано', 'home' => 'home', + 'insert a newline' => 'новий рядок', 'move' => 'перемістити', + 'move between parts' => 'між частинами', + 'move by day' => 'на день', + 'move by week' => 'на тиждень', 'must be @constraint.' => 'має бути @constraint.', 'must rank every option exactly once (@options)' => 'потрібно впорядкувати кожен пункт лише раз (@options)', - 'newline' => 'новий рядок', - 'next/previous' => 'далі/назад', 'no' => 'ні', - 'none/all' => 'нічого/усе', 'on or after @min' => '@min або пізніше', 'on or before @max' => '@max або раніше', 'open' => 'відкрити', + 'open the editor' => 'відкрити редактор', 'option "@value" is disabled' => 'пункт "@value" вимкнено', 'option "@value" is disabled: @reason' => 'пункт "@value" вимкнено: @reason', 'override' => 'перевизначено', @@ -166,13 +195,17 @@ 'reorder' => 'перевпорядкувати', 'reveal' => 'показати', 'select' => 'вибрати', + 'select none or all' => 'нічого/усе', + 'show help' => 'довідка', + 'show hidden' => 'показати приховані', 'space' => 'пробіл', 'tab' => 'tab', + // A legend fragment reads as " ", and Ukrainian needs no + // preposition to join the two, so the action stands on its own. + 'to @action' => '@action', 'toggle' => 'перемкнути', - 'up' => 'вгору', 'value "@value" is not one of: @options' => 'значення "@value" не є одним з: @options', + 'value "@value" was not found' => 'значення "@value" не знайдено', 'value must be a list' => 'значення має бути списком', - 'week' => 'тиждень', 'yes' => 'так', - 'yes/no' => 'так/ні', ];