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.

All widgets, one after another @@ -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'); +``` + + + + + + + + + + + + + + + + + +
ANSINo ANSI
UnicodeToggle: Unicode + ANSIToggle: Unicode + No ANSI
ASCIIToggle: ASCII + ANSIToggle: ASCII + No ANSI
+ ### 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 @@ +Togglewidget-------------edit*Enteraccept*Esccancel(*)Enabled()Disabled \ 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'));