diff --git a/README.md b/README.md index 9ca8636..333bcf7 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,40 @@ 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 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'); +``` + +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); +``` + + + + + + + + + + + + + + + + + +
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..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: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---------------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 e34d534..eddbfff 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---------------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 7130a73..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: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โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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 a5b85ae..cad7649 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โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€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/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..858feb1 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; @@ -46,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); @@ -67,6 +73,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..a1d39c2 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,55 @@ 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); + $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'))); + } + + 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. + * + * @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): ?\DateTimeImmutable { + if ($value === NULL) { + return NULL; + } + + $date = DateBounds::parse($value); + if (!$date instanceof \DateTimeImmutable) { + throw new ConfigException(sprintf('Field "%s" declares an invalid date "%s".', $this->id, $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..ed36949 --- /dev/null +++ b/src/Config/DateBounds.php @@ -0,0 +1,149 @@ +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'))); + } + } + + /** + * Strictly parse an ISO `Y-m-d` date, or NULL when it is not a valid one. + * + * The parse is round-tripped through the same format, so an unpadded, + * rolled-over or otherwise malformed value (e.g. "2026-2-3" or "2026-02-30") + * is rejected rather than silently normalized. + * + * @param string $value + * The candidate date string. + * + * @return \DateTimeImmutable|null + * The parsed date at midnight, or NULL when the value is not a strict + * `Y-m-d` calendar date. + */ + public static function parse(string $value): ?\DateTimeImmutable { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + + return $date instanceof \DateTimeImmutable && $date->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 instanceof \DateTimeImmutable && $value < $this->min) { + return FALSE; + } + + return !$this->max instanceof \DateTimeImmutable || $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 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 instanceof \DateTimeImmutable) { + return sprintf('on or after %s', $this->min->format('Y-m-d')); + } + + if ($this->max instanceof \DateTimeImmutable) { + 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 instanceof \DateTimeImmutable && $value < $this->min) { + return $this->min; + } + + if ($this->max instanceof \DateTimeImmutable && $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/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/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..3e56046 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->value, '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..aedabcb --- /dev/null +++ b/src/Widget/DateWidget.php @@ -0,0 +1,283 @@ +bounds = $bounds ?? new DateBounds(); + $seed = DateBounds::parse($value) ?? new \DateTimeImmutable('today'); + $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 ($keys->matches($key, Action::Accept)) { + $this->accept($this->liveValue()); + + return; + } + + $moved = $this->move($key, $keys); + if ($moved instanceof \DateTimeImmutable) { + $this->cursor = $this->bounds->clamp($moved); + } + } + + /** + * 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, ScopedKeyMap $keys): ?\DateTimeImmutable { + return match (TRUE) { + $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'), + $key->is(KeyName::End) => $this->cursor->modify('last day of this month'), + default => NULL, + }; + } + + /** + * 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} + */ + #[\Override] + 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..4af9db0 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->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->min); + $this->assertNotInstanceOf(\DateTimeImmutable::class, $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 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->assertNotInstanceOf(DateBounds::class, $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..10c19f8 --- /dev/null +++ b/tests/phpunit/Unit/Config/DateBoundsTest.php @@ -0,0 +1,125 @@ +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); + } + + 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. + * + * @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..a5f4df5 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' => Weekday::Sunday->value, 'when' => NULL, 'derive' => NULL, 'discover' => NULL, @@ -115,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, diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index 7c4f168..dbc7d55 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -107,6 +107,27 @@ 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()); + $error = 'Question "due" must be a date (YYYY-MM-DD).'; + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-07-15'])); + // 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.'; + + // 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 { $validator = new SchemaValidator($this->config()); @@ -172,6 +193,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..7bba16d --- /dev/null +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -0,0 +1,241 @@ +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 '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); + + $widget->handle($key); + + $this->assertSame($expected, $widget->value()); + } + + 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']; + 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 + // in place. + $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 { + $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/', $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 { + $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); + } + + 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); + } + +} 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');