From 9600388e98071e9b4070f2b95451847154bb5498 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 22:17:29 +1000 Subject: [PATCH 1/3] [#6] Added file and directory picker widgets with single and multiple modes. --- README.md | 56 +- .../widget-filepicker-ascii-no-ansi.svg | 1 + docs/assets/widget-filepicker-ascii.svg | 1 + docs/assets/widget-filepicker-no-ansi.svg | 1 + docs/assets/widget-filepicker.svg | 1 + .../widget-multifilepicker-ascii-no-ansi.svg | 1 + docs/assets/widget-multifilepicker-ascii.svg | 1 + .../assets/widget-multifilepicker-no-ansi.svg | 1 + docs/assets/widget-multifilepicker.svg | 1 + docs/util/update-assets.php | 40 ++ .../3-widgets/filepicker-tree/README.md | 3 + .../3-widgets/filepicker-tree/composer.json | 4 + .../3-widgets/filepicker-tree/config/app.yml | 2 + .../filepicker-tree/config/services.yml | 2 + .../3-widgets/filepicker-tree/src/Kernel.php | 5 + playground/3-widgets/widget-filepicker.php | 53 ++ .../3-widgets/widget-multifilepicker.php | 52 ++ playground/README.md | 4 +- src/Builder/FieldBuilder.php | 106 ++- src/Builder/PanelBuilder.php | 30 + src/Config/Field.php | 15 + src/Config/FieldType.php | 2 + src/Config/FilePickerMode.php | 22 + src/Resolver/InputResolver.php | 2 +- src/Schema/SchemaValidator.php | 4 +- src/Widget/FilePickerWidget.php | 667 ++++++++++++++++++ src/Widget/WidgetFactory.php | 2 + tests/phpunit/Unit/Builder/FormTest.php | 33 + .../Unit/Resolver/InputResolverTest.php | 13 + .../Unit/Schema/SchemaValidatorTest.php | 16 + .../Unit/Widget/FilePickerWidgetTest.php | 430 +++++++++++ .../phpunit/Unit/Widget/WidgetFactoryTest.php | 16 + 32 files changed, 1580 insertions(+), 7 deletions(-) create mode 100644 docs/assets/widget-filepicker-ascii-no-ansi.svg create mode 100644 docs/assets/widget-filepicker-ascii.svg create mode 100644 docs/assets/widget-filepicker-no-ansi.svg create mode 100644 docs/assets/widget-filepicker.svg create mode 100644 docs/assets/widget-multifilepicker-ascii-no-ansi.svg create mode 100644 docs/assets/widget-multifilepicker-ascii.svg create mode 100644 docs/assets/widget-multifilepicker-no-ansi.svg create mode 100644 docs/assets/widget-multifilepicker.svg create mode 100644 playground/3-widgets/filepicker-tree/README.md create mode 100644 playground/3-widgets/filepicker-tree/composer.json create mode 100644 playground/3-widgets/filepicker-tree/config/app.yml create mode 100644 playground/3-widgets/filepicker-tree/config/services.yml create mode 100644 playground/3-widgets/filepicker-tree/src/Kernel.php create mode 100644 playground/3-widgets/widget-filepicker.php create mode 100644 playground/3-widgets/widget-multifilepicker.php create mode 100644 src/Config/FilePickerMode.php create mode 100644 src/Widget/FilePickerWidget.php create mode 100644 tests/phpunit/Unit/Widget/FilePickerWidgetTest.php diff --git a/README.md b/README.md index 6cd4dce..d533ad2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It powers the [Vortex](https://www.vortextemplate.com) project installer, but kn ## Features - ๐Ÿงญ [**Panel TUI**](#panels-and-navigation) - a full-screen, scrollable, keyboard-driven form: panels hold fields, sub-panels drill in to any depth -- ๐Ÿงฉ [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `confirm`, `toggle`, `pause` +- ๐Ÿงฉ [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `filepicker`, `multifilepicker`, `confirm`, `toggle`, `pause` - ๐Ÿ—๏ธ [**Builder-driven**](#configuration) - panels and fields are declared in PHP with a fluent builder; the common cases need no code - ๐Ÿค– [**Interactive or headless**](#headless-collection) - drive the panel TUI by keyboard, or collect answers non-interactively from a JSON payload and environment variables (and emit a JSON schema for agents and forms) - ๐Ÿ”— [**Derived values**](#derived-values) - compute one field from others with [str2name](https://github.com/AlexSkrypnyk/str2name) transforms; chains settle to a fixpoint @@ -73,7 +73,7 @@ It also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want ## Widgets -Twelve widget types cover text entry, choices and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes. +Fourteen widget types cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.

All widgets, one after another @@ -338,6 +338,58 @@ $p->multisearch('services', 'Services')->default(['redis'])->options(['redis' => +### File picker + +Browse the filesystem for a single path. Arrows move, `โ†’` enters the highlighted directory and `โ†` returns to its parent (never above the start directory), typing filters the current directory and `Tab` reveals hidden entries; `Enter` selects the highlighted entry. Restrict what may be chosen with `filesOnly()` or `directoriesOnly()`, limit files with `extensions()`, and choose where browsing starts with `start()`. It collects the chosen path. + +```php +$p->filePicker('config', 'Config file')->start(getcwd())->filesOnly()->extensions(['yml', 'yaml']); +``` + + + + + + + + + + + + + + + + + +
ANSINo ANSI
UnicodeFile picker: Unicode + ANSIFile picker: Unicode + No ANSI
ASCIIFile picker: ASCII + ANSIFile picker: ASCII + No ANSI
+ +### Multi file picker + +Like the file picker, but several paths can be chosen: `Space` toggles the highlighted entry and selections accumulate as you browse between directories, `Enter` accepts them all. It collects the list of chosen paths. + +```php +$p->multiFilePicker('assets', 'Asset files')->start(getcwd())->extensions(['png', 'svg']); +``` + + + + + + + + + + + + + + + + + +
ANSINo ANSI
UnicodeMulti file picker: Unicode + ANSIMulti file picker: Unicode + No ANSI
ASCIIMulti file picker: ASCII + ANSIMulti file picker: ASCII + No ANSI
+ ### Confirm Yes/No toggle. Arrows or Space switch, `y`/`n` set the choice directly, Enter accepts. diff --git a/docs/assets/widget-filepicker-ascii-no-ansi.svg b/docs/assets/widget-filepicker-ascii-no-ansi.svg new file mode 100644 index 0000000..61bb860 --- /dev/null +++ b/docs/assets/widget-filepicker-ascii-no-ansi.svg @@ -0,0 +1 @@ +Filepickerwidget------------------filepicker-tree>config/src/^/vmove*>open*<up*<select*tabhidden*esccancel \ No newline at end of file diff --git a/docs/assets/widget-filepicker-ascii.svg b/docs/assets/widget-filepicker-ascii.svg new file mode 100644 index 0000000..aff378a --- /dev/null +++ b/docs/assets/widget-filepicker-ascii.svg @@ -0,0 +1 @@ +Filepickerwidget------------------filepicker-tree>config/src/^/vmove*>open*<up*<select*tabhidden*esccancel \ No newline at end of file diff --git a/docs/assets/widget-filepicker-no-ansi.svg b/docs/assets/widget-filepicker-no-ansi.svg new file mode 100644 index 0000000..d17e3cb --- /dev/null +++ b/docs/assets/widget-filepicker-no-ansi.svg @@ -0,0 +1 @@ +Filepickerwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€filepicker-treeโฏconfig/src/โ†‘/โ†“moveยทโ†’openยทโ†upยทโ†ตselectยทtabhiddenยทesccancel \ No newline at end of file diff --git a/docs/assets/widget-filepicker.svg b/docs/assets/widget-filepicker.svg new file mode 100644 index 0000000..6b93c18 --- /dev/null +++ b/docs/assets/widget-filepicker.svg @@ -0,0 +1 @@ +Filepickerwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€filepicker-treeโฏconfig/src/โ†‘/โ†“moveยทโ†’openยทโ†upยทโ†ตselectยทtabhiddenยทesccancel \ No newline at end of file diff --git a/docs/assets/widget-multifilepicker-ascii-no-ansi.svg b/docs/assets/widget-multifilepicker-ascii-no-ansi.svg new file mode 100644 index 0000000..f988a7e --- /dev/null +++ b/docs/assets/widget-multifilepicker-ascii-no-ansi.svg @@ -0,0 +1 @@ +Multifilepickerwidget------------------------filepicker-tree>[]config/[]src/[]composer.json[]README.mdspaceselect*^/vmove*>open*<up*<accept*tabhidden*esccancel \ No newline at end of file diff --git a/docs/assets/widget-multifilepicker-ascii.svg b/docs/assets/widget-multifilepicker-ascii.svg new file mode 100644 index 0000000..52b3adb --- /dev/null +++ b/docs/assets/widget-multifilepicker-ascii.svg @@ -0,0 +1 @@ +Multifilepickerwidget------------------------filepicker-tree>[]config/[]src/[]composer.json[]README.mdspaceselect*^/vmove*>open*<up*<accept*tabhidden*esccancel \ No newline at end of file diff --git a/docs/assets/widget-multifilepicker-no-ansi.svg b/docs/assets/widget-multifilepicker-no-ansi.svg new file mode 100644 index 0000000..e1fcbab --- /dev/null +++ b/docs/assets/widget-multifilepicker-no-ansi.svg @@ -0,0 +1 @@ +Multifilepickerwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€filepicker-treeโฏโ—ปconfig/โ—ปsrc/โ—ปcomposer.jsonโ—ปREADME.mdspaceselectยทโ†‘/โ†“moveยทโ†’openยทโ†upยทโ†ตacceptยทtabhiddenยทesccancel \ No newline at end of file diff --git a/docs/assets/widget-multifilepicker.svg b/docs/assets/widget-multifilepicker.svg new file mode 100644 index 0000000..73d887d --- /dev/null +++ b/docs/assets/widget-multifilepicker.svg @@ -0,0 +1 @@ +Multifilepickerwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€filepicker-treeโฏโ—ปconfig/โ—ปsrc/โ—ปcomposer.jsonโ—ปREADME.mdspaceselectยทโ†‘/โ†“moveยทโ†’openยทโ†upยทโ†ตacceptยทtabhiddenยทesccancel \ No newline at end of file diff --git a/docs/util/update-assets.php b/docs/util/update-assets.php index c1000ff..55d6032 100644 --- a/docs/util/update-assets.php +++ b/docs/util/update-assets.php @@ -518,6 +518,46 @@ function getJobs(string $project_dir): array { } } + // The file pickers browse a fixture tree, so they stand apart from the + // single-screen widget montage; each is still shown in all display modes. + $picker_rows = ['filepicker' => 8, 'multifilepicker' => 10]; + $picker_interactions = [ + 'filepicker' => <<<'EXPECT' +# File picker: descend into the config directory, select the first YAML file. +expect "File picker widget" { + pause 1000 + safe_send "\r" + pause 1000 + wait_and_enter +} +EXPECT, + 'multifilepicker' => <<<'EXPECT' +# Multi file picker: toggle two entries, accept. +expect "Multi file picker widget" { + pause 1000 + toggle_space + arrow_down + toggle_space + wait_and_enter +} +EXPECT, + ]; + + foreach ($picker_interactions as $widget => $interact) { + preg_match('/expect "([^"]+)"/', $interact, $matches); + $gate = $matches[1] ?? ''; + + foreach ($flag_variants as $suffix => $flags) { + $jobs['widget-' . $widget . $suffix] = [ + 'command' => 'php ' . $project_dir . '/playground/3-widgets/widget-' . $widget . '.php' . $flags, + 'interact' => $interact, + 'rows' => $picker_rows[$widget], + 'cols' => 60, + 'at_needle' => $gate, + ]; + } + } + return $jobs; } diff --git a/playground/3-widgets/filepicker-tree/README.md b/playground/3-widgets/filepicker-tree/README.md new file mode 100644 index 0000000..e09bee7 --- /dev/null +++ b/playground/3-widgets/filepicker-tree/README.md @@ -0,0 +1,3 @@ +# Sample tree + +A small fixture tree the file picker demos browse. diff --git a/playground/3-widgets/filepicker-tree/composer.json b/playground/3-widgets/filepicker-tree/composer.json new file mode 100644 index 0000000..28ef74c --- /dev/null +++ b/playground/3-widgets/filepicker-tree/composer.json @@ -0,0 +1,4 @@ +{ + "name": "acme/site", + "type": "project" +} diff --git a/playground/3-widgets/filepicker-tree/config/app.yml b/playground/3-widgets/filepicker-tree/config/app.yml new file mode 100644 index 0000000..9280a95 --- /dev/null +++ b/playground/3-widgets/filepicker-tree/config/app.yml @@ -0,0 +1,2 @@ +name: Acme +env: dev diff --git a/playground/3-widgets/filepicker-tree/config/services.yml b/playground/3-widgets/filepicker-tree/config/services.yml new file mode 100644 index 0000000..2fa0889 --- /dev/null +++ b/playground/3-widgets/filepicker-tree/config/services.yml @@ -0,0 +1,2 @@ +services: + cache: redis diff --git a/playground/3-widgets/filepicker-tree/src/Kernel.php b/playground/3-widgets/filepicker-tree/src/Kernel.php new file mode 100644 index 0000000..458ebc5 --- /dev/null +++ b/playground/3-widgets/filepicker-tree/src/Kernel.php @@ -0,0 +1,5 @@ + !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]); + +// Browse a small fixture tree, limited to YAML files. +$widget = new FilePickerWidget(__DIR__ . '/filepicker-tree', mode: FilePickerMode::File, extensions: ['yml', 'yaml']); + +$terminal = new Terminal(); +$parser = new KeyParser(); +$terminal->setup(); + +try { + // The picker renders its own key-hint line, so no extra hint is added here. + while (!$widget->isComplete() && !$widget->isCancelled()) { + $terminal->render(implode("\n", [ + $theme->renderEditorHeader('File picker widget'), + '', + $widget->view($theme), + ])); + + foreach ($parser->parse($terminal->read()) as $key) { + $widget->handle($key); + } + } +} +finally { + $terminal->restore(); +} + +echo 'File picker: ' . ($widget->isCancelled() ? '(cancelled)' : (string) json_encode($widget->value())) . PHP_EOL; diff --git a/playground/3-widgets/widget-multifilepicker.php b/playground/3-widgets/widget-multifilepicker.php new file mode 100644 index 0000000..ebd61c0 --- /dev/null +++ b/playground/3-widgets/widget-multifilepicker.php @@ -0,0 +1,52 @@ + !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]); + +// Browse a small fixture tree, choosing several entries. +$widget = new FilePickerWidget(__DIR__ . '/filepicker-tree', multiple: TRUE); + +$terminal = new Terminal(); +$parser = new KeyParser(); +$terminal->setup(); + +try { + // The picker renders its own key-hint line, so no extra hint is added here. + while (!$widget->isComplete() && !$widget->isCancelled()) { + $terminal->render(implode("\n", [ + $theme->renderEditorHeader('Multi file picker widget'), + '', + $widget->view($theme), + ])); + + foreach ($parser->parse($terminal->read()) as $key) { + $widget->handle($key); + } + } +} +finally { + $terminal->restore(); +} + +echo 'Multi file picker: ' . ($widget->isCancelled() ? '(cancelled)' : (string) json_encode($widget->value())) . PHP_EOL; diff --git a/playground/README.md b/playground/README.md index 125e094..c1fd6f7 100644 --- a/playground/README.md +++ b/playground/README.md @@ -52,7 +52,9 @@ composer install Per-widget files: `widget-text.php`, `widget-number.php`, `widget-textarea.php`, `widget-password.php`, `widget-select.php`, `widget-multiselect.php`, `widget-suggest.php`, `widget-search.php`, - `widget-multisearch.php`, `widget-confirm.php`, `widget-pause.php`. + `widget-multisearch.php`, `widget-filepicker.php`, + `widget-multifilepicker.php`, `widget-confirm.php`, `widget-toggle.php`, + `widget-pause.php`. - **[`4-nested-panels/`](4-nested-panels)** - a hub with drill-in sub-panels (nested to any depth), per-option descriptions, custom button labels diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index 85f3b34..7ffe8e2 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\ConfigException; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Config\FilePickerMode; use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Derive\Derive; @@ -107,6 +108,29 @@ final class FieldBuilder { */ protected ?int $step = NULL; + /** + * File picker only: which entries may be selected. + */ + protected FilePickerMode $pickerMode = FilePickerMode::Any; + + /** + * File picker only: the directory the browser opens at and cannot ascend + * above. + */ + protected string $pickerStart = ''; + + /** + * File picker only: the extensions selectable files are limited to. + * + * @var list + */ + protected array $pickerExtensions = []; + + /** + * File picker only: whether dot-entries are shown when the browser opens. + */ + protected bool $pickerShowHidden = FALSE; + /** * Construct a field builder. * @@ -275,6 +299,82 @@ public function step(int $step): self { return $this; } + /** + * File picker only: set the directory the browser opens at. + * + * The browser cannot ascend above this directory, so it also bounds where a + * path may be chosen. An empty value falls back to the current working + * directory at collection time. + * + * @param string $directory + * The start directory. + * + * @return $this + * The builder. + */ + public function start(string $directory): self { + $this->pickerStart = $directory; + + return $this; + } + + /** + * File picker only: allow only files to be selected. + * + * Directories stay navigable so files beneath them remain reachable. + * + * @return $this + * The builder. + */ + public function filesOnly(): self { + $this->pickerMode = FilePickerMode::File; + + return $this; + } + + /** + * File picker only: allow only directories to be selected. + * + * @return $this + * The builder. + */ + public function directoriesOnly(): self { + $this->pickerMode = FilePickerMode::Directory; + + return $this; + } + + /** + * File picker only: limit selectable files to the given extensions. + * + * @param list $extensions + * The allowed extensions (dot-less, case-insensitive); empty allows every + * extension. + * + * @return $this + * The builder. + */ + public function extensions(array $extensions): self { + $this->pickerExtensions = $extensions; + + return $this; + } + + /** + * File picker only: show dot-entries when the browser opens. + * + * @param bool $show + * Whether hidden entries are shown initially. + * + * @return $this + * The builder. + */ + public function showHidden(bool $show = TRUE): self { + $this->pickerShowHidden = $show; + + return $this; + } + /** * Set the conditional-visibility rule. * @@ -414,6 +514,10 @@ public function build(): Field { $this->confirm, $this->externalEditor, $this->buildBounds(), + $this->pickerMode, + $this->pickerStart, + $this->pickerExtensions, + $this->pickerShowHidden, ); } @@ -475,7 +579,7 @@ protected function buildBounds(): ?NumberBounds { */ protected function defaultFor(FieldType $type): mixed { return match ($type) { - FieldType::MultiSelect, FieldType::MultiSearch => [], + FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => [], FieldType::Confirm => FALSE, FieldType::Number => 0, // A pause is an interactive acknowledgement; headless runs have nothing diff --git a/src/Builder/PanelBuilder.php b/src/Builder/PanelBuilder.php index ea52e32..87ea5d0 100644 --- a/src/Builder/PanelBuilder.php +++ b/src/Builder/PanelBuilder.php @@ -225,6 +225,36 @@ public function multisearch(string $id, string $label = ''): FieldBuilder { return $this->field($id, $label, FieldType::MultiSearch); } + /** + * Add a file picker field (browse the filesystem for a single path). + * + * @param string $id + * The field id. + * @param string $label + * The label (defaults to the id). + * + * @return \DrevOps\Tui\Builder\FieldBuilder + * The field builder. + */ + public function filePicker(string $id, string $label = ''): FieldBuilder { + return $this->field($id, $label, FieldType::FilePicker); + } + + /** + * Add a multi file picker field (browse the filesystem for several paths). + * + * @param string $id + * The field id. + * @param string $label + * The label (defaults to the id). + * + * @return \DrevOps\Tui\Builder\FieldBuilder + * The field builder. + */ + public function multiFilePicker(string $id, string $label = ''): FieldBuilder { + return $this->field($id, $label, FieldType::MultiFilePicker); + } + /** * Add a pause field (an acknowledgement gate). * diff --git a/src/Config/Field.php b/src/Config/Field.php index e298653..83df68c 100644 --- a/src/Config/Field.php +++ b/src/Config/Field.php @@ -61,6 +61,17 @@ * @param \DrevOps\Tui\Config\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\Config\FilePickerMode $pickerMode + * File picker only: which entries may be selected (any, files or + * directories); 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 list $pickerExtensions + * File picker only: the file extensions selectable files are limited to + * (dot-less, case-insensitive); empty allows every extension. + * @param bool $pickerShowHidden + * File picker only: whether dot-entries are shown when the browser opens. */ public function __construct( public string $id, @@ -80,6 +91,10 @@ public function __construct( public bool $confirm = FALSE, public bool $externalEditor = FALSE, public ?NumberBounds $bounds = NULL, + public FilePickerMode $pickerMode = FilePickerMode::Any, + public string $pickerStart = '', + public array $pickerExtensions = [], + public bool $pickerShowHidden = FALSE, ) { } diff --git a/src/Config/FieldType.php b/src/Config/FieldType.php index 3ce1a86..26127a4 100644 --- a/src/Config/FieldType.php +++ b/src/Config/FieldType.php @@ -22,6 +22,8 @@ enum FieldType: string { case Password = 'password'; case Search = 'search'; case MultiSearch = 'multisearch'; + case FilePicker = 'filepicker'; + case MultiFilePicker = 'multifilepicker'; case Pause = 'pause'; } diff --git a/src/Config/FilePickerMode.php b/src/Config/FilePickerMode.php new file mode 100644 index 0000000..011b7e0 --- /dev/null +++ b/src/Config/FilePickerMode.php @@ -0,0 +1,22 @@ + in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], TRUE), - FieldType::MultiSelect, FieldType::MultiSearch => $this->splitList($value), + FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => $this->splitList($value), FieldType::Number => (int) trim($value), default => $value, }; diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php index 9aff913..a3a675d 100644 --- a/src/Schema/SchemaValidator.php +++ b/src/Schema/SchemaValidator.php @@ -128,7 +128,7 @@ protected function checkBounds(Field $field, mixed $value): ?string { protected function isType(FieldType $type, mixed $value): bool { return match ($type) { FieldType::Confirm, FieldType::Pause => is_bool($value), - FieldType::MultiSelect, FieldType::MultiSearch => is_array($value), + FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => is_array($value), FieldType::Number => is_int($value) || is_float($value), default => is_string($value), }; @@ -146,7 +146,7 @@ protected function isType(FieldType $type, mixed $value): bool { protected function typeName(FieldType $type): string { return match ($type) { FieldType::Confirm, FieldType::Pause => 'a boolean', - FieldType::MultiSelect, FieldType::MultiSearch => 'a list', + FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => 'a list', FieldType::Number => 'a number', default => 'a string', }; diff --git a/src/Widget/FilePickerWidget.php b/src/Widget/FilePickerWidget.php new file mode 100644 index 0000000..a30f725 --- /dev/null +++ b/src/Widget/FilePickerWidget.php @@ -0,0 +1,667 @@ + TRUE), used in multiple mode. + * + * @var array + */ + protected array $selected = []; + + /** + * The normalized allowed extensions (dot-less, lowercase); empty allows all. + * + * @var list + */ + protected array $extensions; + + /** + * 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; + + /** + * The first visible entry index (the scroll offset). + */ + protected int $offset = 0; + + /** + * The scroller keeping the highlighted entry inside the window. + */ + protected Scroller $scroller; + + /** + * 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\Config\FilePickerMode $mode + * Which entries may be selected (any, files or directories). + * @param list $extensions + * The extensions selectable files are limited to (dot-less, + * case-insensitive); empty allows every extension. + * @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 \Closure|null $validate + * Optional validator (see AbstractWidget). + * @param \Closure|null $transform + * Optional transformer (see AbstractWidget). + */ + public function __construct( + string $start = '', + string|array $default = '', + protected FilePickerMode $mode = FilePickerMode::Any, + array $extensions = [], + protected bool $showHidden = FALSE, + protected bool $multiple = FALSE, + ?\Closure $validate = NULL, + ?\Closure $transform = NULL, + ) { + parent::__construct($validate, $transform); + + $this->root = $this->trimTrailingSlash($start !== '' ? $start : (string) getcwd()); + $this->cwd = $this->root; + $this->scroller = new Scroller(); + + $this->extensions = array_values(array_filter(array_map( + static fn(string $extension): string => strtolower(ltrim($extension, '.')), + $extensions, + ), static fn(string $extension): bool => $extension !== '')); + + $this->seed($default); + } + + /** + * {@inheritdoc} + */ + public function handle(Key $key): void { + if ($this->handleCancel($key)) { + return; + } + + if ($key->is(KeyName::Enter)) { + $this->onEnter(); + + return; + } + + if ($key->is(KeyName::Up)) { + $this->moveCursor(-1); + + return; + } + + if ($key->is(KeyName::Down)) { + $this->moveCursor(1); + + return; + } + + if ($key->is(KeyName::Right)) { + $this->descend(); + + return; + } + + if ($key->is(KeyName::Left)) { + $this->ascend(); + + return; + } + + if ($key->is(KeyName::Tab)) { + $this->toggleHidden(); + + return; + } + + if ($this->multiple && $key->is(KeyName::Space)) { + $this->toggleSelection(); + + return; + } + + if ($key->is(KeyName::Backspace)) { + $this->onBackspace(); + + return; + } + + if ($key->isChar()) { + $this->filter .= $key->char ?? ''; + $this->cursor = 0; + } + } + + /** + * {@inheritdoc} + */ + protected function liveValue(): mixed { + if ($this->multiple) { + return array_values(array_keys($this->selected)); + } + + $name = $this->currentName(); + + return $name === '' ? '' : $this->join($name); + } + + /** + * {@inheritdoc} + */ + public function view(ThemeInterface $theme): string { + $lines = [$theme->breadcrumb($this->crumb())]; + + if ($this->filter !== '') { + $lines[] = $this->filter . $theme->caret(); + } + + $entries = $this->entries(); + + if ($entries === []) { + $lines[] = $theme->description('(empty)'); + } + + $viewport = $this->scroller->compute(count($entries), self::WINDOW, $this->cursor, $this->offset); + $this->offset = $viewport->offset; + + if ($viewport->has_above) { + $lines[] = $theme->indicator(' ' . $theme->indicatorUp()); + } + + $last = min(count($entries), $viewport->offset + self::WINDOW); + for ($index = $viewport->offset; $index < $last; $index++) { + $lines[] = $this->renderRow($theme, $entries[$index], $index === $this->cursor); + } + + if ($viewport->has_below) { + $lines[] = $theme->indicator(' ' . $theme->indicatorDown()); + } + + $lines[] = $this->hint($theme); + + return implode("\n", $lines); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function rendersHint(): bool { + return TRUE; + } + + /** + * 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) ? array_values(array_filter($default, 'is_string')) : ($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)); + } + + /** + * 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(); + } + } + + /** + * Delete the last filter character, or ascend when the filter is empty. + */ + protected function onBackspace(): void { + if ($this->filter !== '') { + $this->filter = substr($this->filter, 0, -1); + $this->cursor = 0; + + 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); + } + + /** + * Toggle whether dot-entries are shown. + */ + protected function toggleHidden(): 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 === '.' || $name === '..') { + continue; + } + + if (!$this->showHidden && str_starts_with($name, '.')) { + continue; + } + + if (is_dir($this->cwd . '/' . $name)) { + $dirs[] = $name; + + continue; + } + + if ($this->mode === FilePickerMode::Directory || !$this->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 = strtolower($this->filter); + $names = array_filter($names, static fn(string $name): bool => str_contains(strtolower($name), $needle)); + } + + usort($names, static fn(string $a, string $b): int => strcasecmp($a, $b)); + + return array_values($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 match ($this->mode) { + FilePickerMode::Any => TRUE, + FilePickerMode::File => !$this->isDir($name), + FilePickerMode::Directory => $this->isDir($name), + }; + } + + /** + * Whether a filename's extension is allowed. + * + * @param string $name + * The entry name. + * + * @return bool + * TRUE when the extension is allowed (or no restriction applies). + */ + protected function extensionAllowed(string $name): bool { + if ($this->extensions === []) { + return TRUE; + } + + return in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), $this->extensions, TRUE); + } + + /** + * 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(' ', mb_strlen(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)); + } + + /** + * Build the key-hint line shown beneath the entry list. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return string + * The themed, dot-joined hint line. + */ + protected function hint(ThemeInterface $theme): string { + $move = $theme->arrowUp() . '/' . $theme->arrowDown() . ' move'; + $open = $theme->arrowRight() . ' open'; + $up = $theme->arrowLeft() . ' up'; + + $fragments = $this->multiple + ? ['space select', $move, $open, $up, $theme->enter() . ' accept', 'tab hidden', 'esc cancel'] + : [$move, $open, $up, $theme->enter() . ' select', 'tab hidden', 'esc cancel']; + + return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); + } + + /** + * 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/WidgetFactory.php b/src/Widget/WidgetFactory.php index ea31c34..c11c7aa 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -56,6 +56,8 @@ public function create(Field $field, mixed $current): WidgetInterface { FieldType::MultiSearch => new MultiSearchWidget($labels, $this->toList($current)), FieldType::Suggest => new SuggestWidget(array_keys($labels), is_string($current) ? $current : ''), FieldType::Search => new SearchWidget($labels, is_string($current) ? $current : ''), + FieldType::FilePicker => new FilePickerWidget($field->pickerStart, is_string($current) ? $current : '', $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden), + FieldType::MultiFilePicker => new FilePickerWidget($field->pickerStart, $this->toList($current), $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden, multiple: TRUE), FieldType::Number => new NumberWidget(is_int($current) || is_float($current) ? (string) (int) $current : '', bounds: $field->bounds), FieldType::Textarea => new TextareaWidget(is_string($current) ? $current : '', externalEdit: $field->externalEditor && $this->externalEditorAvailable), FieldType::Password => new PasswordWidget(is_string($current) ? $current : '', revealable: $field->revealable, confirm: $field->confirm), diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 8cd9ebb..ef1cd9d 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -11,6 +11,7 @@ use DrevOps\Tui\Config\ConfigException; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Config\FilePickerMode; use DrevOps\Tui\Config\Fixup; use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Derive\Derive; @@ -145,6 +146,8 @@ public function testDefaultsAndFallbacks(): void { $panel->search('se')->option('a'); $panel->multisearch('ms')->option('a'); $panel->toggle('tg')->option('on', 'On')->option('off', 'Off'); + $panel->filePicker('fp'); + $panel->multiFilePicker('mfp'); $panel->pause('pa'); }) ->build(); @@ -165,6 +168,14 @@ public function testDefaultsAndFallbacks(): void { $this->assertSame([], $config->field('ms')?->default); // A toggle defaults to its first option, since it always holds a value. $this->assertSame('on', $config->field('tg')?->default); + // A single picker defaults to an empty path; a multiple picker to no paths. + $this->assertSame('', $config->field('fp')?->default); + $this->assertSame([], $config->field('mfp')?->default); + // The picker options are opt-in, so they default off. + $this->assertSame(FilePickerMode::Any, $config->field('fp')->pickerMode); + $this->assertSame('', $config->field('fp')->pickerStart); + $this->assertSame([], $config->field('fp')->pickerExtensions); + $this->assertFalse($config->field('fp')->pickerShowHidden); // A pause defaults to acknowledged so headless runs never block on it. $this->assertTrue($config->field('pa')?->default); @@ -247,6 +258,28 @@ public function testNumberNonPositiveStepThrows(): void { ->build(); } + public function testFilePickerOptions(): void { + $config = Form::create('T') + ->panel('p', 'P', function (PanelBuilder $panel): void { + $panel->filePicker('config', 'Config')->start('/opt')->filesOnly()->extensions(['yml', 'yaml'])->showHidden(); + $panel->multiFilePicker('assets', 'Assets')->directoriesOnly(); + }) + ->build(); + + $config_field = $config->field('config'); + $this->assertInstanceOf(Field::class, $config_field); + $this->assertSame(FieldType::FilePicker, $config_field->type); + $this->assertSame(FilePickerMode::File, $config_field->pickerMode); + $this->assertSame('/opt', $config_field->pickerStart); + $this->assertSame(['yml', 'yaml'], $config_field->pickerExtensions); + $this->assertTrue($config_field->pickerShowHidden); + + $assets = $config->field('assets'); + $this->assertInstanceOf(Field::class, $assets); + $this->assertSame(FieldType::MultiFilePicker, $assets->type); + $this->assertSame(FilePickerMode::Directory, $assets->pickerMode); + } + public function testDuplicateFieldIdThrows(): void { $this->expectException(ConfigException::class); $this->expectExceptionMessage('Duplicate field id "x".'); diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php index 7c2ccd6..1137559 100644 --- a/tests/phpunit/Unit/Resolver/InputResolverTest.php +++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php @@ -81,6 +81,17 @@ public function testEnvName(): void { $this->assertSame('VORTEX_MACHINE_NAME', (new InputResolver('VORTEX_'))->envName('machine_name')); } + public function testFilePickerCoercion(): void { + $inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', [ + 'VORTEX_PATHS' => 'a/b, c/d', + 'VORTEX_CFG' => '/etc/app.yml', + ]); + + // A multiple picker splits a comma list; a single picker stays a string. + $this->assertSame(['a/b', 'c/d'], $inputs['paths']); + $this->assertSame('/etc/app.yml', $inputs['cfg']); + } + public function testNumberPauseAndMultisearchCoercion(): void { $inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', [ 'VORTEX_PORT' => ' 8080 ', @@ -108,6 +119,8 @@ protected function fields(): array { new Field('ack', 'Ack', '', FieldType::Pause, TRUE), new Field('tags', 'Tags', '', FieldType::MultiSearch, []), new Field('vis', 'Visibility', '', FieldType::Toggle, 'public'), + new Field('paths', 'Paths', '', FieldType::MultiFilePicker, []), + new Field('cfg', 'Config', '', FieldType::FilePicker, ''), ]; } diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index 2b42832..ed00ec2 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -134,6 +134,20 @@ public function testNumericStringOptionMembership(): void { $this->assertContains('Question "flag" must be one of: 0, 1.', $validator->validate(['flag' => '2'])); } + public function testFilePickerAcceptsString(): void { + $validator = new SchemaValidator($this->config()); + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'cfg' => '/etc/app.yml'])); + $this->assertContains('Question "cfg" must be a string.', $validator->validate(['name' => 'Acme', 'cfg' => ['x']])); + } + + public function testMultiFilePickerAcceptsList(): void { + $validator = new SchemaValidator($this->config()); + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'paths' => ['/a', '/b']])); + $this->assertContains('Question "paths" must be a list.', $validator->validate(['name' => 'Acme', 'paths' => 'notalist'])); + } + /** * Build a config exercising every validation branch. */ @@ -150,6 +164,8 @@ protected function config(): Config { $p->search('engine')->option('solr')->option('none'); $p->multisearch('tags')->option('a')->option('b'); $p->toggle('visibility')->option('public')->option('private'); + $p->filePicker('cfg'); + $p->multiFilePicker('paths'); }) ->build(); } diff --git a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php new file mode 100644 index 0000000..ef18032 --- /dev/null +++ b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php @@ -0,0 +1,430 @@ + ['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, mode: 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, mode: 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, mode: 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, mode: FilePickerMode::File, extensions: ['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($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 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, mode: 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->assertTrue($widget->rendersHint()); + $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); + // The hint line advertises the navigation keys. + $this->assertStringContainsString('open', $view); + $this->assertStringContainsString('tab hidden', $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)); + // The multiple-mode hint leads with the toggle key. + $this->assertStringContainsString('space select', $widget->view($theme)); + } + + 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', Ansi::strip((string) $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 FilePickerWidget(''); + + // With no start the browser roots at the current working directory, so the + // breadcrumb is its basename. + $this->assertStringContainsString((string) basename((string) getcwd()), $this->render($widget)); + } + + /** + * 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/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index e1585a9..acb0e9d 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -12,6 +12,7 @@ use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Widget\ConfirmWidget; +use DrevOps\Tui\Widget\FilePickerWidget; use DrevOps\Tui\Widget\MultiSearchWidget; use DrevOps\Tui\Widget\MultiSelectWidget; use DrevOps\Tui\Widget\NumberWidget; @@ -49,9 +50,24 @@ public function testCreatesByType(): void { $this->assertInstanceOf(PasswordWidget::class, $factory->create($this->field(FieldType::Password), 'x')); $this->assertInstanceOf(SearchWidget::class, $factory->create($this->fieldWithOptions(FieldType::Search), 'a')); $this->assertInstanceOf(MultiSearchWidget::class, $factory->create($this->fieldWithOptions(FieldType::MultiSearch), ['a'])); + $this->assertInstanceOf(FilePickerWidget::class, $factory->create($this->field(FieldType::FilePicker), '/tmp')); + $this->assertInstanceOf(FilePickerWidget::class, $factory->create($this->field(FieldType::MultiFilePicker), ['/tmp'])); $this->assertInstanceOf(PauseWidget::class, $factory->create($this->field(FieldType::Pause), TRUE)); } + public function testFilePickerFlagsPassedThrough(): void { + $single = new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: '/nonexistent'); + $multi = new Field('g', 'G', '', FieldType::MultiFilePicker, [], pickerStart: '/nonexistent'); + + // The single picker yields a string; a current path outside the start is + // ignored and the missing directory lists nothing, so the value is empty. + $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. + $this->assertSame(['/a', '/b'], (new WidgetFactory())->create($multi, ['/a', '/b'])->value()); + } + public function testPasswordFlagsPassedThrough(): void { $field = new Field('f', 'F', '', FieldType::Password, '', revealable: TRUE, confirm: TRUE); From bcee9f7d71a0752244c657b325650272a57a41c8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 22:23:59 +1000 Subject: [PATCH 2/3] [#6] Aligned picker headings, value list handling and a test assertion. --- README.md | 4 ++-- src/Widget/FilePickerWidget.php | 2 +- tests/phpunit/Unit/Widget/FilePickerWidgetTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d533ad2..85e74c5 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ $p->multisearch('services', 'Services')->default(['redis'])->options(['redis' => -### File picker +### FilePicker Browse the filesystem for a single path. Arrows move, `โ†’` enters the highlighted directory and `โ†` returns to its parent (never above the start directory), typing filters the current directory and `Tab` reveals hidden entries; `Enter` selects the highlighted entry. Restrict what may be chosen with `filesOnly()` or `directoriesOnly()`, limit files with `extensions()`, and choose where browsing starts with `start()`. It collects the chosen path. @@ -364,7 +364,7 @@ $p->filePicker('config', 'Config file')->start(getcwd())->filesOnly()->extension -### Multi file picker +### MultiFilePicker Like the file picker, but several paths can be chosen: `Space` toggles the highlighted entry and selections accumulate as you browse between directories, `Enter` accepts them all. It collects the list of chosen paths. diff --git a/src/Widget/FilePickerWidget.php b/src/Widget/FilePickerWidget.php index a30f725..6ec0e48 100644 --- a/src/Widget/FilePickerWidget.php +++ b/src/Widget/FilePickerWidget.php @@ -191,7 +191,7 @@ public function handle(Key $key): void { */ protected function liveValue(): mixed { if ($this->multiple) { - return array_values(array_keys($this->selected)); + return array_keys($this->selected); } $name = $this->currentName(); diff --git a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php index ef18032..9ffe740 100644 --- a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php +++ b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php @@ -396,7 +396,7 @@ public function testValueReflectsHighlightBeforeAccept(): void { $widget = new FilePickerWidget($this->root); // Before acceptance the value tracks the highlighted entry. - $this->assertSame($this->root . '/docs', Ansi::strip((string) $widget->value())); + $this->assertSame($this->root . '/docs', $widget->value()); $widget->handle(Key::named(KeyName::Down)); $this->assertSame($this->root . '/empty', $widget->value()); From a7e4b0951af0a04d5593eb2645a5674b6d19818d Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 22:46:33 +1000 Subject: [PATCH 3/3] [#6] Routed the file picker widgets through the configurable key map. --- .../3-widgets/filepicker-tree/src/Kernel.php | 7 +- src/Builder/FieldBuilder.php | 3 +- src/Input/DefaultKeyMap.php | 7 ++ src/Input/Scope.php | 2 + src/Widget/FilePickerWidget.php | 73 +++++++++++++------ tests/phpunit/Unit/Input/KeyMapTest.php | 7 ++ .../Unit/Widget/FilePickerWidgetTest.php | 2 +- 7 files changed, 72 insertions(+), 29 deletions(-) diff --git a/playground/3-widgets/filepicker-tree/src/Kernel.php b/playground/3-widgets/filepicker-tree/src/Kernel.php index 458ebc5..3bb3d08 100644 --- a/playground/3-widgets/filepicker-tree/src/Kernel.php +++ b/playground/3-widgets/filepicker-tree/src/Kernel.php @@ -1,5 +1,8 @@ seed($default); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field($this->multiple ? FieldType::MultiFilePicker : FieldType::FilePicker); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->onEnter(); return; } - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->moveCursor(-1); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->moveCursor(1); return; } - if ($key->is(KeyName::Right)) { + if ($keys->matches($key, Action::MoveRight)) { $this->descend(); return; } - if ($key->is(KeyName::Left)) { + if ($keys->matches($key, Action::MoveLeft)) { $this->ascend(); return; } - if ($key->is(KeyName::Tab)) { + // Reveal doubles as the show-hidden toggle, mirroring the password reveal. + if ($keys->matches($key, Action::Reveal)) { $this->toggleHidden(); return; } - if ($this->multiple && $key->is(KeyName::Space)) { + if ($keys->matches($key, Action::Toggle)) { $this->toggleSelection(); return; } - if ($key->is(KeyName::Backspace)) { + if ($keys->matches($key, Action::DeleteBack)) { $this->onBackspace(); return; @@ -251,7 +264,7 @@ public function rendersHint(): bool { * The default path or paths. */ protected function seed(string|array $default): void { - $paths = is_array($default) ? array_values(array_filter($default, 'is_string')) : ($default === '' ? [] : [$default]); + $paths = is_array($default) ? array_values(array_filter($default, is_string(...))) : ($default === '' ? [] : [$default]); if ($this->multiple) { foreach ($paths as $path) { @@ -419,14 +432,15 @@ protected function entries(): array { return []; } // @codeCoverageIgnoreEnd - $dirs = []; $files = []; foreach ($raw as $name) { - if ($name === '.' || $name === '..') { + if ($name === '.') { + continue; + } + if ($name === '..') { continue; } - if (!$this->showHidden && str_starts_with($name, '.')) { continue; } @@ -436,8 +450,10 @@ protected function entries(): array { continue; } - - if ($this->mode === FilePickerMode::Directory || !$this->extensionAllowed($name)) { + if ($this->mode === FilePickerMode::Directory) { + continue; + } + if (!$this->extensionAllowed($name)) { continue; } @@ -462,9 +478,9 @@ protected function sortFilter(array $names): array { $names = array_filter($names, static fn(string $name): bool => str_contains(strtolower($name), $needle)); } - usort($names, static fn(string $a, string $b): int => strcasecmp($a, $b)); + usort($names, strcasecmp(...)); - return array_values($names); + return $names; } /** @@ -582,6 +598,11 @@ protected function crumb(): string { /** * Build the key-hint line shown beneath the entry list. * + * Every glyph is drawn from the live bindings, so the line stays truthful + * when the keys are remapped. The Toggle fragment only appears in multiple + * mode, where Space is bound to it; Accept reads "select" for a single pick + * and "accept" for a multiple one. + * * @param \DrevOps\Tui\Theme\ThemeInterface $theme * The theme. * @@ -589,13 +610,17 @@ protected function crumb(): string { * The themed, dot-joined hint line. */ protected function hint(ThemeInterface $theme): string { - $move = $theme->arrowUp() . '/' . $theme->arrowDown() . ' move'; - $open = $theme->arrowRight() . ' open'; - $up = $theme->arrowLeft() . ' up'; - - $fragments = $this->multiple - ? ['space select', $move, $open, $up, $theme->enter() . ' accept', 'tab hidden', 'esc cancel'] - : [$move, $open, $up, $theme->enter() . ' select', 'tab hidden', 'esc cancel']; + $keys = $this->keys(); + + $fragments = array_filter([ + $theme->keysHint($keys, 'select', Action::Toggle), + $theme->keysHint($keys, 'move', Action::MoveUp, Action::MoveDown), + $theme->keysHint($keys, 'open', Action::MoveRight), + $theme->keysHint($keys, 'up', Action::MoveLeft), + $theme->keysHint($keys, $this->multiple ? 'accept' : 'select', Action::Accept), + $theme->keysHint($keys, 'hidden', Action::Reveal), + $theme->keysHint($keys, 'cancel', Action::Cancel), + ]); return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); } diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php index 413f668..ff09a23 100644 --- a/tests/phpunit/Unit/Input/KeyMapTest.php +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -74,6 +74,11 @@ public static function dataProviderDefaultBindings(): \Iterator { yield 'multiselect right selects all' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Right), Action::SelectAll, TRUE]; yield 'multiselect up moves up (inherited)' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Up), Action::MoveUp, TRUE]; yield 'multisearch space toggles' => [Scope::field(FieldType::MultiSearch), Key::named(KeyName::Space), Action::Toggle, TRUE]; + // File pickers reveal hidden entries on Tab; base arrows browse in and out. + yield 'filepicker tab reveals' => [Scope::field(FieldType::FilePicker), Key::named(KeyName::Tab), Action::Reveal, TRUE]; + yield 'filepicker right browses in (inherited)' => [Scope::field(FieldType::FilePicker), Key::named(KeyName::Right), Action::MoveRight, TRUE]; + yield 'multifilepicker space toggles' => [Scope::field(FieldType::MultiFilePicker), Key::named(KeyName::Space), Action::Toggle, TRUE]; + yield 'multifilepicker tab reveals' => [Scope::field(FieldType::MultiFilePicker), Key::named(KeyName::Tab), Action::Reveal, TRUE]; // Pause binds two keys to accept. yield 'pause enter accepts' => [Scope::field(FieldType::Pause), Key::named(KeyName::Enter), Action::Accept, TRUE]; yield 'pause space accepts' => [Scope::field(FieldType::Pause), Key::named(KeyName::Space), Action::Accept, TRUE]; @@ -219,6 +224,8 @@ public static function dataProviderScope(): \Iterator { yield 'navigation' => [Scope::navigation(), '@navigation', 'navigation', FALSE]; yield 'text is a text-entry scope' => [Scope::field(FieldType::Text), 'field:Text', 'text', TRUE]; yield 'select is not a text-entry scope' => [Scope::field(FieldType::Select), 'field:Select', 'select', FALSE]; + // The file picker filters by typing, so it is a text-entry scope. + yield 'filepicker is a text-entry scope' => [Scope::field(FieldType::FilePicker), 'field:FilePicker', 'filepicker', TRUE]; } public function testBindingHoldsItsDeclaration(): void { diff --git a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php index 9ffe740..f5ddc6a 100644 --- a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php +++ b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php @@ -411,7 +411,7 @@ public function testDefaultsToWorkingDirectoryWhenStartEmpty(): void { // With no start the browser roots at the current working directory, so the // breadcrumb is its basename. - $this->assertStringContainsString((string) basename((string) getcwd()), $this->render($widget)); + $this->assertStringContainsString(basename((string) getcwd()), $this->render($widget)); } /**