From 80a84517aa54764a006647818fb70ee2e3caa971 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 22:58:52 +1000 Subject: [PATCH 1/7] [#9] Added a date picker (calendar) widget. --- README.md | 36 ++- docs/assets/widget-date-ascii-no-ansi.svg | 1 + docs/assets/widget-date-ascii.svg | 1 + docs/assets/widget-date-no-ansi.svg | 1 + docs/assets/widget-date.svg | 1 + docs/assets/widgets-ascii-no-ansi.svg | 2 +- docs/assets/widgets-ascii.svg | 2 +- docs/assets/widgets-no-ansi.svg | 2 +- docs/assets/widgets.svg | 2 +- docs/util/update-assets.php | 9 + playground/3-widgets/show.php | 2 + playground/3-widgets/widget-date.php | 50 ++++ playground/3-widgets/widgets.php | 2 + src/Builder/FieldBuilder.php | 114 ++++++++ src/Builder/PanelBuilder.php | 15 + src/Config/DateBounds.php | 143 +++++++++ src/Config/Field.php | 4 + src/Config/FieldType.php | 1 + src/Config/Weekday.php | 85 ++++++ src/Engine/Engine.php | 2 +- src/Schema/AgentHelp.php | 23 +- src/Schema/SchemaGenerator.php | 3 + src/Schema/SchemaValidator.php | 27 ++ src/Widget/DateWidget.php | 276 ++++++++++++++++++ src/Widget/WidgetFactory.php | 1 + tests/phpunit/Unit/Builder/FormTest.php | 56 ++++ tests/phpunit/Unit/Config/DateBoundsTest.php | 117 ++++++++ tests/phpunit/Unit/Config/WeekdayTest.php | 72 +++++ .../Engine/EngineDeclaredBehaviourTest.php | 12 + .../Unit/Resolver/InputResolverTest.php | 7 + tests/phpunit/Unit/Schema/AgentHelpTest.php | 15 + .../Unit/Schema/SchemaGeneratorTest.php | 30 ++ .../Unit/Schema/SchemaValidatorTest.php | 15 + tests/phpunit/Unit/Widget/DateWidgetTest.php | 205 +++++++++++++ .../phpunit/Unit/Widget/WidgetFactoryTest.php | 18 ++ 35 files changed, 1344 insertions(+), 8 deletions(-) create mode 100644 docs/assets/widget-date-ascii-no-ansi.svg create mode 100644 docs/assets/widget-date-ascii.svg create mode 100644 docs/assets/widget-date-no-ansi.svg create mode 100644 docs/assets/widget-date.svg create mode 100644 playground/3-widgets/widget-date.php create mode 100644 src/Config/DateBounds.php create mode 100644 src/Config/Weekday.php create mode 100644 src/Widget/DateWidget.php create mode 100644 tests/phpunit/Unit/Config/DateBoundsTest.php create mode 100644 tests/phpunit/Unit/Config/WeekdayTest.php create mode 100644 tests/phpunit/Unit/Widget/DateWidgetTest.php diff --git a/README.md b/README.md index 9ca8636..927c652 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`, `filepicker`, `multifilepicker`, `confirm`, `toggle`, `pause` +- ๐Ÿงฉ [**Widgets**](#widgets) - `text`, `number`, `date`, `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 -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. +Fifteen widget types cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.

All widgets, one after another @@ -137,6 +137,38 @@ Declare optional `min`, `max` and `step` to bound the value and enable keyboard $p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080); ``` +### Date + +A month calendar that returns a normalized ISO `YYYY-MM-DD` date. Left/Right (or vim `h`/`l`) move by day, Up/Down (or `k`/`j`) move by week, PageUp/PageDown change month, Home/End jump to the first/last day of the month, and Enter accepts. Pass `default` (a `YYYY-MM-DD` string) to open on a specific date; with none the calendar opens on today. + +```php +$p->date('release', 'Release date')->default('2026-07-15'); +``` + +Declare optional `minDate`, `maxDate` and `weekStart` to bound the range and set the first column of the week. Navigation is clamped to the range - the cursor never leaves it - and days outside it render dimmed. The bounds are enforced headlessly too - a `--prompts` or environment value outside the range is rejected - and are reflected in the JSON schema as `min_date`, `max_date` and `week_start` on the prompt. + +```php +$p->date('release', 'Release date')->minDate('2026-01-01')->maxDate('2026-12-31')->weekStart(Weekday::Sunday); +``` + + + + + + + + + + + + + + + + + +
ANSINo ANSI
UnicodeDate: Unicode + ANSIDate: Unicode + No ANSI
ASCIIDate: ASCII + ANSIDate: ASCII + No ANSI
+ ### Textarea Multi-line text input: Enter inserts a newline, Up/Down move between lines keeping the column, Tab accepts. diff --git a/docs/assets/widget-date-ascii-no-ansi.svg b/docs/assets/widget-date-ascii-no-ansi.svg new file mode 100644 index 0000000..1b40b31 --- /dev/null +++ b/docs/assets/widget-date-ascii-no-ansi.svg @@ -0,0 +1 @@ +Datewidget-----------July2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancel \ No newline at end of file diff --git a/docs/assets/widget-date-ascii.svg b/docs/assets/widget-date-ascii.svg new file mode 100644 index 0000000..06af3b6 --- /dev/null +++ b/docs/assets/widget-date-ascii.svg @@ -0,0 +1 @@ +Datewidget-----------July2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancel \ No newline at end of file diff --git a/docs/assets/widget-date-no-ansi.svg b/docs/assets/widget-date-no-ansi.svg new file mode 100644 index 0000000..15fa27a --- /dev/null +++ b/docs/assets/widget-date-no-ansi.svg @@ -0,0 +1 @@ +Datewidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€July2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancel \ No newline at end of file diff --git a/docs/assets/widget-date.svg b/docs/assets/widget-date.svg new file mode 100644 index 0000000..f43b90c --- /dev/null +++ b/docs/assets/widget-date.svg @@ -0,0 +1 @@ +Datewidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€July2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancel \ 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 7b03758..9b0e04a 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[]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 +Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 e34d534..1ad53ff 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[]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 +Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 7130a73..b3d1ea0 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โ—ป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 +Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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 a5b85ae..be59160 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โ—ป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 +Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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 b14de77..f5a1695 100644 --- a/docs/util/update-assets.php +++ b/docs/util/update-assets.php @@ -79,6 +79,14 @@ function widgetInteractions(): array { type_text "3000" wait_and_enter } +EXPECT, + 'date' => <<<'EXPECT' +# Date: move down a week, accept. +expect "Date widget" { + pause 1000 + arrow_down + wait_and_enter +} EXPECT, 'textarea' => <<<'EXPECT' # Textarea: Enter adds a line, type, Tab accepts. @@ -489,6 +497,7 @@ function getJobs(string $project_dir): array { $widget_rows = [ 'text' => 6, 'number' => 6, + 'date' => 14, 'textarea' => 8, 'password' => 6, 'select' => 8, diff --git a/playground/3-widgets/show.php b/playground/3-widgets/show.php index 43894c4..a073504 100644 --- a/playground/3-widgets/show.php +++ b/playground/3-widgets/show.php @@ -18,6 +18,7 @@ use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Widget\ConfirmWidget; +use DrevOps\Tui\Widget\DateWidget; use DrevOps\Tui\Widget\MultiSearchWidget; use DrevOps\Tui\Widget\MultiSelectWidget; use DrevOps\Tui\Widget\NumberWidget; @@ -45,6 +46,7 @@ $widgets = [ 'Text' => static fn(): WidgetInterface => new TextWidget('Acme Site'), 'Number' => static fn(): WidgetInterface => new NumberWidget('8080'), + 'Date' => static fn(): WidgetInterface => new DateWidget('2026-07-15'), 'Textarea' => static fn(): WidgetInterface => new TextareaWidget("Redis for cache\nSolr for search"), 'Password' => static fn(): WidgetInterface => new PasswordWidget('hunter2'), 'Select' => static fn(): WidgetInterface => new SelectWidget([ diff --git a/playground/3-widgets/widget-date.php b/playground/3-widgets/widget-date.php new file mode 100644 index 0000000..69ecbb4 --- /dev/null +++ b/playground/3-widgets/widget-date.php @@ -0,0 +1,50 @@ + !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]); + +$widget = new DateWidget('2026-07-15'); + +$terminal = new Terminal(); +$parser = new KeyParser(); +$terminal->setup(); + +try { + while (!$widget->isComplete() && !$widget->isCancelled()) { + $terminal->render(implode("\n", [ + $theme->renderEditorHeader('Date widget'), + '', + $widget->view($theme), + ])); + + foreach ($parser->parse($terminal->read()) as $key) { + $widget->handle($key); + } + } +} +finally { + $terminal->restore(); +} + +echo 'Date: ' . ($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 2f37b37..bab7d34 100644 --- a/playground/3-widgets/widgets.php +++ b/playground/3-widgets/widgets.php @@ -16,6 +16,7 @@ use DrevOps\Tui\Render\Terminal; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Widget\ConfirmWidget; +use DrevOps\Tui\Widget\DateWidget; use DrevOps\Tui\Widget\MultiSearchWidget; use DrevOps\Tui\Widget\MultiSelectWidget; use DrevOps\Tui\Widget\NumberWidget; @@ -67,6 +68,7 @@ $interact(new TextWidget('Acme Site'), 'Text'); $interact(new NumberWidget('8080'), 'Number'); +$interact(new DateWidget('2026-07-15'), 'Date'); $interact(new TextareaWidget("Redis for cache\nSolr for search"), 'Textarea', ['edit', 'Tab accept', 'Esc cancel']); $interact(new PasswordWidget('hunter2'), 'Password'); $interact(new SelectWidget(['standard' => 'Standard', 'minimal' => 'Minimal', 'demo_umami' => 'Demo Umami'], 'minimal'), 'Select'); diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index 1b381e3..f222b88 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -6,12 +6,14 @@ use DrevOps\Tui\Condition\ConditionInterface; use DrevOps\Tui\Config\ConfigException; +use DrevOps\Tui\Config\DateBounds; 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\Config\OptionKind; +use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\DiscoverInterface; @@ -131,6 +133,21 @@ final class FieldBuilder { */ protected bool $pickerShowHidden = FALSE; + /** + * The date field's inclusive earliest date (ISO `Y-m-d`), when declared. + */ + protected ?string $minDate = NULL; + + /** + * The date field's inclusive latest date (ISO `Y-m-d`), when declared. + */ + protected ?string $maxDate = NULL; + + /** + * The date field's week-start day, when declared. + */ + protected ?Weekday $weekStart = NULL; + /** * Construct a field builder. * @@ -375,6 +392,51 @@ public function showHidden(bool $show = TRUE): self { return $this; } + /** + * Date only: set the inclusive earliest selectable date. + * + * @param string $date + * The earliest date, as an ISO `Y-m-d` string. + * + * @return $this + * The builder. + */ + public function minDate(string $date): self { + $this->minDate = $date; + + return $this; + } + + /** + * Date only: set the inclusive latest selectable date. + * + * @param string $date + * The latest date, as an ISO `Y-m-d` string. + * + * @return $this + * The builder. + */ + public function maxDate(string $date): self { + $this->maxDate = $date; + + return $this; + } + + /** + * Date only: set the day the calendar week begins on. + * + * @param \DrevOps\Tui\Config\Weekday $weekday + * The week-start day. + * + * @return $this + * The builder. + */ + public function weekStart(Weekday $weekday): self { + $this->weekStart = $weekday; + + return $this; + } + /** * Set the conditional-visibility rule. * @@ -562,6 +624,7 @@ public function build(): Field { $this->pickerStart, $this->pickerExtensions, $this->pickerShowHidden, + $this->buildDateBounds(), ); } @@ -612,6 +675,57 @@ protected function buildBounds(): ?NumberBounds { return new NumberBounds($this->min, $this->max, $this->step); } + /** + * Assemble the date bounds for a date field from the declared min/max/start. + * + * @return \DrevOps\Tui\Config\DateBounds|null + * The bounds for a date field, or NULL for any other field type. + * + * @throws \DrevOps\Tui\Config\ConfigException + * When a declared date is not a valid `Y-m-d` date, or min is after max. + */ + protected function buildDateBounds(): ?DateBounds { + if ($this->fieldType !== FieldType::Date) { + return NULL; + } + + $min = $this->parseBoundDate($this->minDate, 'min'); + $max = $this->parseBoundDate($this->maxDate, 'max'); + + if ($min !== NULL && $max !== NULL && $min > $max) { + throw new ConfigException(sprintf('Field "%s" declares min date %s after max date %s.', $this->id, $min->format('Y-m-d'), $max->format('Y-m-d'))); + } + + return new DateBounds($min, $max, $this->weekStart ?? Weekday::Monday); + } + + /** + * Strictly parse a declared bound date, failing loudly on a bad value. + * + * @param string|null $value + * The declared date string, or NULL when the bound is open. + * @param string $which + * The bound name ("min" or "max"), used in the error message. + * + * @return \DateTimeImmutable|null + * The parsed date, or NULL when none was declared. + * + * @throws \DrevOps\Tui\Config\ConfigException + * When the value is not a valid `Y-m-d` date. + */ + protected function parseBoundDate(?string $value, string $which): ?\DateTimeImmutable { + if ($value === NULL) { + return NULL; + } + + $date = DateBounds::parse($value); + if (!$date instanceof \DateTimeImmutable) { + throw new ConfigException(sprintf('Field "%s" declares an invalid %s date "%s".', $this->id, $which, $value)); + } + + return $date; + } + /** * The engine default for a field type when none is declared. * diff --git a/src/Builder/PanelBuilder.php b/src/Builder/PanelBuilder.php index 87ea5d0..d7aa898 100644 --- a/src/Builder/PanelBuilder.php +++ b/src/Builder/PanelBuilder.php @@ -165,6 +165,21 @@ public function number(string $id, string $label = ''): FieldBuilder { return $this->field($id, $label, FieldType::Number); } + /** + * Add a date field (a navigable month calendar). + * + * @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 date(string $id, string $label = ''): FieldBuilder { + return $this->field($id, $label, FieldType::Date); + } + /** * Add a textarea field. * diff --git a/src/Config/DateBounds.php b/src/Config/DateBounds.php new file mode 100644 index 0000000..770baca --- /dev/null +++ b/src/Config/DateBounds.php @@ -0,0 +1,143 @@ +format('Y-m-d') === $value ? $date : NULL; + } + + /** + * Whether a date is within the bounds. + * + * @param \DateTimeInterface $value + * The date. + * + * @return bool + * TRUE when the date is within both declared bounds. + */ + public function contains(\DateTimeInterface $value): bool { + if ($this->min !== NULL && $value < $this->min) { + return FALSE; + } + + return $this->max === NULL || $value <= $this->max; + } + + /** + * The range phrase for a date that violates the bounds, else NULL. + * + * Mirrors {@see NumberBounds::violation()}: a value that is not a strict date + * string is not this object's concern and passes (the type check lives in the + * schema validator), and an in-range date passes too. + * + * @param mixed $value + * The value to test. + * + * @return string|null + * The range phrase when out of range, else NULL. + */ + public function violation(mixed $value): ?string { + if (!is_string($value)) { + return NULL; + } + + $date = self::parse($value); + if (!$date instanceof \DateTimeImmutable) { + return NULL; + } + + return $this->contains($date) ? NULL : $this->describe(); + } + + /** + * The human range phrase, e.g. "between 2026-01-01 and 2026-12-31". + * + * @return string + * The phrase, or an empty string when neither bound is declared. + */ + public function describe(): string { + if ($this->min !== NULL && $this->max !== NULL) { + return sprintf('between %s and %s', $this->min->format('Y-m-d'), $this->max->format('Y-m-d')); + } + + if ($this->min !== NULL) { + return sprintf('on or after %s', $this->min->format('Y-m-d')); + } + + if ($this->max !== NULL) { + return sprintf('on or before %s', $this->max->format('Y-m-d')); + } + + return ''; + } + + /** + * Clamp a date onto the nearest bound it exceeds. + * + * @param \DateTimeImmutable $value + * The date. + * + * @return \DateTimeImmutable + * The date, moved onto the nearest bound it exceeds. + */ + public function clamp(\DateTimeImmutable $value): \DateTimeImmutable { + if ($this->min !== NULL && $value < $this->min) { + return $this->min; + } + + if ($this->max !== NULL && $value > $this->max) { + return $this->max; + } + + return $value; + } + +} diff --git a/src/Config/Field.php b/src/Config/Field.php index 5fec8b8..e87239b 100644 --- a/src/Config/Field.php +++ b/src/Config/Field.php @@ -81,6 +81,9 @@ * (dot-less, case-insensitive); empty allows every extension. * @param bool $pickerShowHidden * File picker only: whether dot-entries are shown when the browser opens. + * @param \DrevOps\Tui\Config\DateBounds|null $dateBounds + * Date only: the min/max range and week-start day; NULL for non-date + * fields. */ public function __construct( public string $id, @@ -104,6 +107,7 @@ public function __construct( public string $pickerStart = '', public array $pickerExtensions = [], public bool $pickerShowHidden = FALSE, + public ?DateBounds $dateBounds = NULL, ) { $this->options = Option::list($options); } diff --git a/src/Config/FieldType.php b/src/Config/FieldType.php index 309494e..a3a6689 100644 --- a/src/Config/FieldType.php +++ b/src/Config/FieldType.php @@ -18,6 +18,7 @@ enum FieldType: string { case Toggle = 'toggle'; case Suggest = 'suggest'; case Number = 'number'; + case Date = 'date'; case Textarea = 'textarea'; case Password = 'password'; case Search = 'search'; diff --git a/src/Config/Weekday.php b/src/Config/Weekday.php new file mode 100644 index 0000000..4629889 --- /dev/null +++ b/src/Config/Weekday.php @@ -0,0 +1,85 @@ + 'Mo', + self::Tuesday => 'Tu', + self::Wednesday => 'We', + self::Thursday => 'Th', + self::Friday => 'Fr', + self::Saturday => 'Sa', + self::Sunday => 'Su', + }; + } + + /** + * The weekday a date falls on. + * + * @param \DateTimeInterface $date + * The date. + * + * @return self + * The weekday. + */ + public static function fromDate(\DateTimeInterface $date): self { + return self::from((int) $date->format('N')); + } + + /** + * The seven weekdays in display order, starting from this one. + * + * @return list + * The rotated sequence of all seven weekdays. + */ + public function sequence(): array { + $out = []; + + for ($offset = 0; $offset < 7; $offset++) { + $out[] = self::from(($this->value - 1 + $offset) % 7 + 1); + } + + return $out; + } + + /** + * The zero-based column a weekday occupies in a week starting on this day. + * + * @param self $weekday + * The weekday to place. + * + * @return int + * The column index, 0 (the week-start day) to 6. + */ + public function columnOf(self $weekday): int { + return ($weekday->value - $this->value + 7) % 7; + } + +} diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 82f3a14..756c082 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -193,7 +193,7 @@ protected function resolveInitial(Field $field, array $inputs, Context $context) * An error message, or NULL when the value is valid. */ protected function validateValue(Field $field, mixed $value): ?string { - $violation = $field->bounds?->violation($value); + $violation = $field->bounds?->violation($value) ?? $field->dateBounds?->violation($value); if ($violation !== NULL) { return sprintf('must be %s.', $violation); } diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php index 6294fcc..e702440 100644 --- a/src/Schema/AgentHelp.php +++ b/src/Schema/AgentHelp.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Schema; use DrevOps\Tui\Config\Config; +use DrevOps\Tui\Config\DateBounds; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\NumberBounds; @@ -50,7 +51,7 @@ public function generate(): string { foreach ($this->config->fields() as $field) { $required = $field->required ? ' (required)' : ''; - $lines[] = sprintf(' %s [%s]%s - %s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field)); + $lines[] = sprintf(' %s [%s]%s - %s%s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field), $this->dateNote($field)); } return implode("\n", $lines); @@ -84,4 +85,24 @@ protected function rangeNote(Field $field): string { return sprintf(' (%s)', implode(', ', $parts)); } + /** + * A format-and-range annotation for a date field. + * + * @param \DrevOps\Tui\Config\Field $field + * The field. + * + * @return string + * The annotation (e.g. " (between 2026-01-01 and 2026-12-31)"), or an empty + * string when the field is not a date. + */ + protected function dateNote(Field $field): string { + if (!$field->dateBounds instanceof DateBounds) { + return ''; + } + + $described = $field->dateBounds->describe(); + + return sprintf(' (%s)', $described === '' ? 'YYYY-MM-DD' : $described); + } + } diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php index f502820..79e022b 100644 --- a/src/Schema/SchemaGenerator.php +++ b/src/Schema/SchemaGenerator.php @@ -50,6 +50,9 @@ public function generate(): array { 'min' => $field->bounds?->min, 'max' => $field->bounds?->max, 'step' => $field->bounds?->step, + 'min_date' => $field->dateBounds?->min?->format('Y-m-d'), + 'max_date' => $field->dateBounds?->max?->format('Y-m-d'), + 'week_start' => $field->dateBounds?->weekStart->name, 'when' => $field->when?->toArray(), 'derive' => $field->derive?->toArray(), 'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL, diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php index 93aff82..3340400 100644 --- a/src/Schema/SchemaValidator.php +++ b/src/Schema/SchemaValidator.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Schema; use DrevOps\Tui\Config\Config; +use DrevOps\Tui\Config\DateBounds; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; @@ -93,6 +94,11 @@ protected function validateValue(Field $field, mixed $value): ?string { return $bounds_error; } + $date_error = $this->checkDateBounds($field, $value); + if ($date_error !== NULL) { + return $date_error; + } + return $this->checkOptions($field, $value); } @@ -113,6 +119,23 @@ protected function checkBounds(Field $field, mixed $value): ?string { return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation); } + /** + * Check a date value against its declared range. + * + * @param \DrevOps\Tui\Config\Field $field + * The field. + * @param mixed $value + * The value. + * + * @return string|null + * An error, or NULL when in range (or when the field declares no range). + */ + protected function checkDateBounds(Field $field, mixed $value): ?string { + $violation = $field->dateBounds?->violation($value); + + return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation); + } + /** * Whether the value matches the field type. * @@ -129,6 +152,9 @@ protected function isType(FieldType $type, mixed $value): bool { FieldType::Confirm, FieldType::Pause => is_bool($value), FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => is_array($value), FieldType::Number => is_int($value) || is_float($value), + // An empty string is an unset date, left to the required check; any other + // value must be a strict `Y-m-d` calendar date. + FieldType::Date => is_string($value) && ($value === '' || DateBounds::parse($value) instanceof \DateTimeImmutable), default => is_string($value), }; } @@ -147,6 +173,7 @@ protected function typeName(FieldType $type): string { FieldType::Confirm, FieldType::Pause => 'a boolean', FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => 'a list', FieldType::Number => 'a number', + FieldType::Date => 'a date (YYYY-MM-DD)', default => 'a string', }; } diff --git a/src/Widget/DateWidget.php b/src/Widget/DateWidget.php new file mode 100644 index 0000000..fa8176e --- /dev/null +++ b/src/Widget/DateWidget.php @@ -0,0 +1,276 @@ +bounds = $bounds ?? new DateBounds(); + $seed = DateBounds::parse($value) ?? new \DateTimeImmutable('today'); + $this->cursor = $this->bounds->clamp($seed); + } + + /** + * {@inheritdoc} + */ + public function handle(Key $key): void { + if ($this->handleCancel($key)) { + return; + } + + if ($key->is(KeyName::Enter)) { + $this->accept($this->liveValue()); + + return; + } + + $moved = $this->move($key); + if ($moved instanceof \DateTimeImmutable) { + $this->cursor = $this->bounds->clamp($moved); + } + } + + /** + * The date a navigation key moves to before clamping, or NULL for no move. + * + * @param \DrevOps\Tui\Input\Key $key + * The key to interpret. + * + * @return \DateTimeImmutable|null + * The unclamped target date, or NULL when the key does not navigate. + */ + protected function move(Key $key): ?\DateTimeImmutable { + return match (TRUE) { + $key->is(KeyName::Left), $this->isChar($key, 'h') => $this->cursor->modify('-1 day'), + $key->is(KeyName::Right), $this->isChar($key, 'l') => $this->cursor->modify('+1 day'), + $key->is(KeyName::Up), $this->isChar($key, 'k') => $this->cursor->modify('-7 days'), + $key->is(KeyName::Down), $this->isChar($key, 'j') => $this->cursor->modify('+7 days'), + $key->is(KeyName::PageUp) => $this->shiftMonths(-1), + $key->is(KeyName::PageDown) => $this->shiftMonths(1), + $key->is(KeyName::Home) => $this->cursor->modify('first day of this month'), + $key->is(KeyName::End) => $this->cursor->modify('last day of this month'), + default => NULL, + }; + } + + /** + * Whether the key is a specific printable character. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + * @param string $char + * The character to match. + * + * @return bool + * TRUE when the key is that character. + */ + protected function isChar(Key $key, string $char): bool { + return $key->isChar() && $key->char === $char; + } + + /** + * The cursor moved by whole months, kept on a valid day-of-month. + * + * Anchoring on the first of the month before shifting avoids the day-of-month + * overflow that a naive "+1 month" produces (e.g. Jan 31 becoming Mar 3); the + * day is then re-applied, capped to the shorter month's length. + * + * @param int $months + * The signed number of months to move. + * + * @return \DateTimeImmutable + * The shifted date. + */ + protected function shiftMonths(int $months): \DateTimeImmutable { + $day = (int) $this->cursor->format('j'); + $first = $this->cursor->modify('first day of this month')->modify(sprintf('%+d months', $months)); + + return $first->setDate((int) $first->format('Y'), (int) $first->format('n'), min($day, (int) $first->format('t'))); + } + + /** + * {@inheritdoc} + */ + protected function liveValue(): mixed { + return $this->cursor->format('Y-m-d'); + } + + /** + * {@inheritdoc} + */ + public function view(ThemeInterface $theme): string { + $rows = array_merge([$this->heading($theme), $this->weekdayRow($theme)], $this->weekRows($theme), [$this->hint($theme)]); + + if ($this->error !== NULL) { + $rows[] = $theme->error($this->error); + } + + return implode("\n", $rows); + } + + /** + * {@inheritdoc} + */ + public function rendersHint(): bool { + return TRUE; + } + + /** + * The centered "Month YYYY" heading over the calendar grid. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return string + * The themed, centered heading. + */ + protected function heading(ThemeInterface $theme): string { + $title = $this->cursor->format('F Y'); + $left = max(0, intdiv(self::GRID_WIDTH - mb_strlen($title), 2)); + + return str_repeat(' ', $left) . $theme->title($title); + } + + /** + * The weekday heading row, ordered from the configured week-start day. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return string + * The themed weekday row. + */ + protected function weekdayRow(ThemeInterface $theme): string { + $cells = array_map(static fn(Weekday $day): string => sprintf(' %2s ', $day->abbreviation()), $this->bounds->weekStart->sequence()); + + return $theme->footer(implode('', $cells)); + } + + /** + * The calendar grid rows for the visible month. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return list + * One string per week row. + */ + protected function weekRows(ThemeInterface $theme): array { + $first = $this->cursor->modify('first day of this month'); + $days = (int) $this->cursor->format('t'); + $lead = $this->bounds->weekStart->columnOf(Weekday::fromDate($first)); + + $cells = array_fill(0, $lead, self::BLANK_CELL); + for ($day = 1; $day <= $days; $day++) { + $cells[] = $this->dayCell($theme, $first->setDate((int) $first->format('Y'), (int) $first->format('n'), $day), $day); + } + + $rows = []; + foreach (array_chunk($cells, 7) as $week) { + $rows[] = implode('', array_pad($week, 7, self::BLANK_CELL)); + } + + return $rows; + } + + /** + * Render one day cell: bracketed at the cursor, dimmed when out of range. + * + * The cursor cell carries literal brackets so it stays distinguishable even + * with colour off, mirroring how the radio glyph marks a selection in ASCII. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * @param \DateTimeImmutable $date + * The cell's date. + * @param int $day + * The day-of-month number. + * + * @return string + * The four-column themed cell. + */ + protected function dayCell(ThemeInterface $theme, \DateTimeImmutable $date, int $day): string { + if ($date->format('Y-m-d') === $this->cursor->format('Y-m-d')) { + return $theme->highlight(sprintf('[%2d]', $day)); + } + + $cell = sprintf(' %2d ', $day); + + return $this->bounds->contains($date) ? $cell : $theme->description($cell); + } + + /** + * Build the key-hint line shown beneath the calendar. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return string + * The themed, dot-joined hint line. + */ + protected function hint(ThemeInterface $theme): string { + $fragments = [ + $theme->arrowLeft() . '/' . $theme->arrowRight() . ' day', + $theme->arrowUp() . '/' . $theme->arrowDown() . ' week', + 'PgUp/PgDn month', + $theme->enter() . ' accept', + 'esc cancel', + ]; + + return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); + } + +} diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php index fb5035c..b9969a9 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -57,6 +57,7 @@ public function create(Field $field, mixed $current): WidgetInterface { 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::Date => new DateWidget(is_string($current) ? $current : '', bounds: $field->dateBounds), 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), FieldType::Pause => new PauseWidget(), diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 388802b..859ffc7 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -9,12 +9,14 @@ use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Condition\Condition; use DrevOps\Tui\Config\ConfigException; +use DrevOps\Tui\Config\DateBounds; 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\Config\OptionKind; +use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\Dotenv; use DrevOps\Tui\Input\Action; @@ -142,6 +144,7 @@ public function testDefaultsAndFallbacks(): void { $panel->confirm('c'); $panel->suggest('g'); $panel->number('n'); + $panel->date('dt'); $panel->textarea('ta'); $panel->password('pw'); $panel->search('se')->option('a'); @@ -160,6 +163,8 @@ public function testDefaultsAndFallbacks(): void { $this->assertFalse($config->field('c')?->default); $this->assertSame('', $config->field('g')?->default); $this->assertSame(0, $config->field('n')?->default); + // A date with no explicit default is empty; the widget opens on today. + $this->assertSame('', $config->field('dt')?->default); $this->assertSame('', $config->field('ta')?->default); $this->assertSame('', $config->field('pw')?->default); // The password options are opt-in, so they default off. @@ -241,6 +246,57 @@ public function testNumberBoundsAssembled(): void { $this->assertNotInstanceOf(NumberBounds::class, $config->field('plain')?->bounds); } + public function testDateBoundsAssembled(): void { + $config = Form::create('T') + ->panel('p', 'P', function (PanelBuilder $panel): void { + $panel->date('birthday', 'Birthday')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday); + $panel->date('plain', 'Plain'); + }) + ->build(); + + $birthday = $config->field('birthday'); + $this->assertInstanceOf(Field::class, $birthday); + $this->assertInstanceOf(DateBounds::class, $birthday->dateBounds); + $this->assertSame('2000-01-01', $birthday->dateBounds->min?->format('Y-m-d')); + $this->assertSame('2030-12-31', $birthday->dateBounds->max?->format('Y-m-d')); + $this->assertSame(Weekday::Sunday, $birthday->dateBounds->weekStart); + + // A date with nothing declared still carries bounds, defaulting to a + // Monday-first, open range. + $plain = $config->field('plain'); + $this->assertInstanceOf(DateBounds::class, $plain?->dateBounds); + $this->assertNull($plain->dateBounds->min); + $this->assertNull($plain->dateBounds->max); + $this->assertSame(Weekday::Monday, $plain->dateBounds->weekStart); + } + + public function testDateInvalidBoundThrows(): void { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Field "d" declares an invalid min date "2026-13-01".'); + + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->date('d')->minDate('2026-13-01')) + ->build(); + } + + public function testDateMinAfterMaxThrows(): void { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Field "d" declares min date 2026-12-31 after max date 2026-01-01.'); + + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->date('d')->minDate('2026-12-31')->maxDate('2026-01-01')) + ->build(); + } + + public function testDateBoundsIgnoredOnNonDateField(): void { + $config = Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->minDate('2020-01-01')->weekStart(Weekday::Sunday)) + ->build(); + + // The date setters are inert on a non-date field: no bounds are attached. + $this->assertNull($config->field('t')?->dateBounds); + } + public function testNumberMinGreaterThanMaxThrows(): void { $this->expectException(ConfigException::class); $this->expectExceptionMessage('Field "n" declares min 10 greater than max 1.'); diff --git a/tests/phpunit/Unit/Config/DateBoundsTest.php b/tests/phpunit/Unit/Config/DateBoundsTest.php new file mode 100644 index 0000000..e781152 --- /dev/null +++ b/tests/phpunit/Unit/Config/DateBoundsTest.php @@ -0,0 +1,117 @@ +assertSame($expected, DateBounds::parse($value)?->format('Y-m-d')); + } + + public static function dataProviderParse(): \Iterator { + yield 'valid' => ['2026-07-15', '2026-07-15']; + yield 'leap day' => ['2024-02-29', '2024-02-29']; + yield 'unpadded rejected' => ['2026-7-5', NULL]; + yield 'rolled-over rejected' => ['2026-02-30', NULL]; + yield 'non-leap feb 29 rejected' => ['2026-02-29', NULL]; + yield 'invalid month rejected' => ['2026-13-01', NULL]; + yield 'garbage rejected' => ['not-a-date', NULL]; + yield 'empty rejected' => ['', NULL]; + yield 'trailing time rejected' => ['2026-07-15 10:00', NULL]; + } + + #[DataProvider('dataProviderContains')] + public function testContains(?string $min, ?string $max, string $value, bool $expected): void { + $this->assertSame($expected, $this->bounds($min, $max)->contains(new \DateTimeImmutable($value))); + } + + public static function dataProviderContains(): \Iterator { + yield 'within both' => ['2026-01-01', '2026-12-31', '2026-07-15', TRUE]; + yield 'on lower bound' => ['2026-01-01', '2026-12-31', '2026-01-01', TRUE]; + yield 'on upper bound' => ['2026-01-01', '2026-12-31', '2026-12-31', TRUE]; + yield 'before lower' => ['2026-01-01', '2026-12-31', '2025-12-31', FALSE]; + yield 'after upper' => ['2026-01-01', '2026-12-31', '2027-01-01', FALSE]; + yield 'before open max' => ['2026-01-01', NULL, '2025-12-31', FALSE]; + yield 'after open max' => ['2026-01-01', NULL, '2099-01-01', TRUE]; + yield 'after open min' => [NULL, '2026-12-31', '2027-01-01', FALSE]; + yield 'before open min' => [NULL, '2026-12-31', '1999-01-01', TRUE]; + yield 'unbounded' => [NULL, NULL, '2099-01-01', TRUE]; + } + + #[DataProvider('dataProviderViolation')] + public function testViolation(?string $min, ?string $max, mixed $value, ?string $expected): void { + $this->assertSame($expected, $this->bounds($min, $max)->violation($value)); + } + + public static function dataProviderViolation(): \Iterator { + yield 'in range' => ['2026-01-01', '2026-12-31', '2026-07-15', NULL]; + yield 'out of range' => ['2026-01-01', '2026-12-31', '2027-01-01', 'between 2026-01-01 and 2026-12-31']; + yield 'before min only' => ['2026-01-01', NULL, '2025-01-01', 'on or after 2026-01-01']; + yield 'after max only' => [NULL, '2026-12-31', '2027-01-01', 'on or before 2026-12-31']; + yield 'malformed ignored' => ['2026-01-01', '2026-12-31', 'not-a-date', NULL]; + yield 'empty ignored' => ['2026-01-01', '2026-12-31', '', NULL]; + yield 'non-string ignored' => ['2026-01-01', '2026-12-31', 42, NULL]; + } + + #[DataProvider('dataProviderDescribe')] + public function testDescribe(?string $min, ?string $max, string $expected): void { + $this->assertSame($expected, $this->bounds($min, $max)->describe()); + } + + public static function dataProviderDescribe(): \Iterator { + yield 'both' => ['2026-01-01', '2026-12-31', 'between 2026-01-01 and 2026-12-31']; + yield 'min only' => ['2026-01-01', NULL, 'on or after 2026-01-01']; + yield 'max only' => [NULL, '2026-12-31', 'on or before 2026-12-31']; + yield 'neither' => [NULL, NULL, '']; + } + + #[DataProvider('dataProviderClamp')] + public function testClamp(?string $min, ?string $max, string $value, string $expected): void { + $this->assertSame($expected, $this->bounds($min, $max)->clamp(new \DateTimeImmutable($value))->format('Y-m-d')); + } + + public static function dataProviderClamp(): \Iterator { + yield 'within' => ['2026-01-01', '2026-12-31', '2026-07-15', '2026-07-15']; + yield 'before min' => ['2026-01-01', '2026-12-31', '2025-01-01', '2026-01-01']; + yield 'after max' => ['2026-01-01', '2026-12-31', '2027-01-01', '2026-12-31']; + yield 'open min below max' => [NULL, '2026-12-31', '2027-01-01', '2026-12-31']; + yield 'open max above min' => ['2026-01-01', NULL, '2025-01-01', '2026-01-01']; + yield 'unbounded' => [NULL, NULL, '2099-01-01', '2099-01-01']; + } + + public function testWeekStartDefaultsToMonday(): void { + $this->assertSame(Weekday::Monday, (new DateBounds())->weekStart); + $this->assertSame(Weekday::Sunday, (new DateBounds(weekStart: Weekday::Sunday))->weekStart); + } + + /** + * Build date bounds from optional ISO date strings. + * + * @param string|null $min + * The minimum date, or NULL for an open lower bound. + * @param string|null $max + * The maximum date, or NULL for an open upper bound. + */ + protected function bounds(?string $min, ?string $max): DateBounds { + return new DateBounds( + $min === NULL ? NULL : new \DateTimeImmutable($min), + $max === NULL ? NULL : new \DateTimeImmutable($max), + ); + } + +} diff --git a/tests/phpunit/Unit/Config/WeekdayTest.php b/tests/phpunit/Unit/Config/WeekdayTest.php new file mode 100644 index 0000000..fa05814 --- /dev/null +++ b/tests/phpunit/Unit/Config/WeekdayTest.php @@ -0,0 +1,72 @@ +assertSame($expected, $weekday->abbreviation()); + } + + public static function dataProviderAbbreviation(): \Iterator { + yield 'monday' => [Weekday::Monday, 'Mo']; + yield 'tuesday' => [Weekday::Tuesday, 'Tu']; + yield 'wednesday' => [Weekday::Wednesday, 'We']; + yield 'thursday' => [Weekday::Thursday, 'Th']; + yield 'friday' => [Weekday::Friday, 'Fr']; + yield 'saturday' => [Weekday::Saturday, 'Sa']; + yield 'sunday' => [Weekday::Sunday, 'Su']; + } + + #[DataProvider('dataProviderFromDate')] + public function testFromDate(string $date, Weekday $expected): void { + $this->assertSame($expected, Weekday::fromDate(new \DateTimeImmutable($date))); + } + + public static function dataProviderFromDate(): \Iterator { + // 2026-07-13 is a Monday; the week runs to Sunday 2026-07-19. + yield 'monday' => ['2026-07-13', Weekday::Monday]; + yield 'wednesday' => ['2026-07-15', Weekday::Wednesday]; + yield 'sunday' => ['2026-07-19', Weekday::Sunday]; + } + + #[DataProvider('dataProviderSequence')] + public function testSequence(Weekday $start, array $expected): void { + $this->assertSame($expected, array_map(static fn(Weekday $day): string => $day->abbreviation(), $start->sequence())); + } + + public static function dataProviderSequence(): \Iterator { + yield 'from monday' => [Weekday::Monday, ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']]; + yield 'from sunday' => [Weekday::Sunday, ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']]; + yield 'from saturday' => [Weekday::Saturday, ['Sa', 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr']]; + } + + #[DataProvider('dataProviderColumnOf')] + public function testColumnOf(Weekday $start, Weekday $weekday, int $expected): void { + $this->assertSame($expected, $start->columnOf($weekday)); + } + + public static function dataProviderColumnOf(): \Iterator { + yield 'monday start, monday' => [Weekday::Monday, Weekday::Monday, 0]; + yield 'monday start, wednesday' => [Weekday::Monday, Weekday::Wednesday, 2]; + yield 'monday start, sunday' => [Weekday::Monday, Weekday::Sunday, 6]; + yield 'sunday start, sunday' => [Weekday::Sunday, Weekday::Sunday, 0]; + yield 'sunday start, monday' => [Weekday::Sunday, Weekday::Monday, 1]; + yield 'sunday start, wednesday' => [Weekday::Sunday, Weekday::Wednesday, 3]; + } + +} diff --git a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php index e1e988b..7086b3d 100644 --- a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php +++ b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php @@ -82,6 +82,18 @@ public function testNumberBoundsRejectOutOfRangeFloat(): void { $engine->collect(['port' => 50.5], new Context()); } + public function testDateBoundsRejectOutOfRange(): void { + $engine = $this->engine(function (PanelBuilder $p): void { + $p->date('due')->minDate('2026-01-01')->maxDate('2026-12-31'); + }); + + $this->assertSame(['due' => '2026-06-15'], $engine->collect(['due' => '2026-06-15'], new Context())); + + $this->expectException(EngineException::class); + $this->expectExceptionMessage('Invalid value for field "due": must be between 2026-01-01 and 2026-12-31.'); + $engine->collect(['due' => '2027-01-01'], new Context()); + } + public function testDeclaredTransformApplies(): void { $engine = $this->engine(function (PanelBuilder $p): void { $p->text('name')->transform(fn (mixed $v): mixed => is_string($v) ? trim($v) : $v); diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php index 1137559..ce39dab 100644 --- a/tests/phpunit/Unit/Resolver/InputResolverTest.php +++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php @@ -77,6 +77,12 @@ public function testPromptsFromFile(): void { $this->assertSame('FromFile', $inputs['name']); } + public function testDateCoercionPassesThroughString(): void { + $inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', ['VORTEX_DUE' => '2026-07-15']); + + $this->assertSame('2026-07-15', $inputs['due']); + } + public function testEnvName(): void { $this->assertSame('VORTEX_MACHINE_NAME', (new InputResolver('VORTEX_'))->envName('machine_name')); } @@ -121,6 +127,7 @@ protected function fields(): array { new Field('vis', 'Visibility', '', FieldType::Toggle, 'public'), new Field('paths', 'Paths', '', FieldType::MultiFilePicker, []), new Field('cfg', 'Config', '', FieldType::FilePicker, ''), + new Field('due', 'Due', '', FieldType::Date, ''), ]; } diff --git a/tests/phpunit/Unit/Schema/AgentHelpTest.php b/tests/phpunit/Unit/Schema/AgentHelpTest.php index f43cfdc..8aba94c 100644 --- a/tests/phpunit/Unit/Schema/AgentHelpTest.php +++ b/tests/phpunit/Unit/Schema/AgentHelpTest.php @@ -54,6 +54,21 @@ public function testNumberRangeAnnotation(): void { $this->assertStringContainsString('plain [number] - Plain', $help); } + public function testDateRangeAnnotation(): void { + $config = Form::create('T') + ->panel('p', 'p', function (PanelBuilder $p): void { + $p->date('due', 'Due date')->minDate('2026-01-01')->maxDate('2026-12-31'); + $p->date('any', 'Any date'); + }) + ->build(); + + $help = (new AgentHelp($config))->generate(); + + $this->assertStringContainsString('due [date] - Due date (between 2026-01-01 and 2026-12-31)', $help); + // With no range declared, the format itself is the annotation. + $this->assertStringContainsString('any [date] - Any date (YYYY-MM-DD)', $help); + } + public function testNoEnvPrefixOmitsEnvLine(): void { $config = Form::create('T') ->panel('p', 'p', function (PanelBuilder $p): void { diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index 1f11d57..3476173 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -7,6 +7,7 @@ use DrevOps\Tui\Builder\Form; use DrevOps\Tui\Builder\PanelBuilder; use DrevOps\Tui\Condition\Condition; +use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Schema\SchemaGenerator; use PHPUnit\Framework\Attributes\CoversClass; @@ -27,6 +28,7 @@ public function testGenerate(): void { $profile->option('standard', 'Standard', 'Std')->option('minimal', 'Minimal'); $p->text('theme')->derive(new Derive('{{profile}}'))->when(new Condition('profile', eq: 'standard')); $p->number('port', 'Port')->min(1)->max(65535)->step(5); + $p->date('release', 'Release date')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday); }) ->build(); @@ -46,6 +48,9 @@ public function testGenerate(): void { 'min' => NULL, 'max' => NULL, 'step' => NULL, + 'min_date' => NULL, + 'max_date' => NULL, + 'week_start' => NULL, 'when' => NULL, 'derive' => NULL, 'discover' => NULL, @@ -62,6 +67,9 @@ public function testGenerate(): void { 'min' => NULL, 'max' => NULL, 'step' => NULL, + 'min_date' => NULL, + 'max_date' => NULL, + 'week_start' => NULL, 'when' => ['field' => 'profile', 'eq' => 'standard'], 'derive' => ['template' => '{{profile}}'], 'discover' => NULL, @@ -78,6 +86,28 @@ public function testGenerate(): void { 'min' => 1, 'max' => 65535, 'step' => 5, + 'min_date' => NULL, + 'max_date' => NULL, + 'week_start' => NULL, + 'when' => NULL, + 'derive' => NULL, + 'discover' => NULL, + 'depends_on' => [], + ], + [ + 'id' => 'release', + 'type' => 'date', + 'label' => 'Release date', + 'description' => '', + 'options' => [], + 'default' => '', + 'required' => FALSE, + 'min' => NULL, + 'max' => NULL, + 'step' => NULL, + 'min_date' => '2000-01-01', + 'max_date' => '2030-12-31', + 'week_start' => 'Sunday', 'when' => NULL, 'derive' => NULL, 'discover' => NULL, diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index 7c4f168..d32d28d 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -107,6 +107,20 @@ public function testNumberBoundsRejectOutOfRange(): void { $this->assertContains('Question "port" must be between 1 and 65535.', $validator->validate(['name' => 'Acme', 'port' => 99999])); } + public function testDateAcceptsValidRejectsMalformed(): void { + $validator = new SchemaValidator($this->config()); + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-07-15'])); + $this->assertContains('Question "due" must be a date (YYYY-MM-DD).', $validator->validate(['name' => 'Acme', 'due' => '2026-7-5'])); + } + + public function testDateBoundsRejectOutOfRange(): void { + $validator = new SchemaValidator($this->config()); + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-06-15'])); + $this->assertContains('Question "due" must be between 2026-01-01 and 2026-12-31.', $validator->validate(['name' => 'Acme', 'due' => '2027-01-01'])); + } + public function testPauseAcceptsBoolRejectsString(): void { $validator = new SchemaValidator($this->config()); @@ -172,6 +186,7 @@ protected function config(): Config { $p->multiselect('mods')->option('a')->option('b')->option('c', 'C', disabled: TRUE); $p->text('custom')->required()->when(new Condition('profile', eq: 'custom')); $p->number('port')->min(1)->max(65535); + $p->date('due')->minDate('2026-01-01')->maxDate('2026-12-31'); $p->pause('ack'); $p->search('engine')->option('solr')->option('none'); $p->multisearch('tags')->option('a')->option('b'); diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php new file mode 100644 index 0000000..f3897b3 --- /dev/null +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -0,0 +1,205 @@ +assertSame('2026-07-15', $widget->value()); + } + + public function testOpensOnTodayWhenEmpty(): void { + $widget = new DateWidget(); + + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); + } + + public function testInvalidSeedFallsBackToToday(): void { + $widget = new DateWidget('not-a-date'); + + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); + } + + #[DataProvider('dataProviderNavigation')] + public function testNavigation(Key $key, string $expected): void { + $widget = new DateWidget('2026-07-15'); + + $widget->handle($key); + + $this->assertSame($expected, $widget->value()); + } + + public static function dataProviderNavigation(): \Iterator { + yield 'left is previous day' => [Key::named(KeyName::Left), '2026-07-14']; + yield 'right is next day' => [Key::named(KeyName::Right), '2026-07-16']; + yield 'up is previous week' => [Key::named(KeyName::Up), '2026-07-08']; + yield 'down is next week' => [Key::named(KeyName::Down), '2026-07-22']; + yield 'vim h is previous day' => [Key::char('h'), '2026-07-14']; + yield 'vim l is next day' => [Key::char('l'), '2026-07-16']; + yield 'vim k is previous week' => [Key::char('k'), '2026-07-08']; + yield 'vim j is next week' => [Key::char('j'), '2026-07-22']; + yield 'page up is previous month' => [Key::named(KeyName::PageUp), '2026-06-15']; + yield 'page down is next month' => [Key::named(KeyName::PageDown), '2026-08-15']; + yield 'home is first of month' => [Key::named(KeyName::Home), '2026-07-01']; + yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31']; + } + + #[DataProvider('dataProviderMonthClamp')] + public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void { + $widget = new DateWidget($seed); + + $widget->handle($key); + + $this->assertSame($expected, $widget->value()); + } + + public static function dataProviderMonthClamp(): \Iterator { + // Jan 31 has no counterpart in the shorter month, so the day caps to its end. + yield 'jan 31 to non-leap feb' => ['2026-01-31', Key::named(KeyName::PageDown), '2026-02-28']; + yield 'jan 31 to leap feb' => ['2024-01-31', Key::named(KeyName::PageDown), '2024-02-29']; + yield 'mar 31 back to feb' => ['2026-03-31', Key::named(KeyName::PageUp), '2026-02-28']; + yield 'oct 31 to nov' => ['2026-10-31', Key::named(KeyName::PageDown), '2026-11-30']; + } + + public function testUnhandledKeysAreNoOps(): void { + $widget = new DateWidget('2026-07-15'); + + // An unmapped character and an unmapped named key both leave the cursor put. + $widget->handle(Key::char('z')); + $widget->handle(Key::named(KeyName::Tab)); + + $this->assertSame('2026-07-15', $widget->value()); + } + + public function testNavigationClampsWithinBounds(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); + $widget = new DateWidget('2026-07-11', bounds: $bounds); + + // A week back would land before the minimum, so it clamps to the minimum. + $widget->handle(Key::named(KeyName::Up)); + $this->assertSame('2026-07-10', $widget->value()); + + // Already on the minimum, a further step left stays on it. + $widget->handle(Key::named(KeyName::Left)); + $this->assertSame('2026-07-10', $widget->value()); + + // The end of the month is past the maximum, so it clamps to the maximum. + $widget->handle(Key::named(KeyName::End)); + $this->assertSame('2026-07-20', $widget->value()); + } + + public function testConstructionClampsSeedIntoBounds(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')); + + $this->assertSame('2026-07-10', (new DateWidget('2026-07-01', bounds: $bounds))->value()); + $this->assertSame('2026-07-20', (new DateWidget('2026-07-31', bounds: $bounds))->value()); + } + + public function testAcceptReturnsIsoDate(): void { + $widget = new DateWidget('2026-07-15'); + + $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame('2026-07-16', $value); + $this->assertTrue($widget->isComplete()); + } + + public function testCancel(): void { + $widget = new DateWidget('2026-07-15'); + + $widget->handle(Key::named(KeyName::Escape)); + + $this->assertTrue($widget->isCancelled()); + } + + public function testValidatorErrorIsShown(): void { + $widget = new DateWidget('2026-07-15', validate: static fn(mixed $value): ?string => 'No dates allowed.'); + + $widget->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($widget->isComplete()); + $this->assertStringContainsString('No dates allowed.', Ansi::strip($widget->view(new DefaultTheme()))); + } + + public function testRendersCalendar(): void { + $widget = new DateWidget('2026-07-15'); + + $view = Ansi::strip($widget->view(new DefaultTheme())); + + $this->assertStringContainsString('July 2026', $view); + // The cursor day is bracketed. + $this->assertStringContainsString('[15]', $view); + // The weekday header defaults to a Monday-first week. + $this->assertMatchesRegularExpression('/Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa\s+Su/', $view); + } + + public function testRendersOwnHint(): void { + $widget = new DateWidget('2026-07-15'); + + $this->assertTrue($widget->rendersHint()); + + $view = Ansi::strip($widget->view(new DefaultTheme())); + $this->assertStringContainsString('day', $view); + $this->assertStringContainsString('week', $view); + $this->assertStringContainsString('month', $view); + $this->assertStringContainsString('accept', $view); + $this->assertStringContainsString('cancel', $view); + } + + public function testWeekStartRotatesHeaderAndLayout(): void { + $widget = new DateWidget('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)); + + $view = Ansi::strip($widget->view(new DefaultTheme())); + + // A Sunday-first week reorders the weekday header. + $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $view); + } + + public function testAsciiRendering(): void { + $widget = new DateWidget('2026-07-15'); + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + $view = $widget->view($theme); + + $this->assertStringContainsString('July 2026', $view); + // The bracket keeps the cursor day distinguishable without colour. + $this->assertStringContainsString('[15]', $view); + } + + public function testDimsOutOfRangeDays(): void { + $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10')); + $widget = new DateWidget('2026-07-15', bounds: $bounds); + $theme = new DefaultTheme(); + + $view = $widget->view($theme); + + // A day before the minimum is rendered dimmed, not plain. + $this->assertStringContainsString($theme->description(sprintf(' %2d ', 5)), $view); + // The cursor day stays bracketed and highlighted. + $this->assertStringContainsString($theme->highlight('[15]'), $view); + } + +} diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 5a992a0..5a8ad13 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -4,6 +4,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; +use DrevOps\Tui\Config\DateBounds; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\NumberBounds; @@ -14,6 +15,7 @@ use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Widget\ConfirmWidget; +use DrevOps\Tui\Widget\DateWidget; use DrevOps\Tui\Widget\FilePickerWidget; use DrevOps\Tui\Widget\MultiSearchWidget; use DrevOps\Tui\Widget\MultiSelectWidget; @@ -48,6 +50,7 @@ public function testCreatesByType(): void { $this->assertInstanceOf(MultiSelectWidget::class, $factory->create($this->fieldWithOptions(FieldType::MultiSelect), ['a'])); $this->assertInstanceOf(SuggestWidget::class, $factory->create($this->fieldWithOptions(FieldType::Suggest), 'a')); $this->assertInstanceOf(NumberWidget::class, $factory->create($this->field(FieldType::Number), 42)); + $this->assertInstanceOf(DateWidget::class, $factory->create($this->field(FieldType::Date), '2026-07-15')); $this->assertInstanceOf(TextareaWidget::class, $factory->create($this->field(FieldType::Textarea), 'x')); $this->assertInstanceOf(PasswordWidget::class, $factory->create($this->field(FieldType::Password), 'x')); $this->assertInstanceOf(SearchWidget::class, $factory->create($this->fieldWithOptions(FieldType::Search), 'a')); @@ -106,6 +109,21 @@ public function testNumberBoundsPassedThrough(): void { $this->assertSame(6, $widget->value()); } + public function testDateBoundsPassedThrough(): void { + $field = new Field('f', 'F', '', FieldType::Date, '', dateBounds: new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'))); + + $widget = (new WidgetFactory())->create($field, '2026-07-01'); + + // The seed is clamped into the field's declared range. + $this->assertSame('2026-07-10', $widget->value()); + } + + public function testDateWithNonStringCurrentOpensOnToday(): void { + $widget = (new WidgetFactory())->create($this->field(FieldType::Date), 42); + + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); + } + public function testSeedsCurrentValue(): void { $widget = (new WidgetFactory())->create($this->field(FieldType::Text), 'Acme'); From f0580fb4ef6a3a902e4e3334b0131f3c93ec61f1 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:11:37 +1000 Subject: [PATCH 2/7] [#9] Added boundary, impossible-date and layout assertions to date tests. --- tests/phpunit/Unit/Schema/SchemaValidatorTest.php | 13 ++++++++++--- tests/phpunit/Unit/Widget/DateWidgetTest.php | 14 ++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index d32d28d..dbc7d55 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -109,16 +109,23 @@ public function testNumberBoundsRejectOutOfRange(): void { public function testDateAcceptsValidRejectsMalformed(): void { $validator = new SchemaValidator($this->config()); + $error = 'Question "due" must be a date (YYYY-MM-DD).'; $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-07-15'])); - $this->assertContains('Question "due" must be a date (YYYY-MM-DD).', $validator->validate(['name' => 'Acme', 'due' => '2026-7-5'])); + // A wrongly-padded value and an impossible calendar date are both rejected. + $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2026-7-5'])); + $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2026-02-30'])); } public function testDateBoundsRejectOutOfRange(): void { $validator = new SchemaValidator($this->config()); + $error = 'Question "due" must be between 2026-01-01 and 2026-12-31.'; - $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-06-15'])); - $this->assertContains('Question "due" must be between 2026-01-01 and 2026-12-31.', $validator->validate(['name' => 'Acme', 'due' => '2027-01-01'])); + // The inclusive endpoints are accepted; a date on either side is rejected. + $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-01-01'])); + $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-12-31'])); + $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2025-12-31'])); + $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2027-01-01'])); } public function testPauseAcceptsBoolRejectsString(): void { diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php index f3897b3..bbaa84b 100644 --- a/tests/phpunit/Unit/Widget/DateWidgetTest.php +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -170,12 +170,18 @@ public function testRendersOwnHint(): void { } public function testWeekStartRotatesHeaderAndLayout(): void { - $widget = new DateWidget('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)); - - $view = Ansi::strip($widget->view(new DefaultTheme())); + $sunday = Ansi::strip((new DateWidget('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)))->view(new DefaultTheme())); // A Sunday-first week reorders the weekday header. - $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $view); + $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $sunday); + + // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the month + // one column right, so the first row holds only days 1-4 (through Saturday) + // and day 5 (Sunday) starts the next row. The default Monday-first week fits + // days 1-5 in the first row. The first grid row is the third rendered line. + $monday = Ansi::strip((new DateWidget('2026-07-15'))->view(new DefaultTheme())); + $this->assertStringContainsString('5', explode("\n", $monday)[2]); + $this->assertStringNotContainsString('5', explode("\n", $sunday)[2]); } public function testAsciiRendering(): void { From c44af27fa31f496a7b359e2c59b25d9f5183b8bf Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:14:59 +1000 Subject: [PATCH 3/7] [#9] Suppressed the generic hint for self-hinting widgets in the showcase. --- docs/assets/widgets-ascii-no-ansi.svg | 2 +- docs/assets/widgets-ascii.svg | 2 +- docs/assets/widgets-no-ansi.svg | 2 +- docs/assets/widgets.svg | 2 +- playground/3-widgets/widgets.php | 17 +++++++++++------ 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/assets/widgets-ascii-no-ansi.svg b/docs/assets/widgets-ascii-no-ansi.svg index 9b0e04a..a4007d6 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:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 +Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------Redisforcacheenternewline*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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 1ad53ff..eddbfff 100644 --- a/docs/assets/widgets-ascii.svg +++ b/docs/assets/widgets-ascii.svg @@ -1 +1 @@ -Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 +Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------Redisforcacheenternewline*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|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|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 b3d1ea0..214c116 100644 --- a/docs/assets/widgets-no-ansi.svg +++ b/docs/assets/widgets-no-ansi.svg @@ -1 +1 @@ -Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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 +Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€Redisforcacheenternewlineยท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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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 be59160..cad7649 100644 --- a/docs/assets/widgets.svg +++ b/docs/assets/widgets.svg @@ -1 +1 @@ -Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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 +Textwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€editยทEnteracceptยทEsccancelText:"AcmeCorp"Numberwidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ˆNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ†/โ†’dayยทโ†‘/โ†“weekยทPgUp/PgDnmonthยทโ†ตacceptยทesccancelDate:"2026-07-22"Textareawidgetโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€Redisforcacheenternewlineยท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โ–ˆ1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearchโ–ˆ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/playground/3-widgets/widgets.php b/playground/3-widgets/widgets.php index bab7d34..858feb1 100644 --- a/playground/3-widgets/widgets.php +++ b/playground/3-widgets/widgets.php @@ -47,12 +47,17 @@ try { while (!$widget->isComplete() && !$widget->isCancelled()) { - $terminal->render(implode("\n", [ - $theme->renderEditorHeader($label . ' widget'), - $theme->renderHintLine(...$hints), - '', - $widget->view($theme), - ])); + $lines = [$theme->renderEditorHeader($label . ' widget')]; + + // A widget that draws its own key hints (like the date calendar) gets no + // generic hint line, so the two cannot contradict. + if (!$widget->rendersHint()) { + $lines[] = $theme->renderHintLine(...$hints); + } + + $lines[] = ''; + $lines[] = $widget->view($theme); + $terminal->render(implode("\n", $lines)); foreach ($parser->parse($terminal->read()) as $key) { $widget->handle($key); From 9e8e49e923cc0bde0af6d6078a0beac0c9d9b686 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:34:39 +1000 Subject: [PATCH 4/7] [#9] Aligned date widget code and tests with the project linting rules. --- src/Builder/FieldBuilder.php | 2 +- src/Config/DateBounds.php | 14 ++++++------- src/Widget/DateWidget.php | 1 + tests/phpunit/Unit/Builder/FormTest.php | 6 +++--- tests/phpunit/Unit/Widget/DateWidgetTest.php | 21 +++++++++++--------- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index f222b88..e83aa6b 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -692,7 +692,7 @@ protected function buildDateBounds(): ?DateBounds { $min = $this->parseBoundDate($this->minDate, 'min'); $max = $this->parseBoundDate($this->maxDate, 'max'); - if ($min !== NULL && $max !== NULL && $min > $max) { + if ($min instanceof \DateTimeImmutable && $max instanceof \DateTimeImmutable && $min > $max) { throw new ConfigException(sprintf('Field "%s" declares min date %s after max date %s.', $this->id, $min->format('Y-m-d'), $max->format('Y-m-d'))); } diff --git a/src/Config/DateBounds.php b/src/Config/DateBounds.php index 770baca..4faaeee 100644 --- a/src/Config/DateBounds.php +++ b/src/Config/DateBounds.php @@ -64,11 +64,11 @@ public static function parse(string $value): ?\DateTimeImmutable { * TRUE when the date is within both declared bounds. */ public function contains(\DateTimeInterface $value): bool { - if ($this->min !== NULL && $value < $this->min) { + if ($this->min instanceof \DateTimeImmutable && $value < $this->min) { return FALSE; } - return $this->max === NULL || $value <= $this->max; + return !$this->max instanceof \DateTimeImmutable || $value <= $this->max; } /** @@ -104,15 +104,15 @@ public function violation(mixed $value): ?string { * The phrase, or an empty string when neither bound is declared. */ public function describe(): string { - if ($this->min !== NULL && $this->max !== NULL) { + if ($this->min instanceof \DateTimeImmutable && $this->max instanceof \DateTimeImmutable) { return sprintf('between %s and %s', $this->min->format('Y-m-d'), $this->max->format('Y-m-d')); } - if ($this->min !== NULL) { + if ($this->min instanceof \DateTimeImmutable) { return sprintf('on or after %s', $this->min->format('Y-m-d')); } - if ($this->max !== NULL) { + if ($this->max instanceof \DateTimeImmutable) { return sprintf('on or before %s', $this->max->format('Y-m-d')); } @@ -129,11 +129,11 @@ public function describe(): string { * The date, moved onto the nearest bound it exceeds. */ public function clamp(\DateTimeImmutable $value): \DateTimeImmutable { - if ($this->min !== NULL && $value < $this->min) { + if ($this->min instanceof \DateTimeImmutable && $value < $this->min) { return $this->min; } - if ($this->max !== NULL && $value > $this->max) { + if ($this->max instanceof \DateTimeImmutable && $value > $this->max) { return $this->max; } diff --git a/src/Widget/DateWidget.php b/src/Widget/DateWidget.php index fa8176e..e56140f 100644 --- a/src/Widget/DateWidget.php +++ b/src/Widget/DateWidget.php @@ -164,6 +164,7 @@ public function view(ThemeInterface $theme): string { /** * {@inheritdoc} */ + #[\Override] public function rendersHint(): bool { return TRUE; } diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 859ffc7..09e8af4 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -265,8 +265,8 @@ public function testDateBoundsAssembled(): void { // Monday-first, open range. $plain = $config->field('plain'); $this->assertInstanceOf(DateBounds::class, $plain?->dateBounds); - $this->assertNull($plain->dateBounds->min); - $this->assertNull($plain->dateBounds->max); + $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->min); + $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->max); $this->assertSame(Weekday::Monday, $plain->dateBounds->weekStart); } @@ -294,7 +294,7 @@ public function testDateBoundsIgnoredOnNonDateField(): void { ->build(); // The date setters are inert on a non-date field: no bounds are attached. - $this->assertNull($config->field('t')?->dateBounds); + $this->assertNotInstanceOf(DateBounds::class, $config->field('t')?->dateBounds); } public function testNumberMinGreaterThanMaxThrows(): void { diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php index bbaa84b..8ae8023 100644 --- a/tests/phpunit/Unit/Widget/DateWidgetTest.php +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -67,7 +67,7 @@ public static function dataProviderNavigation(): \Iterator { yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31']; } - #[DataProvider('dataProviderMonthClamp')] + #[DataProvider('dataProviderPageMonthClampsToShortMonth')] public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void { $widget = new DateWidget($seed); @@ -76,8 +76,9 @@ public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $ $this->assertSame($expected, $widget->value()); } - public static function dataProviderMonthClamp(): \Iterator { - // Jan 31 has no counterpart in the shorter month, so the day caps to its end. + public static function dataProviderPageMonthClampsToShortMonth(): \Iterator { + // Jan 31 has no counterpart in the shorter month, so the day caps to + // that month's end. yield 'jan 31 to non-leap feb' => ['2026-01-31', Key::named(KeyName::PageDown), '2026-02-28']; yield 'jan 31 to leap feb' => ['2024-01-31', Key::named(KeyName::PageDown), '2024-02-29']; yield 'mar 31 back to feb' => ['2026-03-31', Key::named(KeyName::PageUp), '2026-02-28']; @@ -87,7 +88,8 @@ public static function dataProviderMonthClamp(): \Iterator { public function testUnhandledKeysAreNoOps(): void { $widget = new DateWidget('2026-07-15'); - // An unmapped character and an unmapped named key both leave the cursor put. + // An unmapped character and an unmapped named key both leave the cursor + // in place. $widget->handle(Key::char('z')); $widget->handle(Key::named(KeyName::Tab)); @@ -136,7 +138,7 @@ public function testCancel(): void { } public function testValidatorErrorIsShown(): void { - $widget = new DateWidget('2026-07-15', validate: static fn(mixed $value): ?string => 'No dates allowed.'); + $widget = new DateWidget('2026-07-15', validate: static fn(mixed $value): string => 'No dates allowed.'); $widget->handle(Key::named(KeyName::Enter)); @@ -175,10 +177,11 @@ public function testWeekStartRotatesHeaderAndLayout(): void { // A Sunday-first week reorders the weekday header. $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $sunday); - // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the month - // one column right, so the first row holds only days 1-4 (through Saturday) - // and day 5 (Sunday) starts the next row. The default Monday-first week fits - // days 1-5 in the first row. The first grid row is the third rendered line. + // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the + // month one column right, so the first row holds only days 1-4 (through + // Saturday) and day 5 (Sunday) starts the next row. The default + // Monday-first week fits days 1-5 in the first row. The first grid row is + // the third rendered line. $monday = Ansi::strip((new DateWidget('2026-07-15'))->view(new DefaultTheme())); $this->assertStringContainsString('5', explode("\n", $monday)[2]); $this->assertStringNotContainsString('5', explode("\n", $sunday)[2]); From ed5a8a7e1ead819a687ae9fabfd2cbd4cbfd28e7 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:50:26 +1000 Subject: [PATCH 5/7] [#9] Serialized the schema week-start via its enum value. --- src/Schema/SchemaGenerator.php | 2 +- tests/phpunit/Unit/Schema/SchemaGeneratorTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php index 79e022b..3e56046 100644 --- a/src/Schema/SchemaGenerator.php +++ b/src/Schema/SchemaGenerator.php @@ -52,7 +52,7 @@ public function generate(): array { 'step' => $field->bounds?->step, 'min_date' => $field->dateBounds?->min?->format('Y-m-d'), 'max_date' => $field->dateBounds?->max?->format('Y-m-d'), - 'week_start' => $field->dateBounds?->weekStart->name, + 'week_start' => $field->dateBounds?->weekStart->value, 'when' => $field->when?->toArray(), 'derive' => $field->derive?->toArray(), 'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL, diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index 3476173..5b1b4b3 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -107,7 +107,7 @@ public function testGenerate(): void { 'step' => NULL, 'min_date' => '2000-01-01', 'max_date' => '2030-12-31', - 'week_start' => 'Sunday', + 'week_start' => Weekday::Sunday->value, 'when' => NULL, 'derive' => NULL, 'discover' => NULL, From e47aff7ee29d96ea3831f213d1a4291305033578 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 08:59:03 +1000 Subject: [PATCH 6/7] [#9] Reflected the date schema keys in the non-selectable-options test. --- tests/phpunit/Unit/Schema/SchemaGeneratorTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index 5b1b4b3..a5f4df5 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -145,6 +145,9 @@ public function testExcludesNonSelectableOptions(): void { 'min' => NULL, 'max' => NULL, 'step' => NULL, + 'min_date' => NULL, + 'max_date' => NULL, + 'week_start' => NULL, 'when' => NULL, 'derive' => NULL, 'discover' => NULL, From 075ee98c7af88a7e8e75f0c0a030ad6a6fcf9339 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:25:42 +1000 Subject: [PATCH 7/7] [#9] Routed date navigation through the key map and hardened date bounds. --- README.md | 4 +- src/Builder/FieldBuilder.php | 10 ++-- src/Config/DateBounds.php | 6 ++ src/Input/VimKeyMap.php | 8 ++- src/Widget/DateWidget.php | 60 +++++++++++--------- tests/phpunit/Unit/Builder/FormTest.php | 2 +- tests/phpunit/Unit/Config/DateBoundsTest.php | 8 +++ tests/phpunit/Unit/Widget/DateWidgetTest.php | 35 ++++++++++-- 8 files changed, 93 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 927c652..333bcf7 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ $p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080); ### Date -A month calendar that returns a normalized ISO `YYYY-MM-DD` date. Left/Right (or vim `h`/`l`) move by day, Up/Down (or `k`/`j`) move by week, PageUp/PageDown change month, Home/End jump to the first/last day of the month, and Enter accepts. Pass `default` (a `YYYY-MM-DD` string) to open on a specific date; with none the calendar opens on today. +A month calendar that returns a normalized ISO `YYYY-MM-DD` date. Left/Right move by day and Up/Down by week (the vim preset binds `h`/`l` and `k`/`j` to the same moves), PageUp/PageDown change month, Home/End jump to the first/last day of the month, and Enter accepts. Pass `default` (a `YYYY-MM-DD` string) to open on a specific date; with none the calendar opens on today. The move keys are configurable through the key map, like every other widget. ```php $p->date('release', 'Release date')->default('2026-07-15'); @@ -148,6 +148,8 @@ $p->date('release', 'Release date')->default('2026-07-15'); Declare optional `minDate`, `maxDate` and `weekStart` to bound the range and set the first column of the week. Navigation is clamped to the range - the cursor never leaves it - and days outside it render dimmed. The bounds are enforced headlessly too - a `--prompts` or environment value outside the range is rejected - and are reflected in the JSON schema as `min_date`, `max_date` and `week_start` on the prompt. ```php +use DrevOps\Tui\Config\Weekday; + $p->date('release', 'Release date')->minDate('2026-01-01')->maxDate('2026-12-31')->weekStart(Weekday::Sunday); ``` diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index e83aa6b..a1d39c2 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -689,8 +689,8 @@ protected function buildDateBounds(): ?DateBounds { return NULL; } - $min = $this->parseBoundDate($this->minDate, 'min'); - $max = $this->parseBoundDate($this->maxDate, 'max'); + $min = $this->parseBoundDate($this->minDate); + $max = $this->parseBoundDate($this->maxDate); if ($min instanceof \DateTimeImmutable && $max instanceof \DateTimeImmutable && $min > $max) { throw new ConfigException(sprintf('Field "%s" declares min date %s after max date %s.', $this->id, $min->format('Y-m-d'), $max->format('Y-m-d'))); @@ -704,8 +704,6 @@ protected function buildDateBounds(): ?DateBounds { * * @param string|null $value * The declared date string, or NULL when the bound is open. - * @param string $which - * The bound name ("min" or "max"), used in the error message. * * @return \DateTimeImmutable|null * The parsed date, or NULL when none was declared. @@ -713,14 +711,14 @@ protected function buildDateBounds(): ?DateBounds { * @throws \DrevOps\Tui\Config\ConfigException * When the value is not a valid `Y-m-d` date. */ - protected function parseBoundDate(?string $value, string $which): ?\DateTimeImmutable { + protected function parseBoundDate(?string $value): ?\DateTimeImmutable { if ($value === NULL) { return NULL; } $date = DateBounds::parse($value); if (!$date instanceof \DateTimeImmutable) { - throw new ConfigException(sprintf('Field "%s" declares an invalid %s date "%s".', $this->id, $which, $value)); + throw new ConfigException(sprintf('Field "%s" declares an invalid date "%s".', $this->id, $value)); } return $date; diff --git a/src/Config/DateBounds.php b/src/Config/DateBounds.php index 4faaeee..ed36949 100644 --- a/src/Config/DateBounds.php +++ b/src/Config/DateBounds.php @@ -26,12 +26,18 @@ * The inclusive latest date, or NULL for an open upper bound. * @param \DrevOps\Tui\Config\Weekday $weekStart * The day the calendar week begins on. + * + * @throws \DrevOps\Tui\Config\ConfigException + * When both bounds are declared and the minimum falls after the maximum. */ public function __construct( public ?\DateTimeImmutable $min = NULL, public ?\DateTimeImmutable $max = NULL, public Weekday $weekStart = Weekday::Monday, ) { + if ($this->min instanceof \DateTimeImmutable && $this->max instanceof \DateTimeImmutable && $this->min > $this->max) { + throw new ConfigException(sprintf('Date bounds declare a minimum of %s after the maximum of %s.', $this->min->format('Y-m-d'), $this->max->format('Y-m-d'))); + } } /** diff --git a/src/Input/VimKeyMap.php b/src/Input/VimKeyMap.php index 34afc65..bcad105 100644 --- a/src/Input/VimKeyMap.php +++ b/src/Input/VimKeyMap.php @@ -10,7 +10,8 @@ * A vim-style preset: h/j/k/l navigate alongside the arrow keys. * * The letter keys are added only where they cannot be swallowed as typed input: - * panel navigation and the single-choice list. Text fields and the filtering + * panel navigation, the single-choice list and the date calendar. Text fields + * and the filtering * lists (search, suggest, checkbox) keep the arrow keys, because there a letter * is a character the user is typing - binding it to movement would make it * un-typeable, which the key map rejects outright. Everything else is inherited @@ -33,6 +34,11 @@ public function bindings(): array { new Binding(Scope::field(FieldType::Select), Action::MoveUp, 'k', KeyName::Up), new Binding(Scope::field(FieldType::Select), Action::MoveDown, 'j', KeyName::Down), + + new Binding(Scope::field(FieldType::Date), Action::MoveUp, 'k', KeyName::Up), + new Binding(Scope::field(FieldType::Date), Action::MoveDown, 'j', KeyName::Down), + new Binding(Scope::field(FieldType::Date), Action::MoveLeft, 'h', KeyName::Left), + new Binding(Scope::field(FieldType::Date), Action::MoveRight, 'l', KeyName::Right), ]); } diff --git a/src/Widget/DateWidget.php b/src/Widget/DateWidget.php index e56140f..aedabcb 100644 --- a/src/Widget/DateWidget.php +++ b/src/Widget/DateWidget.php @@ -5,19 +5,24 @@ namespace DrevOps\Tui\Widget; use DrevOps\Tui\Config\DateBounds; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Weekday; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; +use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Theme\ThemeInterface; /** * A month-calendar date picker returning a normalized ISO `Y-m-d` string. * - * Arrow keys - and the vim keys h/j/k/l - move the cursor by day and by week, - * the page keys change month, and Home/End jump to the first/last day of the - * visible month. Every motion is clamped to the declared min/max range, so the - * cursor never settles on an out-of-range day; days outside the range stay - * visible but dimmed. + * The move actions - bound to the arrow keys by default, and to h/j/k/l too + * under the vim preset - move the cursor by day and by week; the page keys + * change month, and Home/End jump to the first/last day of the visible month. + * Every motion is clamped to the declared min/max range, so the cursor never + * settles on an out-of-range day; days outside the range stay visible but + * dimmed. * * @package DrevOps\Tui\Widget */ @@ -63,21 +68,31 @@ public function __construct(string $value = '', ?\Closure $validate = NULL, ?\Cl $this->cursor = $this->bounds->clamp($seed); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Date); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); return; } - $moved = $this->move($key); + $moved = $this->move($key, $keys); if ($moved instanceof \DateTimeImmutable) { $this->cursor = $this->bounds->clamp($moved); } @@ -86,18 +101,24 @@ public function handle(Key $key): void { /** * The date a navigation key moves to before clamping, or NULL for no move. * + * Day and week movement resolve through the injected key bindings, so the + * arrow keys, the vim preset and any consumer remap all reach them; the month + * and month-edge jumps have no action of their own and stay on their keys. + * * @param \DrevOps\Tui\Input\Key $key * The key to interpret. + * @param \DrevOps\Tui\Input\ScopedKeyMap $keys + * The resolved bindings for this widget's scope. * * @return \DateTimeImmutable|null * The unclamped target date, or NULL when the key does not navigate. */ - protected function move(Key $key): ?\DateTimeImmutable { + protected function move(Key $key, ScopedKeyMap $keys): ?\DateTimeImmutable { return match (TRUE) { - $key->is(KeyName::Left), $this->isChar($key, 'h') => $this->cursor->modify('-1 day'), - $key->is(KeyName::Right), $this->isChar($key, 'l') => $this->cursor->modify('+1 day'), - $key->is(KeyName::Up), $this->isChar($key, 'k') => $this->cursor->modify('-7 days'), - $key->is(KeyName::Down), $this->isChar($key, 'j') => $this->cursor->modify('+7 days'), + $keys->matches($key, Action::MoveLeft) => $this->cursor->modify('-1 day'), + $keys->matches($key, Action::MoveRight) => $this->cursor->modify('+1 day'), + $keys->matches($key, Action::MoveUp) => $this->cursor->modify('-7 days'), + $keys->matches($key, Action::MoveDown) => $this->cursor->modify('+7 days'), $key->is(KeyName::PageUp) => $this->shiftMonths(-1), $key->is(KeyName::PageDown) => $this->shiftMonths(1), $key->is(KeyName::Home) => $this->cursor->modify('first day of this month'), @@ -106,21 +127,6 @@ protected function move(Key $key): ?\DateTimeImmutable { }; } - /** - * Whether the key is a specific printable character. - * - * @param \DrevOps\Tui\Input\Key $key - * The key. - * @param string $char - * The character to match. - * - * @return bool - * TRUE when the key is that character. - */ - protected function isChar(Key $key, string $char): bool { - return $key->isChar() && $key->char === $char; - } - /** * The cursor moved by whole months, kept on a valid day-of-month. * diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 09e8af4..4af9db0 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -272,7 +272,7 @@ public function testDateBoundsAssembled(): void { public function testDateInvalidBoundThrows(): void { $this->expectException(ConfigException::class); - $this->expectExceptionMessage('Field "d" declares an invalid min date "2026-13-01".'); + $this->expectExceptionMessage('Field "d" declares an invalid date "2026-13-01".'); Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->date('d')->minDate('2026-13-01')) diff --git a/tests/phpunit/Unit/Config/DateBoundsTest.php b/tests/phpunit/Unit/Config/DateBoundsTest.php index e781152..10c19f8 100644 --- a/tests/phpunit/Unit/Config/DateBoundsTest.php +++ b/tests/phpunit/Unit/Config/DateBoundsTest.php @@ -4,6 +4,7 @@ namespace DrevOps\Tui\Tests\Unit\Config; +use DrevOps\Tui\Config\ConfigException; use DrevOps\Tui\Config\DateBounds; use DrevOps\Tui\Config\Weekday; use PHPUnit\Framework\Attributes\CoversClass; @@ -99,6 +100,13 @@ public function testWeekStartDefaultsToMonday(): void { $this->assertSame(Weekday::Sunday, (new DateBounds(weekStart: Weekday::Sunday))->weekStart); } + public function testConstructorRejectsReversedBounds(): void { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Date bounds declare a minimum of 2026-12-31 after the maximum of 2026-01-01.'); + + new DateBounds(new \DateTimeImmutable('2026-12-31'), new \DateTimeImmutable('2026-01-01')); + } + /** * Build date bounds from optional ISO date strings. * diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php index 8ae8023..7bba16d 100644 --- a/tests/phpunit/Unit/Widget/DateWidgetTest.php +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -5,9 +5,11 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Config\DateBounds; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Input\ArrayKeyStream; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Theme\DefaultTheme; @@ -57,16 +59,30 @@ public static function dataProviderNavigation(): \Iterator { yield 'right is next day' => [Key::named(KeyName::Right), '2026-07-16']; yield 'up is previous week' => [Key::named(KeyName::Up), '2026-07-08']; yield 'down is next week' => [Key::named(KeyName::Down), '2026-07-22']; - yield 'vim h is previous day' => [Key::char('h'), '2026-07-14']; - yield 'vim l is next day' => [Key::char('l'), '2026-07-16']; - yield 'vim k is previous week' => [Key::char('k'), '2026-07-08']; - yield 'vim j is next week' => [Key::char('j'), '2026-07-22']; yield 'page up is previous month' => [Key::named(KeyName::PageUp), '2026-06-15']; yield 'page down is next month' => [Key::named(KeyName::PageDown), '2026-08-15']; yield 'home is first of month' => [Key::named(KeyName::Home), '2026-07-01']; yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31']; } + #[DataProvider('dataProviderVimNavigation')] + public function testVimNavigation(Key $key, string $expected): void { + // Injecting the vim scope map proves day and week movement resolve through + // the key bindings: the vim preset reaches the same moves via h/j/k/l. + $widget = (new DateWidget('2026-07-15'))->setKeys(KeyMapManager::create('vim')->forField(FieldType::Date)); + + $widget->handle($key); + + $this->assertSame($expected, $widget->value()); + } + + public static function dataProviderVimNavigation(): \Iterator { + yield 'h is previous day' => [Key::char('h'), '2026-07-14']; + yield 'l is next day' => [Key::char('l'), '2026-07-16']; + yield 'k is previous week' => [Key::char('k'), '2026-07-08']; + yield 'j is next week' => [Key::char('j'), '2026-07-22']; + } + #[DataProvider('dataProviderPageMonthClampsToShortMonth')] public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void { $widget = new DateWidget($seed); @@ -211,4 +227,15 @@ public function testDimsOutOfRangeDays(): void { $this->assertStringContainsString($theme->highlight('[15]'), $view); } + public function testDimsDaysPastMaximum(): void { + $bounds = new DateBounds(max: new \DateTimeImmutable('2026-07-20')); + $widget = new DateWidget('2026-07-15', bounds: $bounds); + $theme = new DefaultTheme(); + + $view = $widget->view($theme); + + // A day after the maximum is dimmed too, guarding the upper bound. + $this->assertStringContainsString($theme->description(sprintf(' %2d ', 25)), $view); + } + }