diff --git a/README.md b/README.md
index 6cd4dce..85e74c5 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.
@@ -338,6 +338,58 @@ $p->multisearch('services', 'Services')->default(['redis'])->options(['redis' =>
+### 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.
+
+```php
+$p->filePicker('config', 'Config file')->start(getcwd())->filesOnly()->extensions(['yml', 'yaml']);
+```
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
+### 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.
+
+```php
+$p->multiFilePicker('assets', 'Asset files')->start(getcwd())->extensions(['png', 'svg']);
+```
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
### 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 @@
+
\ 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..3bb3d08
--- /dev/null
+++ b/playground/3-widgets/filepicker-tree/src/Kernel.php
@@ -0,0 +1,8 @@
+ !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..5677a74 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,28 @@ final class FieldBuilder {
*/
protected ?int $step = NULL;
+ /**
+ * File picker only: which entries may be selected.
+ */
+ 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.
+ *
+ * @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 +298,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 +513,10 @@ public function build(): Field {
$this->confirm,
$this->externalEditor,
$this->buildBounds(),
+ $this->pickerMode,
+ $this->pickerStart,
+ $this->pickerExtensions,
+ $this->pickerShowHidden,
);
}
@@ -475,7 +578,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..bc7edeb
--- /dev/null
+++ b/src/Widget/FilePickerWidget.php
@@ -0,0 +1,692 @@
+ 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}
+ */
+ #[\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 ($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->toggleHidden();
+
+ 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->cursor = 0;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ if ($this->multiple) {
+ return 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 === '.') {
+ continue;
+ }
+ if ($name === '..') {
+ continue;
+ }
+ if (!$this->showHidden && str_starts_with($name, '.')) {
+ continue;
+ }
+
+ if (is_dir($this->cwd . '/' . $name)) {
+ $dirs[] = $name;
+
+ continue;
+ }
+ if ($this->mode === FilePickerMode::Directory) {
+ continue;
+ }
+ if (!$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, strcasecmp(...));
+
+ 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 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.
+ *
+ * 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.
+ *
+ * @return string
+ * The themed, dot-joined hint line.
+ */
+ protected function hint(ThemeInterface $theme): string {
+ $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));
+ }
+
+ /**
+ * 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/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/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..f5ddc6a
--- /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', $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(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);