diff --git a/README.md b/README.md
index 6662d10..d0d3e53 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`, `pause`
+- ๐งฉ [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `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
@@ -72,7 +72,7 @@ It also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want
## Widgets
-Eleven 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.
+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.
@@ -357,6 +357,32 @@ $p->confirm('cdn', 'Serve via CDN?')->default(TRUE);
+### Toggle
+
+An inline switch between two labeled values. Arrows or Space flip, the first letter of each label sets the choice directly, Enter accepts. Pass `default` (an option value) to start on the other value.
+
+```php
+$p->toggle('telemetry', 'Telemetry')->options(['enabled' => 'Enabled', 'disabled' => 'Disabled'])->default('enabled');
+```
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
### Pause
An acknowledgement gate: Enter (or Space) accepts `TRUE`. Headless runs auto-acknowledge it, so it never blocks automation.
diff --git a/composer.json b/composer.json
index e6e04db..bd6f7ba 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
},
"require": {
"php": ">=8.3",
+ "ext-mbstring": "*",
"alexskrypnyk/str2name": "^1.5.0",
"symfony/console": "^7.4.14"
},
diff --git a/docs/assets/widget-toggle-ascii-no-ansi.svg b/docs/assets/widget-toggle-ascii-no-ansi.svg
new file mode 100644
index 0000000..6a9e8f5
--- /dev/null
+++ b/docs/assets/widget-toggle-ascii-no-ansi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-ascii.svg b/docs/assets/widget-toggle-ascii.svg
new file mode 100644
index 0000000..75af2b2
--- /dev/null
+++ b/docs/assets/widget-toggle-ascii.svg
@@ -0,0 +1 @@
+Togglewidget-------------edit*Enteraccept*Esccancel(*)Enabled()Disabled
\ No newline at end of file
diff --git a/docs/assets/widget-toggle-no-ansi.svg b/docs/assets/widget-toggle-no-ansi.svg
new file mode 100644
index 0000000..ad9a06f
--- /dev/null
+++ b/docs/assets/widget-toggle-no-ansi.svg
@@ -0,0 +1 @@
+TogglewidgetโโโโโโโโโโโโโeditยทEnteracceptยทEsccancelโEnabledโDisabled
\ No newline at end of file
diff --git a/docs/assets/widget-toggle.svg b/docs/assets/widget-toggle.svg
new file mode 100644
index 0000000..ea7bcae
--- /dev/null
+++ b/docs/assets/widget-toggle.svg
@@ -0,0 +1 @@
+TogglewidgetโโโโโโโโโโโโโeditยทEnteracceptยทEsccancelโEnabledโDisabled
\ No newline at end of file
diff --git a/docs/assets/widgets-ascii-no-ansi.svg b/docs/assets/widgets-ascii-no-ansi.svg
index b499f19..7b03758 100644
--- a/docs/assets/widgets-ascii-no-ansi.svg
+++ b/docs/assets/widgets-ascii-no-ansi.svg
@@ -1 +1 @@
-Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAV[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falsePause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)NoPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
+Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets-ascii.svg b/docs/assets/widgets-ascii.svg
index 6be95ec..e34d534 100644
--- a/docs/assets/widgets-ascii.svg
+++ b/docs/assets/widgets-ascii.svg
@@ -1 +1 @@
-Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAV[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falsePause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)NoPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
+Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets-no-ansi.svg b/docs/assets/widgets-no-ansi.svg
index 31c02b0..7130a73 100644
--- a/docs/assets/widgets-no-ansi.svg
+++ b/docs/assets/widgets-no-ansi.svg
@@ -1 +1 @@
-TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falsePause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
+TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets.svg b/docs/assets/widgets.svg
index 6f4e151..a5b85ae 100644
--- a/docs/assets/widgets.svg
+++ b/docs/assets/widgets.svg
@@ -1 +1 @@
-TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falsePause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
+TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
diff --git a/docs/util/update-assets.php b/docs/util/update-assets.php
index 02b6549..c1000ff 100644
--- a/docs/util/update-assets.php
+++ b/docs/util/update-assets.php
@@ -148,6 +148,14 @@ function widgetInteractions(): array {
type_text "n"
wait_and_enter
}
+EXPECT,
+ 'toggle' => <<<'EXPECT'
+# Toggle: select the second value by its first letter, accept.
+expect "Toggle widget" {
+ pause 1000
+ type_text "d"
+ wait_and_enter
+}
EXPECT,
'pause' => <<<'EXPECT'
# Pause: acknowledge.
@@ -489,6 +497,7 @@ function getJobs(string $project_dir): array {
'search' => 10,
'multisearch' => 10,
'confirm' => 6,
+ 'toggle' => 6,
'pause' => 6,
];
diff --git a/playground/3-widgets/show.php b/playground/3-widgets/show.php
index b784d76..43894c4 100644
--- a/playground/3-widgets/show.php
+++ b/playground/3-widgets/show.php
@@ -28,6 +28,7 @@
use DrevOps\Tui\Widget\SuggestWidget;
use DrevOps\Tui\Widget\TextareaWidget;
use DrevOps\Tui\Widget\TextWidget;
+use DrevOps\Tui\Widget\ToggleWidget;
use DrevOps\Tui\Widget\WidgetInterface;
require __DIR__ . '/../../vendor/autoload.php';
@@ -75,6 +76,10 @@
'memcached' => 'Memcached',
], ['redis']),
'Confirm' => static fn(): WidgetInterface => new ConfirmWidget(TRUE),
+ 'Toggle' => static fn(): WidgetInterface => new ToggleWidget([
+ 'enabled' => 'Enabled',
+ 'disabled' => 'Disabled',
+ ], 'enabled'),
'Pause' => static fn(): WidgetInterface => new PauseWidget(),
];
diff --git a/playground/3-widgets/widget-toggle.php b/playground/3-widgets/widget-toggle.php
new file mode 100644
index 0000000..82f74c1
--- /dev/null
+++ b/playground/3-widgets/widget-toggle.php
@@ -0,0 +1,51 @@
+ !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]);
+
+$widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+
+$terminal = new Terminal();
+$parser = new KeyParser();
+$terminal->setup();
+
+try {
+ while (!$widget->isComplete() && !$widget->isCancelled()) {
+ $terminal->render(implode("\n", [
+ $theme->renderEditorHeader('Toggle widget'),
+ $theme->renderHintLine('edit', 'Enter accept', 'Esc cancel'),
+ '',
+ $widget->view($theme),
+ ]));
+
+ foreach ($parser->parse($terminal->read()) as $key) {
+ $widget->handle($key);
+ }
+ }
+}
+finally {
+ $terminal->restore();
+}
+
+echo 'Toggle: ' . ($widget->isCancelled() ? '(cancelled)' : (string) json_encode($widget->value())) . PHP_EOL;
diff --git a/playground/3-widgets/widgets.php b/playground/3-widgets/widgets.php
index 15f928d..2f37b37 100644
--- a/playground/3-widgets/widgets.php
+++ b/playground/3-widgets/widgets.php
@@ -26,6 +26,7 @@
use DrevOps\Tui\Widget\SuggestWidget;
use DrevOps\Tui\Widget\TextareaWidget;
use DrevOps\Tui\Widget\TextWidget;
+use DrevOps\Tui\Widget\ToggleWidget;
use DrevOps\Tui\Widget\WidgetInterface;
require __DIR__ . '/../../vendor/autoload.php';
@@ -79,4 +80,5 @@
], 'london'), 'Search');
$interact(new MultiSearchWidget(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV', 'memcached' => 'Memcached'], ['redis']), 'MultiSearch');
$interact(new ConfirmWidget(TRUE), 'Confirm');
+$interact(new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'), 'Toggle');
$interact(new PauseWidget(), 'Pause', ['Enter continue', 'Esc cancel']);
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 51a452c..d87c0ac 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -339,7 +339,7 @@ public function build(): Field {
$this->label,
$this->description,
$this->fieldType,
- $this->hasDefault ? $this->default : $this->defaultFor($this->fieldType),
+ $this->resolveDefault(),
$this->options,
$this->required,
$this->when,
@@ -354,6 +354,28 @@ public function build(): Field {
);
}
+ /**
+ * The effective default: the declared one, or the type's implicit default.
+ *
+ * @return mixed
+ * The default value.
+ */
+ protected function resolveDefault(): mixed {
+ if ($this->hasDefault) {
+ return $this->default;
+ }
+
+ // A toggle is always in one of its two states, so it defaults to the first
+ // option's value rather than an empty value that would not match either.
+ // The value is read off the option, not its array key, so a numeric-string
+ // value like "0" is not coerced to an int.
+ if ($this->fieldType === FieldType::Toggle && $this->options !== []) {
+ return reset($this->options)->value;
+ }
+
+ return $this->defaultFor($this->fieldType);
+ }
+
/**
* The engine default for a field type when none is declared.
*
diff --git a/src/Builder/Form.php b/src/Builder/Form.php
index 5150cf4..3fc6ec6 100644
--- a/src/Builder/Form.php
+++ b/src/Builder/Form.php
@@ -6,7 +6,9 @@
use DrevOps\Tui\Config\Config;
use DrevOps\Tui\Config\ConfigException;
+use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\Fixup;
+use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Config\Panel;
/**
@@ -288,6 +290,7 @@ public function build(): Config {
);
$this->assertUniqueFieldIds($config);
+ $this->assertToggleOptions($config);
return $config;
}
@@ -310,4 +313,35 @@ protected function assertUniqueFieldIds(Config $config): void {
}
}
+ /**
+ * Assert that every toggle field declares exactly two options.
+ *
+ * @param \DrevOps\Tui\Config\Config $config
+ * The built config.
+ */
+ protected function assertToggleOptions(Config $config): void {
+ foreach ($config->fields() as $field) {
+ if ($field->type !== FieldType::Toggle) {
+ continue;
+ }
+
+ if (count($field->options) !== 2) {
+ throw new ConfigException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id, count($field->options)));
+ }
+
+ // A dynamic default is a closure resolved at runtime; every literal
+ // default - whatever its type - must be one of the two option values,
+ // otherwise the widget would silently coerce it and select the first.
+ if ($field->default instanceof \Closure) {
+ continue;
+ }
+
+ $values = array_map(static fn(Option $option): string => $option->value, $field->options);
+
+ if (!is_string($field->default) || !in_array($field->default, $values, TRUE)) {
+ throw new ConfigException(sprintf('Toggle field "%s" default must be one of: %s.', $field->id, implode(', ', $values)));
+ }
+ }
+ }
+
}
diff --git a/src/Builder/PanelBuilder.php b/src/Builder/PanelBuilder.php
index ca8e23b..ea52e32 100644
--- a/src/Builder/PanelBuilder.php
+++ b/src/Builder/PanelBuilder.php
@@ -120,6 +120,21 @@ public function confirm(string $id, string $label = ''): FieldBuilder {
return $this->field($id, $label, FieldType::Confirm);
}
+ /**
+ * Add a toggle field (an inline switch between two labeled values).
+ *
+ * @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 toggle(string $id, string $label = ''): FieldBuilder {
+ return $this->field($id, $label, FieldType::Toggle);
+ }
+
/**
* Add a suggest field.
*
diff --git a/src/Config/FieldType.php b/src/Config/FieldType.php
index 17879ec..3ce1a86 100644
--- a/src/Config/FieldType.php
+++ b/src/Config/FieldType.php
@@ -15,6 +15,7 @@ enum FieldType: string {
case Select = 'select';
case MultiSelect = 'multiselect';
case Confirm = 'confirm';
+ case Toggle = 'toggle';
case Suggest = 'suggest';
case Number = 'number';
case Textarea = 'textarea';
diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php
index 6a17d88..67725d7 100644
--- a/src/Schema/SchemaValidator.php
+++ b/src/Schema/SchemaValidator.php
@@ -7,6 +7,7 @@
use DrevOps\Tui\Config\Config;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
+use DrevOps\Tui\Config\Option;
/**
* Validates an answer set against the configuration.
@@ -158,9 +159,12 @@ protected function checkOptions(Field $field, mixed $value): ?string {
return NULL;
}
- $valid = array_keys($field->options);
+ // Option values are read off each option, not from the array keys, so a
+ // numeric-string value like "0" is compared as a string rather than an
+ // int coerced by the array key.
+ $valid = array_map(static fn(Option $option): string => $option->value, $field->options);
- if (in_array($field->type, [FieldType::Select, FieldType::Search], TRUE) && is_string($value) && !in_array($value, $valid, TRUE)) {
+ if (in_array($field->type, [FieldType::Select, FieldType::Search, FieldType::Toggle], TRUE) && is_string($value) && !in_array($value, $valid, TRUE)) {
return sprintf('Question "%s" must be one of: %s.', $field->id, implode(', ', $valid));
}
diff --git a/src/Widget/ToggleWidget.php b/src/Widget/ToggleWidget.php
new file mode 100644
index 0000000..2648900
--- /dev/null
+++ b/src/Widget/ToggleWidget.php
@@ -0,0 +1,141 @@
+
+ */
+ protected array $values;
+
+ /**
+ * The selected option index.
+ */
+ protected int $cursor = 0;
+
+ /**
+ * Construct a toggle widget.
+ *
+ * @param array $labels
+ * Options as value => label, in display order.
+ * @param string $default
+ * The initially selected value.
+ * @param \Closure|null $validate
+ * Optional validator (see AbstractWidget).
+ * @param \Closure|null $transform
+ * Optional transformer (see AbstractWidget).
+ */
+ public function __construct(protected array $labels, string $default = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ parent::__construct($validate, $transform);
+ $this->values = array_keys($this->labels);
+ $index = array_search($default, $this->values, TRUE);
+ $this->cursor = $index === FALSE ? 0 : $index;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($key->is(KeyName::Enter)) {
+ $this->accept($this->liveValue());
+
+ return;
+ }
+
+ if ($this->isToggle($key)) {
+ $this->flip();
+
+ return;
+ }
+
+ if ($key->isChar()) {
+ $this->applyChar($key->char ?? '');
+ }
+ }
+
+ /**
+ * Whether the key flips the switch.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key to test.
+ *
+ * @return bool
+ * TRUE when the key flips.
+ */
+ protected function isToggle(Key $key): bool {
+ return in_array($key->name, [KeyName::Left, KeyName::Right, KeyName::Space, KeyName::Up, KeyName::Down], TRUE);
+ }
+
+ /**
+ * Move the selection to the next value, wrapping at the end.
+ */
+ protected function flip(): void {
+ $count = count($this->values);
+ if ($count < 2) {
+ return;
+ }
+
+ $this->cursor = ($this->cursor + 1) % $count;
+ }
+
+ /**
+ * Select the value whose label starts with the typed character.
+ *
+ * The first matching label wins, so labels sharing a first letter resolve to
+ * the one declared first; the other stays reachable by flipping.
+ *
+ * @param string $char
+ * The typed character.
+ */
+ protected function applyChar(string $char): void {
+ $char = mb_strtolower($char);
+
+ foreach ($this->values as $index => $value) {
+ $label = $this->labels[$value] ?? $value;
+ if ($label !== '' && mb_strtolower(mb_substr($label, 0, 1)) === $char) {
+ $this->cursor = $index;
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->values[$this->cursor] ?? '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function view(ThemeInterface $theme): string {
+ $parts = [];
+
+ foreach ($this->values as $index => $value) {
+ $parts[] = $this->renderRadioRow($theme, $this->labels[$value] ?? $value, $index === $this->cursor);
+ }
+
+ return implode(' ', $parts);
+ }
+
+}
diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php
index f57e5a4..e828eda 100644
--- a/src/Widget/WidgetFactory.php
+++ b/src/Widget/WidgetFactory.php
@@ -40,6 +40,7 @@ public function create(Field $field, mixed $current): WidgetInterface {
return match ($field->type) {
FieldType::Confirm => new ConfirmWidget((bool) $current),
+ FieldType::Toggle => new ToggleWidget($labels, is_string($current) ? $current : ''),
FieldType::Select => new SelectWidget($labels, is_string($current) ? $current : ''),
FieldType::MultiSelect => new MultiSelectWidget($labels, $this->toList($current)),
FieldType::MultiSearch => new MultiSearchWidget($labels, $this->toList($current)),
diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php
index 26bb377..5c0ae66 100644
--- a/tests/phpunit/Unit/Builder/FormTest.php
+++ b/tests/phpunit/Unit/Builder/FormTest.php
@@ -15,6 +15,7 @@
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Discovery\Dotenv;
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
@@ -46,6 +47,7 @@ public function testBuildsExpectedConfig(): void {
$p->select('profile', 'Profile')->options(['standard' => 'Standard', 'minimal' => 'Minimal'])->default('standard');
$p->multiselect('services', 'Services')->option('solr', 'Solr', 'Search')->option('redis', 'Redis');
$p->confirm('docs', 'Keep docs?')->default(TRUE)->when(new Condition('profile', eq: 'standard'));
+ $p->toggle('visibility', 'Visibility')->options(['public' => 'Public', 'private' => 'Private'])->default('private');
$p->password('secret', 'Secret')->revealable()->confirm();
$p->suggest('timezone', 'Timezone')->discover(new Dotenv('TZ'));
$p->panel('advanced', 'Advanced', function (PanelBuilder $sp): void {
@@ -98,6 +100,12 @@ public function testBuildsExpectedConfig(): void {
$this->assertTrue($docs->default);
$this->assertSame(['field' => 'profile', 'eq' => 'standard'], $docs->when?->toArray());
+ $visibility = $config->field('visibility');
+ $this->assertInstanceOf(Field::class, $visibility);
+ $this->assertSame(FieldType::Toggle, $visibility->type);
+ $this->assertSame('private', $visibility->default);
+ $this->assertSame('Public', $visibility->option('public')?->label);
+
$secret = $config->field('secret');
$this->assertInstanceOf(Field::class, $secret);
$this->assertSame(FieldType::Password, $secret->type);
@@ -129,6 +137,7 @@ public function testDefaultsAndFallbacks(): void {
$panel->password('pw');
$panel->search('se')->option('a');
$panel->multisearch('ms')->option('a');
+ $panel->toggle('tg')->option('on', 'On')->option('off', 'Off');
$panel->pause('pa');
})
->build();
@@ -147,6 +156,8 @@ public function testDefaultsAndFallbacks(): void {
$this->assertFalse($config->field('pw')->confirm);
$this->assertSame('', $config->field('se')?->default);
$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 pause defaults to acknowledged so headless runs never block on it.
$this->assertTrue($config->field('pa')?->default);
@@ -186,4 +197,46 @@ public function testDuplicateFieldIdThrows(): void {
->build();
}
+ public function testToggleWithoutTwoOptionsThrows(): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Toggle field "t" must have exactly two options, 1 given.');
+
+ Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('only'))
+ ->build();
+ }
+
+ #[DataProvider('dataProviderToggleInvalidDefaultThrows')]
+ public function testToggleInvalidDefaultThrows(mixed $default): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Toggle field "t" default must be one of: a, b.');
+
+ Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('a')->option('b')->default($default))
+ ->build();
+ }
+
+ /**
+ * Data provider for testToggleInvalidDefaultThrows().
+ *
+ * @return \Iterator
+ * A default value that is not one of the toggle's option values.
+ */
+ public static function dataProviderToggleInvalidDefaultThrows(): \Iterator {
+ yield 'unknown string' => ['c'];
+ yield 'boolean' => [TRUE];
+ yield 'integer' => [123];
+ yield 'null' => [NULL];
+ }
+
+ public function testToggleNumericStringOptionsDefaultToFirstValue(): void {
+ $config = Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('flag')->option('0', 'Off')->option('1', 'On'))
+ ->build();
+
+ // The implicit default is the first option's value "0" as a string, not a
+ // numeric-string coerced to int by the array key.
+ $this->assertSame('0', $config->field('flag')?->default);
+ }
+
}
diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php
index ddc1e73..7c2ccd6 100644
--- a/tests/phpunit/Unit/Resolver/InputResolverTest.php
+++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php
@@ -37,6 +37,12 @@ public function testConfirmFalsey(): void {
$this->assertFalse($inputs['agree']);
}
+ public function testToggleCoercion(): void {
+ $inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', ['VORTEX_VIS' => 'private']);
+
+ $this->assertSame('private', $inputs['vis']);
+ }
+
public function testEmptyMultiselect(): void {
$inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', ['VORTEX_MODS' => '']);
@@ -101,6 +107,7 @@ protected function fields(): array {
new Field('port', 'Port', '', FieldType::Number, 0),
new Field('ack', 'Ack', '', FieldType::Pause, TRUE),
new Field('tags', 'Tags', '', FieldType::MultiSearch, []),
+ new Field('vis', 'Visibility', '', FieldType::Toggle, 'public'),
];
}
diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
index 9bfc6e6..8ee06f4 100644
--- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
+++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
@@ -80,6 +80,20 @@ public function testDependsOnCollectsNestedFieldRefs(): void {
$this->assertStringContainsString('"depends_on":["a","b"]', $json);
}
+ public function testToggleDescribesBothValues(): void {
+ $config = Form::create('T')
+ ->panel('p', 'p', function (PanelBuilder $p): void {
+ $p->toggle('visibility', 'Visibility')->options(['public' => 'Public', 'private' => 'Private'])->default('public');
+ })
+ ->build();
+
+ $json = (string) json_encode((new SchemaGenerator($config))->generate());
+
+ $this->assertStringContainsString('"type":"toggle"', $json);
+ $this->assertStringContainsString('"value":"public"', $json);
+ $this->assertStringContainsString('"value":"private"', $json);
+ }
+
public function testRoundTripsThroughJson(): void {
$config = Form::create('T')
->panel('p', 'p', function (PanelBuilder $p): void {
diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
index c8eff74..e0a86be 100644
--- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
+++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
@@ -4,6 +4,7 @@
namespace DrevOps\Tui\Tests\Unit\Schema;
+use DrevOps\Tui\Builder\FieldBuilder;
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Condition\Condition;
@@ -108,6 +109,24 @@ public function testMultisearchOptionMembership(): void {
$this->assertContains('Question "tags" contains an invalid option "z".', $validator->validate(['name' => 'Acme', 'tags' => ['z']]));
}
+ public function testToggleOptionMembership(): void {
+ $validator = new SchemaValidator($this->config());
+
+ $this->assertSame([], $validator->validate(['name' => 'Acme', 'visibility' => 'private']));
+ $this->assertContains('Question "visibility" must be one of: public, private.', $validator->validate(['name' => 'Acme', 'visibility' => 'bogus']));
+ }
+
+ public function testNumericStringOptionMembership(): void {
+ $config = Form::create('T')
+ ->panel('p', 'p', fn(PanelBuilder $p): FieldBuilder => $p->toggle('flag')->option('0', 'Off')->option('1', 'On'))
+ ->build();
+ $validator = new SchemaValidator($config);
+
+ // A numeric-string value stays valid: values are compared as strings.
+ $this->assertSame([], $validator->validate(['flag' => '1']));
+ $this->assertContains('Question "flag" must be one of: 0, 1.', $validator->validate(['flag' => '2']));
+ }
+
/**
* Build a config exercising every validation branch.
*/
@@ -123,6 +142,7 @@ protected function config(): Config {
$p->pause('ack');
$p->search('engine')->option('solr')->option('none');
$p->multisearch('tags')->option('a')->option('b');
+ $p->toggle('visibility')->option('public')->option('private');
})
->build();
}
diff --git a/tests/phpunit/Unit/Widget/ToggleWidgetTest.php b/tests/phpunit/Unit/Widget/ToggleWidgetTest.php
new file mode 100644
index 0000000..1c6549b
--- /dev/null
+++ b/tests/phpunit/Unit/Widget/ToggleWidgetTest.php
@@ -0,0 +1,139 @@
+ 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+ $this->assertSame('enabled', $widget->value());
+ $this->assertStringContainsString('โ Enabled', Ansi::strip($widget->view(new DefaultTheme())));
+
+ $widget->handle(Key::named(KeyName::Space));
+ $this->assertSame('disabled', $widget->value());
+ $this->assertStringContainsString('โ Disabled', Ansi::strip($widget->view(new DefaultTheme())));
+ }
+
+ public function testHonoursExplicitDefault(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'disabled');
+
+ $this->assertSame('disabled', $widget->value());
+ $this->assertStringContainsString('โ Disabled', Ansi::strip($widget->view(new DefaultTheme())));
+ }
+
+ public function testUnknownDefaultFallsBackToFirst(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'nope');
+
+ $this->assertSame('enabled', $widget->value());
+ }
+
+ #[DataProvider('dataProviderFlipKeys')]
+ public function testFlipKeys(Key $key): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+
+ $widget->handle($key);
+
+ $this->assertSame('disabled', $widget->value());
+ }
+
+ /**
+ * Data provider for testFlipKeys().
+ *
+ * @return \Iterator
+ * Each key that flips the switch.
+ */
+ public static function dataProviderFlipKeys(): \Iterator {
+ yield 'space' => [Key::named(KeyName::Space)];
+ yield 'left' => [Key::named(KeyName::Left)];
+ yield 'right' => [Key::named(KeyName::Right)];
+ yield 'up' => [Key::named(KeyName::Up)];
+ yield 'down' => [Key::named(KeyName::Down)];
+ }
+
+ public function testDirectSelectionByFirstLetter(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+
+ $widget->handle(Key::char('d'));
+ $this->assertSame('disabled', $widget->value());
+
+ $widget->handle(Key::char('e'));
+ $this->assertSame('enabled', $widget->value());
+
+ // Selection is case-insensitive.
+ $widget->handle(Key::char('D'));
+ $this->assertSame('disabled', $widget->value());
+
+ // A letter matching neither label is a no-op.
+ $widget->handle(Key::char('z'));
+ $this->assertSame('disabled', $widget->value());
+ }
+
+ public function testFirstLetterCollisionSelectsFirstLabel(): void {
+ $widget = new ToggleWidget(['public' => 'Public', 'private' => 'Private'], 'private');
+
+ // Both labels start with "p"; the first-declared label wins.
+ $widget->handle(Key::char('p'));
+ $this->assertSame('public', $widget->value());
+
+ // The colliding label stays reachable by flipping.
+ $widget->handle(Key::named(KeyName::Space));
+ $this->assertSame('private', $widget->value());
+ }
+
+ public function testAccept(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Space), Key::named(KeyName::Enter)));
+
+ $this->assertSame('disabled', $value);
+ $this->assertTrue($widget->isComplete());
+ }
+
+ public function testCancel(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+
+ $widget->handle(Key::named(KeyName::Escape));
+
+ $this->assertTrue($widget->isCancelled());
+ }
+
+ public function testAsciiRendering(): void {
+ $widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');
+ $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]);
+
+ $view = $widget->view($theme);
+
+ $this->assertStringContainsString('(*) Enabled', $view);
+ $this->assertStringContainsString('( ) Disabled', $view);
+ }
+
+ public function testFlipWithoutOptionsIsSafe(): void {
+ $widget = new ToggleWidget([]);
+
+ $widget->handle(Key::named(KeyName::Space));
+
+ $this->assertSame('', $widget->value());
+ }
+
+}
diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
index e8cdc84..9f4e63b 100644
--- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
+++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
@@ -20,6 +20,7 @@
use DrevOps\Tui\Widget\SuggestWidget;
use DrevOps\Tui\Widget\TextareaWidget;
use DrevOps\Tui\Widget\TextWidget;
+use DrevOps\Tui\Widget\ToggleWidget;
use DrevOps\Tui\Widget\WidgetFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
@@ -37,6 +38,7 @@ public function testCreatesByType(): void {
$this->assertInstanceOf(TextWidget::class, $factory->create($this->field(FieldType::Text), 'x'));
$this->assertInstanceOf(ConfirmWidget::class, $factory->create($this->field(FieldType::Confirm), TRUE));
+ $this->assertInstanceOf(ToggleWidget::class, $factory->create($this->fieldWithOptions(FieldType::Toggle), 'a'));
$this->assertInstanceOf(SelectWidget::class, $factory->create($this->fieldWithOptions(FieldType::Select), 'a'));
$this->assertInstanceOf(MultiSelectWidget::class, $factory->create($this->fieldWithOptions(FieldType::MultiSelect), ['a']));
$this->assertInstanceOf(SuggestWidget::class, $factory->create($this->fieldWithOptions(FieldType::Suggest), 'a'));