diff --git a/.github/workflows/ecode-nightly.yml b/.github/workflows/ecode-nightly.yml index 73f1c9d05..ae8e16b42 100644 --- a/.github/workflows/ecode-nightly.yml +++ b/.github/workflows/ecode-nightly.yml @@ -201,7 +201,7 @@ jobs: config: - compiler: default arch: x86_64 - container: ubuntu-22.04 + container: ubuntu-24.04 runs-on: ${{ matrix.config.container }} env: CC: gcc @@ -222,17 +222,9 @@ jobs: sudo apt-get update - name: Install dependencies run: | - sudo apt-get install -y premake4 libfuse2 fuse mingw-w64 gcc-12 g++-12 + sudo apt-get install -y premake4 libfuse2t64 fuse mingw-w64 wget https://cdn.ensoft.dev/eepp-assets/premake-5.0.0-beta6-linux.tar.gz tar xvzf premake-5.0.0-beta6-linux.tar.gz - sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 10 - sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 10 - sudo update-alternatives --install /usr/bin/cc cc /usr/bin/gcc 30 - sudo update-alternatives --set cc /usr/bin/gcc - sudo update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++ 30 - sudo update-alternatives --set c++ /usr/bin/g++ - sudo update-alternatives --config gcc - sudo update-alternatives --config g++ - name: Build ecode run: | bash projects/scripts/patch_commit_number.sh diff --git a/bin/assets/ui/breeze.css b/bin/assets/ui/breeze.css index 2e84f6655..50a1429f5 100644 --- a/bin/assets/ui/breeze.css +++ b/bin/assets/ui/breeze.css @@ -167,6 +167,10 @@ SpinBox::input, Tab, TextEdit, TextInput, +DateTimeEdit, +DatePicker, +TimePicker, +DateTimePicker, TextArea, textarea, TextView, @@ -188,6 +192,10 @@ TextEdit, TextArea, textarea, TextInput, +DateTimeEdit, +DatePicker, +TimePicker, +DateTimePicker, ComboBox::DropDownList, SpinBox::input, { selection-color: var(--font-selected-pressed); @@ -250,7 +258,7 @@ SelectButton:selected:pressed { pushbutton:disabled, selectbutton:disabled, -textinput:disabled, +textinput:disabled, datetimeedit:disabled, datepicker:disabled, timepicker:disabled, datetimepicker:disabled, textedit:disabled, textarea:disabled, DropDownList:disabled, @@ -402,7 +410,7 @@ ComboBox::DropDownList::ListBox::item:selected { tint: var(--font-selected-pressed); } -TextInput { +TextInput, DateTimeEdit, DatePicker, TimePicker, DateTimePicker { padding-left: var(--base-horizontal-padding); padding-right: var(--base-horizontal-padding); padding-top: var(--base-vertical-padding); @@ -430,8 +438,8 @@ ComboBox::DropDownList { padding-bottom: var(--base-vertical-padding); } -TextInput:hover, -TextInput:focus, +TextInput:hover, DateTimeEdit:hover, DatePicker:hover, TimePicker:hover, DateTimePicker:hover, +TextInput:focus, DateTimeEdit:focus, DatePicker:focus, TimePicker:focus, DateTimePicker:focus, SpinBox:hover, SpinBox:focus { border-color: var(--primary); @@ -1297,3 +1305,123 @@ Menu::Separator { } } + +/* Civil date/time controls use the current light or dark palette. */ +DatePicker::button, DateTimePicker::button { + foreground-image: url("data:image/svg,"); + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: var(--font); + color: var(--font); + background-color: transparent; + border-width: 0; + padding: 0; + cursor: arrow; +} +DatePicker::button:hover, DateTimePicker::button:hover { + foreground-tint: var(--font-selected-pressed); + background-color: var(--primary); + color: var(--font-selected-pressed); +} +Calendar { + color: var(--font); + background-color: var(--list-back); + border-color: var(--button-border); + border-width: var(--border-width); + border-radius: var(--button-radius); + width: auto; + height: auto; + min-width: 160dp; + min-height: 160dp; +} +Calendar::previous, Calendar::next, Calendar::title, Calendar::today, Calendar::day { + color: var(--font); + padding: 0; + border-width: 0; + background-color: transparent; +} +Calendar::previous { + foreground-image: url("data:image/svg,"); +} +Calendar::next { + foreground-image: url("data:image/svg,"); +} +Calendar::previous, Calendar::next { + cursor: arrow; + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: var(--font); +} +Calendar::previous:hover, Calendar::next:hover { + foreground-tint: var(--font-selected-pressed); +} +Calendar::weekday { color: var(--font-hint); } +Calendar::day.outside-month { color: var(--font-hint); } +Calendar::day:hover, Calendar::previous:hover, Calendar::next:hover, +Calendar::title:hover, Calendar::today:hover, Calendar::day.selected { + background-color: var(--primary); + color: var(--font-selected-pressed); +} +Calendar::day.today { + border-width: 1dp; + border-color: var(--primary); +} +Calendar::day.focused { border-width: 1dp; border-color: var(--font-hint); } +Calendar::day:disabled { opacity: 0.35; } + +DatePicker:disabled > DatePicker::button, +DateTimePicker:disabled > DateTimePicker::button, +DatePicker[allow-editing=false] > DatePicker::button, +DateTimePicker[allow-editing=false] > DateTimePicker::button { + foreground-tint: var(--disabled-color); + background-color: transparent; + cursor: arrow; +} + +/* Combined popup: a reusable calendar followed by a segmented time editor. */ +DateTimePicker::popup { + width: auto; + height: auto; + background-color: var(--list-back); + border-color: var(--button-border); + border-width: 1dp; + border-radius: 2dp; +} +DateTimePicker::popup > Calendar { + border-width: 0; + background-color: transparent; +} +DateTimePicker::time { + text-align: center; + font-size: 18dp; + padding: 24dp 8dp; + min-height: 72dp; + color: var(--font); + background-color: transparent; + border-color: var(--button-border); + border-width: 1dp 0 0 0; +} +DateTimePicker::time-up, DateTimePicker::time-down { + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: var(--font); + background-color: transparent; + border-width: 0; + padding: 0; + width: 24dp; + height: 22dp; + cursor: arrow; +} +DateTimePicker::time-up { + foreground-image: url("data:image/svg,"); +} +DateTimePicker::time-down { + foreground-image: url("data:image/svg,"); +} +DateTimePicker::time-up:hover, DateTimePicker::time-down:hover { + foreground-tint: var(--font-selected-pressed); + background-color: var(--primary); +} diff --git a/bin/assets/ui/uitheme.css b/bin/assets/ui/uitheme.css index 8eaf58d13..edc8cb0d4 100644 --- a/bin/assets/ui/uitheme.css +++ b/bin/assets/ui/uitheme.css @@ -116,3 +116,103 @@ Menu::RadioButton::shortcut { margin-right: 8dp; } + +Calendar { + background-color: #2f3030; + border-color: #969696; + border-width: 1dp; + width: auto; + height: auto; + min-width: 160dp; + min-height: 160dp; +} +Calendar::previous, Calendar::next, Calendar::title, Calendar::today, Calendar::day { + padding: 0; + border-width: 0; + background-color: transparent; +} +Calendar::previous { + foreground-image: url("data:image/svg,"); +} +Calendar::next { + foreground-image: url("data:image/svg,"); +} +Calendar::previous, Calendar::next { + cursor: arrow; + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: #E6E6E6; +} +Calendar::previous:hover, Calendar::next:hover { + foreground-tint: #fff; +} +Calendar::weekday, Calendar::day.outside-month { color: #969696; } +Calendar::day.selected, Calendar::day:hover { background-color: #606060; color: #fff; } +Calendar::day.today { border-color: #969696; border-width: 1dp; } +Calendar::day.focused { border-color: #E6E6E6; border-width: 1dp; } +Calendar::day:disabled { opacity: 0.35; } +DatePicker::button, DateTimePicker::button { + foreground-image: url("data:image/svg,"); + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: #E6E6E6; + padding: 0; + cursor: arrow; + border-width: 0; + background-color: transparent; +} +DatePicker:disabled > DatePicker::button, DateTimePicker:disabled > DateTimePicker::button, +DatePicker[allow-editing=false] > DatePicker::button, +DateTimePicker[allow-editing=false] > DateTimePicker::button { + foreground-tint: #969696; + background-color: transparent; + cursor: arrow; +} + +/* Combined popup: a reusable calendar followed by a segmented time editor. */ +DateTimePicker::popup { + width: auto; + height: auto; + background-color: #2f3030; + border-color: #969696; + border-width: 1dp; + border-radius: 2dp; +} +DateTimePicker::popup > Calendar { + border-width: 0; + background-color: transparent; +} +DateTimePicker::time { + text-align: center; + font-size: 18dp; + padding: 24dp 8dp; + min-height: 72dp; + color: #E6E6E6; + background-color: transparent; + border-color: #969696; + border-width: 1dp 0 0 0; +} +DateTimePicker::time-up, DateTimePicker::time-down { + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: #E6E6E6; + background-color: transparent; + border-width: 0; + padding: 0; + width: 24dp; + height: 22dp; + cursor: arrow; +} +DateTimePicker::time-up { + foreground-image: url("data:image/svg,"); +} +DateTimePicker::time-down { + foreground-image: url("data:image/svg,"); +} +DateTimePicker::time-up:hover, DateTimePicker::time-down:hover { + foreground-tint: #fff; + background-color: #606060; +} diff --git a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp index 56fdbadce..55498bf1f 100644 Binary files a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp and b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp differ diff --git a/docs/articles/css_specification.md b/docs/articles/css_specification.md index 693218d68..c27a24517 100644 --- a/docs/articles/css_specification.md +++ b/docs/articles/css_specification.md @@ -153,6 +153,37 @@ Example: `TextView + image` will match all `` elements that directly foll --- +## Date/time control styling + +Native tags are `DateTimeEdit`, `DatePicker`, `TimePicker`, `DateTimePicker`, and `Calendar`. +Picker button tags are `DatePicker::button` and `DateTimePicker::button`. Calendar subparts +are `Calendar::header`, `::previous`, `::next`, `::title`, `::weekdays`, `::weekday`, `::grid`, +`::day`, and `::today` (each uses the `Calendar` prefix). Day-cell classes are `selected`, +`today`, `outside-month`, and `focused`; unavailable dates use `:disabled`. + +The combined popup exposes `DateTimePicker::popup`, `DateTimePicker::time`, +`DateTimePicker::time-up`, and `DateTimePicker::time-down`. Time buttons have segment classes +`hour`, `minute`, `second`, `millisecond`, or `am-pm`. Standard foreground image/size/tint, +font, padding, border, and color properties style these parts: + +```css +DateTimePicker { show-seconds: true; minute-step: 15; } +Calendar { font-size: 12dp; min-width: 160dp; min-height: 160dp; } +DateTimePicker::button { foreground-size: 12dp 12dp; cursor: arrow; } +DateTimePicker[allow-editing=false] > DateTimePicker::button { foreground-tint: gray; } +Calendar::day.selected { background-color: #0078d7; color: white; } +``` + +Calendars fit their text metrics automatically unless an explicit fixed size overrides them. +Seconds and milliseconds default to hidden; changing display settings also updates an open +native date-time popup. Theme icons are CSS-defined inline SVG images sized in `dp`. + +In HTML documents, style the `input` host. Anonymous control parts use private light defaults +from the document's user-agent CSS and ignore author rules. HTML temporal constraints use +`min`, `max`, `step`, `required`, `readonly`, and `disabled`, rather than the native bound and +editing properties. See [date/time pickers](ui_date_time_pickers.md) for interactions and APIs, +and [HTML compatibility](ui_html_compatibility.md) for the supported form behavior. + ## CSS Properties eepp CSS properties can be categorized in two big groups: CSS standard properties and eepp CSS @@ -177,6 +208,19 @@ properties. --- +### active-section + +Selects the active segment of a date/time editor. Values: `none`, `day`, `month`, `year`, +`hour`, `minute`, `second`, `millisecond`, and `am-pm`. The segment must exist in the current +display format; `none` clears segment selection. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: `none` + +--- + ### animation-delay Read [animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-delay) docs. @@ -232,9 +276,24 @@ elastic-out, elastic-in-out, none. ### allow-editing -Enable or disable editing on input elements. +Enable or disable editing on input elements. Read-only date/time controls retain text +selection, close an open popup, and mute their calendar button using theme CSS. -* Applicable to: EE::UI::UITextEdit (TextEdit), EE::UI::UITextInput (TextInput) +* Applicable to: EE::UI::UITextEdit (TextEdit), EE::UI::UITextInput (TextInput), + EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [boolean](#boolean-data-type) +* Default value: `true` + +--- + +### allow-empty + +Allows a date/time editor to have no value. Setting this to `false` initializes an empty +editor from the current date/time, subject to its bounds, and prevents clearing it. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) * Data Type: [boolean](#boolean-data-type) * Default value: `true` @@ -432,6 +491,17 @@ Defines where the border box is drawn. --- +### calendar-view + +Selects the calendar view: `days`, `months`, or `years`. Changing the view does not select +a date. + +* Applicable to: EE::UI::UICalendar (Calendar) +* Data Type: [string](#string-data-type) +* Default value: `days` + +--- + ### change-page-percent Sets the percentage of scroll of a page that triggers a page change in a ViewPager. @@ -577,6 +647,32 @@ Custom cursors not yet supported (but supported by the engine, only not implemen --- +### date-format + +Sets the date display pattern, or `locale` to use the editor locale. Supported tokens are +`d`/`dd`, `M`/`MM`, and `yy`/`yyyy`; all three date components are required. Single quotes +escape literal text. For example: `date-format: "dd/MM/yyyy";`. This changes display and +editing, while [value](#value) retains ISO serialization. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: `locale` + +--- + +### disabled + +Disables an HTML input when the attribute/property is present, including an empty value. +Disabled temporal inputs are excluded from constraint validation and form submission. This +HTML presence attribute is distinct from the native [enabled](#enabled) boolean property. + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: HTML presence attribute +* Default value: _Absent_ + +--- + ### disable-editor-flags Allows disabling specific behavior flags for the code editor component. @@ -592,6 +688,17 @@ Multiple flags are separated by `|`. --- +### displayed-month + +Sets the calendar month to display using an ISO date (`yyyy-MM-dd`). The day component is +normalized to the first day of that month. Navigation does not change the selected value. + +* Applicable to: EE::UI::UICalendar (Calendar) +* Data Type: [string](#string-data-type) +* Default value: Current month for a standalone calendar; picker popups synchronize on opening + +--- + ### display-options Allows to set what information should be displayed of the current image. @@ -706,6 +813,28 @@ If `true` the inner element for text is expanded to occupy as much horizontal sp --- +### first-day-of-week + +Sets the first weekday of the calendar: Sunday `0` through Saturday `6`. This controls +the weekday header and day-grid ordering. + +* Applicable to: EE::UI::UICalendar (Calendar) +* Data Type: [integer](#integer-data-type) +* Default value: The calendar locale's first weekday + +--- + +### focused-date + +Sets the calendar keyboard-focus date using ISO format (`yyyy-MM-dd`). It is clamped to +the calendar bounds and brings its month into view without selecting it. + +* Applicable to: EE::UI::UICalendar (Calendar) +* Data Type: [string](#string-data-type) +* Default value: Today for a standalone calendar; picker popups synchronize on opening + +--- + ### font-family Read [font-family](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) documentation. @@ -1006,6 +1135,32 @@ Sets the hint font stroke (the outline) width. --- +### hour-cycle + +Sets the hour cycle for locale-derived time patterns: `locale`, `12`, or `24`. Aliases +`h12` and `h24` are accepted as values. An explicit [time-format](#time-format) defines its +own hour tokens and takes precedence. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: `locale` + +--- + +### hour-step + +Sets the positive integer hour increment for stepping the active hour segment, including +date-time popup steppers. Time-only stepping wraps within the day; date-time stepping +carries across date boundaries. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [integer](#integer-data-type) +* Default value: `1` + +--- + ### href Specifies the link destination of an Anchor element @@ -1355,6 +1510,63 @@ Read [margin-top](https://developer.mozilla.org/en-US/docs/Web/CSS/margin-top) d --- +### max + +Sets the maximum valid value for HTML `date`, `time`, and `datetime-local` inputs. +Use the input type's ISO value syntax (see [value](#value)). Invalid bounds are ignored. +Out-of-range values report a validation error rather than being silently clamped. Time +ranges with `min` later than `max` wrap across midnight. + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### max-date + +Sets the inclusive maximum for native `date` mode using ISO syntax +(`yyyy-MM-dd`). Native editors clamp values to their active mode's bounds. Calendar bounds +also disable unavailable dates. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker), + EE::UI::UICalendar (Calendar) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### max-datetime + +Sets the inclusive maximum for native `datetime` mode using ISO syntax +(`yyyy-MM-ddTHH:mm:ss[.SSS]`). Native editors clamp values to their active mode's bounds. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### max-time + +Sets the inclusive maximum for native `time` mode using ISO syntax +(`HH:mm:ss[.SSS]`). Native editors clamp values to their active mode's bounds. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + ### max-height Read [max-height](https://developer.mozilla.org/en-US/docs/Web/CSS/max-height) documentation. @@ -1455,6 +1667,63 @@ Sets how is the dropdown-menu width calculated. --- +### min + +Sets the minimum valid value for HTML `date`, `time`, and `datetime-local` inputs. +Use the input type's ISO value syntax (see [value](#value)). Invalid bounds are ignored. +Out-of-range values report a validation error rather than being silently clamped. Time +ranges with `min` later than `max` wrap across midnight. + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### min-date + +Sets the inclusive minimum for native `date` mode using ISO syntax +(`yyyy-MM-dd`). Native editors clamp values to their active mode's bounds. Calendar bounds +also disable unavailable dates. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker), + EE::UI::UICalendar (Calendar) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### min-datetime + +Sets the inclusive minimum for native `datetime` mode using ISO syntax +(`yyyy-MM-ddTHH:mm:ss[.SSS]`). Native editors clamp values to their active mode's bounds. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + +### min-time + +Sets the inclusive minimum for native `time` mode using ISO syntax +(`HH:mm:ss[.SSS]`). Native editors clamp values to their active mode's bounds. +An empty string removes the bound. If the bounds cross, the opposite bound moves to the +new bound. HTML inputs use [min](#min) and [max](#max) instead. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: _No bound_ + +--- + ### min-height Read [min-height](https://developer.mozilla.org/en-US/docs/Web/CSS/min-height) documentation. @@ -1496,6 +1765,19 @@ Sets the minimum value that a range element will hold. --- +### minute-step + +Sets the positive integer minute increment when stepping the active minute segment or +using the date-time popup's minute buttons. For example, `minute-step: 15;` advances by +fifteen minutes. This controls the increment, not validation or snapping to multiples. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [integer](#integer-data-type) +* Default value: `1` + +--- + ### min-width Read [min-width](https://developer.mozilla.org/en-US/docs/Web/CSS/min-width) documentation. @@ -1512,6 +1794,18 @@ Sets the movement speed in displaced length per second of the progress bar fille --- +### mode + +Sets the shared editor mode: `date`, `time`, or `datetime`. The picker classes initialize +their corresponding mode automatically. Values and display formats follow the active mode. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: `date` for DateTimeEdit and DatePicker; `time` for TimePicker; `datetime` for DateTimePicker + +--- + ### numeric Sets the text input as a numeric input, this means that only numbers will be accepted and @@ -1612,12 +1906,25 @@ component. --- +### popup-open + +Exposes whether the calendar popup is currently open as `true` or `false`, for inspection, +binding and attribute selectors. This property is read-only; a declaration does not open +or close the popup. Use the picker API or normal keyboard/mouse interaction. + +* Applicable to: EE::UI::UIDatePicker (DatePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [boolean](#boolean-data-type) +* Default value: `false` + +--- + ### popup-to-root -Sets if the drop down listbox should popup in the root widget node, otherwise will popup in the -parent window. +Sets whether a dropdown or picker popup is parented to the scene root instead of its +parent window container. Popup placement is clamped to the scene's visible viewport. -* Applicable to: EE::UI::UIDropDownList (DropDownList) +* Applicable to: EE::UI::UIDropDownList (DropDownList), EE::UI::UIDatePicker (DatePicker), + EE::UI::UIDateTimePicker (DateTimePicker) * Data Type: [boolean](#boolean-data-type) * Default value: `false` @@ -1655,6 +1962,32 @@ the last child to the first. --- +### readonly + +Makes an HTML input read-only when the attribute/property is present, including an empty +value. Temporal inputs retain text selection but prevent value edits and opening their +popup, and are excluded from constraint validation. Use [allow-editing](#allow-editing) +for native date/time controls. + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: HTML presence attribute +* Default value: _Absent_ + +--- + +### required + +Requires a nonempty value for HTML `date`, `time`, and `datetime-local` inputs during +constraint validation when the attribute/property is present, including an empty value. +Read-only and disabled inputs are excluded from validation. Unlike native +[allow-empty](#allow-empty), this does not initialize or prevent an empty value. + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: HTML presence attribute +* Default value: _Absent_ + +--- + ### rotation Sets the element rotation in degrees. @@ -1864,6 +2197,47 @@ Sets the scrollbar style (for the moment defines if it contains buttons at its e --- +### second-step + +Sets the positive integer second increment when stepping the active second segment or +using the date-time popup's second buttons. Show the segment with [show-seconds](#show-seconds) +or an explicit [time-format](#time-format). + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [integer](#integer-data-type) +* Default value: `1` + +--- + +### show-milliseconds + +Includes milliseconds and seconds in locale-derived time patterns. Enabling this also +enables [show-seconds](#show-seconds); disabling it leaves seconds enabled. Hidden +precision remains in the stored value. Explicit [time-format](#time-format) patterns +control their own segments. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [boolean](#boolean-data-type) +* Default value: `false` + +--- + +### show-seconds + +Includes seconds in locale-derived time patterns, independently of milliseconds. +Disabling seconds also disables [show-milliseconds](#show-milliseconds). Hidden precision +remains in the stored value. Explicit [time-format](#time-format) patterns control their +own segments. The date-time popup follows the editor's current display precision. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [boolean](#boolean-data-type) +* Default value: `false` + +--- + ### selected Sets the element state as selected when supported by the specific widget. @@ -2160,6 +2534,20 @@ Sets boolean flags for table and tree views. Multiple flags can be set, separate --- +### step + +Sets the allowed interval for HTML `date`, `time`, and `datetime-local` input validation. +Date steps use days; time and date-time steps use seconds and accept fractional seconds. +`any` disables step-mismatch validation. The base is a valid `min`, otherwise the initial +`value` attribute, otherwise the Unix civil epoch. This HTML constraint is distinct from +native [hour-step](#hour-step), [minute-step](#minute-step), and [second-step](#second-step). + +* Applicable to: EE::UI::UIHTMLInput (input) +* Data Type: Positive [number](#number-data-type) or `any` +* Default value: `1` for date; `60` for time and datetime-local + +--- + ### text Sets the text to any element that holds or contains text. @@ -2316,6 +2704,21 @@ Sets the timing function (easing function) of a view pager. --- +### time-format + +Sets the time display pattern, or `locale` to use the editor locale. Tokens: `H`/`HH` +(24-hour), `h`/`hh` (12-hour), `m`/`mm`, `s`/`ss`, `S`/`SS`/`SSS`, and `a` (AM/PM). +An hour and minutes are required; 12-hour patterns also require `a`. Single quotes escape +literal text. For example: `time-format: "HH:mm:ss";`. Explicit patterns control their own +precision and hour cycle; [value](#value) retains ISO serialization. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [string](#string-data-type) +* Default value: `locale` + +--- + ### tint Sets the tint color of an image. @@ -2401,10 +2804,27 @@ elastic-out, elastic-in-out, cubic-bezier, none. ### value -Sets the current value to an element that accepts values. +Sets the current value to an element that accepts values. Numeric controls use numbers. +Date/time controls use locale-independent ISO strings: -* Applicable to: EE::UI::UIScrollBar (ScrollBar), EE::UI::UISlider (Slider), EE::UI::UISpinBox (SpinBox) -* Data Type: [number](#number-data-type) +| Control or mode | Value syntax | Example | +| --- | --- | --- | +| Calendar, native date mode, HTML `date` | `yyyy-MM-dd` | `2026-09-28` | +| Native time mode | `HH:mm:ss[.SSS]` | `20:14:32.125` | +| Native datetime mode | `yyyy-MM-ddTHH:mm:ss[.SSS]` | `2026-09-28T20:14:32.125` | +| HTML `time` | `HH:mm[:ss[.SSS]]` | `20:14` | +| HTML `datetime-local` | `yyyy-MM-ddTHH:mm[:ss[.SSS]]` | `2026-09-28T20:14` | + +An empty string clears a native editor when [allow-empty](#allow-empty) permits it, or clears +calendar selection. Display formats and hidden precision do not change ISO serialization. +HTML temporal inputs sanitize invalid strings to empty and validate constraints without +clamping their supplied value. Local date-time values carry no timezone. + +* Applicable to: EE::UI::UIScrollBar (ScrollBar), EE::UI::UISlider (Slider), EE::UI::UISpinBox (SpinBox), + EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker), + EE::UI::UICalendar (Calendar), EE::UI::UIHTMLInput (input) +* Data Type: [number](#number-data-type) or ISO [string](#string-data-type), depending on the control * Default value: _Not set_ --- @@ -2452,6 +2872,19 @@ The mode defines the visibility of the vertical scroll. --- +### wheel-editing + +Allows mouse-wheel stepping of the active date/time segment while the editor has focus. +The selected segment uses its configured step and bounds. [allow-editing](#allow-editing) +must also be enabled. + +* Applicable to: EE::UI::UIDateTimeEdit (DateTimeEdit), EE::UI::UIDatePicker (DatePicker), + EE::UI::UITimePicker (TimePicker), EE::UI::UIDateTimePicker (DateTimePicker) +* Data Type: [boolean](#boolean-data-type) +* Default value: `false` + +--- + ### window-border-auto-size Sets if the window border size should be automatically calculated based on the skin size. diff --git a/docs/articles/ui_date_time_pickers.md b/docs/articles/ui_date_time_pickers.md new file mode 100644 index 000000000..6e04e48f2 --- /dev/null +++ b/docs/articles/ui_date_time_pickers.md @@ -0,0 +1,211 @@ +# Date and time controls + +`UIDateTimeEdit` is one segmented `UITextInput` surface with date, time, and local date-time +modes. `UIDatePicker` adds a calendar popup, `UITimePicker` configures time mode without a +popup, and `UIDateTimePicker` combines a calendar and time editor in one popup. +`UICalendar` can also be used as a standalone widget. + +## Native XML + +```xml + + + + + +``` + +Values are optional typed civil values: `CalendarDate`, `TimeOfDay`, and `LocalDateTime`, +from ``. They do not carry time zones. Use `setDate()`, `setTime()`, +or `setDateTime()` and the corresponding optional getters. `clear()` removes a value when +`allow-empty` is true (the default). Canonical setters clamp to the active mode's bounds and +emit `OnValueChange` only when the typed value changes. The `value` property exposes ISO +serialization for ordinary data binding. Hidden seconds and milliseconds remain part of the +value; serialization preserves nonzero milliseconds even if they are not displayed. + +## Serialization and Unix timestamps + +`DateTimeFormatter` in `` formats ISO values independently of +the display locale and parses them with an explicit pattern: + +```cpp +using namespace EE; +using namespace EE::System; + +const CalendarDate date{ 2026, 9, 28 }; +const std::string storedDate = DateTimeFormatter::toISODate( date ); // "2026-09-28" +const auto restoredDate = DateTimeFormatter::parseDate( storedDate, DateTimeFormat( "yyyy-MM-dd" ) ); + +const LocalDateTime value{ date, { 20, 14, 32, 125 } }; +const std::string storedValue = DateTimeFormatter::toISOLocalDateTime( value, true ); +const auto restoredValue = DateTimeFormatter::parseDateTime( + storedValue, DateTimeFormat( "yyyy-MM-dd'T'HH:mm:ss.SSS" ) ); + +// The application knows this value uses UTC-03:00; the types do not store a timezone. +const Int32 utcOffsetSeconds = -3 * 60 * 60; +const auto timestamp = value.toUnixTimestampMilliseconds( utcOffsetSeconds ); +if ( timestamp ) { + const auto restored = LocalDateTime::fromUnixTimestampMilliseconds( + *timestamp, utcOffsetSeconds ); +} +``` + +Unix timestamps count seconds or milliseconds since `1970-01-01T00:00:00Z`, without leap +seconds. `CalendarDate::toUnixTimestamp()` uses midnight UTC; `fromUnixTimestamp()` extracts +the UTC date containing that instant. `LocalDateTime` conversions default to UTC and accept an offset +in seconds, where `local = UTC + offset`. The offset is not inferred from the +system timezone and does not resolve daylight-saving gaps or ambiguous times. Applications +using named timezones must resolve the offset for the particular date/time before conversion. + +The seconds methods discard milliseconds, rounding down even before the epoch. The +`toUnixTimestampMilliseconds()` / `fromUnixTimestampMilliseconds()` methods preserve them. +Conversions return `std::optional`: invalid civil values, an out-of-range year, or an +unrepresentable Int64 millisecond timestamp produce `std::nullopt`. The full Int32 year range +fits in Int64 seconds, but only part of it fits in Int64 milliseconds. These conversions are +independent of locale, `time_t` size, and the machine's timezone. For a date-only database +column, ISO date text or typed date fields preserve its civil meaning directly. + +## Editing + +Click a section to select it. Left/Right navigate sections; Home/End select the first/last. +Up/Down step the active section. Numeric entry automatically advances when a complete value +has been entered. Pending digits are shown with underscores, commit on navigation, Enter, +focus loss, or timeout, and can be canceled with Escape. Configure the timeout in C++ with +`setPendingDigitTimeout()` (default: one second). Delete/Backspace clear the value when allowed. +Copy and select-all use normal input commands. Paste replaces the whole value, including when +only one section is selected, and accepts ISO regardless of the display format. Invalid input +preserves the previous valid value. Wheel edits require both focus and `wheel-editing: true`; +wheel editing is disabled by default. `allow-editing: false` prevents user mutation. + +Month/year edits clamp the day at month ends. Time-only arithmetic wraps within the day; +local date-time arithmetic carries across date boundaries. AM/PM entry changes the period +without advancing the date. Utility arithmetic outside the representable Int32 year range +leaves the value unchanged. + +## Locale and properties + +Display defaults come from `DateTimeLocale::system()` (Windows regional settings on Windows), with a centralized locale-name fallback +for information unavailable through the standard library. `setLocale()` accepts explicit date +order/separator, hour cycle, month and weekday names, AM/PM labels, and first weekday; this also +supports locales unavailable on the host. `fromLocaleName()` resolves ordering and cycle +fallbacks; applications can supply translated names in the returned structure. + +| Property | Meaning | +| --- | --- | +| `mode` | `date`, `time`, or `datetime` on the shared editor | +| `date-format`, `time-format` | `locale` (default), or quoted token pattern | +| `hour-cycle` | `locale`, `12`, or `24` | +| `show-seconds` | Show seconds without milliseconds; default `false` | +| `show-milliseconds` | Show milliseconds and seconds; default `false` | +| `hour-step`, `minute-step`, `second-step` | Positive integer steps | +| `min-date`, `max-date` | Date-mode ISO bounds | +| `min-time`, `max-time` | Time-mode ISO bounds | +| `min-datetime`, `max-datetime` | Local date-time ISO bounds | +| `allow-empty`, `wheel-editing` | Empty-value and focused wheel editing policies | +| `active-section` | `none`, `day`, `month`, `year`, `hour`, `minute`, `second`, `millisecond`, `am-pm` | +| `popup-to-root` | Parent picker popup to scene root instead of its window container | +| `popup-open` | Inspectable picker open state | +| `first-day-of-week` | Calendar weekday: Sunday `0` through Saturday `6` | +| `calendar-view` | Calendar `days`, `months`, or `years` | +| `displayed-month`, `focused-date` | Calendar ISO navigation state | + +Date patterns require day, month, and year. Time patterns require one hour token and minutes; +12-hour patterns also require `a`. Tokens: `d`/`dd`, `M`/`MM`, `yy`/`yyyy`, `H`/`HH`, `h`/`hh`, +`m`/`mm`, `s`/`ss`, `S`/`SS`/`SSS`, and `a`. Quote literal text with single quotes inside the +pattern. Two-digit years parse into 2000–2099. Standard serialized dates use `yyyy-MM-dd`, +times `HH:mm:ss[.SSS]`, and local date-times `yyyy-MM-ddTHH:mm:ss[.SSS]`. + +Setting a minimum above the maximum moves the maximum to that minimum; setting a maximum +below the minimum moves the minimum to that maximum. Empty bound properties remove constraints. + +Seconds and milliseconds can be toggled separately with `setShowSeconds()` and +`setShowMilliseconds()` or their CSS/XML properties. Both default to hidden. Enabling +milliseconds includes seconds; hiding milliseconds leaves seconds enabled. Hiding seconds +hides both. These options change display precision without discarding stored precision, and +also update the date-time popup footer. An explicit `time-format` controls its own sections. + +## Calendar popup + +Click the trailing button, press F4, or press Alt+Down. Opening an empty picker displays today +without assigning it. Date-only selection updates the value, closes the popup, and restores +editor focus. In a date-time picker, selecting a date preserves the time and keeps the popup +open so the footer can edit it. Selecting a date into an empty value uses midnight, then +applies its constraints. + +The footer uses the same segmented editing as `UITimePicker`, with SVG up/down buttons for +hour, minute, and any displayed seconds, milliseconds, or AM/PM sections. It follows the +field's locale, format, steps, and boundary-date time constraints. Changing these settings while +the popup is open updates its controls immediately. Setting the owner read-only closes the popup. +Committed edits update the field immediately and preserve hidden precision. `getPopupTimePicker()` returns the owned +footer editor after the first opening. Enter in the footer closes the popup; Escape cancels +pending digits and closes it while preserving already committed changes. Focus leaving the +field and its entire popup also closes it. The popup measures both calendar and time footer +before choosing its placement, flips above when space permits, and clamps to scene bounds. +`UIPopUp::findBestPosition()` supplies the shared world-pixel placement calculation used by +field popups and `UIMenu::findBestMenuPos()` for trigger and cursor menus. Cascading menus +retain their menu-specific rules for avoiding the previous menu. + +Calendar selection and navigation are separate. Arrows move focus by day/week, Home/End move +to week boundaries, PageUp/PageDown move by month, and Ctrl+PageUp/PageDown move by year. +Enter explicitly selects focus. Click the title to choose a month, then click it again to choose +a year. Previous/Next page through those views. Today navigates to today without selecting it. +`OnItemSelected` reports explicit selection, including re-selection of the same date; +`OnValueChange` reports a changed selection. The 42 day widgets are reused across navigation. + +## Theme and example + +Breeze provides light/dark palette styling. Theme tags include `datetimeedit`, `datepicker`, +`timepicker`, `datetimepicker`, `datepicker::button`, `datetimepicker::button`, `calendar`, +and `calendar::header/previous/next/title/weekdays/weekday/grid/day/today`. +The combined popup exposes `datetimepicker::popup`, `datetimepicker::time`, and +`datetimepicker::time-up/time-down`; arrow classes identify `hour`, `minute`, `second`, +`millisecond`, and `am-pm`. CSS controls the footer font, padding, minimum height, and SVG +arrow size in `dp`. Picker buttons use the standard arrow cursor; editable text uses the I-beam. +Calendars size to their text metrics by default, with a theme minimum of `160dp` +set by the `Calendar` rule in `bin/assets/ui/breeze.css` and `bin/assets/ui/uitheme.css`. +Override `min-width` / `min-height` to change the preferred size while retaining automatic +text fitting. +Changing fonts automatically updates the popup size, header, weekday row, and day grid. +Explicit fixed width/height remain application overrides; choose enough space for the selected font. +Picker buttons use an inline SVG in `foreground-image`, with `foreground-size: 12dp 12dp`, +centered positioning, and theme-controlled `foreground-tint`. The SVG uses a white base fill +so the native image tint supplies the light/dark and hover colors. No font glyph is required. +Calendar previous/next buttons likewise use CSS-supplied left/right SVGs, centered at `12dp`. +Read-only picker buttons use `[allow-editing=false]` selectors to mute the calendar icon and +remove its hover highlight, while leaving the input text readable and selectable. +Day classes are `selected`, `today`, `outside-month`, and `focused`; constrained cells use +normal disabled state. Today uses a border, selection a fill. + +Build `eepp-ui-date-time-picker` and run `bin/eepp-ui-date-time-picker`; `--us` switches to a +Sunday-first, 12-hour locale. `--light`, `--small-font`, `--large-font`, and `--narrow` +exercise theme and sizing variants. F11 opens the inspector. Set `EEPP_PIXEL_DENSITY=2` to check +scaling, and use the inspector to vary font size, width, and placement. + +## Follow-up scope + +Range selection, named timezone support, and time-list popups remain separate follow-ups. + +HTML `input` types `date`, `time`, and `datetime-local` now map to these native controls through +`UIHTMLInput`. HTML values are sanitized and serialized independently of the display locale: +`yyyy-MM-dd`, `HH:mm[:ss[.SSS]]`, and `yyyy-MM-ddTHH:mm[:ss[.SSS]]`. Local date-time values +have no timezone. Valid date/time value attributes retain their ISO spelling; local date-time +values normalize their separator to `T`, omit zero seconds, and trim trailing fractional zeros. + +The adapter supports `min`, `max`, `step` (including `any` and fractional seconds), `required`, +`readonly`, and `disabled`. Range violations remain unchanged and report invalidity; native +controls retain their default clamping policy. Time ranges can cross midnight. Step validation +uses the minimum, otherwise the value attribute, otherwise the Unix civil epoch as its base. +Date steps use days; time and local date-time steps use seconds, defaulting to 60. + +Use `UIHTMLInput::getValidity()` / `checkValidity()` and `UIHTMLForm::checkValidity()` / +`requestSubmit()` for the implemented temporal constraints. Clicking a submit control validates; +direct `UIHTMLForm::submit()` bypasses validation. Anonymous subparts use the light user-agent +defaults in `getHTMLDocumentDefaultsCSS()` in `uiwidgetcreator.cpp`, independently of the application +theme. The HTML +adapter marks private control content with the existing `UI_IGNORE_GLOBAL_CSS`; stylesheet matching +keeps the document's low-priority UA rules and excludes author rules for that content. Popups remain +in the document scene and fit its visible viewport. Date/time editors measure their intrinsic width +from the displayed text or placeholder plus padding and the calendar button, so author CSS is not +required to give an input a usable size. +General HTML validity for other input types, validation messages, and +`novalidate` / `formnovalidate` are outside this temporal mapping. diff --git a/docs/articles/ui_html_compatibility.md b/docs/articles/ui_html_compatibility.md index 8acb664da..b5657b285 100644 --- a/docs/articles/ui_html_compatibility.md +++ b/docs/articles/ui_html_compatibility.md @@ -1159,16 +1159,32 @@ Current explicit native mappings include: button / submit / reset -> UIPushButton checkbox -> UICheckBox hidden -> no visible child +date -> UIDatePicker +time -> UITimePicker +datetime-local -> UIDateTimePicker number -> UISpinBox password -> password text input radio -> UIRadioButton other text-like types -> UIHTMLTextInput ``` +Form controls use light user-agent defaults from `getHTMLDocumentDefaultsCSS()` in +`uiwidgetcreator.cpp`, independently of the application's native theme. Author CSS styles the +HTML control host; anonymous implementation parts retain their private defaults. Temporal controls +have intrinsic text/placeholder sizing, so they remain usable without an authored width. + `textarea` uses a `UITextEdit`-based implementation with `rows` and `cols` intrinsic sizing. Forms collect named control values and can submit navigation requests. +Temporal inputs sanitize invalid value strings to empty and submit normalized ISO values independently +of their display locale. They support `min`, `max`, `step`, `required`, `readonly`, and `disabled`. +Range validation never silently changes the supplied value; reversed time ranges wrap midnight. +`UIHTMLInput::getValidity()` exposes the implemented temporal validity flags. `UIHTMLForm::requestSubmit()` +and submit-button activation validate these controls, while direct `submit()` bypasses validation. +Disabled temporal controls are excluded from form data, and named empty temporal controls submit an +empty string. See [date/time pickers](ui_date_time_pickers.md) for APIs and the supported subset. + An omitted or empty form `action` submits to the full current document URL, preserving its query. Supported submission encodings include: diff --git a/include/eepp/scene/event.hpp b/include/eepp/scene/event.hpp index 2728c7dd1..0b2a72093 100644 --- a/include/eepp/scene/event.hpp +++ b/include/eepp/scene/event.hpp @@ -139,6 +139,8 @@ class EE_API Event { MouseWheel, OnLinkOpenRequested, OnFaviconChanged, + OnAllowEditingChange, + OnFocusedDateChange, NoEvent = eeINDEX_NOT_FOUND }; diff --git a/include/eepp/system.hpp b/include/eepp/system.hpp index ca22be668..bd97924ec 100644 --- a/include/eepp/system.hpp +++ b/include/eepp/system.hpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/include/eepp/system/datetime.hpp b/include/eepp/system/datetime.hpp new file mode 100644 index 000000000..94165b242 --- /dev/null +++ b/include/eepp/system/datetime.hpp @@ -0,0 +1,119 @@ +#ifndef EE_SYSTEM_DATETIME_HPP +#define EE_SYSTEM_DATETIME_HPP + +// Required for the comparison categories deduced by defaulted operator<=>. +#include // IWYU pragma: keep +#include +#include + +namespace EE { namespace System { + +/** Civil Gregorian calendar date without timezone or timestamp semantics. + * Arithmetic outside the Int32 year range leaves the value unchanged. */ +struct EE_API CalendarDate { + Int32 year{ 1970 }; + Uint8 month{ 1 }; + Uint8 day{ 1 }; + + bool isValid() const; + + int daysInMonth() const; + + /** Signed number of Gregorian days since 1970-01-01; requires a valid date. */ + Int64 daysSinceEpoch() const; + + /** Unix seconds at midnight UTC; returns nullopt for an invalid date. */ + std::optional toUnixTimestamp() const; + + /** UTC date containing the Unix timestamp, including negative timestamps. + * Returns nullopt if the date is outside the Int32 year range. */ + static std::optional fromUnixTimestamp( Int64 seconds ); + + CalendarDate addDays( Int64 days ) const; + + CalendarDate addMonths( Int64 months ) const; + + CalendarDate addYears( Int64 years ) const; + + static bool isLeapYear( Int32 year ); + + static int daysInMonth( Int32 year, Uint8 month ); + + static CalendarDate today(); + + auto operator<=>( const CalendarDate& ) const = default; +}; + +/** Civil wall-clock time without timezone semantics. + * Arithmetic wraps within the day and preserves smaller units. */ +struct EE_API TimeOfDay { + Uint8 hour{ 0 }; + Uint8 minute{ 0 }; + Uint8 second{ 0 }; + Uint16 millisecond{ 0 }; + + bool isValid() const; + + TimeOfDay addHours( Int64 hours ) const; + + TimeOfDay addMinutes( Int64 minutes ) const; + + TimeOfDay addSeconds( Int64 seconds ) const; + + static TimeOfDay now(); + + auto operator<=>( const TimeOfDay& ) const = default; +}; + +/** A local civil date and wall-clock time. This value does not imply a timezone. */ +struct EE_API LocalDateTime { + CalendarDate date; + TimeOfDay time; + + bool isValid() const; + + /** Unix seconds using a UTC offset in seconds (local = UTC + offset), defaulting to UTC. + * Discards milliseconds; returns nullopt for an invalid value. + * Does not infer the system timezone or resolve daylight-saving transitions. */ + std::optional toUnixTimestamp( Int32 utcOffsetSeconds = 0 ) const; + + /** Unix milliseconds preserving the millisecond field, with the same offset convention. + * Returns nullopt for an invalid value or an Int64 timestamp overflow. */ + std::optional toUnixTimestampMilliseconds( Int32 utcOffsetSeconds = 0 ) const; + + /** Civil date/time at the given UTC offset (default: UTC), with zero milliseconds. + * Returns nullopt if the result is outside the Int32 year range. */ + static std::optional fromUnixTimestamp( Int64 seconds, + Int32 utcOffsetSeconds = 0 ); + + /** Civil date/time at the given UTC offset (default: UTC), preserving milliseconds. + * Returns nullopt if the result is outside the Int32 year range. */ + static std::optional fromUnixTimestampMilliseconds( Int64 milliseconds, + Int32 utcOffsetSeconds = 0 ); + + LocalDateTime addHours( Int64 hours ) const; + + LocalDateTime addMinutes( Int64 minutes ) const; + + LocalDateTime addSeconds( Int64 seconds ) const; + + static LocalDateTime now(); + + auto operator<=>( const LocalDateTime& ) const = default; +}; + +/** Inclusive range of civil Gregorian dates. */ +struct EE_API DateRange { + CalendarDate start; + CalendarDate end; + + bool isValid() const; + + bool contains( const CalendarDate& date ) const; + + auto operator<=>( const DateRange& ) const = default; +}; + +}} // namespace EE::System + +#endif diff --git a/include/eepp/system/datetimeformat.hpp b/include/eepp/system/datetimeformat.hpp new file mode 100644 index 000000000..3706195be --- /dev/null +++ b/include/eepp/system/datetimeformat.hpp @@ -0,0 +1,166 @@ +#ifndef EE_SYSTEM_DATETIMEFORMAT_HPP +#define EE_SYSTEM_DATETIMEFORMAT_HPP + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace System { + +enum class DateTimeFormatTokenType : Uint8 { + Literal, + Day, + Month, + Year, + Hour24, + Hour12, + Minute, + Second, + Millisecond, + AmPm +}; + +struct EE_API DateTimeFormatToken { + std::string literal; + DateTimeFormatTokenType type{ DateTimeFormatTokenType::Literal }; + Uint8 width{ 0 }; +}; + +struct EE_API DateTimeFormatTokenRange { + DateTimeFormatTokenType type{ DateTimeFormatTokenType::Literal }; + size_t tokenIndex{ 0 }; + size_t start{ 0 }; + size_t length{ 0 }; +}; + +struct EE_API FormattedDateTime { + std::string text; + SmallVector ranges; +}; + +enum class DateOrder : Uint8 { DMY, MDY, YMD }; + +enum class HourCycle : Uint8 { Locale, H12, H24 }; + +/** Resolved locale information used by the date/time controls. + * + * The standard library is used where it exposes the information. Fields that are not available + * portably use one centralized locale-name fallback instead of leaking locale assumptions into UI + * code. + */ +struct EE_API DateTimeLocale { + DateOrder dateOrder{ DateOrder::DMY }; + char dateSeparator{ '/' }; + HourCycle hourCycle{ HourCycle::H24 }; + Uint8 firstDayOfWeek{ 1 }; // 0 = Sunday, 1 = Monday + std::string am{ "AM" }; + std::string pm{ "PM" }; + std::array shortWeekdayNames{ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + std::array weekdayNames{ "Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday" }; + std::array shortMonthNames{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + std::array monthNames{ "January", "February", "March", "April", + "May", "June", "July", "August", + "September", "October", "November", "December" }; + + std::string datePattern() const; + + std::string timePattern( bool showSeconds = false, bool showMilliseconds = false, + HourCycle requestedCycle = HourCycle::Locale ) const; + + std::string dateTimePattern( bool showSeconds = false, bool showMilliseconds = false, + HourCycle requestedCycle = HourCycle::Locale ) const; + + bool operator==( const DateTimeLocale& ) const = default; + + static DateTimeLocale system(); + + static DateTimeLocale fromLocaleName( std::string_view localeName ); +}; + +class EE_API DateTimeFormat { + public: + DateTimeFormat() = default; + + explicit DateTimeFormat( std::string pattern ); + + static DateTimeFormat compile( std::string pattern ); + + bool isValid() const { return mValid; } + + const std::string& getPattern() const { return mPattern; } + + const std::vector& getTokens() const { return mTokens; } + + protected: + std::string mPattern; + std::vector mTokens; + bool mValid{ false }; +}; + +class EE_API DateTimeFormatter { + public: + static FormattedDateTime + formatDateDetailed( const CalendarDate& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static FormattedDateTime + formatTimeDetailed( const TimeOfDay& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static FormattedDateTime + formatDateTimeDetailed( const LocalDateTime& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::string formatDate( const CalendarDate& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::string formatTime( const TimeOfDay& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::string formatDateTime( const LocalDateTime& value, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::optional + parseDate( std::string_view text, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::optional + parseTime( std::string_view text, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::optional + parseDateTime( std::string_view text, const DateTimeFormat& format, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + /** Parsing intended for paste/replacement. ISO is always accepted, then the display format and + * separator-tolerant numeric forms are attempted. */ + static std::optional + parseDateTolerant( std::string_view text, const DateTimeFormat& displayFormat, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::optional + parseTimeTolerant( std::string_view text, const DateTimeFormat& displayFormat, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::optional + parseDateTimeTolerant( std::string_view text, const DateTimeFormat& displayFormat, + const DateTimeLocale& locale = DateTimeLocale::system() ); + + static std::string toISODate( const CalendarDate& value ); + + static std::string toISOTime( const TimeOfDay& value, bool includeMilliseconds = false ); + + static std::string toISOLocalDateTime( const LocalDateTime& value, + bool includeMilliseconds = false ); +}; + +}} // namespace EE::System + +#endif diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 194a8c574..984c756b1 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -104,11 +104,15 @@ #include #include #include +#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -193,6 +197,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/ui/css/idnamemap.hpp b/include/eepp/ui/css/idnamemap.hpp index c3c254750..197eccd85 100644 --- a/include/eepp/ui/css/idnamemap.hpp +++ b/include/eepp/ui/css/idnamemap.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -39,34 +40,39 @@ template class IdNameMap { * * @return true on success. */ - bool addBuiltin( Id id, const std::string& canonicalName ) { + bool addBuiltin( Id id, std::string_view canonicalName ) { if ( mBuiltinsFinalized ) { EE::System::Log::error( - "IdNameMap: cannot register built-in \"%s\" after finalization.", - canonicalName.c_str() ); + "IdNameMap: cannot register built-in \"%.*s\" after finalization.", + static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return false; } const auto underlying = static_cast>( id ); const auto maxUnderlying = static_cast>( MaxIds ); if ( underlying == 0 ) { - EE::System::Log::error( "IdNameMap: cannot register Invalid built-in \"%s\".", - canonicalName.c_str() ); + EE::System::Log::error( "IdNameMap: cannot register Invalid built-in \"%.*s\".", + static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return false; } if ( underlying >= maxUnderlying ) { - EE::System::Log::error( "IdNameMap: built-in ID %d for \"%s\" is out of range.", - underlying, canonicalName.c_str() ); + EE::System::Log::error( "IdNameMap: built-in ID %d for \"%.*s\" is out of range.", + underlying, static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return false; } if ( mIdsByName.find( canonicalName ) != mIdsByName.end() ) { - EE::System::Log::error( "IdNameMap: duplicate built-in name \"%s\".", - canonicalName.c_str() ); + EE::System::Log::error( "IdNameMap: duplicate built-in name \"%.*s\".", + static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return false; } if ( underlying < mNames.size() && !mNames[underlying].empty() ) { EE::System::Log::error( - "IdNameMap: built-in ID %d already bound to \"%s\", not \"%s\".", underlying, - mNames[underlying].c_str(), canonicalName.c_str() ); + "IdNameMap: built-in ID %d already bound to \"%s\", not \"%.*s\".", underlying, + mNames[underlying].c_str(), static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return false; } if ( mNames.size() <= underlying ) @@ -137,7 +143,7 @@ template class IdNameMap { * @return Invalid when the name is unknown. */ Id getId( std::string_view name ) const { - const auto it = mIdsByName.find( std::string( name ) ); + const auto it = mIdsByName.find( name ); return it != mIdsByName.end() ? it->second : static_cast( 0 ); } @@ -166,11 +172,12 @@ template class IdNameMap { * Only valid after finalizeBuiltins(). Reaching the capacity returns Invalid * and logs a clear error containing the rejected name. */ - Id getOrCreateId( const std::string& canonicalName ) { + Id getOrCreateId( std::string_view canonicalName ) { if ( !mBuiltinsFinalized ) { EE::System::Log::error( - "IdNameMap: getOrCreateId(\"%s\") called before finalizeBuiltins().", - canonicalName.c_str() ); + "IdNameMap: getOrCreateId(\"%.*s\") called before finalizeBuiltins().", + static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return static_cast( 0 ); } const auto existing = getId( canonicalName ); @@ -178,8 +185,10 @@ template class IdNameMap { return existing; const auto next = static_cast>( mNextCustomId ); if ( next >= static_cast>( MaxIds ) ) { - EE::System::Log::error( "IdNameMap: ID capacity exhausted (max %zu), rejecting \"%s\".", - MaxIds, canonicalName.c_str() ); + EE::System::Log::error( + "IdNameMap: ID capacity exhausted (max %zu), rejecting \"%.*s\".", MaxIds, + static_cast( canonicalName.size() ), + canonicalName.empty() ? "" : canonicalName.data() ); return static_cast( 0 ); } if ( mNames.size() <= next ) @@ -196,7 +205,7 @@ template class IdNameMap { } bool contains( std::string_view name ) const { - return mIdsByName.find( std::string( name ) ) != mIdsByName.end(); + return mIdsByName.find( name ) != mIdsByName.end(); } bool contains( const std::string& name ) const { @@ -209,7 +218,12 @@ template class IdNameMap { private: std::vector mNames; - UnorderedMap mIdsByName; + // Preserve the selected container's string-view hash (including its avalanche marker), + // and allow borrowed names to find owned string keys without temporary allocations. + struct NameHash : UnorderedMap::hasher { + using is_transparent = void; + }; + UnorderedMap> mIdsByName; Id mNextCustomId; bool mBuiltinsFinalized{ false }; }; diff --git a/include/eepp/ui/css/propertydefinition.hpp b/include/eepp/ui/css/propertydefinition.hpp index a3039e810..90acba8d3 100644 --- a/include/eepp/ui/css/propertydefinition.hpp +++ b/include/eepp/ui/css/propertydefinition.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace EE { namespace UI { namespace CSS { @@ -44,8 +45,8 @@ enum class PropertyRelativeTarget : Uint32 { class EE_API PropertyDefinition { public: - PropertyDefinition( PropertyId propertyId, const std::string& name, - const std::string& defaultValue, const bool& inherited = false ); + PropertyDefinition( PropertyId propertyId, std::string_view name, std::string_view defaultValue, + const bool& inherited = false ); const std::string& getName() const; diff --git a/include/eepp/ui/css/propertyids.hpp b/include/eepp/ui/css/propertyids.hpp index e4114631c..3eda81c3f 100644 --- a/include/eepp/ui/css/propertyids.hpp +++ b/include/eepp/ui/css/propertyids.hpp @@ -330,6 +330,35 @@ enum class PropertyId : Uint16 { JustifyItems, JustifySelf, Defer, + DateTimeMode, + AllowEmpty, + DateFormat, + TimeFormat, + HourCycle, + ShowSeconds, + ShowMilliseconds, + WheelEditing, + HourStep, + MinuteStep, + SecondStep, + MinDate, + MaxDate, + MinTime, + MaxTime, + MinDateTime, + MaxDateTime, + FirstDayOfWeek, + CalendarView, + DisplayedMonth, + FocusedDate, + ActiveSection, + PopUpOpen, + Min, + Max, + Step, + Required, + ReadOnly, + Disabled, NumDefinedIds, FirstCustomId = NumDefinedIds, MaxNumIds = 512, diff --git a/include/eepp/ui/css/propertyspecification.hpp b/include/eepp/ui/css/propertyspecification.hpp index 1c567dc66..ba75f8e66 100644 --- a/include/eepp/ui/css/propertyspecification.hpp +++ b/include/eepp/ui/css/propertyspecification.hpp @@ -16,11 +16,11 @@ class EE_API PropertySpecification { public: ~PropertySpecification(); - PropertyDefinition& registerProperty( PropertyId id, const std::string& propertyName, - const std::string& defaultValue, bool inherited ); + PropertyDefinition& registerProperty( PropertyId id, std::string_view propertyName, + std::string_view defaultValue, bool inherited ); - PropertyDefinition* registerProperty( const std::string& propertyName, - const std::string& defaultValue, bool inherited ); + PropertyDefinition* registerProperty( std::string_view propertyName, + std::string_view defaultValue, bool inherited ); const PropertyDefinition* getProperty( const PropertyId& id ) const; diff --git a/include/eepp/ui/css/stylesheetselector.hpp b/include/eepp/ui/css/stylesheetselector.hpp index 8638a6817..2d9d4f89d 100644 --- a/include/eepp/ui/css/stylesheetselector.hpp +++ b/include/eepp/ui/css/stylesheetselector.hpp @@ -15,9 +15,9 @@ class EE_API StyleSheetSelector { explicit StyleSheetSelector( const std::string& selectorName ); - const std::string& getName() const; + inline const std::string& getName() const { return mName; } - const Int64& getSpecificity() const; + inline const Int64& getSpecificity() const { return mSpecificity; } void setSpecificity( const Int64& specificity ); @@ -27,7 +27,8 @@ class EE_API StyleSheetSelector { bool hasPseudoClasses() const; - SmallVector getRelatedElements( UIWidget* element, bool applyPseudo = true ) const; + SmallVector getRelatedElements( UIWidget* element, + bool applyPseudo = true ) const; bool isStructurallyVolatile() const; diff --git a/include/eepp/ui/css/stylesheetspecification.hpp b/include/eepp/ui/css/stylesheetspecification.hpp index bc5fe97de..f56b1dbb8 100644 --- a/include/eepp/ui/css/stylesheetspecification.hpp +++ b/include/eepp/ui/css/stylesheetspecification.hpp @@ -38,11 +38,11 @@ class EE_API StyleSheetSpecification { ~StyleSheetSpecification(); - PropertyDefinition& registerProperty( PropertyId id, const std::string& propertyName, - const std::string& defaultValue, bool inherited = false ); + PropertyDefinition& registerProperty( PropertyId id, std::string_view propertyName, + std::string_view defaultValue, bool inherited = false ); - PropertyDefinition* registerProperty( const std::string& propertyName, - const std::string& defaultValue, bool inherited = false ); + PropertyDefinition* registerProperty( std::string_view propertyName, + std::string_view defaultValue, bool inherited = false ); const PropertyDefinition* getProperty( const PropertyId& id ) const; diff --git a/include/eepp/ui/css/stylesheetstyle.hpp b/include/eepp/ui/css/stylesheetstyle.hpp index b19a1da0f..7674a114c 100644 --- a/include/eepp/ui/css/stylesheetstyle.hpp +++ b/include/eepp/ui/css/stylesheetstyle.hpp @@ -21,11 +21,11 @@ class EE_API StyleSheetStyle { std::string build( bool emitMediaQueryStart = true, bool emitMediaQueryEnd = true ); - const StyleSheetSelector& getSelector() const; + inline const StyleSheetSelector& getSelector() const { return mSelector; } - const StyleSheetProperties& getProperties() const; + inline const StyleSheetProperties& getProperties() const { return mProperties; } - StyleSheetProperties& getPropertiesRef(); + inline StyleSheetProperties& getPropertiesRef() { return mProperties; } const StyleSheetVariables& getVariables() const; diff --git a/include/eepp/ui/uicalendar.hpp b/include/eepp/ui/uicalendar.hpp new file mode 100644 index 000000000..01c7e140a --- /dev/null +++ b/include/eepp/ui/uicalendar.hpp @@ -0,0 +1,169 @@ +#ifndef EE_UI_UICALENDAR_HPP +#define EE_UI_UICALENDAR_HPP + +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +using namespace EE::System; +using namespace EE::UI::CSS; + +class UILinearLayout; +class UIGridLayout; +class UIPushButton; +class UITextView; + +enum class CalendarView : Uint8 { Days, Months, Years }; + +/** Standalone Gregorian calendar widget used by the date picker family. + * + * Display navigation is intentionally independent from the selected value. Navigating months or + * years never changes the selection until a date is explicitly selected. + */ +class EE_API UICalendar : public UIWidget { + public: + static UICalendar* New(); + + virtual ~UICalendar() = default; + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + UICalendar* setSelectedDate( const CalendarDate& date ); + + UICalendar* setSelectedDate( std::optional date ); + + const std::optional& getSelectedDate() const; + + UICalendar* clearSelection(); + + UICalendar* setDisplayedMonth( const CalendarDate& date ); + + const CalendarDate& getDisplayedMonth() const; + + UICalendar* setFocusedDate( const CalendarDate& date ); + + const CalendarDate& getFocusedDate() const; + + UICalendar* setMinDate( std::optional date ); + + UICalendar* setMaxDate( std::optional date ); + + const std::optional& getMinDate() const; + + const std::optional& getMaxDate() const; + + UICalendar* setView( CalendarView view ); + + CalendarView getView() const; + + UICalendar* setLocale( const DateTimeLocale& locale ); + + const DateTimeLocale& getLocale() const; + + UICalendar* setFirstDayOfWeek( Uint8 weekday ); + + Uint8 getFirstDayOfWeek() const; + + UICalendar* previous(); + + UICalendar* next(); + + UICalendar* goToToday(); + + bool selectFocusedDate(); + + /** First date rendered by the fixed 42-cell day grid. Useful for tests/inspection. */ + CalendarDate getFirstVisibleDate() const; + + /** Returns the date represented by one day cell (0..41). */ + CalendarDate getDayCellDate( size_t index ) const; + + size_t getDayCellCount() const { return mDayCells.size(); } + + virtual bool applyProperty( const StyleSheetProperty& attribute ) override; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const override; + + virtual std::vector getPropertiesImplemented() const override; + + protected: + UICalendar(); + + virtual Uint32 onKeyDown( const KeyEvent& event ) override; + + virtual void onSizeChange() override; + + virtual void onThemeLoaded() override; + + virtual void onAutoSize() override; + + void buildUI(); + + void refresh(); + + void refreshHeader(); + + void refreshDays(); + + void refreshMonths(); + + void refreshYears(); + + void updateCellClasses( UIPushButton* button, bool selected, bool today, bool outside, + bool focused ); + + bool isDateEnabled( const CalendarDate& date ) const; + + CalendarDate clampDate( CalendarDate date ) const; + + void moveFocusedDate( Int64 days ); + + void setFocusedAndDisplay( const CalendarDate& date ); + + void handleDayClicked( size_t index ); + + void handleOptionClicked( size_t index ); + + void updateLayoutSize(); + + static int dayOfWeek( const CalendarDate& date ); // Sunday=0 + + static const char* viewToString( CalendarView view ); + + DateTimeLocale mLocale; + CalendarDate mDisplayedMonth; + CalendarDate mFocusedDate; + std::optional mSelectedDate; + std::optional mMinDate; + std::optional mMaxDate; + Int64 mYearPageStart{ 0 }; + CalendarView mView{ CalendarView::Days }; + bool mUpdatingLayout{ false }; + + UILinearLayout* mMainLayout{ nullptr }; + UILinearLayout* mHeader{ nullptr }; + UIPushButton* mPreviousButton{ nullptr }; + UIPushButton* mTitleButton{ nullptr }; + UIPushButton* mNextButton{ nullptr }; + UIGridLayout* mWeekdayRow{ nullptr }; + UIGridLayout* mDayGrid{ nullptr }; + UIGridLayout* mOptionGrid{ nullptr }; + UIPushButton* mTodayButton{ nullptr }; + std::array mWeekdayLabels{}; + std::array mDayCells{}; + std::array mDayCellDates{}; + std::array mOptionCells{}; + EventConnectionList mConnections; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uidatepicker.hpp b/include/eepp/ui/uidatepicker.hpp new file mode 100644 index 000000000..dbf7e2401 --- /dev/null +++ b/include/eepp/ui/uidatepicker.hpp @@ -0,0 +1,98 @@ +#ifndef EE_UI_UIDATEPICKER_HPP +#define EE_UI_UIDATEPICKER_HPP + +#include +#include +#include + +namespace EE { namespace UI { + +using namespace EE::System; +using namespace EE::UI::CSS; + +class UICalendar; +class UIPushButton; + +/** Segmented date editor with a lazily created calendar popup. Opening the popup never assigns + * a value. Alt+Down or F4 opens it; Escape closes it and restores editor focus. */ +class EE_API UIDatePicker : public UIDateTimeEdit { + public: + static UIDatePicker* New(); + + virtual ~UIDatePicker(); + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + virtual Float getMinIntrinsicWidth() const override; + + virtual UIDatePicker* showCalendar(); + + virtual UIDatePicker* hideCalendar( bool restoreFocus = true ); + + bool isCalendarVisible() const; + + /** Returns nullptr until the first opening. The picker owns the calendar, even while it is + * parented to the window or scene root. */ + UICalendar* getCalendar() const; + + UIPushButton* getCalendarButton() const; + + virtual bool applyProperty( const StyleSheetProperty& attribute ) override; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const override; + + virtual std::vector getPropertiesImplemented() const override; + + protected: + UIDatePicker(); + + UIDatePicker( DateTimeEditMode mode, const std::string& tag ); + + virtual Uint32 onKeyDown( const KeyEvent& event ) override; + + virtual void onSizeChange() override; + + virtual void onPaddingChange() override; + + virtual void onPositionChange() override; + + virtual void onVisibilityChange() override; + + virtual void onEnabledChange() override; + + virtual void draw() override; + + void updateButton(); + + void closeOnFocusLoss(); + + virtual void selectCalendarDate(); + + virtual UIWidget* getCalendarPopup() const; + + virtual void prepareCalendarPopup(); + + virtual void onConstraintsChange() override; + + virtual void onConfigurationChange() override; + + virtual void onCalendarFocusedDateChange(); + + EventConnection mSceneSizeConnection; + UICalendar* mCalendar{ nullptr }; + UIPushButton* mCalendarButton{ nullptr }; + Float mEditorRightPadding{ 0 }; + Float mReservedButtonWidth{ 0 }; + bool mUpdatingButton{ false }; + bool mCalendarVisible{ false }; + bool mPopUpToRoot{ false }; + std::array mConnections; + std::array mCalendarConnections; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uidatetimeedit.hpp b/include/eepp/ui/uidatetimeedit.hpp new file mode 100644 index 000000000..69f693b4e --- /dev/null +++ b/include/eepp/ui/uidatetimeedit.hpp @@ -0,0 +1,301 @@ +#ifndef EE_UI_UIDATETIMEEDIT_HPP +#define EE_UI_UIDATETIMEEDIT_HPP + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +using namespace EE::System; +using namespace EE::UI::CSS; + +enum class DateTimeEditMode : Uint8 { Date, Time, DateTime }; + +enum class DateTimeSection : Uint8 { + None, + Day, + Month, + Year, + Hour, + Minute, + Second, + Millisecond, + AmPm +}; + +class EE_API UIDateTimeEdit : public UITextInput { + public: + static UIDateTimeEdit* New(); + + static UIDateTimeEdit* New( DateTimeEditMode mode ); + + virtual ~UIDateTimeEdit() = default; + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + virtual Float getMinIntrinsicWidth() const override; + + virtual Float getMaxIntrinsicWidth() const override; + + UIDateTimeEdit* setEditMode( DateTimeEditMode mode ); + + DateTimeEditMode getEditMode() const; + + UIDateTimeEdit* setDate( const CalendarDate& date ); + + UIDateTimeEdit* setDate( std::optional date ); + + const std::optional& getDate() const; + + UIDateTimeEdit* setTime( const TimeOfDay& time ); + + UIDateTimeEdit* setTime( std::optional time ); + + const std::optional& getTime() const; + + UIDateTimeEdit* setDateTime( const LocalDateTime& dateTime ); + + UIDateTimeEdit* setDateTime( std::optional dateTime ); + + const std::optional& getDateTime() const; + + bool hasValue() const; + + UIDateTimeEdit* clear(); + + UIDateTimeEdit* setAllowEmpty( bool allow ); + + bool getAllowEmpty() const; + + UIDateTimeEdit* setLocale( const DateTimeLocale& locale ); + + const DateTimeLocale& getLocale() const; + + UIDateTimeEdit* setDateFormat( const std::string& pattern ); + + UIDateTimeEdit* setTimeFormat( const std::string& pattern ); + + const std::string& getDateFormat() const; + + const std::string& getTimeFormat() const; + + const DateTimeFormat& getDisplayFormat() const; + + UIDateTimeEdit* setHourCycle( HourCycle cycle ); + + HourCycle getHourCycle() const; + + /** Shows seconds in the locale format (off by default). Hiding also hides milliseconds. */ + UIDateTimeEdit* setShowSeconds( bool show ); + + bool getShowSeconds() const; + + /** Shows milliseconds in the locale format (off by default). Enabling also shows seconds; + * disabling leaves seconds visible. Hidden precision is preserved in the value. */ + UIDateTimeEdit* setShowMilliseconds( bool show ); + + bool getShowMilliseconds() const; + + UIDateTimeEdit* setDayStep( Uint32 step ); + + Uint32 getDayStep() const; + + /** Native controls clamp to bounds by default. HTML adapters disable clamping so range + * constraints report invalid values instead of silently changing them. */ + UIDateTimeEdit* setClampToBounds( bool clamp ); + + bool getClampToBounds() const; + + UIDateTimeEdit* setHourStep( Uint32 step ); + + UIDateTimeEdit* setMinuteStep( Uint32 step ); + + UIDateTimeEdit* setSecondStep( Uint32 step ); + + Uint32 getHourStep() const; + + Uint32 getMinuteStep() const; + + Uint32 getSecondStep() const; + + UIDateTimeEdit* setMinDate( std::optional date ); + + UIDateTimeEdit* setMaxDate( std::optional date ); + + const std::optional& getMinDate() const; + + const std::optional& getMaxDate() const; + + UIDateTimeEdit* setMinTime( std::optional time ); + + UIDateTimeEdit* setMaxTime( std::optional time ); + + const std::optional& getMinTime() const; + + const std::optional& getMaxTime() const; + + UIDateTimeEdit* setMinDateTime( std::optional dateTime ); + + UIDateTimeEdit* setMaxDateTime( std::optional dateTime ); + + const std::optional& getMinDateTime() const; + + const std::optional& getMaxDateTime() const; + + UIDateTimeEdit* setWheelEditingEnabled( bool enabled ); + + bool isWheelEditingEnabled() const; + + UIDateTimeEdit* setPendingDigitTimeout( const Time& timeout ); + + const Time& getPendingDigitTimeout() const; + + DateTimeSection getActiveSection() const; + + UIDateTimeEdit* setActiveSection( DateTimeSection section ); + + bool stepActiveSection( Int32 direction ); + + std::string getSerializedValue() const; + + virtual UITextView* setText( const String& text ) override; + + virtual bool applyProperty( const StyleSheetProperty& attribute ) override; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const override; + + virtual std::vector getPropertiesImplemented() const override; + + virtual void scheduledUpdate( const Time& time ) override; + + protected: + virtual void onAutoSize() override; + + struct Section { + size_t start{ 0 }; + size_t length{ 0 }; + Int32 minValue{ 0 }; + Int32 maxValue{ 0 }; + Uint32 displayDigits{ 0 }; + DateTimeSection type{ DateTimeSection::None }; + }; + + UIDateTimeEdit(); + + explicit UIDateTimeEdit( DateTimeEditMode mode, const std::string& tag = "datetimeedit" ); + + virtual Uint32 onKeyDown( const KeyEvent& event ) override; + + virtual Uint32 onTextInput( const TextInputEvent& event ) override; + + virtual Uint32 onMouseClick( const Vector2i& position, const Uint32& flags ) override; + + virtual Uint32 onMouseWheel( const Vector2f& offset, bool flipped ) override; + + virtual void onTextChanged() override; + + virtual void onDocumentTextChanged( const DocumentContentChange& change ) override; + + virtual Uint32 onFocus( NodeFocusReason reason ) override; + + void pasteValue(); + + virtual Uint32 onFocusLoss() override; + + void rebuildFormat(); + + void updateTextFromValue( bool preserveSection = true ); + + void rebuildSections( const FormattedDateTime& formatted ); + + void updateAutoHint(); + + void selectActiveSection(); + + void selectSectionAtColumn( Int64 column ); + + void moveActiveSection( Int32 direction ); + + void updatePendingText(); + + void resetPendingDigits(); + + bool commitPendingDigits( bool advance ); + + bool processDigit( char digit ); + + bool processAmPm( char value ); + + bool setSectionNumericValue( DateTimeSection section, Int32 value ); + + std::optional
findSection( DateTimeSection section ) const; + + SmallVector getSectionOrder() const; + + bool parseAndSetDisplayText( std::string_view text ); + + bool parseAndSetSerializedValue( std::string_view text ); + + void initializeValueForEditing(); + + void clampCurrentValue(); + + virtual void onConstraintsChange(); + + virtual void onConfigurationChange(); + + static DateTimeSection tokenToSection( DateTimeFormatTokenType type ); + + static const char* modeToString( DateTimeEditMode mode ); + + static std::optional modeFromString( std::string_view mode ); + + static const char* hourCycleToString( HourCycle cycle ); + + DateTimeEditMode mEditMode{ DateTimeEditMode::Date }; + std::optional mDate; + std::optional mTime; + std::optional mDateTime; + std::optional mMinDate; + std::optional mMaxDate; + std::optional mMinTime; + std::optional mMaxTime; + std::optional mMinDateTime; + std::optional mMaxDateTime; + DateTimeLocale mLocale; + DateTimeFormat mDisplayFormat; + std::string mDateFormat; + std::string mTimeFormat; + std::string mLastAutoHint; + HourCycle mHourCycle{ HourCycle::Locale }; + bool mShowSeconds{ false }; + bool mShowMilliseconds{ false }; + bool mAllowEmpty{ true }; + bool mWheelEditing{ false }; + bool mUpdatingText{ false }; + bool mClampToBounds{ true }; + Uint32 mDayStep{ 1 }; + Uint32 mHourStep{ 1 }; + Uint32 mMinuteStep{ 1 }; + Uint32 mSecondStep{ 1 }; + DateTimeSection mActiveSection{ DateTimeSection::None }; + SmallVector mSections; + std::string mPendingDigits; + Clock mPendingDigitClock; + + Time mPendingDigitTimeout{ Milliseconds( 1000 ) }; + EventConnection mAllowEditingConnection; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uidatetimepicker.hpp b/include/eepp/ui/uidatetimepicker.hpp new file mode 100644 index 000000000..5157fb6f2 --- /dev/null +++ b/include/eepp/ui/uidatetimepicker.hpp @@ -0,0 +1,57 @@ +#ifndef EE_UI_UIDATETIMEPICKER_HPP +#define EE_UI_UIDATETIMEPICKER_HPP + +#include + +namespace EE { namespace UI { + +class UITimePicker; + +/** One segmented local date/time field with a calendar and time editor popup. Date selection + * preserves the time and keeps the popup open. Changes commit immediately; Enter in the time + * editor or Escape closes it. Opening an empty picker does not assign a value. */ +class EE_API UIDateTimePicker : public UIDatePicker { + public: + static UIDateTimePicker* New(); + + virtual ~UIDateTimePicker(); + + virtual UIDateTimePicker* hideCalendar( bool restoreFocus = true ) override; + + /** Returns nullptr until the first popup opening. The popup owns the time editor. */ + UITimePicker* getPopupTimePicker() const; + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + protected: + UIDateTimePicker(); + + virtual UIWidget* getCalendarPopup() const override; + + virtual void prepareCalendarPopup() override; + + virtual void selectCalendarDate() override; + + virtual void onConstraintsChange() override; + + virtual void onConfigurationChange() override; + + virtual void onCalendarFocusedDateChange() override; + + void synchronizeTimePopup(); + + void selectPopupTime(); + + UIWidget* mPopup{ nullptr }; + UITimePicker* mPopupTimePicker{ nullptr }; + bool mSynchronizingPopup{ false }; + std::array mConnections; + std::array mPopupConnections; + std::array mTimeConnections; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 8612543f0..2e1e11e3f 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -143,6 +143,11 @@ enum UINodeType { UI_TYPE_TEXTNODE, UI_TYPE_FONTPICKERDIALOG, UI_TYPE_SCROLLABLEMARKDOWNVIEW, + UI_TYPE_DATETIMEEDIT, + UI_TYPE_CALENDAR, + UI_TYPE_DATEPICKER, + UI_TYPE_TIMEPICKER, + UI_TYPE_DATETIMEPICKER, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uihtmlform.hpp b/include/eepp/ui/uihtmlform.hpp index a172b0d92..5e568055f 100644 --- a/include/eepp/ui/uihtmlform.hpp +++ b/include/eepp/ui/uihtmlform.hpp @@ -28,15 +28,24 @@ class EE_API UIHTMLForm : public UIRichText { virtual std::vector getPropertiesImplemented() const; + /** Direct submission bypasses validation, as HTMLFormElement.submit() does. */ void submit(); + /** Validates implemented input constraints before submitting. */ + bool requestSubmit(); + + bool checkValidity() const; + const std::string& getAction() const { return mAction; } + void setAction( const std::string& action ) { mAction = action; } const std::string& getMethod() const { return mMethod; } + void setMethod( const std::string& method ) { mMethod = method; } const std::string& getEnctype() const { return mEnctype; } + void setEnctype( const std::string& enctype ) { mEnctype = enctype; } protected: @@ -47,7 +56,7 @@ class EE_API UIHTMLForm : public UIRichText { virtual Uint32 onMessage( const NodeMessage* msg ); static void collectFormData( Node* node, - std::vector>& fields ); + std::vector>& fields ); bool isSubmitTrigger( Node* sender ) const; }; diff --git a/include/eepp/ui/uihtmlinput.hpp b/include/eepp/ui/uihtmlinput.hpp index 3233d1e01..bb866d446 100644 --- a/include/eepp/ui/uihtmlinput.hpp +++ b/include/eepp/ui/uihtmlinput.hpp @@ -1,11 +1,29 @@ #ifndef EE_UI_UIHTMLINPUT_HPP #define EE_UI_UIHTMLINPUT_HPP +#include +#include #include #include +#include namespace EE { namespace UI { +using namespace EE::System; + +/** Constraint flags for the implemented HTML temporal input states. */ +struct HTMLInputValidity { + bool valueMissing{ false }; + bool rangeUnderflow{ false }; + bool rangeOverflow{ false }; + bool stepMismatch{ false }; + bool badInput{ false }; + + bool valid() const { + return !( valueMissing || rangeUnderflow || rangeOverflow || stepMismatch || badInput ); + } +}; + class EE_API UIHTMLInput : public UIHTMLWidget { public: static UIHTMLInput* New(); @@ -37,11 +55,21 @@ class EE_API UIHTMLInput : public UIHTMLWidget { UIWidget* getChildWidget() const; - String getFormValue() const; + virtual String getFormValue() const; + + /** HTML constraint validation for date, time and datetime-local inputs. Read-only and + * disabled controls are barred from validation. */ + HTMLInputValidity getValidity() const; + + bool willValidate() const; + + bool checkValidity() const; protected: std::string mInputType{ "text" }; UIWidget* mChildWidget{ nullptr }; + EventConnection mControlChildrenConnection; + EventConnection mPopupTimeChildrenConnection; std::map mImplementationProperties; String mValue; bool mChecked{ false }; @@ -53,15 +81,33 @@ class EE_API UIHTMLInput : public UIHTMLWidget { CSSDisplay mDisplayBeforeHidden{ CSSDisplay::InlineBlock }; void createChildWidget(); + void configureChildWidget(); + void applyImplementationProperty( const StyleSheetProperty& property ); + void syncImplementationState(); + void syncStateFromImplementation(); + void updateHostGeometry(); + void updateChildGeometry(); + void syncCheckedState(); + bool isTemporalInput() const; + + void setTemporalValue( const String& value ); + + void syncTemporalConfiguration(); + + const std::string& temporalAttribute( PropertyId id ) const; + + std::optional getTemporalValue() const; + virtual void onSizeChange(); + virtual void onPaddingChange(); }; diff --git a/include/eepp/ui/uinode.hpp b/include/eepp/ui/uinode.hpp index bc39c3a02..c3f639299 100644 --- a/include/eepp/ui/uinode.hpp +++ b/include/eepp/ui/uinode.hpp @@ -765,7 +765,7 @@ class EE_API UINode : public Node { * * @return The flags as a Uint64 bitmask. */ - const Uint64& getFlags() const; + inline const Uint64& getFlags() const { return mFlags; } /** * @brief Sets multiple flags on the node. diff --git a/include/eepp/ui/uipopup.hpp b/include/eepp/ui/uipopup.hpp new file mode 100644 index 000000000..8e3b829a4 --- /dev/null +++ b/include/eepp/ui/uipopup.hpp @@ -0,0 +1,44 @@ +#ifndef EE_UI_UIPOPUP_HPP +#define EE_UI_UIPOPUP_HPP + +#include +#include + +namespace EE { namespace Scene { +class Node; +}} // namespace EE::Scene + +namespace EE { namespace UI { + +using namespace EE::Math; +using namespace EE::Scene; + +class UIWidget; + +/** Shared popup placement and visibility helpers for anchored UI controls. + * + * The helper deliberately owns no widget lifetime or selection semantics. Callers decide when a + * popup should close and where focus returns; this only centralizes root/window parenting, + * below/above placement, scene-bound clamping and the standard eepp fade effects. + */ +class EE_API UIPopUp { + public: + /** Returns a world-pixel position without reparenting or moving the popup. With an anchor, + * prefer below then above; trySides also permits right/left placement for trigger menus. + * Without an anchor, try the four quadrants around position for cursor menus. */ + static Vector2f findBestPosition( UIWidget* popup, const Vector2f& position, + Node* anchor = nullptr, bool trySides = false ); + + static void align( UIWidget* anchor, UIWidget* popup, bool popUpToRoot = false, + bool centered = false ); + + static bool hasFocus( UIWidget* anchor, UIWidget* popup ); + + static void show( UIWidget* popup ); + + static void hide( UIWidget* popup ); +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uitimepicker.hpp b/include/eepp/ui/uitimepicker.hpp new file mode 100644 index 000000000..b4d58c2b5 --- /dev/null +++ b/include/eepp/ui/uitimepicker.hpp @@ -0,0 +1,23 @@ +#ifndef EE_UI_UITIMEPICKER_HPP +#define EE_UI_UITIMEPICKER_HPP + +#include + +namespace EE { namespace UI { + +/** Time-only segmented editor. Time steps wrap within the day; no popup is required. */ +class EE_API UITimePicker : public UIDateTimeEdit { + public: + static UITimePicker* New(); + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + protected: + UITimePicker(); +}; + +}} // namespace EE::UI + +#endif diff --git a/premake4.lua b/premake4.lua index 64ff2e968..e5c2f2fe4 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1783,6 +1783,12 @@ solution "eepp" files { "src/examples/ui_application_multi_window/*.cpp" } build_link_configuration( "eepp-ui-application-multi-window", true ) + project "eepp-ui-date-time-picker" + set_kind() + language "C++" + files { "src/examples/ui_date_time_picker/*.cpp" } + build_link_configuration( "eepp-ui-date-time-picker", true ) + project "eepp-ui-font-picker" set_kind() language "C++" diff --git a/premake5.lua b/premake5.lua index ac447ce5e..fe73f0e1d 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1820,6 +1820,12 @@ workspace "eepp" files { "src/examples/ui_application_multi_window/*.cpp" } build_link_configuration( "eepp-ui-application-multi-window", true ) + project "eepp-ui-date-time-picker" + set_kind() + language "C++" + files { "src/examples/ui_date_time_picker/*.cpp" } + build_link_configuration( "eepp-ui-date-time-picker", true ) + project "eepp-ui-font-picker" set_kind() language "C++" diff --git a/src/eepp/system/datetime.cpp b/src/eepp/system/datetime.cpp new file mode 100644 index 000000000..17aeb800b --- /dev/null +++ b/src/eepp/system/datetime.cpp @@ -0,0 +1,320 @@ +#include + +#include +#include +#include +#include + +namespace EE { namespace System { + +namespace { + +// Howard Hinnant's civil calendar conversion, adapted to eepp integer types. The epoch is +// arbitrary; only differences and round trips matter here. +constexpr Int64 daysFromCivil( Int64 year, unsigned month, unsigned day ) { + year -= month <= 2; + const Int64 era = ( year >= 0 ? year : year - 399 ) / 400; + const unsigned yoe = static_cast( year - era * 400 ); + const unsigned doy = ( 153 * ( month > 2 ? month - 3 : month + 9 ) + 2 ) / 5 + day - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + static_cast( doe ); +} + +CalendarDate civilFromDays( Int64 days ) { + const Int64 era = ( days >= 0 ? days : days - 146096 ) / 146097; + const unsigned doe = static_cast( days - era * 146097 ); + const unsigned yoe = ( doe - doe / 1460 + doe / 36524 - doe / 146096 ) / 365; + Int64 year = static_cast( yoe ) + era * 400; + const unsigned doy = doe - ( 365 * yoe + yoe / 4 - yoe / 100 ); + const unsigned mp = ( 5 * doy + 2 ) / 153; + const unsigned day = doy - ( 153 * mp + 2 ) / 5 + 1; + const unsigned month = mp < 10 ? mp + 3 : mp - 9; + year += month <= 2; + + if ( year < std::numeric_limits::min() || year > std::numeric_limits::max() ) + return {}; + return { static_cast( year ), static_cast( month ), static_cast( day ) }; +} + +Int64 floorDiv( Int64 numerator, Int64 denominator ) { + const Int64 quotient = numerator / denominator; + const Int64 remainder = numerator % denominator; + return quotient - ( remainder != 0 && ( remainder < 0 ) != ( denominator < 0 ) ); +} + +Int64 positiveModulo( Int64 value, Int64 modulus ) { + const Int64 remainder = value % modulus; + return remainder < 0 ? remainder + modulus : remainder; +} + +bool getLocalTime( std::time_t time, std::tm& out ) { +#if EE_PLATFORM == EE_PLATFORM_WIN + return localtime_s( &out, &time ) == 0; +#else + return localtime_r( &time, &out ) != nullptr; +#endif +} + +constexpr Int64 SecondsPerDay = 24 * 60 * 60; +constexpr Int64 EpochCivilDays = daysFromCivil( 1970, 1, 1 ); + +std::optional dateFromEpochDays( Int64 days ) { + constexpr Int64 minimum = + daysFromCivil( std::numeric_limits::min(), 1, 1 ) - EpochCivilDays; + constexpr Int64 maximum = + daysFromCivil( std::numeric_limits::max(), 12, 31 ) - EpochCivilDays; + if ( days < minimum || days > maximum ) + return std::nullopt; + return civilFromDays( days + EpochCivilDays ); +} + +std::optional dateTimeFromUnixTimestamp( Int64 timestamp, Int32 utcOffsetSeconds, + Int64 unitsPerSecond ) { + const Int64 unitsPerDay = SecondsPerDay * unitsPerSecond; + // Apply the offset after splitting the timestamp so even Int64 endpoints cannot overflow. + const Int64 dayUnits = positiveModulo( timestamp, unitsPerDay ) + + static_cast( utcOffsetSeconds ) * unitsPerSecond; + const auto date = + dateFromEpochDays( floorDiv( timestamp, unitsPerDay ) + floorDiv( dayUnits, unitsPerDay ) ); + if ( !date ) + return std::nullopt; + const Int64 wrapped = positiveModulo( dayUnits, unitsPerDay ); + const Int64 seconds = wrapped / unitsPerSecond; + return LocalDateTime{ *date, + { static_cast( seconds / 3600 ), + static_cast( seconds / 60 % 60 ), + static_cast( seconds % 60 ), + static_cast( unitsPerSecond == 1000 ? wrapped % 1000 : 0 ) } }; +} + +} // namespace + +bool CalendarDate::isLeapYear( Int32 year ) { + return year % 4 == 0 && ( year % 100 != 0 || year % 400 == 0 ); +} + +int CalendarDate::daysInMonth( Int32 year, Uint8 month ) { + static constexpr int Days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + if ( month < 1 || month > 12 ) + return 0; + if ( month == 2 && isLeapYear( year ) ) + return 29; + return Days[month - 1]; +} + +bool CalendarDate::isValid() const { + return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth( year, month ); +} + +int CalendarDate::daysInMonth() const { + return daysInMonth( year, month ); +} + +Int64 CalendarDate::daysSinceEpoch() const { + return daysFromCivil( year, month, day ) - EpochCivilDays; +} + +std::optional CalendarDate::toUnixTimestamp() const { + if ( !isValid() ) + return std::nullopt; + return daysSinceEpoch() * SecondsPerDay; +} + +std::optional CalendarDate::fromUnixTimestamp( Int64 seconds ) { + return dateFromEpochDays( floorDiv( seconds, SecondsPerDay ) ); +} + +CalendarDate CalendarDate::addDays( Int64 days ) const { + if ( !isValid() ) + return *this; + const Int64 current = daysFromCivil( year, month, day ); + const Int64 minimum = daysFromCivil( std::numeric_limits::min(), 1, 1 ); + const Int64 maximum = daysFromCivil( std::numeric_limits::max(), 12, 31 ); + if ( days < minimum - current || days > maximum - current ) + return *this; + return civilFromDays( current + days ); +} + +CalendarDate CalendarDate::addMonths( Int64 months ) const { + if ( !isValid() ) + return *this; + + const Int64 current = static_cast( year ) * 12 + ( month - 1 ); + const Int64 minimum = static_cast( std::numeric_limits::min() ) * 12; + const Int64 maximum = static_cast( std::numeric_limits::max() ) * 12 + 11; + if ( months < minimum - current || months > maximum - current ) + return *this; + const Int64 totalMonths = current + months; + const Int64 newYear = floorDiv( totalMonths, 12 ); + if ( newYear < std::numeric_limits::min() || + newYear > std::numeric_limits::max() ) + return *this; + const Uint8 newMonth = static_cast( positiveModulo( totalMonths, 12 ) + 1 ); + const Uint8 newDay = static_cast( + std::min( day, daysInMonth( static_cast( newYear ), newMonth ) ) ); + return { static_cast( newYear ), newMonth, newDay }; +} + +CalendarDate CalendarDate::addYears( Int64 years ) const { + if ( !isValid() ) + return *this; + if ( years < static_cast( std::numeric_limits::min() ) - year || + years > static_cast( std::numeric_limits::max() ) - year ) + return *this; + const Int64 newYear = static_cast( year ) + years; + if ( newYear < std::numeric_limits::min() || + newYear > std::numeric_limits::max() ) + return *this; + const Uint8 newDay = static_cast( + std::min( day, daysInMonth( static_cast( newYear ), month ) ) ); + return { static_cast( newYear ), month, newDay }; +} + +CalendarDate CalendarDate::today() { + const std::time_t current = std::time( nullptr ); + std::tm local{}; + if ( !getLocalTime( current, local ) ) + return {}; + return { static_cast( local.tm_year + 1900 ), static_cast( local.tm_mon + 1 ), + static_cast( local.tm_mday ) }; +} + +bool TimeOfDay::isValid() const { + return hour < 24 && minute < 60 && second < 60 && millisecond < 1000; +} + +TimeOfDay TimeOfDay::addHours( Int64 hours ) const { + if ( !isValid() ) + return *this; + TimeOfDay result = *this; + result.hour = + static_cast( positiveModulo( static_cast( hour ) + hours % 24, 24 ) ); + return result; +} + +TimeOfDay TimeOfDay::addMinutes( Int64 minutes ) const { + if ( !isValid() ) + return *this; + const Int64 totalMinutes = static_cast( hour ) * 60 + minute + minutes % ( 24 * 60 ); + const Int64 wrapped = positiveModulo( totalMinutes, 24 * 60 ); + TimeOfDay result = *this; + result.hour = static_cast( wrapped / 60 ); + result.minute = static_cast( wrapped % 60 ); + return result; +} + +TimeOfDay TimeOfDay::addSeconds( Int64 seconds ) const { + if ( !isValid() ) + return *this; + const Int64 totalSeconds = + static_cast( hour ) * 3600 + minute * 60 + second + seconds % SecondsPerDay; + const Int64 wrapped = positiveModulo( totalSeconds, SecondsPerDay ); + TimeOfDay result = *this; + result.hour = static_cast( wrapped / 3600 ); + result.minute = static_cast( ( wrapped % 3600 ) / 60 ); + result.second = static_cast( wrapped % 60 ); + return result; +} + +TimeOfDay TimeOfDay::now() { + return LocalDateTime::now().time; +} + +bool LocalDateTime::isValid() const { + return date.isValid() && time.isValid(); +} + +std::optional LocalDateTime::toUnixTimestamp( Int32 utcOffsetSeconds ) const { + if ( !isValid() ) + return std::nullopt; + // The complete Int32 year range fits in Int64 seconds, including any Int32 offset. + return date.daysSinceEpoch() * SecondsPerDay + time.hour * 3600 + time.minute * 60 + + time.second - static_cast( utcOffsetSeconds ); +} + +std::optional LocalDateTime::toUnixTimestampMilliseconds( Int32 utcOffsetSeconds ) const { + const auto seconds = toUnixTimestamp( utcOffsetSeconds ); + if ( !seconds ) + return std::nullopt; + constexpr Int64 minimum = std::numeric_limits::min(); + constexpr Int64 maximum = std::numeric_limits::max(); + const Int64 minimumSeconds = floorDiv( minimum, 1000 ); + const Int64 minimumFraction = positiveModulo( minimum, 1000 ); + if ( *seconds < minimumSeconds || *seconds > maximum / 1000 || + ( *seconds == minimumSeconds && time.millisecond < minimumFraction ) || + ( *seconds == maximum / 1000 && time.millisecond > maximum % 1000 ) ) + return std::nullopt; + // The floor-divided minimum second cannot be multiplied by 1000 without overflowing. + if ( *seconds == minimumSeconds ) + return minimum + ( time.millisecond - minimumFraction ); + return *seconds * 1000 + time.millisecond; +} + +std::optional LocalDateTime::fromUnixTimestamp( Int64 seconds, + Int32 utcOffsetSeconds ) { + return dateTimeFromUnixTimestamp( seconds, utcOffsetSeconds, 1 ); +} + +std::optional +LocalDateTime::fromUnixTimestampMilliseconds( Int64 milliseconds, Int32 utcOffsetSeconds ) { + return dateTimeFromUnixTimestamp( milliseconds, utcOffsetSeconds, 1000 ); +} + +namespace { + +LocalDateTime addLocalUnits( const LocalDateTime& value, Int64 amount, Int64 unitsPerDay, + Int64 secondsPerUnit ) { + if ( !value.isValid() ) + return value; + const Int64 current = value.time.hour * 3600 + value.time.minute * 60 + value.time.second; + // Divide before multiplying: arbitrary Int64 steps must not overflow intermediates. + const Int64 total = current + ( amount % unitsPerDay ) * secondsPerUnit; + const Int64 dayDelta = amount / unitsPerDay + floorDiv( total, SecondsPerDay ); + const CalendarDate date = value.date.addDays( dayDelta ); + if ( dayDelta != 0 && date == value.date ) + return value; + const Int64 wrapped = positiveModulo( total, SecondsPerDay ); + return { date, + { static_cast( wrapped / 3600 ), static_cast( ( wrapped % 3600 ) / 60 ), + static_cast( wrapped % 60 ), value.time.millisecond } }; +} + +} // namespace + +LocalDateTime LocalDateTime::addHours( Int64 hours ) const { + return addLocalUnits( *this, hours, 24, 3600 ); +} + +LocalDateTime LocalDateTime::addMinutes( Int64 minutes ) const { + return addLocalUnits( *this, minutes, 1440, 60 ); +} + +LocalDateTime LocalDateTime::addSeconds( Int64 seconds ) const { + return addLocalUnits( *this, seconds, SecondsPerDay, 1 ); +} + +LocalDateTime LocalDateTime::now() { + // Sample once so crossing midnight cannot combine yesterday's date with today's time. + const auto now = std::chrono::system_clock::now(); + const std::time_t current = std::chrono::system_clock::to_time_t( now ); + std::tm local{}; + if ( !getLocalTime( current, local ) ) + return {}; + const auto milliseconds = + std::chrono::duration_cast( now.time_since_epoch() ).count(); + return { { static_cast( local.tm_year + 1900 ), static_cast( local.tm_mon + 1 ), + static_cast( local.tm_mday ) }, + { static_cast( local.tm_hour ), static_cast( local.tm_min ), + static_cast( local.tm_sec ), + static_cast( positiveModulo( milliseconds, 1000 ) ) } }; +} + +bool DateRange::isValid() const { + return start.isValid() && end.isValid() && start <= end; +} + +bool DateRange::contains( const CalendarDate& date ) const { + return isValid() && date.isValid() && date >= start && date <= end; +} + +}} // namespace EE::System diff --git a/src/eepp/system/datetimeformat.cpp b/src/eepp/system/datetimeformat.cpp new file mode 100644 index 000000000..01bcea132 --- /dev/null +++ b/src/eepp/system/datetimeformat.cpp @@ -0,0 +1,980 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if EE_PLATFORM == EE_PLATFORM_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace EE { namespace System { + +namespace { + +bool isTokenChar( char c ) { + switch ( c ) { + case 'd': + case 'M': + case 'y': + case 'H': + case 'h': + case 'm': + case 's': + case 'S': + case 'a': + return true; + default: + return false; + } +} + +DateTimeFormatTokenType tokenType( char c ) { + switch ( c ) { + case 'd': + return DateTimeFormatTokenType::Day; + case 'M': + return DateTimeFormatTokenType::Month; + case 'y': + return DateTimeFormatTokenType::Year; + case 'H': + return DateTimeFormatTokenType::Hour24; + case 'h': + return DateTimeFormatTokenType::Hour12; + case 'm': + return DateTimeFormatTokenType::Minute; + case 's': + return DateTimeFormatTokenType::Second; + case 'S': + return DateTimeFormatTokenType::Millisecond; + case 'a': + return DateTimeFormatTokenType::AmPm; + default: + return DateTimeFormatTokenType::Literal; + } +} + +bool validTokenWidth( DateTimeFormatTokenType type, size_t width ) { + switch ( type ) { + case DateTimeFormatTokenType::Day: + case DateTimeFormatTokenType::Month: + case DateTimeFormatTokenType::Hour24: + case DateTimeFormatTokenType::Hour12: + case DateTimeFormatTokenType::Minute: + case DateTimeFormatTokenType::Second: + return width >= 1 && width <= 2; + case DateTimeFormatTokenType::Year: + return width == 2 || width == 4; + case DateTimeFormatTokenType::Millisecond: + return width >= 1 && width <= 3; + case DateTimeFormatTokenType::AmPm: + return width == 1; + case DateTimeFormatTokenType::Literal: + return width == 0; + } + return false; +} + +void appendLiteral( std::vector& tokens, std::string_view literal ) { + if ( literal.empty() ) + return; + if ( !tokens.empty() && tokens.back().type == DateTimeFormatTokenType::Literal ) { + tokens.back().literal.append( literal ); + } else { + tokens.push_back( { std::string( literal ), DateTimeFormatTokenType::Literal, 0 } ); + } +} + +std::string lowerASCII( std::string_view value ) { + std::string result; + result.reserve( value.size() ); + for ( char c : value ) + result.push_back( static_cast( std::tolower( static_cast( c ) ) ) ); + return result; +} + +bool equalsASCIIInsensitive( std::string_view a, std::string_view b ) { + return lowerASCII( a ) == lowerASCII( b ); +} + +bool containsASCIIInsensitive( std::string_view haystack, std::string_view needle ) { + if ( needle.empty() ) + return false; + return lowerASCII( haystack ).find( lowerASCII( needle ) ) != std::string::npos; +} + +std::string formatTm( const std::locale& locale, std::tm value, const char* pattern ) { + std::ostringstream stream; + stream.imbue( locale ); + stream << std::put_time( &value, pattern ); + return stream.str(); +} + +char detectDateSeparator( const std::string& formattedDate, char fallback ) { + for ( char c : formattedDate ) { + if ( c == '/' || c == '-' || c == '.' ) + return c; + } + return fallback; +} + +DateOrder fallbackDateOrder( std::string_view localeName ) { + const std::string name = lowerASCII( localeName ); + if ( name == "c" || name == "posix" || name.find( "en_us" ) != std::string::npos || + name.find( "en-us" ) != std::string::npos || name.find( "en_ph" ) != std::string::npos || + name.find( "en-ph" ) != std::string::npos ) + return DateOrder::MDY; + if ( name.rfind( "ja", 0 ) == 0 || name.rfind( "zh", 0 ) == 0 || name.rfind( "ko", 0 ) == 0 ) + return DateOrder::YMD; + return DateOrder::DMY; +} + +bool fallbackSundayFirst( std::string_view localeName ) { + const std::string name = lowerASCII( localeName ); + return name == "c" || name == "posix" || name.find( "_us" ) != std::string::npos || + name.find( "-us" ) != std::string::npos || name.find( "_ca" ) != std::string::npos || + name.find( "-ca" ) != std::string::npos || name.find( "_ph" ) != std::string::npos || + name.find( "-ph" ) != std::string::npos; +} + +bool fallback12Hour( std::string_view localeName ) { + const std::string name = lowerASCII( localeName ); + return name.find( "_us" ) != std::string::npos || name.find( "-us" ) != std::string::npos || + name.find( "_ph" ) != std::string::npos || name.find( "-ph" ) != std::string::npos; +} + +std::string paddedNumber( int value, size_t width ) { + char buffer[16]; + const auto result = std::to_chars( buffer, buffer + sizeof( buffer ), value ); + const size_t length = static_cast( result.ptr - buffer ); + std::string text; + text.reserve( std::max( width, length ) ); + if ( value < 0 ) { + text.push_back( '-' ); + if ( width > length ) + text.append( width - length, '0' ); + text.append( buffer + 1, length - 1 ); + } else { + if ( width > length ) + text.append( width - length, '0' ); + text.append( buffer, length ); + } + return text; +} + +std::string twoDigits( int value ) { + return paddedNumber( value, 2 ); +} + +std::string fourDigits( int value ) { + return paddedNumber( value, 4 ); +} + +std::string numberForToken( DateTimeFormatTokenType type, Uint8 width, const CalendarDate* date, + const TimeOfDay* time, const DateTimeLocale& locale, bool& supported ) { + supported = true; + int value = 0; + switch ( type ) { + case DateTimeFormatTokenType::Day: + if ( !date ) { + supported = false; + return {}; + } + value = date->day; + break; + case DateTimeFormatTokenType::Month: + if ( !date ) { + supported = false; + return {}; + } + value = date->month; + break; + case DateTimeFormatTokenType::Year: + if ( !date ) { + supported = false; + return {}; + } + if ( width == 2 ) + return twoDigits( ( date->year % 100 + 100 ) % 100 ); + return fourDigits( date->year ); + case DateTimeFormatTokenType::Hour24: + if ( !time ) { + supported = false; + return {}; + } + value = time->hour; + break; + case DateTimeFormatTokenType::Hour12: + if ( !time ) { + supported = false; + return {}; + } + value = time->hour % 12; + if ( value == 0 ) + value = 12; + break; + case DateTimeFormatTokenType::Minute: + if ( !time ) { + supported = false; + return {}; + } + value = time->minute; + break; + case DateTimeFormatTokenType::Second: + if ( !time ) { + supported = false; + return {}; + } + value = time->second; + break; + case DateTimeFormatTokenType::Millisecond: + if ( !time ) { + supported = false; + return {}; + } + value = time->millisecond; + if ( width == 1 ) + value /= 100; + else if ( width == 2 ) + value /= 10; + break; + case DateTimeFormatTokenType::AmPm: + if ( !time ) { + supported = false; + return {}; + } + return time->hour < 12 ? locale.am : locale.pm; + case DateTimeFormatTokenType::Literal: + return {}; + } + + return paddedNumber( value, width ); +} + +FormattedDateTime formatImpl( const CalendarDate* date, const TimeOfDay* time, + const DateTimeFormat& format, const DateTimeLocale& locale ) { + FormattedDateTime result; + if ( !format.isValid() || ( date && !date->isValid() ) || ( time && !time->isValid() ) ) + return result; + + const auto& tokens = format.getTokens(); + result.text.reserve( format.getPattern().size() ); + result.ranges.reserve( tokens.size() ); + for ( size_t i = 0; i < tokens.size(); ++i ) { + const auto& token = tokens[i]; + const size_t start = result.text.size(); + if ( token.type == DateTimeFormatTokenType::Literal ) { + result.text += token.literal; + } else { + bool supported = false; + result.text += numberForToken( token.type, token.width, date, time, locale, supported ); + if ( !supported ) + return {}; + } + result.ranges.push_back( { token.type, i, start, result.text.size() - start } ); + } + return result; +} + +struct ParsedFields { + std::optional day; + std::optional month; + std::optional year; + std::optional hour24; + std::optional hour12; + std::optional minute; + std::optional second; + std::optional millisecond; + std::optional pm; +}; + +bool parseUnsigned( std::string_view text, size_t& pos, size_t minDigits, size_t maxDigits, + int& out ) { + const size_t start = pos; + Int64 value = 0; + while ( pos < text.size() && pos - start < maxDigits && + std::isdigit( static_cast( text[pos] ) ) ) { + value = value * 10 + ( text[pos] - '0' ); + ++pos; + } + if ( pos - start < minDigits ) + return false; + out = static_cast( value ); + return true; +} + +bool parseLiteral( std::string_view text, size_t& pos, std::string_view literal ) { + if ( pos + literal.size() > text.size() || text.substr( pos, literal.size() ) != literal ) + return false; + pos += literal.size(); + return true; +} + +bool parseYear( std::string_view text, size_t& pos, int& out ) { + const size_t start = pos; + const bool negative = pos < text.size() && text[pos] == '-'; + if ( negative ) + ++pos; + const size_t digits = pos; + while ( pos < text.size() && std::isdigit( static_cast( text[pos] ) ) ) + ++pos; + // Formatting pads to four characters, including a negative year's sign. + if ( pos - digits < ( negative ? 3u : 4u ) ) + return false; + const auto result = std::from_chars( text.data() + start, text.data() + pos, out ); + return result.ec == std::errc{} && result.ptr == text.data() + pos; +} + +bool parseAmPm( std::string_view text, size_t& pos, const DateTimeLocale& locale, bool& pm ) { + const std::array, 4> candidates = { + std::pair{ locale.am, false }, + { locale.pm, true }, + { "AM", false }, + { "PM", true } }; + for ( const auto& candidate : candidates ) { + if ( candidate.first.empty() || pos + candidate.first.size() > text.size() ) + continue; + if ( equalsASCIIInsensitive( text.substr( pos, candidate.first.size() ), + candidate.first ) ) { + pos += candidate.first.size(); + pm = candidate.second; + return true; + } + } + return false; +} + +std::optional parseFields( std::string_view text, const DateTimeFormat& format, + const DateTimeLocale& locale ) { + if ( !format.isValid() ) + return std::nullopt; + + ParsedFields fields; + size_t pos = 0; + const auto& tokens = format.getTokens(); + for ( size_t index = 0; index < tokens.size(); ++index ) { + const auto& token = tokens[index]; + if ( token.type == DateTimeFormatTokenType::Literal ) { + if ( !parseLiteral( text, pos, token.literal ) ) + return std::nullopt; + continue; + } + + if ( token.type == DateTimeFormatTokenType::AmPm ) { + bool pm = false; + if ( !parseAmPm( text, pos, locale, pm ) ) + return std::nullopt; + fields.pm = pm; + continue; + } + + int number = 0; + size_t minDigits = token.width; + size_t maxDigits = token.width; + if ( token.width == 1 && token.type != DateTimeFormatTokenType::Year ) + maxDigits = token.type == DateTimeFormatTokenType::Millisecond ? 1 : 2; + // Delimited full years must accept every year emitted by the formatter. Adjacent + // numeric tokens keep their fixed widths, so yyyyMMdd remains unambiguous. + const bool fullYear = token.type == DateTimeFormatTokenType::Year && token.width == 4 && + ( index + 1 == tokens.size() || + ( tokens[index + 1].type == DateTimeFormatTokenType::Literal && + !tokens[index + 1].literal.empty() && + !std::isdigit( static_cast( + tokens[index + 1].literal.front() ) ) ) ); + if ( !( fullYear ? parseYear( text, pos, number ) + : parseUnsigned( text, pos, minDigits, maxDigits, number ) ) ) + return std::nullopt; + + switch ( token.type ) { + case DateTimeFormatTokenType::Day: + fields.day = number; + break; + case DateTimeFormatTokenType::Month: + fields.month = number; + break; + case DateTimeFormatTokenType::Year: + fields.year = token.width == 2 ? 2000 + number : number; + break; + case DateTimeFormatTokenType::Hour24: + fields.hour24 = number; + break; + case DateTimeFormatTokenType::Hour12: + fields.hour12 = number; + break; + case DateTimeFormatTokenType::Minute: + fields.minute = number; + break; + case DateTimeFormatTokenType::Second: + fields.second = number; + break; + case DateTimeFormatTokenType::Millisecond: + fields.millisecond = token.width == 1 ? number * 100 + : token.width == 2 ? number * 10 + : number; + break; + default: + break; + } + } + + if ( pos != text.size() ) + return std::nullopt; + return fields; +} + +std::optional dateFromFields( const ParsedFields& fields ) { + if ( !fields.day || !fields.month || !fields.year ) + return std::nullopt; + if ( *fields.month < 1 || *fields.month > 12 || *fields.day < 1 || *fields.day > 31 ) + return std::nullopt; + CalendarDate value{ static_cast( *fields.year ), static_cast( *fields.month ), + static_cast( *fields.day ) }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +std::optional timeFromFields( const ParsedFields& fields ) { + if ( !fields.minute || ( !fields.hour24 && !fields.hour12 ) ) + return std::nullopt; + if ( fields.hour24 && fields.hour12 ) + return std::nullopt; + + int hour = 0; + if ( fields.hour24 ) { + if ( fields.pm ) + return std::nullopt; + hour = *fields.hour24; + } else { + if ( !fields.pm || *fields.hour12 < 1 || *fields.hour12 > 12 ) + return std::nullopt; + hour = *fields.hour12 % 12 + ( *fields.pm ? 12 : 0 ); + } + + TimeOfDay value{ static_cast( hour ), static_cast( *fields.minute ), + fields.second ? static_cast( *fields.second ) : Uint8{ 0 }, + fields.millisecond ? static_cast( *fields.millisecond ) + : Uint16{ 0 } }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +SmallVector, 4> numericGroups( std::string_view text ) { + SmallVector, 4> values; + for ( size_t pos = 0; pos < text.size(); ) { + if ( !std::isdigit( static_cast( text[pos] ) ) ) { + const char c = text[pos]; + if ( c != '/' && c != '-' && c != '.' && c != ':' && + !std::isspace( static_cast( c ) ) ) + return {}; + ++pos; + continue; + } + const size_t start = pos; + int value = 0; + while ( pos < text.size() && std::isdigit( static_cast( text[pos] ) ) ) { + if ( value > ( std::numeric_limits::max() - ( text[pos] - '0' ) ) / 10 ) + return {}; + value = value * 10 + ( text[pos] - '0' ); + ++pos; + } + if ( values.size() == 4 ) + return {}; + values.emplace_back( value, pos - start ); + } + return values; +} + +DateOrder dateOrderFromFormat( const DateTimeFormat& format, DateOrder fallback ) { + SmallVector dateTokens; + for ( const auto& token : format.getTokens() ) { + if ( token.type == DateTimeFormatTokenType::Day || + token.type == DateTimeFormatTokenType::Month || + token.type == DateTimeFormatTokenType::Year ) + dateTokens.push_back( token.type ); + } + if ( dateTokens.size() < 3 ) + return fallback; + if ( dateTokens[0] == DateTimeFormatTokenType::Day ) + return DateOrder::DMY; + if ( dateTokens[0] == DateTimeFormatTokenType::Month ) + return DateOrder::MDY; + if ( dateTokens[0] == DateTimeFormatTokenType::Year ) + return DateOrder::YMD; + return fallback; +} + +std::string_view trimView( std::string_view text ) { + while ( !text.empty() && std::isspace( static_cast( text.front() ) ) ) + text.remove_prefix( 1 ); + while ( !text.empty() && std::isspace( static_cast( text.back() ) ) ) + text.remove_suffix( 1 ); + return text; +} + +} // namespace + +std::string DateTimeLocale::datePattern() const { + const std::string separator( 1, dateSeparator ); + switch ( dateOrder ) { + case DateOrder::DMY: + return "dd" + separator + "MM" + separator + "yyyy"; + case DateOrder::MDY: + return "MM" + separator + "dd" + separator + "yyyy"; + case DateOrder::YMD: + return "yyyy" + separator + "MM" + separator + "dd"; + } + return "dd/MM/yyyy"; +} + +std::string DateTimeLocale::timePattern( bool showSeconds, bool showMilliseconds, + HourCycle requestedCycle ) const { + const HourCycle resolved = requestedCycle == HourCycle::Locale ? hourCycle : requestedCycle; + std::string result = resolved == HourCycle::H12 ? "hh:mm" : "HH:mm"; + if ( showSeconds || showMilliseconds ) + result += ":ss"; + if ( showMilliseconds ) + result += ".SSS"; + if ( resolved == HourCycle::H12 ) + result += " a"; + return result; +} + +std::string DateTimeLocale::dateTimePattern( bool showSeconds, bool showMilliseconds, + HourCycle requestedCycle ) const { + return datePattern() + " " + timePattern( showSeconds, showMilliseconds, requestedCycle ); +} + +DateTimeLocale DateTimeLocale::fromLocaleName( std::string_view localeName ) { + DateTimeLocale result; + result.dateOrder = fallbackDateOrder( localeName ); + result.dateSeparator = result.dateOrder == DateOrder::YMD ? '-' : '/'; + result.hourCycle = fallback12Hour( localeName ) ? HourCycle::H12 : HourCycle::H24; + result.firstDayOfWeek = fallbackSundayFirst( localeName ) ? 0 : 1; + return result; +} + +DateTimeLocale DateTimeLocale::system() { +#if EE_PLATFORM == EE_PLATFORM_WIN + // MinGW does not provide the named std::locale facets on all installations. Read Windows + // regional settings directly, including the user's overrides, instead of falling back to C. + const auto text = []( LCTYPE type ) { + wchar_t buffer[128]{}; + return GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, type, buffer, 128 ) > 0 + ? String::fromWide( buffer ).toUtf8() + : std::string{}; + }; + const auto number = []( LCTYPE type, DWORD fallback ) { + DWORD value; + return GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, type | LOCALE_RETURN_NUMBER, + reinterpret_cast( &value ), + sizeof( value ) / sizeof( wchar_t ) ) > 0 + ? value + : fallback; + }; + DateTimeLocale result = fromLocaleName( text( LOCALE_SNAME ) ); + switch ( number( LOCALE_IDATE, 1 ) ) { + case 0: + result.dateOrder = DateOrder::MDY; + break; + case 1: + result.dateOrder = DateOrder::DMY; + break; + case 2: + result.dateOrder = DateOrder::YMD; + break; + } + result.dateSeparator = detectDateSeparator( text( LOCALE_SDATE ), result.dateSeparator ); + result.hourCycle = number( LOCALE_ITIME, 1 ) == 0 ? HourCycle::H12 : HourCycle::H24; + // Windows numbers weekdays from Monday; the public date/time API numbers from Sunday. + result.firstDayOfWeek = static_cast( ( number( LOCALE_IFIRSTDAYOFWEEK, 0 ) + 1 ) % 7 ); + if ( auto am = text( LOCALE_S1159 ); !am.empty() ) + result.am = std::move( am ); + if ( auto pm = text( LOCALE_S2359 ); !pm.empty() ) + result.pm = std::move( pm ); + for ( int month = 0; month < 12; ++month ) { + if ( auto name = text( LOCALE_SABBREVMONTHNAME1 + month ); !name.empty() ) + result.shortMonthNames[month] = std::move( name ); + if ( auto name = text( LOCALE_SMONTHNAME1 + month ); !name.empty() ) + result.monthNames[month] = std::move( name ); + } + for ( int weekday = 0; weekday < 7; ++weekday ) { + const int index = ( weekday + 6 ) % 7; + if ( auto name = text( LOCALE_SABBREVDAYNAME1 + index ); !name.empty() ) + result.shortWeekdayNames[weekday] = std::move( name ); + if ( auto name = text( LOCALE_SDAYNAME1 + index ); !name.empty() ) + result.weekdayNames[weekday] = std::move( name ); + } + return result; +#else + try { + const std::locale locale( "" ); + DateTimeLocale result = fromLocaleName( locale.name() ); + + const auto order = std::use_facet>( locale ).date_order(); + switch ( order ) { + case std::time_base::dmy: + result.dateOrder = DateOrder::DMY; + break; + case std::time_base::mdy: + result.dateOrder = DateOrder::MDY; + break; + case std::time_base::ymd: + case std::time_base::ydm: + result.dateOrder = DateOrder::YMD; + break; + case std::time_base::no_order: + break; + } + + std::tm sample{}; + sample.tm_year = 103; // 2003 + sample.tm_mon = 10; // November + sample.tm_mday = 22; + sample.tm_hour = 13; + sample.tm_min = 45; + sample.tm_sec = 56; + result.dateSeparator = + detectDateSeparator( formatTm( locale, sample, "%x" ), result.dateSeparator ); + + const std::string pm = formatTm( locale, sample, "%p" ); + if ( !pm.empty() ) + result.pm = pm; + sample.tm_hour = 1; + const std::string am = formatTm( locale, sample, "%p" ); + if ( !am.empty() ) + result.am = am; + sample.tm_hour = 13; + const std::string displayTime = formatTm( locale, sample, "%X" ); + if ( !result.pm.empty() && containsASCIIInsensitive( displayTime, result.pm ) ) + result.hourCycle = HourCycle::H12; + + for ( int month = 0; month < 12; ++month ) { + sample.tm_mon = month; + result.shortMonthNames[month] = formatTm( locale, sample, "%b" ); + result.monthNames[month] = formatTm( locale, sample, "%B" ); + } + for ( int weekday = 0; weekday < 7; ++weekday ) { + sample.tm_wday = weekday; + result.shortWeekdayNames[weekday] = formatTm( locale, sample, "%a" ); + result.weekdayNames[weekday] = formatTm( locale, sample, "%A" ); + } + return result; + } catch ( ... ) { + return fromLocaleName( "C" ); + } +#endif +} + +DateTimeFormat::DateTimeFormat( std::string pattern ) : + DateTimeFormat( compile( std::move( pattern ) ) ) {} + +DateTimeFormat DateTimeFormat::compile( std::string pattern ) { + DateTimeFormat result; + result.mPattern = std::move( pattern ); + if ( result.mPattern.empty() ) + return result; + + for ( size_t pos = 0; pos < result.mPattern.size(); ) { + const char c = result.mPattern[pos]; + if ( c == '\'' ) { + std::string literal; + ++pos; + bool closed = false; + while ( pos < result.mPattern.size() ) { + if ( result.mPattern[pos] == '\'' ) { + if ( pos + 1 < result.mPattern.size() && result.mPattern[pos + 1] == '\'' ) { + literal.push_back( '\'' ); + pos += 2; + continue; + } + ++pos; + closed = true; + break; + } + literal.push_back( result.mPattern[pos++] ); + } + if ( !closed ) + return result; + appendLiteral( result.mTokens, literal ); + continue; + } + + if ( isTokenChar( c ) ) { + size_t end = pos + 1; + while ( end < result.mPattern.size() && result.mPattern[end] == c ) + ++end; + const auto type = tokenType( c ); + const size_t width = end - pos; + if ( !validTokenWidth( type, width ) ) + return result; + result.mTokens.push_back( { {}, type, static_cast( width ) } ); + pos = end; + continue; + } + + if ( std::isalpha( static_cast( c ) ) ) + return result; + appendLiteral( result.mTokens, std::string_view( &result.mPattern[pos], 1 ) ); + ++pos; + } + + result.mValid = !result.mTokens.empty(); + return result; +} + +FormattedDateTime DateTimeFormatter::formatDateDetailed( const CalendarDate& value, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + return formatImpl( &value, nullptr, format, locale ); +} + +FormattedDateTime DateTimeFormatter::formatTimeDetailed( const TimeOfDay& value, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + return formatImpl( nullptr, &value, format, locale ); +} + +FormattedDateTime DateTimeFormatter::formatDateTimeDetailed( const LocalDateTime& value, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + if ( !value.isValid() ) + return {}; + return formatImpl( &value.date, &value.time, format, locale ); +} + +std::string DateTimeFormatter::formatDate( const CalendarDate& value, const DateTimeFormat& format, + const DateTimeLocale& locale ) { + return formatDateDetailed( value, format, locale ).text; +} + +std::string DateTimeFormatter::formatTime( const TimeOfDay& value, const DateTimeFormat& format, + const DateTimeLocale& locale ) { + return formatTimeDetailed( value, format, locale ).text; +} + +std::string DateTimeFormatter::formatDateTime( const LocalDateTime& value, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + return formatDateTimeDetailed( value, format, locale ).text; +} + +std::optional DateTimeFormatter::parseDate( std::string_view text, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + const auto fields = parseFields( trimView( text ), format, locale ); + return fields ? dateFromFields( *fields ) : std::nullopt; +} + +std::optional DateTimeFormatter::parseTime( std::string_view text, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + const auto fields = parseFields( trimView( text ), format, locale ); + return fields ? timeFromFields( *fields ) : std::nullopt; +} + +std::optional DateTimeFormatter::parseDateTime( std::string_view text, + const DateTimeFormat& format, + const DateTimeLocale& locale ) { + const auto fields = parseFields( trimView( text ), format, locale ); + if ( !fields ) + return std::nullopt; + const auto date = dateFromFields( *fields ); + const auto time = timeFromFields( *fields ); + if ( !date || !time ) + return std::nullopt; + return LocalDateTime{ *date, *time }; +} + +std::optional +DateTimeFormatter::parseDateTolerant( std::string_view text, const DateTimeFormat& displayFormat, + const DateTimeLocale& locale ) { + text = trimView( text ); + if ( text.empty() ) + return std::nullopt; + static const DateTimeFormat ISODate( "yyyy-MM-dd" ); + if ( auto iso = parseDate( text, ISODate, locale ) ) + return iso; + if ( auto exact = parseDate( text, displayFormat, locale ) ) + return exact; + + const auto groups = numericGroups( text ); + if ( groups.size() != 3 ) + return std::nullopt; + DateOrder order = dateOrderFromFormat( displayFormat, locale.dateOrder ); + if ( groups[0].second == 4 ) + order = DateOrder::YMD; + int day = 0; + int month = 0; + int year = 0; + switch ( order ) { + case DateOrder::DMY: + day = groups[0].first; + month = groups[1].first; + year = groups[2].first; + break; + case DateOrder::MDY: + month = groups[0].first; + day = groups[1].first; + year = groups[2].first; + break; + case DateOrder::YMD: + year = groups[0].first; + month = groups[1].first; + day = groups[2].first; + break; + } + if ( year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31 ) + return std::nullopt; + CalendarDate value{ static_cast( year ), static_cast( month ), + static_cast( day ) }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +std::optional DateTimeFormatter::parseTimeTolerant( std::string_view text, + const DateTimeFormat& displayFormat, + const DateTimeLocale& locale ) { + text = trimView( text ); + if ( text.empty() ) + return std::nullopt; + if ( auto exact = parseTime( text, displayFormat, locale ) ) + return exact; + + std::string normalized( text ); + bool hasPm = false; + bool hasAmPm = false; + for ( const auto& marker : { std::pair{ locale.am, false }, + std::pair{ locale.pm, true }, + { "AM", false }, + { "PM", true } } ) { + if ( marker.first.empty() ) + continue; + const std::string lowerText = lowerASCII( normalized ); + const std::string lowerMarker = lowerASCII( marker.first ); + const size_t found = lowerText.find( lowerMarker ); + if ( found != std::string::npos ) { + normalized.erase( found, marker.first.size() ); + hasAmPm = true; + hasPm = marker.second; + break; + } + } + + normalized = std::string( trimView( normalized ) ); + const auto groups = numericGroups( normalized ); + if ( groups.size() < 2 || groups.size() > 4 ) + return std::nullopt; + int hour = groups[0].first; + const int minute = groups[1].first; + const int second = groups.size() >= 3 ? groups[2].first : 0; + int millisecond = 0; + if ( groups.size() == 4 ) { + millisecond = groups[3].first; + if ( groups[3].second == 1 ) + millisecond *= 100; + else if ( groups[3].second == 2 ) + millisecond *= 10; + else if ( groups[3].second > 3 ) + return std::nullopt; + } + if ( hasAmPm ) { + if ( hour < 1 || hour > 12 ) + return std::nullopt; + hour = hour % 12 + ( hasPm ? 12 : 0 ); + } + if ( hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 || + millisecond < 0 || millisecond > 999 ) + return std::nullopt; + TimeOfDay value{ static_cast( hour ), static_cast( minute ), + static_cast( second ), static_cast( millisecond ) }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +std::optional DateTimeFormatter::parseDateTimeTolerant( + std::string_view text, const DateTimeFormat& displayFormat, const DateTimeLocale& locale ) { + text = trimView( text ); + if ( text.empty() ) + return std::nullopt; + // ISO paste has stable ordering even when a custom display pattern swaps day and month. + const size_t isoSeparator = text.find( 'T' ); + if ( isoSeparator != std::string_view::npos ) { + static const DateTimeFormat isoDate( "yyyy-MM-dd" ); + static const DateTimeFormat isoTime( "HH:mm:ss" ); + const auto date = parseDate( text.substr( 0, isoSeparator ), isoDate, locale ); + const auto time = parseTimeTolerant( text.substr( isoSeparator + 1 ), isoTime, locale ); + if ( date && time ) + return LocalDateTime{ *date, *time }; + } + if ( auto exact = parseDateTime( text, displayFormat, locale ) ) + return exact; + + size_t separator = text.find( 'T' ); + if ( separator == std::string_view::npos ) { + for ( size_t i = 0; i < text.size(); ++i ) { + if ( std::isspace( static_cast( text[i] ) ) ) { + separator = i; + break; + } + } + } + if ( separator == std::string_view::npos ) + return std::nullopt; + + const auto dateText = trimView( text.substr( 0, separator ) ); + const auto timeText = trimView( text.substr( separator + 1 ) ); + std::string datePattern; + for ( const auto& token : displayFormat.getTokens() ) { + if ( token.type == DateTimeFormatTokenType::Hour24 || + token.type == DateTimeFormatTokenType::Hour12 ) + break; + if ( token.type == DateTimeFormatTokenType::Literal ) + datePattern += "'" + token.literal + "'"; + else if ( token.type == DateTimeFormatTokenType::Day || + token.type == DateTimeFormatTokenType::Month || + token.type == DateTimeFormatTokenType::Year ) + datePattern += + std::string( token.width, token.type == DateTimeFormatTokenType::Day ? 'd' + : token.type == DateTimeFormatTokenType::Month ? 'M' + : 'y' ); + } + DateTimeFormat dateFormat( datePattern.empty() ? locale.datePattern() : datePattern ); + DateTimeFormat timeFormat( locale.timePattern( true, true ) ); + const auto date = parseDateTolerant( dateText, dateFormat, locale ); + const auto time = parseTimeTolerant( timeText, timeFormat, locale ); + if ( !date || !time ) + return std::nullopt; + return LocalDateTime{ *date, *time }; +} + +std::string DateTimeFormatter::toISODate( const CalendarDate& value ) { + if ( !value.isValid() ) + return {}; + return fourDigits( value.year ) + "-" + twoDigits( value.month ) + "-" + twoDigits( value.day ); +} + +std::string DateTimeFormatter::toISOTime( const TimeOfDay& value, bool includeMilliseconds ) { + if ( !value.isValid() ) + return {}; + std::string result = + twoDigits( value.hour ) + ":" + twoDigits( value.minute ) + ":" + twoDigits( value.second ); + if ( includeMilliseconds ) { + result += '.'; + result += paddedNumber( value.millisecond, 3 ); + } + return result; +} + +std::string DateTimeFormatter::toISOLocalDateTime( const LocalDateTime& value, + bool includeMilliseconds ) { + if ( !value.isValid() ) + return {}; + return toISODate( value.date ) + "T" + toISOTime( value.time, includeMilliseconds ); +} + +}} // namespace EE::System diff --git a/src/eepp/ui/css/propertydefinition.cpp b/src/eepp/ui/css/propertydefinition.cpp index 2c434c69b..79d2d59b9 100644 --- a/src/eepp/ui/css/propertydefinition.cpp +++ b/src/eepp/ui/css/propertydefinition.cpp @@ -4,8 +4,8 @@ namespace EE { namespace UI { namespace CSS { -PropertyDefinition::PropertyDefinition( PropertyId propertyId, const std::string& name, - const std::string& defaultValue, const bool& inherited ) : +PropertyDefinition::PropertyDefinition( PropertyId propertyId, std::string_view name, + std::string_view defaultValue, const bool& inherited ) : mName( name ), mId( String::hash( name ) ), mPropertyId( propertyId ), diff --git a/src/eepp/ui/css/propertyspecification.cpp b/src/eepp/ui/css/propertyspecification.cpp index c43ee67e1..e76f5b3d4 100644 --- a/src/eepp/ui/css/propertyspecification.cpp +++ b/src/eepp/ui/css/propertyspecification.cpp @@ -9,13 +9,14 @@ SINGLETON_DECLARE_IMPLEMENTATION( PropertySpecification ) PropertySpecification::~PropertySpecification() {} PropertyDefinition& PropertySpecification::registerProperty( PropertyId id, - const std::string& propertyName, - const std::string& defaultValue, + std::string_view propertyName, + std::string_view defaultValue, bool inherited ) { if ( !mPropertyIds.addBuiltin( id, propertyName ) ) { // A built-in ID already bound to another name is a programming error. - Log::error( "PropertySpecification: failed to register built-in property \"%s\".", - propertyName.c_str() ); + Log::error( "PropertySpecification: failed to register built-in property \"%.*s\".", + static_cast( propertyName.size() ), + propertyName.empty() ? "" : propertyName.data() ); eeASSERT( false ); } @@ -41,8 +42,8 @@ PropertyDefinition& PropertySpecification::registerProperty( PropertyId id, return *propDef; } -PropertyDefinition* PropertySpecification::registerProperty( const std::string& propertyName, - const std::string& defaultValue, +PropertyDefinition* PropertySpecification::registerProperty( std::string_view propertyName, + std::string_view defaultValue, bool inherited ) { PropertyId id = mPropertyIds.getId( propertyName ); PropertyDefinition* existing = ( id != PropertyId::Invalid ) @@ -50,15 +51,18 @@ PropertyDefinition* PropertySpecification::registerProperty( const std::string& : nullptr; if ( nullptr != existing && !String::startsWith( propertyName, "-" ) ) { - Log::warning( "Property \"%s\" already registered.", propertyName.c_str() ); + Log::warning( "Property \"%.*s\" already registered.", + static_cast( propertyName.size() ), + propertyName.empty() ? "" : propertyName.data() ); return existing; } if ( id == PropertyId::Invalid ) { id = mPropertyIds.getOrCreateId( propertyName ); if ( id == PropertyId::Invalid ) { - Log::error( "PropertySpecification: could not allocate an ID for property \"%s\".", - propertyName.c_str() ); + Log::error( "PropertySpecification: could not allocate an ID for property \"%.*s\".", + static_cast( propertyName.size() ), + propertyName.empty() ? "" : propertyName.data() ); return nullptr; } } diff --git a/src/eepp/ui/css/stylesheet.cpp b/src/eepp/ui/css/stylesheet.cpp index 99d99eb1b..77f2168db 100644 --- a/src/eepp/ui/css/stylesheet.cpp +++ b/src/eepp/ui/css/stylesheet.cpp @@ -291,6 +291,10 @@ std::shared_ptr StyleSheet::getElementStyles( UIWidget* eleme const std::string& tag = element->getElementTag(); const std::string& id = element->getId(); + // Anonymous HTML control content ignores author CSS but retains the existing UA defaults, + // which loadHTMLDefaults marks with negative specificity in the document stylesheet. + const bool privateDefaults = element->getFlags() & UI_IGNORE_GLOBAL_CSS; + const bool applyPseudoClasses = applyPseudo; std::array nodeHash; int numHashes = 2; @@ -309,7 +313,9 @@ std::shared_ptr StyleSheet::getElementStyles( UIWidget* eleme if ( itNodes != mNodeIndex.end() ) { const StyleSheetStyleVector& nodes = itNodes->second; for ( StyleSheetStyle* node : nodes ) { - if ( node->isMediaValid() && node->getSelector().select( element, applyPseudo ) ) { + if ( ( !privateDefaults || node->getSelector().getSpecificity() < 0 ) && + node->isMediaValid() && + node->getSelector().select( element, applyPseudoClasses ) ) { applicableNodes.push_back( node ); } } @@ -321,7 +327,8 @@ std::shared_ptr StyleSheet::getElementStyles( UIWidget* eleme if ( itNodes == mClassNodeIndex.end() ) continue; for ( StyleSheetStyle* node : itNodes->second ) { - if ( node->isMediaValid() && node->getSelector().select( element, applyPseudo ) ) + if ( ( !privateDefaults || node->getSelector().getSpecificity() < 0 ) && + node->isMediaValid() && node->getSelector().select( element, applyPseudoClasses ) ) applicableNodes.push_back( node ); } } diff --git a/src/eepp/ui/css/stylesheetselector.cpp b/src/eepp/ui/css/stylesheetselector.cpp index 1ee064166..f2329f932 100644 --- a/src/eepp/ui/css/stylesheetselector.cpp +++ b/src/eepp/ui/css/stylesheetselector.cpp @@ -12,14 +12,6 @@ StyleSheetSelector::StyleSheetSelector( const std::string& selectorName ) : parseSelector( mName ); } -const std::string& StyleSheetSelector::getName() const { - return mName; -} - -const Int64& StyleSheetSelector::getSpecificity() const { - return mSpecificity; -} - void StyleSheetSelector::setSpecificity( const Int64& specificity ) { mSpecificity = specificity; } diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index 98946e168..a4bb3c10c 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -23,14 +23,14 @@ StyleSheetSpecification::~StyleSheetSpecification() { } PropertyDefinition& StyleSheetSpecification::registerProperty( PropertyId id, - const std::string& propertyName, - const std::string& defaultValue, + std::string_view propertyName, + std::string_view defaultValue, bool inherited ) { return mPropertySpecification->registerProperty( id, propertyName, defaultValue, inherited ); } -PropertyDefinition* StyleSheetSpecification::registerProperty( const std::string& propertyName, - const std::string& defaultValue, +PropertyDefinition* StyleSheetSpecification::registerProperty( std::string_view propertyName, + std::string_view defaultValue, bool inherited ) { return mPropertySpecification->registerProperty( propertyName, defaultValue, inherited ); } @@ -406,6 +406,13 @@ void StyleSheetSpecification::registerDefaultProperties() { .setType( PropertyType::Bool ); registerProperty( PropertyId::MovementSpeed, "movement-speed", "" ) .setType( PropertyType::Vector2 ); + // HTML form attributes are strings: temporal min/max and step use HTML value syntax. + registerProperty( PropertyId::Min, "min", "" ); + registerProperty( PropertyId::Max, "max", "" ); + registerProperty( PropertyId::Step, "step", "" ); + registerProperty( PropertyId::Required, "required", "" ); + registerProperty( PropertyId::ReadOnly, "readonly", "" ); + registerProperty( PropertyId::Disabled, "disabled", "" ); registerProperty( PropertyId::MinValue, "min-value", "" ).setType( PropertyType::NumberFloat ); registerProperty( PropertyId::MaxValue, "max-value", "" ).setType( PropertyType::NumberFloat ); registerProperty( PropertyId::Value, "value", "" ).setType( PropertyType::NumberFloat ); @@ -422,6 +429,41 @@ void StyleSheetSpecification::registerDefaultProperties() { registerProperty( PropertyId::MaxLength, "max-length", "" ).setType( PropertyType::NumberInt ); registerProperty( PropertyId::Numeric, "numeric", "" ).setType( PropertyType::Bool ); registerProperty( PropertyId::AllowFloat, "allow-float", "" ).setType( PropertyType::Bool ); + registerProperty( PropertyId::FirstDayOfWeek, "first-day-of-week", "" ) + .setType( PropertyType::NumberInt ); + registerProperty( PropertyId::CalendarView, "calendar-view", "days" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::DisplayedMonth, "displayed-month", "" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::FocusedDate, "focused-date", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::ActiveSection, "active-section", "none" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::PopUpOpen, "popup-open", "false" ).setType( PropertyType::Bool ); + registerProperty( PropertyId::DateTimeMode, "mode", "date" ).setType( PropertyType::String ); + registerProperty( PropertyId::AllowEmpty, "allow-empty", "true" ).setType( PropertyType::Bool ); + registerProperty( PropertyId::DateFormat, "date-format", "locale" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::TimeFormat, "time-format", "locale" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::HourCycle, "hour-cycle", "locale" ) + .setType( PropertyType::String ); + registerProperty( PropertyId::ShowSeconds, "show-seconds", "false" ) + .setType( PropertyType::Bool ); + registerProperty( PropertyId::ShowMilliseconds, "show-milliseconds", "false" ) + .setType( PropertyType::Bool ); + registerProperty( PropertyId::WheelEditing, "wheel-editing", "false" ) + .setType( PropertyType::Bool ); + registerProperty( PropertyId::HourStep, "hour-step", "1" ).setType( PropertyType::NumberInt ); + registerProperty( PropertyId::MinuteStep, "minute-step", "1" ) + .setType( PropertyType::NumberInt ); + registerProperty( PropertyId::SecondStep, "second-step", "1" ) + .setType( PropertyType::NumberInt ); + registerProperty( PropertyId::MinDate, "min-date", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::MaxDate, "max-date", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::MinTime, "min-time", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::MaxTime, "max-time", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::MinDateTime, "min-datetime", "" ).setType( PropertyType::String ); + registerProperty( PropertyId::MaxDateTime, "max-datetime", "" ).setType( PropertyType::String ); registerProperty( PropertyId::TouchDrag, "touch-drag", "" ).setType( PropertyType::Bool ); registerProperty( PropertyId::TouchDragDeceleration, "touch-drag-deceleration", "" ) .setType( PropertyType::NumberFloat ); diff --git a/src/eepp/ui/css/stylesheetstyle.cpp b/src/eepp/ui/css/stylesheetstyle.cpp index 006dc6fd9..675eb8bee 100644 --- a/src/eepp/ui/css/stylesheetstyle.cpp +++ b/src/eepp/ui/css/stylesheetstyle.cpp @@ -57,18 +57,6 @@ std::string StyleSheetStyle::build( bool emitMediaQueryStart, bool emitMediaQuer return css; } -const StyleSheetSelector& StyleSheetStyle::getSelector() const { - return mSelector; -} - -const StyleSheetProperties& StyleSheetStyle::getProperties() const { - return mProperties; -} - -StyleSheetProperties& StyleSheetStyle::getPropertiesRef() { - return mProperties; -} - bool StyleSheetStyle::updatePropertyValue( const std::string& name, const std::string& value ) { bool updated = false; for ( auto& prop : mProperties ) { diff --git a/src/eepp/ui/uicalendar.cpp b/src/eepp/ui/uicalendar.cpp new file mode 100644 index 000000000..94cff3c61 --- /dev/null +++ b/src/eepp/ui/uicalendar.cpp @@ -0,0 +1,791 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +using namespace System; +using namespace CSS; + +namespace { + +std::optional parseISODateProperty( const StyleSheetProperty& property ) { + DateTimeFormat iso( "yyyy-MM-dd" ); + return DateTimeFormatter::parseDate( property.getValue(), iso ); +} + +std::string yearText( Int64 year ) { + return String::toString( year ); +} + +} // namespace + +UICalendar* UICalendar::New() { + return eeNew( UICalendar, () ); +} + +UICalendar::UICalendar() : UIWidget( "calendar" ), mLocale( DateTimeLocale::system() ) { + mDisplayedMonth = CalendarDate::today(); + if ( !mDisplayedMonth.isValid() ) + mDisplayedMonth = { 1970, 1, 1 }; + mDisplayedMonth.day = 1; + mFocusedDate = CalendarDate::today(); + if ( !mFocusedDate.isValid() ) + mFocusedDate = mDisplayedMonth; + mYearPageStart = + static_cast( mDisplayedMonth.year ) - ( ( mDisplayedMonth.year % 12 + 12 ) % 12 ); + setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ); + setSize( Sizef( 280, 276 ) ); + buildUI(); + refresh(); +} + +Uint32 UICalendar::getType() const { + return UI_TYPE_CALENDAR; +} + +bool UICalendar::isType( const Uint32& type ) const { + return getType() == type ? true : UIWidget::isType( type ); +} + +UICalendar* UICalendar::setSelectedDate( const CalendarDate& date ) { + return date.isValid() ? setSelectedDate( std::optional{ date } ) : this; +} + +UICalendar* UICalendar::setSelectedDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + if ( date ) + date = clampDate( *date ); + const bool changed = mSelectedDate != date; + mSelectedDate = date; + if ( date ) { + mFocusedDate = *date; + mDisplayedMonth = { date->year, date->month, 1 }; + } + refresh(); + if ( changed ) + onValueChange(); + return this; +} + +const std::optional& UICalendar::getSelectedDate() const { + return mSelectedDate; +} + +UICalendar* UICalendar::clearSelection() { + return setSelectedDate( std::nullopt ); +} + +UICalendar* UICalendar::setDisplayedMonth( const CalendarDate& date ) { + if ( !date.isValid() ) + return this; + mDisplayedMonth = { date.year, date.month, 1 }; + mYearPageStart = + static_cast( mDisplayedMonth.year ) - ( ( mDisplayedMonth.year % 12 + 12 ) % 12 ); + refresh(); + return this; +} + +const CalendarDate& UICalendar::getDisplayedMonth() const { + return mDisplayedMonth; +} + +UICalendar* UICalendar::setFocusedDate( const CalendarDate& date ) { + if ( !date.isValid() ) + return this; + setFocusedAndDisplay( clampDate( date ) ); + return this; +} + +const CalendarDate& UICalendar::getFocusedDate() const { + return mFocusedDate; +} + +UICalendar* UICalendar::setMinDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + mMinDate = date; + if ( mMaxDate && mMinDate && *mMinDate > *mMaxDate ) + mMaxDate = mMinDate; + mFocusedDate = clampDate( mFocusedDate ); + if ( mSelectedDate ) + setSelectedDate( clampDate( *mSelectedDate ) ); + else + setFocusedAndDisplay( mFocusedDate ); + return this; +} + +UICalendar* UICalendar::setMaxDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + mMaxDate = date; + if ( mMinDate && mMaxDate && *mMaxDate < *mMinDate ) + mMinDate = mMaxDate; + mFocusedDate = clampDate( mFocusedDate ); + if ( mSelectedDate ) + setSelectedDate( clampDate( *mSelectedDate ) ); + else + setFocusedAndDisplay( mFocusedDate ); + return this; +} + +const std::optional& UICalendar::getMinDate() const { + return mMinDate; +} + +const std::optional& UICalendar::getMaxDate() const { + return mMaxDate; +} + +UICalendar* UICalendar::setView( CalendarView view ) { + if ( mView == view ) + return this; + mView = view; + if ( view == CalendarView::Years ) + mYearPageStart = static_cast( mDisplayedMonth.year ) - + ( ( mDisplayedMonth.year % 12 + 12 ) % 12 ); + refresh(); + return this; +} + +CalendarView UICalendar::getView() const { + return mView; +} + +UICalendar* UICalendar::setLocale( const DateTimeLocale& locale ) { + mLocale = locale; + mLocale.firstDayOfWeek %= 7; + refresh(); + return this; +} + +const DateTimeLocale& UICalendar::getLocale() const { + return mLocale; +} + +UICalendar* UICalendar::setFirstDayOfWeek( Uint8 weekday ) { + mLocale.firstDayOfWeek = weekday % 7; + refresh(); + return this; +} + +Uint8 UICalendar::getFirstDayOfWeek() const { + return mLocale.firstDayOfWeek; +} + +UICalendar* UICalendar::previous() { + switch ( mView ) { + case CalendarView::Days: + mDisplayedMonth = mDisplayedMonth.addMonths( -1 ); + break; + case CalendarView::Months: + mDisplayedMonth = mDisplayedMonth.addYears( -1 ); + break; + case CalendarView::Years: + if ( mYearPageStart > std::numeric_limits::min() ) + mYearPageStart -= 12; + break; + } + refresh(); + return this; +} + +UICalendar* UICalendar::next() { + switch ( mView ) { + case CalendarView::Days: + mDisplayedMonth = mDisplayedMonth.addMonths( 1 ); + break; + case CalendarView::Months: + mDisplayedMonth = mDisplayedMonth.addYears( 1 ); + break; + case CalendarView::Years: + if ( mYearPageStart + 11 < std::numeric_limits::max() ) + mYearPageStart += 12; + break; + } + refresh(); + return this; +} + +UICalendar* UICalendar::goToToday() { + CalendarDate today = CalendarDate::today(); + if ( today.isValid() ) + setFocusedAndDisplay( clampDate( today ) ); + return this; +} + +bool UICalendar::selectFocusedDate() { + if ( !isDateEnabled( mFocusedDate ) ) + return false; + setSelectedDate( mFocusedDate ); + // Explicit selection is distinct from a changed value (reselecting a day closes a picker). + sendCommonEvent( Event::OnItemSelected ); + return true; +} + +CalendarDate UICalendar::getFirstVisibleDate() const { + CalendarDate first{ mDisplayedMonth.year, mDisplayedMonth.month, 1 }; + const int firstDow = dayOfWeek( first ); + const int offset = ( firstDow - static_cast( mLocale.firstDayOfWeek ) + 7 ) % 7; + return first.addDays( -offset ); +} + +CalendarDate UICalendar::getDayCellDate( size_t index ) const { + return index < mDayCellDates.size() ? mDayCellDates[index] : CalendarDate{}; +} + +void UICalendar::buildUI() { + mMainLayout = UILinearLayout::NewVertical(); + mMainLayout->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ) + ->setParent( this ); + + mHeader = UILinearLayout::NewWithTag( "calendar::header", UIOrientation::Horizontal ); + mHeader->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ) + ->setSize( Sizef( 0, 34 ) ) + ->setParent( mMainLayout ); + + mPreviousButton = UIPushButton::NewWithTag( "calendar::previous" ); + mPreviousButton->unsetFlags( UI_AUTO_SIZE ); + mPreviousButton->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::MatchParent ) + ->setSize( Sizef( 34, 0 ) ) + ->setParent( mHeader ); + mConnections += mPreviousButton->connect( Event::MouseClick, [this]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + + previous(); + setFocus(); + } ); + + mTitleButton = UIPushButton::NewWithTag( "calendar::title" ); + mTitleButton->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::MatchParent ) + ->setLayoutWeight( 1 ) + ->setParent( mHeader ); + mConnections += mTitleButton->connect( Event::MouseClick, [this]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + + if ( mView == CalendarView::Days ) + setView( CalendarView::Months ); + else if ( mView == CalendarView::Months ) + setView( CalendarView::Years ); + setFocus(); + } ); + + mNextButton = UIPushButton::NewWithTag( "calendar::next" ); + mNextButton->unsetFlags( UI_AUTO_SIZE ); + mNextButton->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::MatchParent ) + ->setSize( Sizef( 34, 0 ) ) + ->setParent( mHeader ); + mConnections += mNextButton->connect( Event::MouseClick, [this]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + + next(); + setFocus(); + } ); + + mWeekdayRow = UIGridLayout::New(); + mWeekdayRow->setElementTag( "calendar::weekdays" ); + mWeekdayRow->setColumnWeight( 1.f / 7.f )->setRowMode( UIGridLayout::Size )->setRowHeight( 24 ); + mWeekdayRow->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ) + ->setSize( Sizef( 0, 24 ) ) + ->setParent( mMainLayout ); + for ( size_t i = 0; i < mWeekdayLabels.size(); ++i ) { + auto* label = UITextView::New(); + label->setElementTag( "calendar::weekday" ); + label->setTextAlign( UI_HALIGN_CENTER | UI_VALIGN_CENTER ); + label->setEnabled( false ); + label->setParent( mWeekdayRow ); + mWeekdayLabels[i] = label; + } + + mDayGrid = UIGridLayout::New(); + mDayGrid->setElementTag( "calendar::grid" ); + mDayGrid->setColumnWeight( 1.f / 7.f )->setRowWeight( 1.f / 6.f ); + mDayGrid->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ) + ->setSize( Sizef( 0, 180 ) ) + ->setParent( mMainLayout ); + for ( size_t i = 0; i < mDayCells.size(); ++i ) { + auto* button = UIPushButton::NewWithTag( "calendar::day" ); + button->setTextAlign( UI_HALIGN_CENTER | UI_VALIGN_CENTER ); + button->setParent( mDayGrid ); + mConnections += button->connect( Event::KeyDown, [this]( const Event* event ) { + const auto* key = event->asKeyEvent(); + if ( key->getKeyCode() != KEY_RETURN && key->getKeyCode() != KEY_KP_ENTER ) + onKeyDown( *key ); + } ); + mConnections += button->connect( Event::MouseClick, [this, i]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + handleDayClicked( i ); + } ); + mDayCells[i] = button; + } + + mOptionGrid = UIGridLayout::New(); + mOptionGrid->setElementTag( "calendar::grid" ); + mOptionGrid->setColumnWeight( 0.25f )->setRowWeight( 1.f / 3.f ); + mOptionGrid->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ) + ->setSize( Sizef( 0, 180 ) ) + ->setParent( mMainLayout ); + for ( size_t i = 0; i < mOptionCells.size(); ++i ) { + auto* button = UIPushButton::NewWithTag( "calendar::day" ); + button->setTextAlign( UI_HALIGN_CENTER | UI_VALIGN_CENTER ); + button->setParent( mOptionGrid ); + mConnections += button->connect( Event::MouseClick, [this, i]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + handleOptionClicked( i ); + } ); + mOptionCells[i] = button; + } + + mTodayButton = UIPushButton::NewWithTag( "calendar::today" ); + mTodayButton->setText( "Today" ) + ->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ) + ->setSize( Sizef( 0, 32 ) ) + ->setParent( mMainLayout ); + for ( auto* button : { mPreviousButton, mTitleButton, mNextButton, mTodayButton } ) { + mConnections += button->connect( Event::KeyDown, [this]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_ESCAPE ) + sendEvent( event ); + } ); + } + for ( auto* button : mOptionCells ) { + mConnections += button->connect( Event::KeyDown, [this]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_ESCAPE ) + sendEvent( event ); + } ); + } + mConnections += mTodayButton->connect( Event::MouseClick, [this]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + + goToToday(); + setView( CalendarView::Days ); + setFocus(); + } ); + // Text metrics change with fonts, density, and styles; resize only on those notifications. + auto watchFont = [this]( UITextView* text ) { + mConnections += + text->connect( Event::OnFontChanged, [this]( const Event* ) { updateLayoutSize(); } ); + mConnections += text->connect( Event::OnFontStyleChanged, + [this]( const Event* ) { updateLayoutSize(); } ); + }; + for ( auto* label : mWeekdayLabels ) + watchFont( label ); + for ( auto* button : { mPreviousButton, mTitleButton, mNextButton, mTodayButton } ) + watchFont( button->getTextView() ); + for ( auto* button : mDayCells ) + watchFont( button->getTextView() ); + for ( auto* button : mOptionCells ) + watchFont( button->getTextView() ); +} + +void UICalendar::refresh() { + refreshHeader(); + if ( mView == CalendarView::Days ) + refreshDays(); + else if ( mView == CalendarView::Months ) + refreshMonths(); + else + refreshYears(); + updateLayoutSize(); +} + +void UICalendar::refreshHeader() { + if ( !mTitleButton ) + return; + if ( mView == CalendarView::Days ) { + std::string title = + mLocale.monthNames[mDisplayedMonth.month - 1] + " " + yearText( mDisplayedMonth.year ); + mTitleButton->setText( title ); + } else if ( mView == CalendarView::Months ) { + mTitleButton->setText( yearText( mDisplayedMonth.year ) ); + } else { + mTitleButton->setText( yearText( mYearPageStart ) + " - " + + yearText( mYearPageStart + 11 ) ); + } +} + +void UICalendar::refreshDays() { + mWeekdayRow->setVisible( true ); + mDayGrid->setVisible( true ); + mOptionGrid->setVisible( false ); + const CalendarDate today = CalendarDate::today(); + for ( size_t i = 0; i < 7; ++i ) { + const size_t weekday = ( mLocale.firstDayOfWeek + i ) % 7; + mWeekdayLabels[i]->setText( mLocale.shortWeekdayNames[weekday] ); + } + CalendarDate date = getFirstVisibleDate(); + for ( size_t i = 0; i < mDayCells.size(); ++i, date = date.addDays( 1 ) ) { + mDayCellDates[i] = date; + auto* cell = mDayCells[i]; + cell->setText( String::toString( static_cast( date.day ) ) ); + cell->setEnabled( isDateEnabled( date ) ); + updateCellClasses( cell, mSelectedDate && *mSelectedDate == date, + today.isValid() && today == date, + date.month != mDisplayedMonth.month || date.year != mDisplayedMonth.year, + mFocusedDate == date ); + } +} + +void UICalendar::refreshMonths() { + mWeekdayRow->setVisible( false ); + mDayGrid->setVisible( false ); + mOptionGrid->setVisible( true ); + for ( size_t i = 0; i < mOptionCells.size(); ++i ) { + auto* cell = mOptionCells[i]; + cell->setText( mLocale.shortMonthNames[i] ); + const CalendarDate first{ mDisplayedMonth.year, static_cast( i + 1 ), 1 }; + const CalendarDate last{ mDisplayedMonth.year, static_cast( i + 1 ), + static_cast( CalendarDate::daysInMonth( + mDisplayedMonth.year, static_cast( i + 1 ) ) ) }; + const bool enabled = + !( mMaxDate && first > *mMaxDate ) && !( mMinDate && last < *mMinDate ); + cell->setEnabled( enabled ); + updateCellClasses( cell, mDisplayedMonth.month == i + 1, false, false, false ); + } +} + +void UICalendar::refreshYears() { + mWeekdayRow->setVisible( false ); + mDayGrid->setVisible( false ); + mOptionGrid->setVisible( true ); + for ( size_t i = 0; i < mOptionCells.size(); ++i ) { + const Int64 year = mYearPageStart + static_cast( i ); + auto* cell = mOptionCells[i]; + cell->setText( yearText( year ) ); + if ( year < std::numeric_limits::min() || + year > std::numeric_limits::max() ) { + cell->setEnabled( false ); + updateCellClasses( cell, false, false, false, false ); + continue; + } + const CalendarDate first{ static_cast( year ), 1, 1 }; + const CalendarDate last{ static_cast( year ), 12, 31 }; + const bool enabled = + !( mMaxDate && first > *mMaxDate ) && !( mMinDate && last < *mMinDate ); + cell->setEnabled( enabled ); + updateCellClasses( cell, mDisplayedMonth.year == year, false, false, false ); + } +} + +void UICalendar::updateCellClasses( UIPushButton* button, bool selected, bool today, bool outside, + bool focused ) { + auto toggle = [button]( const char* name, bool active ) { + if ( button->hasClass( name ) == active ) + return; + if ( active ) + button->addClass( name ); + else + button->removeClass( name ); + }; + toggle( "selected", selected ); + toggle( "today", today ); + toggle( "outside-month", outside ); + toggle( "focused", focused ); +} + +bool UICalendar::isDateEnabled( const CalendarDate& date ) const { + return date.isValid() && !( mMinDate && date < *mMinDate ) && !( mMaxDate && date > *mMaxDate ); +} + +CalendarDate UICalendar::clampDate( CalendarDate date ) const { + if ( mMinDate && date < *mMinDate ) + date = *mMinDate; + if ( mMaxDate && date > *mMaxDate ) + date = *mMaxDate; + return date; +} + +void UICalendar::moveFocusedDate( Int64 days ) { + setFocusedAndDisplay( clampDate( mFocusedDate.addDays( days ) ) ); +} + +void UICalendar::setFocusedAndDisplay( const CalendarDate& date ) { + if ( !date.isValid() ) + return; + const bool changed = mFocusedDate != date; + mFocusedDate = date; + mDisplayedMonth = { date.year, date.month, 1 }; + refresh(); + if ( changed ) + sendCommonEvent( Event::OnFocusedDateChange ); +} + +void UICalendar::handleDayClicked( size_t index ) { + if ( index >= mDayCellDates.size() || !isDateEnabled( mDayCellDates[index] ) ) + return; + mFocusedDate = mDayCellDates[index]; + setFocus(); + selectFocusedDate(); +} + +void UICalendar::handleOptionClicked( size_t index ) { + if ( index >= mOptionCells.size() || !mOptionCells[index]->isEnabled() ) + return; + setFocus(); + if ( mView == CalendarView::Months ) { + mDisplayedMonth.month = static_cast( index + 1 ); + mDisplayedMonth.day = 1; + mFocusedDate = clampDate( mDisplayedMonth ); + setView( CalendarView::Days ); + } else if ( mView == CalendarView::Years ) { + mDisplayedMonth.year = static_cast( mYearPageStart + static_cast( index ) ); + mDisplayedMonth.day = 1; + setView( CalendarView::Months ); + } +} + +Uint32 UICalendar::onKeyDown( const KeyEvent& event ) { + if ( mView == CalendarView::Days ) { + switch ( event.getKeyCode() ) { + case KEY_LEFT: + moveFocusedDate( -1 ); + return 1; + case KEY_RIGHT: + moveFocusedDate( 1 ); + return 1; + case KEY_UP: + moveFocusedDate( -7 ); + return 1; + case KEY_DOWN: + moveFocusedDate( 7 ); + return 1; + case KEY_HOME: { + const int delta = + ( dayOfWeek( mFocusedDate ) - static_cast( mLocale.firstDayOfWeek ) + 7 ) % + 7; + moveFocusedDate( -delta ); + return 1; + } + case KEY_END: { + const int delta = + ( dayOfWeek( mFocusedDate ) - static_cast( mLocale.firstDayOfWeek ) + 7 ) % + 7; + moveFocusedDate( 6 - delta ); + return 1; + } + case KEY_PAGEUP: + if ( event.getMod() & KEYMOD_CTRL ) + setFocusedAndDisplay( clampDate( mFocusedDate.addYears( -1 ) ) ); + else + setFocusedAndDisplay( clampDate( mFocusedDate.addMonths( -1 ) ) ); + return 1; + case KEY_PAGEDOWN: + if ( event.getMod() & KEYMOD_CTRL ) + setFocusedAndDisplay( clampDate( mFocusedDate.addYears( 1 ) ) ); + else + setFocusedAndDisplay( clampDate( mFocusedDate.addMonths( 1 ) ) ); + return 1; + case KEY_RETURN: + case KEY_KP_ENTER: + selectFocusedDate(); + return 1; + default: + break; + } + } + return UIWidget::onKeyDown( event ); +} + +void UICalendar::onSizeChange() { + UIWidget::onSizeChange(); + updateLayoutSize(); +} + +void UICalendar::onThemeLoaded() { + UIWidget::onThemeLoaded(); + refresh(); +} + +void UICalendar::onAutoSize() { + updateLayoutSize(); +} + +void UICalendar::updateLayoutSize() { + if ( !mTodayButton || mUpdatingLayout ) + return; + mUpdatingLayout = true; + const Float headerHeight = std::max( + 34, PixelDensity::pxToDp( mTitleButton->getTextView()->getTextHeight() ) + 8 ); + const Float todayHeight = std::max( + 32, PixelDensity::pxToDp( mTodayButton->getTextView()->getTextHeight() ) + 8 ); + Float weekdayHeight = 24; + Float weekdayWidth = 0; + Float cellHeight = 0; + Float cellWidth = 0; + for ( auto* label : mWeekdayLabels ) { + weekdayHeight = + std::max( weekdayHeight, PixelDensity::pxToDp( label->getTextHeight() ) + 4 ); + weekdayWidth = std::max( weekdayWidth, PixelDensity::pxToDp( label->getTextWidth() ) + 8 ); + } + for ( auto* cell : mDayCells ) { + cellHeight = std::max( cellHeight, + PixelDensity::pxToDp( cell->getTextView()->getTextHeight() ) + 8 ); + cellWidth = + std::max( cellWidth, PixelDensity::pxToDp( cell->getTextView()->getTextWidth() ) + 8 ); + } + const Float fixed = + headerHeight + todayHeight + ( mView == CalendarView::Days ? weekdayHeight : 0.f ); + // CSS sets preferred minimum dimensions; content measurement only prevents text clipping. + Float width = std::max( std::max( weekdayWidth, cellWidth ) * 7, + PixelDensity::pxToDp( mTitleButton->getTextView()->getTextWidth() ) + + headerHeight * 2 + 16 ); + Float height = fixed + cellHeight * 6; + if ( mView != CalendarView::Days ) { + Float optionWidth = 0; + Float optionHeight = 0; + for ( auto* cell : mOptionCells ) { + optionWidth = std::max( + optionWidth, PixelDensity::pxToDp( cell->getTextView()->getTextWidth() ) + 8 ); + optionHeight = std::max( + optionHeight, PixelDensity::pxToDp( cell->getTextView()->getTextHeight() ) + 8 ); + } + width = std::max( width, optionWidth * 4 ); + height = std::max( height, fixed + optionHeight * 3 ); + } + const Sizef size = fitMinMaxSizeDp( Sizef( + getLayoutWidthPolicy() == SizePolicy::WrapContent ? width : getSize().getWidth(), + getLayoutHeightPolicy() == SizePolicy::WrapContent ? height : getSize().getHeight() ) ); + if ( size != getSize() ) + setSize( size ); + mMainLayout->setSize( getSize() ); + mHeader->setSize( Sizef( getSize().getWidth(), headerHeight ) ); + mPreviousButton->setSize( headerHeight, headerHeight ); + mNextButton->setSize( headerHeight, headerHeight ); + mTodayButton->setSize( Sizef( getSize().getWidth(), todayHeight ) ); + mWeekdayRow->setRowHeight( weekdayHeight ); + mWeekdayRow->setSize( Sizef( getSize().getWidth(), weekdayHeight ) ); + const Float content = std::max( 0, getSize().getHeight() - fixed ); + mDayGrid->setSize( Sizef( getSize().getWidth(), content ) ); + mOptionGrid->setSize( Sizef( getSize().getWidth(), content ) ); + mUpdatingLayout = false; +} + +int UICalendar::dayOfWeek( const CalendarDate& date ) { + // Tomohiko Sakamoto's Gregorian algorithm. 0 = Sunday. + static constexpr int offsets[] = { 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 }; + Int64 year = date.year; + if ( date.month < 3 ) + --year; + Int64 value = year + ( year >= 0 ? year / 4 : ( year - 3 ) / 4 ) - + ( year >= 0 ? year / 100 : ( year - 99 ) / 100 ) + + ( year >= 0 ? year / 400 : ( year - 399 ) / 400 ) + offsets[date.month - 1] + + date.day; + value %= 7; + if ( value < 0 ) + value += 7; + return static_cast( value ); +} + +const char* UICalendar::viewToString( CalendarView view ) { + switch ( view ) { + case CalendarView::Days: + return "days"; + case CalendarView::Months: + return "months"; + case CalendarView::Years: + return "years"; + } + return "days"; +} + +bool UICalendar::applyProperty( const StyleSheetProperty& attribute ) { + if ( !attribute.getPropertyDefinition() ) + return false; + switch ( attribute.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::FirstDayOfWeek: + setFirstDayOfWeek( attribute.asUint() ); + return true; + case PropertyId::CalendarView: { + const auto& value = attribute.getValue(); + if ( value == "days" ) + setView( CalendarView::Days ); + else if ( value == "months" ) + setView( CalendarView::Months ); + else if ( value == "years" ) + setView( CalendarView::Years ); + else + return false; + return true; + } + case PropertyId::DisplayedMonth: + case PropertyId::FocusedDate: { + const auto date = parseISODateProperty( attribute ); + if ( !date ) + return false; + if ( attribute.getPropertyDefinition()->getPropertyId() == PropertyId::DisplayedMonth ) + setDisplayedMonth( *date ); + else + setFocusedDate( *date ); + return true; + } + case PropertyId::Value: { + if ( attribute.getValue().empty() ) + clearSelection(); + else if ( auto value = parseISODateProperty( attribute ) ) + setSelectedDate( *value ); + return true; + } + case PropertyId::MinDate: + case PropertyId::MaxDate: { + const auto date = + attribute.getValue().empty() ? std::nullopt : parseISODateProperty( attribute ); + if ( !attribute.getValue().empty() && !date ) + return false; + if ( attribute.getPropertyDefinition()->getPropertyId() == PropertyId::MinDate ) + setMinDate( date ); + else + setMaxDate( date ); + return true; + } + default: + return UIWidget::applyProperty( attribute ); + } +} + +std::string UICalendar::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( !propertyDef ) + return {}; + switch ( propertyDef->getPropertyId() ) { + case PropertyId::FirstDayOfWeek: + return String::toString( mLocale.firstDayOfWeek ); + case PropertyId::CalendarView: + return viewToString( mView ); + case PropertyId::DisplayedMonth: + return DateTimeFormatter::toISODate( mDisplayedMonth ); + case PropertyId::FocusedDate: + return DateTimeFormatter::toISODate( mFocusedDate ); + case PropertyId::Value: + return mSelectedDate ? DateTimeFormatter::toISODate( *mSelectedDate ) : std::string{}; + case PropertyId::MinDate: + return mMinDate ? DateTimeFormatter::toISODate( *mMinDate ) : std::string{}; + case PropertyId::MaxDate: + return mMaxDate ? DateTimeFormatter::toISODate( *mMaxDate ) : std::string{}; + default: + return UIWidget::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UICalendar::getPropertiesImplemented() const { + auto props = UIWidget::getPropertiesImplemented(); + const std::initializer_list local = { + PropertyId::Value, PropertyId::MinDate, PropertyId::MaxDate, + PropertyId::FirstDayOfWeek, PropertyId::CalendarView, PropertyId::DisplayedMonth, + PropertyId::FocusedDate }; + props.insert( props.end(), local.begin(), local.end() ); + return props; +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index 4191bc5e2..f5f3f8c1a 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -210,7 +210,11 @@ UICodeEditor::UICodeEditor( const std::string& elementTag, const bool& autoRegis mFontStyleConfig.Font = mFont; - setFontSize( getUISceneNode()->getUIThemeManager()->getDefaultFontSize() ); + auto* themeManager = getUISceneNode()->getUIThemeManager(); + auto* theme = themeManager->getDefaultTheme(); + // Match UITextView's theme default and whole-pixel character size. + setFontSize( static_cast( theme ? theme->getDefaultFontSize() + : themeManager->getDefaultFontSize() ) ); setClipType( ClipType::ContentBox ); mDoc->registerClient( this ); diff --git a/src/eepp/ui/uidatepicker.cpp b/src/eepp/ui/uidatepicker.cpp new file mode 100644 index 000000000..a32d73d56 --- /dev/null +++ b/src/eepp/ui/uidatepicker.cpp @@ -0,0 +1,299 @@ +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +using namespace System; +using namespace CSS; + +UIDatePicker* UIDatePicker::New() { + return eeNew( UIDatePicker, () ); +} + +UIDatePicker::UIDatePicker() : UIDatePicker( DateTimeEditMode::Date, "datepicker" ) {} + +UIDatePicker::UIDatePicker( DateTimeEditMode mode, const std::string& tag ) : + UIDateTimeEdit( mode, tag ), mEditorRightPadding( getPadding().Right ) { + setClipType( ClipType::BorderBox ); + mCalendarButton = UIPushButton::NewWithTag( tag + "::button" ); + // The editor owns the button's hit area; its CSS image must not collapse to an empty label. + mCalendarButton->unsetFlags( UI_AUTO_SIZE ); + mCalendarButton->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + mCalendarButton->setParent( this ); + mConnections[0] = mCalendarButton->connect( Event::MouseClick, [this]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) ) + return; + if ( isCalendarVisible() ) + hideCalendar(); + else + showCalendar(); + } ); + mConnections[1] = + mCalendarButton->connect( Event::OnSizeChange, [this]( const Event* ) { updateButton(); } ); + mConnections[2] = connect( Event::OnUpdateScreenPosition, [this]( const Event* ) { + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); + } ); + mConnections[3] = + connect( Event::OnFocusWithinLoss, [this]( const Event* ) { closeOnFocusLoss(); } ); + updateButton(); +} + +UIDatePicker::~UIDatePicker() { + mCalendarVisible = false; + mConnections = {}; + mCalendarConnections = {}; + // The calendar can outlive its original parent after popup alignment reparents it. + if ( mCalendar ) { + auto* calendar = mCalendar; + mCalendar = nullptr; + // A closing calendar already belongs to the scene's deferred deletion queue. + if ( !calendar->isClosing() ) + eeDelete( calendar ); + } +} + +Uint32 UIDatePicker::getType() const { + return UI_TYPE_DATEPICKER; +} + +bool UIDatePicker::isType( const Uint32& type ) const { + return type == UI_TYPE_DATEPICKER || UIDateTimeEdit::isType( type ); +} + +Float UIDatePicker::getMinIntrinsicWidth() const { + // Reserve the square button's full width even before the editor has its first used width. + return UIDateTimeEdit::getMinIntrinsicWidth() - PixelDensity::dpToPx( mReservedButtonWidth ) + + getPixelsSize().getHeight(); +} + +UIDatePicker* UIDatePicker::showCalendar() { + if ( !isEnabled() || !isVisible() || !isEditingAllowed() || !getUISceneNode() || + getEditMode() == DateTimeEditMode::Time ) + return this; + auto* popup = getCalendarPopup(); + if ( ( mCalendar && mCalendar->isClosing() ) || ( popup && popup->isClosing() ) ) + return this; + commitPendingDigits( false ); + if ( !mCalendar ) { + mCalendar = UICalendar::New(); + mCalendar->setVisible( false ); + mCalendar->setEnabled( false ); + mCalendar->setParent( this ); + mCalendarConnections[0] = mCalendar->connect( Event::OnClose, [this]( const Event* ) { + auto* popup = getCalendarPopup(); + if ( popup != mCalendar ) + UIPopUp::hide( popup ); + mCalendar = nullptr; + mCalendarVisible = false; + mSceneSizeConnection.disconnect(); + } ); + mCalendarConnections[1] = mCalendar->connect( Event::OnSizeChange, [this]( const Event* ) { + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); + } ); + mCalendarConnections[2] = mCalendar->connect( + Event::OnFocusedDateChange, [this]( const Event* ) { onCalendarFocusedDateChange(); } ); + mCalendarConnections[3] = mCalendar->connect( + Event::OnItemSelected, [this]( const Event* ) { selectCalendarDate(); } ); + mCalendarConnections[4] = mCalendar->connect( + Event::OnFocusWithinLoss, [this]( const Event* ) { closeOnFocusLoss(); } ); + mCalendarConnections[5] = mCalendar->connect( Event::KeyDown, [this]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_ESCAPE ) + hideCalendar(); + } ); + } + mCalendar->setLocale( mLocale ); + UIDatePicker::onConstraintsChange(); + const std::optional selected = + getEditMode() == DateTimeEditMode::DateTime + ? ( getDateTime() ? std::optional( getDateTime()->date ) : std::nullopt ) + : getDate(); + mCalendar->setSelectedDate( selected ); + mCalendar->setFocusedDate( selected.value_or( CalendarDate::today() ) ); + mCalendar->setView( CalendarView::Days ); + prepareCalendarPopup(); + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); + mCalendarVisible = true; + mSceneSizeConnection = getUISceneNode()->connect( Event::OnSizeChange, [this]( const Event* ) { + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); + } ); + UIPopUp::show( getCalendarPopup() ); + mCalendar->setFocus(); + return this; +} + +UIDatePicker* UIDatePicker::hideCalendar( bool restoreFocus ) { + if ( !mCalendarVisible ) + return this; + mCalendarVisible = false; + mSceneSizeConnection.disconnect(); + UIPopUp::hide( getCalendarPopup() ); + if ( restoreFocus && isEnabled() && isVisible() && !isClosing() ) + setFocus(); + return this; +} + +bool UIDatePicker::isCalendarVisible() const { + return mCalendarVisible; +} + +UICalendar* UIDatePicker::getCalendar() const { + return mCalendar; +} + +UIPushButton* UIDatePicker::getCalendarButton() const { + return mCalendarButton; +} + +UIWidget* UIDatePicker::getCalendarPopup() const { + return mCalendar; +} + +void UIDatePicker::prepareCalendarPopup() {} + +void UIDatePicker::onConstraintsChange() { + if ( !mCalendar ) + return; + // Reset old constraints first so changing a range does not inherit a previous popup bound. + mCalendar->setMinDate( std::nullopt ); + mCalendar->setMaxDate( std::nullopt ); + auto minimum = getMinDate(); + auto maximum = getMaxDate(); + if ( getEditMode() == DateTimeEditMode::DateTime ) { + if ( getMinDateTime() ) + minimum = getMinDateTime()->date; + if ( getMaxDateTime() ) + maximum = getMaxDateTime()->date; + } + mCalendar->setMinDate( minimum ); + mCalendar->setMaxDate( maximum ); +} + +void UIDatePicker::onCalendarFocusedDateChange() {} + +void UIDatePicker::onConfigurationChange() { + if ( mCalendar && mCalendar->getLocale() != getLocale() ) + mCalendar->setLocale( getLocale() ); + if ( !isEditingAllowed() ) + hideCalendar( false ); +} + +void UIDatePicker::selectCalendarDate() { + if ( !mCalendar || !mCalendar->getSelectedDate() || !isEditingAllowed() ) + return; + const CalendarDate date = *mCalendar->getSelectedDate(); + if ( getEditMode() == DateTimeEditMode::DateTime ) + setDateTime( LocalDateTime{ date, getDateTime() ? getDateTime()->time : TimeOfDay{} } ); + else + setDate( date ); + hideCalendar(); +} + +void UIDatePicker::closeOnFocusLoss() { + if ( mCalendarVisible && !UIPopUp::hasFocus( this, getCalendarPopup() ) ) + hideCalendar( false ); +} + +Uint32 UIDatePicker::onKeyDown( const KeyEvent& event ) { + if ( event.getKeyCode() == KEY_ESCAPE && mCalendarVisible ) { + hideCalendar(); + return 1; + } + if ( event.getKeyCode() == KEY_F4 || + ( event.getKeyCode() == KEY_DOWN && ( event.getSanitizedMod() & KEYMOD_ALT ) ) ) { + if ( mCalendarVisible ) + hideCalendar(); + else + showCalendar(); + return 1; + } + return UIDateTimeEdit::onKeyDown( event ); +} + +void UIDatePicker::draw() { + // Clip only the inherited text drawing; the trailing child button needs the full field box. + clipSmartEnable( mScreenPos.x + mPaddingPx.Left, mScreenPos.y + mPaddingPx.Top, + eemax( 0.f, mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right ), + eemax( 0.f, mSize.getHeight() - mPaddingPx.Top - mPaddingPx.Bottom ) ); + UIDateTimeEdit::draw(); + clipSmartDisable(); +} + +void UIDatePicker::updateButton() { + if ( !mCalendarButton || mUpdatingButton ) + return; + mUpdatingButton = true; + const Float width = eeclamp( getSize().getHeight(), 0.f, getSize().getWidth() ); + mCalendarButton->setSize( width, getSize().getHeight() ); + mCalendarButton->setPosition( getSize().getWidth() - width, 0 ); + mReservedButtonWidth = width; + setPaddingRight( mEditorRightPadding + width ); + mUpdatingButton = false; +} + +void UIDatePicker::onSizeChange() { + UIDateTimeEdit::onSizeChange(); + updateButton(); + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); +} + +void UIDatePicker::onPaddingChange() { + if ( !mUpdatingButton && getPadding().Right != mEditorRightPadding + mReservedButtonWidth ) + mEditorRightPadding = getPadding().Right; + UIDateTimeEdit::onPaddingChange(); + updateButton(); +} + +void UIDatePicker::onPositionChange() { + UIDateTimeEdit::onPositionChange(); + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); +} + +void UIDatePicker::onVisibilityChange() { + UIDateTimeEdit::onVisibilityChange(); + if ( !isVisible() ) + hideCalendar( false ); +} + +void UIDatePicker::onEnabledChange() { + UIDateTimeEdit::onEnabledChange(); + if ( !isEnabled() ) + hideCalendar( false ); +} + +bool UIDatePicker::applyProperty( const StyleSheetProperty& attribute ) { + if ( attribute.getPropertyDefinition() && + attribute.getPropertyDefinition()->getPropertyId() == PropertyId::PopUpToRoot ) { + mPopUpToRoot = attribute.asBool(); + if ( mCalendarVisible ) + UIPopUp::align( this, getCalendarPopup(), mPopUpToRoot ); + return true; + } + return UIDateTimeEdit::applyProperty( attribute ); +} + +std::string UIDatePicker::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& index ) const { + if ( propertyDef && propertyDef->getPropertyId() == PropertyId::PopUpToRoot ) + return mPopUpToRoot ? "true" : "false"; + if ( propertyDef && propertyDef->getPropertyId() == PropertyId::PopUpOpen ) + return mCalendarVisible ? "true" : "false"; + return UIDateTimeEdit::getPropertyString( propertyDef, index ); +} + +std::vector UIDatePicker::getPropertiesImplemented() const { + auto properties = UIDateTimeEdit::getPropertiesImplemented(); + properties.push_back( PropertyId::PopUpToRoot ); + properties.push_back( PropertyId::PopUpOpen ); + return properties; +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uidatetimeedit.cpp b/src/eepp/ui/uidatetimeedit.cpp new file mode 100644 index 000000000..7d74f029c --- /dev/null +++ b/src/eepp/ui/uidatetimeedit.cpp @@ -0,0 +1,1592 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +using namespace System; +using namespace CSS; + +namespace { + +template +std::optional clampOptional( std::optional value, const std::optional& minValue, + const std::optional& maxValue ) { + if ( !value ) + return value; + if ( minValue && *value < *minValue ) + value = *minValue; + if ( maxValue && *value > *maxValue ) + value = *maxValue; + return value; +} + +Int32 positiveMod( Int64 value, Int32 modulo ) { + Int64 result = value % modulo; + return static_cast( result < 0 ? result + modulo : result ); +} + +bool validEditorFormat( const DateTimeFormat& format, bool date ) { + if ( !format.isValid() ) + return false; + std::array counts{}; + for ( const auto& token : format.getTokens() ) { + if ( token.type == DateTimeFormatTokenType::Literal ) + continue; + const bool dateToken = token.type == DateTimeFormatTokenType::Day || + token.type == DateTimeFormatTokenType::Month || + token.type == DateTimeFormatTokenType::Year; + if ( dateToken != date || ++counts[static_cast( token.type )] > 1 ) + return false; + } + if ( date ) + return counts[static_cast( DateTimeFormatTokenType::Day )] && + counts[static_cast( DateTimeFormatTokenType::Month )] && + counts[static_cast( DateTimeFormatTokenType::Year )]; + const auto hour12 = counts[static_cast( DateTimeFormatTokenType::Hour12 )]; + const auto hour24 = counts[static_cast( DateTimeFormatTokenType::Hour24 )]; + return hour12 + hour24 == 1 && counts[static_cast( DateTimeFormatTokenType::Minute )] && + counts[static_cast( DateTimeFormatTokenType::AmPm )] == hour12; +} + +constexpr const char* SectionNames[] = { "none", "day", "month", "year", "hour", + "minute", "second", "millisecond", "am-pm" }; + +std::string lowerAscii( std::string_view value ) { + std::string out( value ); + std::transform( out.begin(), out.end(), out.begin(), + []( unsigned char c ) { return static_cast( std::tolower( c ) ); } ); + return out; +} + +} // namespace + +UIDateTimeEdit* UIDateTimeEdit::New() { + return eeNew( UIDateTimeEdit, () ); +} + +UIDateTimeEdit* UIDateTimeEdit::New( DateTimeEditMode mode ) { + return eeNew( UIDateTimeEdit, ( mode ) ); +} + +UIDateTimeEdit::UIDateTimeEdit() : UIDateTimeEdit( DateTimeEditMode::Date ) {} + +UIDateTimeEdit::UIDateTimeEdit( DateTimeEditMode mode, const std::string& tag ) : + UITextInput( tag ), mEditMode( mode ), mLocale( DateTimeLocale::system() ) { + mAllowEditingConnection = + connect( Event::OnAllowEditingChange, [this]( const Event* ) { onConfigurationChange(); } ); + setSelectAllDocOnTabNavigate( false ); + mDoc.setCommand( "paste", [this] { pasteValue(); } ); + mDoc.setCommand( "cut", [this] { + if ( !isEditingAllowed() ) + return; + copy(); + clear(); + } ); + rebuildFormat(); +} + +Uint32 UIDateTimeEdit::getType() const { + return UI_TYPE_DATETIMEEDIT; +} + +bool UIDateTimeEdit::isType( const Uint32& type ) const { + return type == UI_TYPE_DATETIMEEDIT || UITextInput::isType( type ); +} + +Float UIDateTimeEdit::getMinIntrinsicWidth() const { + // Text metrics lazily refresh their existing cache. + return eemax( const_cast( this )->getTextWidth(), + mHintCache ? mHintCache->getTextWidth() : 0.f ) + + mPaddingPx.getWidth(); +} + +Float UIDateTimeEdit::getMaxIntrinsicWidth() const { + return getMinIntrinsicWidth(); +} + +void UIDateTimeEdit::onAutoSize() { + UITextInput::onAutoSize(); + if ( mWidthPolicy == SizePolicy::WrapContent && getFont() ) + setInternalPixelsWidth( getMinIntrinsicWidth() ); +} + +UIDateTimeEdit* UIDateTimeEdit::setEditMode( DateTimeEditMode mode ) { + if ( mEditMode == mode ) + return this; + mEditMode = mode; + mActiveSection = DateTimeSection::None; + resetPendingDigits(); + rebuildFormat(); + if ( !mAllowEmpty ) + initializeValueForEditing(); + return this; +} + +DateTimeEditMode UIDateTimeEdit::getEditMode() const { + return mEditMode; +} + +UIDateTimeEdit* UIDateTimeEdit::setDate( const CalendarDate& date ) { + return date.isValid() ? setDate( std::optional{ date } ) : this; +} + +UIDateTimeEdit* UIDateTimeEdit::setDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + if ( !date && !mAllowEmpty ) + return this; + resetPendingDigits(); + if ( mClampToBounds ) + date = clampOptional( date, mMinDate, mMaxDate ); + const bool changed = mDate != date; + mDate = date; + if ( mEditMode == DateTimeEditMode::Date ) + updateTextFromValue(); + if ( changed && mEditMode == DateTimeEditMode::Date ) + onValueChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getDate() const { + return mDate; +} + +UIDateTimeEdit* UIDateTimeEdit::setTime( const TimeOfDay& time ) { + return time.isValid() ? setTime( std::optional{ time } ) : this; +} + +UIDateTimeEdit* UIDateTimeEdit::setTime( std::optional time ) { + if ( time && !time->isValid() ) + return this; + if ( !time && !mAllowEmpty ) + return this; + resetPendingDigits(); + if ( mClampToBounds ) + time = clampOptional( time, mMinTime, mMaxTime ); + const bool changed = mTime != time; + mTime = time; + if ( mEditMode == DateTimeEditMode::Time ) + updateTextFromValue(); + if ( changed && mEditMode == DateTimeEditMode::Time ) + onValueChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getTime() const { + return mTime; +} + +UIDateTimeEdit* UIDateTimeEdit::setDateTime( const LocalDateTime& dateTime ) { + return dateTime.isValid() ? setDateTime( std::optional{ dateTime } ) : this; +} + +UIDateTimeEdit* UIDateTimeEdit::setDateTime( std::optional dateTime ) { + if ( dateTime && !dateTime->isValid() ) + return this; + if ( !dateTime && !mAllowEmpty ) + return this; + resetPendingDigits(); + if ( mClampToBounds ) + dateTime = clampOptional( dateTime, mMinDateTime, mMaxDateTime ); + const bool changed = mDateTime != dateTime; + mDateTime = dateTime; + if ( mEditMode == DateTimeEditMode::DateTime ) + updateTextFromValue(); + if ( changed && mEditMode == DateTimeEditMode::DateTime ) + onValueChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getDateTime() const { + return mDateTime; +} + +bool UIDateTimeEdit::hasValue() const { + switch ( mEditMode ) { + case DateTimeEditMode::Date: + return mDate.has_value(); + case DateTimeEditMode::Time: + return mTime.has_value(); + case DateTimeEditMode::DateTime: + return mDateTime.has_value(); + } + return false; +} + +UIDateTimeEdit* UIDateTimeEdit::clear() { + if ( !mAllowEmpty ) + return this; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + setDate( std::nullopt ); + break; + case DateTimeEditMode::Time: + setTime( std::nullopt ); + break; + case DateTimeEditMode::DateTime: + setDateTime( std::nullopt ); + break; + } + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setAllowEmpty( bool allow ) { + if ( mAllowEmpty == allow ) + return this; + mAllowEmpty = allow; + if ( !allow && !hasValue() ) { + switch ( mEditMode ) { + case DateTimeEditMode::Date: + setDate( CalendarDate::today() ); + break; + case DateTimeEditMode::Time: + setTime( TimeOfDay::now() ); + break; + case DateTimeEditMode::DateTime: + setDateTime( LocalDateTime::now() ); + break; + } + } + return this; +} + +bool UIDateTimeEdit::getAllowEmpty() const { + return mAllowEmpty; +} + +UIDateTimeEdit* UIDateTimeEdit::setLocale( const DateTimeLocale& locale ) { + mLocale = locale; + mLocale.firstDayOfWeek %= 7; + rebuildFormat(); + return this; +} + +const DateTimeLocale& UIDateTimeEdit::getLocale() const { + return mLocale; +} + +UIDateTimeEdit* UIDateTimeEdit::setDateFormat( const std::string& pattern ) { + const std::string value = String::iequals( pattern, "locale" ) ? "" : pattern; + if ( mDateFormat != value ) { + DateTimeFormat candidate( value.empty() ? mLocale.datePattern() : value ); + if ( !validEditorFormat( candidate, true ) ) + return this; + mDateFormat = value; + rebuildFormat(); + } + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setTimeFormat( const std::string& pattern ) { + const std::string value = String::iequals( pattern, "locale" ) ? "" : pattern; + if ( mTimeFormat != value ) { + DateTimeFormat candidate( + value.empty() ? mLocale.timePattern( mShowSeconds, mShowMilliseconds, mHourCycle ) + : value ); + if ( !validEditorFormat( candidate, false ) ) + return this; + mTimeFormat = value; + rebuildFormat(); + } + return this; +} + +const std::string& UIDateTimeEdit::getDateFormat() const { + return mDateFormat; +} + +const std::string& UIDateTimeEdit::getTimeFormat() const { + return mTimeFormat; +} + +const DateTimeFormat& UIDateTimeEdit::getDisplayFormat() const { + return mDisplayFormat; +} + +UIDateTimeEdit* UIDateTimeEdit::setHourCycle( HourCycle cycle ) { + if ( mHourCycle != cycle ) { + mHourCycle = cycle; + rebuildFormat(); + } + return this; +} + +HourCycle UIDateTimeEdit::getHourCycle() const { + return mHourCycle; +} + +UIDateTimeEdit* UIDateTimeEdit::setShowSeconds( bool show ) { + if ( mShowSeconds != show ) { + mShowSeconds = show; + if ( !show ) + mShowMilliseconds = false; + rebuildFormat(); + } + return this; +} + +bool UIDateTimeEdit::getShowSeconds() const { + return mShowSeconds; +} + +UIDateTimeEdit* UIDateTimeEdit::setShowMilliseconds( bool show ) { + if ( mShowMilliseconds != show ) { + mShowMilliseconds = show; + if ( show ) + mShowSeconds = true; + rebuildFormat(); + } + return this; +} + +bool UIDateTimeEdit::getShowMilliseconds() const { + return mShowMilliseconds; +} + +UIDateTimeEdit* UIDateTimeEdit::setDayStep( Uint32 step ) { + step = std::max( 1, step ); + if ( mDayStep != step ) { + mDayStep = step; + onConfigurationChange(); + } + return this; +} + +Uint32 UIDateTimeEdit::getDayStep() const { + return mDayStep; +} + +UIDateTimeEdit* UIDateTimeEdit::setClampToBounds( bool clamp ) { + if ( mClampToBounds != clamp ) { + mClampToBounds = clamp; + clampCurrentValue(); + onConstraintsChange(); + } + return this; +} + +bool UIDateTimeEdit::getClampToBounds() const { + return mClampToBounds; +} + +UIDateTimeEdit* UIDateTimeEdit::setHourStep( Uint32 step ) { + step = std::max( 1, step ); + if ( mHourStep != step ) { + mHourStep = step; + onConfigurationChange(); + } + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setMinuteStep( Uint32 step ) { + step = std::max( 1, step ); + if ( mMinuteStep != step ) { + mMinuteStep = step; + onConfigurationChange(); + } + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setSecondStep( Uint32 step ) { + step = std::max( 1, step ); + if ( mSecondStep != step ) { + mSecondStep = step; + onConfigurationChange(); + } + return this; +} + +Uint32 UIDateTimeEdit::getHourStep() const { + return mHourStep; +} + +Uint32 UIDateTimeEdit::getMinuteStep() const { + return mMinuteStep; +} + +Uint32 UIDateTimeEdit::getSecondStep() const { + return mSecondStep; +} + +void UIDateTimeEdit::onConstraintsChange() {} + +void UIDateTimeEdit::onConfigurationChange() {} + +UIDateTimeEdit* UIDateTimeEdit::setMinDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + mMinDate = date; + if ( mMinDate && mMaxDate && *mMinDate > *mMaxDate ) + mMaxDate = mMinDate; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setMaxDate( std::optional date ) { + if ( date && !date->isValid() ) + return this; + mMaxDate = date; + if ( mMinDate && mMaxDate && *mMaxDate < *mMinDate ) + mMinDate = mMaxDate; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getMinDate() const { + return mMinDate; +} + +const std::optional& UIDateTimeEdit::getMaxDate() const { + return mMaxDate; +} + +UIDateTimeEdit* UIDateTimeEdit::setMinTime( std::optional time ) { + if ( time && !time->isValid() ) + return this; + mMinTime = time; + if ( mMinTime && mMaxTime && *mMinTime > *mMaxTime ) + mMaxTime = mMinTime; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setMaxTime( std::optional time ) { + if ( time && !time->isValid() ) + return this; + mMaxTime = time; + if ( mMinTime && mMaxTime && *mMaxTime < *mMinTime ) + mMinTime = mMaxTime; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getMinTime() const { + return mMinTime; +} + +const std::optional& UIDateTimeEdit::getMaxTime() const { + return mMaxTime; +} + +UIDateTimeEdit* UIDateTimeEdit::setMinDateTime( std::optional dateTime ) { + if ( dateTime && !dateTime->isValid() ) + return this; + mMinDateTime = dateTime; + if ( mMinDateTime && mMaxDateTime && *mMinDateTime > *mMaxDateTime ) + mMaxDateTime = mMinDateTime; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +UIDateTimeEdit* UIDateTimeEdit::setMaxDateTime( std::optional dateTime ) { + if ( dateTime && !dateTime->isValid() ) + return this; + mMaxDateTime = dateTime; + if ( mMinDateTime && mMaxDateTime && *mMaxDateTime < *mMinDateTime ) + mMinDateTime = mMaxDateTime; + clampCurrentValue(); + onConstraintsChange(); + return this; +} + +const std::optional& UIDateTimeEdit::getMinDateTime() const { + return mMinDateTime; +} + +const std::optional& UIDateTimeEdit::getMaxDateTime() const { + return mMaxDateTime; +} + +UIDateTimeEdit* UIDateTimeEdit::setWheelEditingEnabled( bool enabled ) { + mWheelEditing = enabled; + return this; +} + +bool UIDateTimeEdit::isWheelEditingEnabled() const { + return mWheelEditing; +} + +UIDateTimeEdit* UIDateTimeEdit::setPendingDigitTimeout( const Time& timeout ) { + const Time value = timeout < Time::Zero ? Time::Zero : timeout; + if ( mPendingDigitTimeout != value ) { + mPendingDigitTimeout = value; + onConfigurationChange(); + } + return this; +} + +const Time& UIDateTimeEdit::getPendingDigitTimeout() const { + return mPendingDigitTimeout; +} + +DateTimeSection UIDateTimeEdit::getActiveSection() const { + return mActiveSection; +} + +UIDateTimeEdit* UIDateTimeEdit::setActiveSection( DateTimeSection section ) { + if ( section != DateTimeSection::None && !findSection( section ) ) + return this; + commitPendingDigits( false ); + mActiveSection = section; + resetPendingDigits(); + selectActiveSection(); + return this; +} + +bool UIDateTimeEdit::stepActiveSection( Int32 direction ) { + if ( direction == 0 || !isEditingAllowed() ) + return false; + if ( mActiveSection == DateTimeSection::None ) { + const auto order = getSectionOrder(); + if ( order.empty() ) + return false; + mActiveSection = order.front(); + } + initializeValueForEditing(); + bool changed = false; + const Int64 sign = direction > 0 ? 1 : -1; + if ( mEditMode == DateTimeEditMode::Date && mDate ) { + CalendarDate value = *mDate; + switch ( mActiveSection ) { + case DateTimeSection::Day: + value = value.addDays( sign * mDayStep ); + break; + case DateTimeSection::Month: + value = value.addMonths( sign ); + break; + case DateTimeSection::Year: + value = value.addYears( sign ); + break; + default: + return false; + } + auto old = mDate; + setDate( value ); + changed = old != mDate; + } else if ( mEditMode == DateTimeEditMode::Time && mTime ) { + TimeOfDay value = *mTime; + switch ( mActiveSection ) { + case DateTimeSection::Hour: + value = value.addHours( sign * mHourStep ); + break; + case DateTimeSection::Minute: + value = value.addMinutes( sign * mMinuteStep ); + break; + case DateTimeSection::Second: + value = value.addSeconds( sign * mSecondStep ); + break; + case DateTimeSection::Millisecond: { + const Int64 ms = value.millisecond + sign; + value = value.addSeconds( ms < 0 ? -1 : ms >= 1000 ? 1 : 0 ); + value.millisecond = static_cast( positiveMod( ms, 1000 ) ); + break; + } + case DateTimeSection::AmPm: + value = value.addHours( 12 ); + break; + default: + return false; + } + auto old = mTime; + setTime( value ); + changed = old != mTime; + } else if ( mEditMode == DateTimeEditMode::DateTime && mDateTime ) { + LocalDateTime value = *mDateTime; + switch ( mActiveSection ) { + case DateTimeSection::Day: + value.date = value.date.addDays( sign * mDayStep ); + break; + case DateTimeSection::Month: + value.date = value.date.addMonths( sign ); + break; + case DateTimeSection::Year: + value.date = value.date.addYears( sign ); + break; + case DateTimeSection::Hour: + value = value.addHours( sign * mHourStep ); + break; + case DateTimeSection::Minute: + value = value.addMinutes( sign * mMinuteStep ); + break; + case DateTimeSection::Second: + value = value.addSeconds( sign * mSecondStep ); + break; + case DateTimeSection::Millisecond: { + Int32 ms = + static_cast( value.time.millisecond ) + static_cast( sign ); + if ( ms < 0 || ms >= 1000 ) { + const auto stepped = value.addSeconds( sign ); + if ( stepped != value ) { + value = stepped; + value.time.millisecond = static_cast( positiveMod( ms, 1000 ) ); + } + } else { + value.time.millisecond = static_cast( ms ); + } + break; + } + case DateTimeSection::AmPm: + value.time = value.time.addHours( 12 ); + break; + default: + return false; + } + auto old = mDateTime; + setDateTime( value ); + changed = old != mDateTime; + } + resetPendingDigits(); + selectActiveSection(); + return changed; +} + +std::string UIDateTimeEdit::getSerializedValue() const { + switch ( mEditMode ) { + case DateTimeEditMode::Date: + return mDate ? DateTimeFormatter::toISODate( *mDate ) : ""; + case DateTimeEditMode::Time: + return mTime ? DateTimeFormatter::toISOTime( *mTime, mTime->millisecond != 0 ) : ""; + case DateTimeEditMode::DateTime: + return mDateTime ? DateTimeFormatter::toISOLocalDateTime( + *mDateTime, mDateTime->time.millisecond != 0 ) + : ""; + } + return ""; +} + +UITextView* UIDateTimeEdit::setText( const String& text ) { + if ( mUpdatingText ) + return UITextInput::setText( text ); + if ( text.empty() ) + clear(); + else + parseAndSetDisplayText( text.toUtf8() ); + return this; +} + +void UIDateTimeEdit::rebuildFormat() { + const std::string datePattern = mDateFormat.empty() ? mLocale.datePattern() : mDateFormat; + const std::string timePattern = + mTimeFormat.empty() ? mLocale.timePattern( mShowSeconds, mShowMilliseconds, mHourCycle ) + : mTimeFormat; + std::string pattern; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + pattern = datePattern; + break; + case DateTimeEditMode::Time: + pattern = timePattern; + break; + case DateTimeEditMode::DateTime: + pattern = datePattern + " " + timePattern; + break; + } + DateTimeFormat candidate( pattern ); + if ( candidate.isValid() ) + mDisplayFormat = std::move( candidate ); + updateAutoHint(); + updateTextFromValue(); + onConfigurationChange(); +} + +void UIDateTimeEdit::updateTextFromValue( bool preserveSection ) { + const DateTimeSection previous = preserveSection ? mActiveSection : DateTimeSection::None; + FormattedDateTime formatted; + bool hasFormatted = false; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + if ( mDate ) { + formatted = + DateTimeFormatter::formatDateDetailed( *mDate, mDisplayFormat, mLocale ); + hasFormatted = true; + } + break; + case DateTimeEditMode::Time: + if ( mTime ) { + formatted = + DateTimeFormatter::formatTimeDetailed( *mTime, mDisplayFormat, mLocale ); + hasFormatted = true; + } + break; + case DateTimeEditMode::DateTime: + if ( mDateTime ) { + formatted = DateTimeFormatter::formatDateTimeDetailed( *mDateTime, mDisplayFormat, + mLocale ); + hasFormatted = true; + } + break; + } + mUpdatingText = true; + UITextInput::setText( hasFormatted ? String::fromUtf8( formatted.text ) : String() ); + mUpdatingText = false; + mSections.clear(); + if ( hasFormatted ) + rebuildSections( formatted ); + else { + const LocalDateTime sample{ CalendarDate{ 2000, 12, 28 }, TimeOfDay{ 20, 14, 32, 123 } }; + FormattedDateTime skeleton; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + skeleton = + DateTimeFormatter::formatDateDetailed( sample.date, mDisplayFormat, mLocale ); + break; + case DateTimeEditMode::Time: + skeleton = + DateTimeFormatter::formatTimeDetailed( sample.time, mDisplayFormat, mLocale ); + break; + case DateTimeEditMode::DateTime: + skeleton = + DateTimeFormatter::formatDateTimeDetailed( sample, mDisplayFormat, mLocale ); + break; + } + rebuildSections( skeleton ); + } + mActiveSection = previous; + if ( mActiveSection != DateTimeSection::None && !findSection( mActiveSection ) ) + mActiveSection = DateTimeSection::None; + selectActiveSection(); +} + +void UIDateTimeEdit::rebuildSections( const FormattedDateTime& formatted ) { + mSections.clear(); + for ( const auto& range : formatted.ranges ) { + const DateTimeSection type = tokenToSection( range.type ); + if ( type == DateTimeSection::None ) + continue; + Section section; + section.type = type; + section.start = + String::utf8Length( std::string_view( formatted.text ).substr( 0, range.start ) ); + section.length = String::utf8Length( + std::string_view( formatted.text ).substr( range.start, range.length ) ); + section.displayDigits = mDisplayFormat.getTokens()[range.tokenIndex].width; + if ( type != DateTimeSection::Year && type != DateTimeSection::Millisecond && + type != DateTimeSection::AmPm ) + section.displayDigits = 2; + switch ( type ) { + case DateTimeSection::Day: + section.minValue = 1; + section.maxValue = 31; + break; + case DateTimeSection::Month: + section.minValue = 1; + section.maxValue = 12; + break; + case DateTimeSection::Year: + section.minValue = 1; + section.maxValue = 9999; + break; + case DateTimeSection::Hour: + section.minValue = range.type == DateTimeFormatTokenType::Hour12 ? 1 : 0; + section.maxValue = range.type == DateTimeFormatTokenType::Hour12 ? 12 : 23; + break; + case DateTimeSection::Minute: + case DateTimeSection::Second: + section.minValue = 0; + section.maxValue = 59; + break; + case DateTimeSection::Millisecond: + section.minValue = 0; + section.maxValue = section.displayDigits == 1 ? 9 + : section.displayDigits == 2 ? 99 + : 999; + break; + case DateTimeSection::AmPm: + section.minValue = 0; + section.maxValue = 1; + break; + case DateTimeSection::None: + break; + } + mSections.push_back( section ); + } +} + +void UIDateTimeEdit::updateAutoHint() { + std::string hint; + for ( const auto& token : mDisplayFormat.getTokens() ) { + switch ( token.type ) { + case DateTimeFormatTokenType::Literal: + hint += token.literal; + break; + case DateTimeFormatTokenType::Day: + hint += std::string( token.width, 'd' ); + break; + case DateTimeFormatTokenType::Month: + hint += std::string( token.width, 'm' ); + break; + case DateTimeFormatTokenType::Year: + hint += std::string( token.width, 'y' ); + break; + case DateTimeFormatTokenType::Hour24: + case DateTimeFormatTokenType::Hour12: + hint += std::string( token.width, 'h' ); + break; + case DateTimeFormatTokenType::Minute: + hint += std::string( token.width, 'm' ); + break; + case DateTimeFormatTokenType::Second: + hint += std::string( token.width, 's' ); + break; + case DateTimeFormatTokenType::Millisecond: + hint += std::string( token.width, 's' ); + break; + case DateTimeFormatTokenType::AmPm: + hint += "am/pm"; + break; + } + } + if ( getHint().empty() || getHint().toUtf8() == mLastAutoHint ) + UITextInput::setHint( String::fromUtf8( hint ) ); + mLastAutoHint = std::move( hint ); +} + +void UIDateTimeEdit::selectActiveSection() { + if ( !hasValue() || mActiveSection == DateTimeSection::None ) + return; + auto section = findSection( mActiveSection ); + if ( !section ) + return; + mDoc.setSelection( { { 0, static_cast( section->start ) }, + { 0, static_cast( section->start + section->length ) } } ); +} + +void UIDateTimeEdit::selectSectionAtColumn( Int64 column ) { + if ( mSections.empty() ) + return; + const Section* best = &mSections.front(); + Int64 bestDistance = std::numeric_limits::max(); + for ( const auto& section : mSections ) { + const Int64 start = static_cast( section.start ); + const Int64 end = static_cast( section.start + section.length ); + if ( column >= start && column <= end ) { + best = §ion; + bestDistance = 0; + break; + } + const Int64 distance = column < start ? start - column : column - end; + if ( distance < bestDistance ) { + bestDistance = distance; + best = §ion; + } + } + mActiveSection = best->type; + resetPendingDigits(); + selectActiveSection(); +} + +void UIDateTimeEdit::moveActiveSection( Int32 direction ) { + commitPendingDigits( false ); + const auto order = getSectionOrder(); + if ( order.empty() ) + return; + auto it = std::find( order.begin(), order.end(), mActiveSection ); + Int32 index = it == order.end() ? 0 : static_cast( std::distance( order.begin(), it ) ); + index = std::clamp( index + direction, 0, static_cast( order.size() - 1 ) ); + mActiveSection = order[index]; + resetPendingDigits(); + selectActiveSection(); +} + +void UIDateTimeEdit::updatePendingText() { + const auto section = findSection( mActiveSection ); + if ( !section || mPendingDigits.empty() || !hasValue() ) + return; + String text = getText(); + String pending( mPendingDigits ); + while ( pending.size() < section->length ) + pending += '_'; + text = text.substr( 0, section->start ) + pending + + text.substr( section->start + section->length ); + mUpdatingText = true; + UITextInput::setText( text ); + mUpdatingText = false; + selectActiveSection(); +} + +void UIDateTimeEdit::resetPendingDigits() { + const bool pending = !mPendingDigits.empty(); + mPendingDigits.clear(); + if ( pending ) + updateTextFromValue(); + mPendingDigitClock.restart(); +} + +bool UIDateTimeEdit::commitPendingDigits( bool advance ) { + if ( mPendingDigits.empty() || mActiveSection == DateTimeSection::None ) + return false; + Int32 value = 0; + if ( !String::fromString( value, mPendingDigits ) ) { + resetPendingDigits(); + return false; + } + if ( mActiveSection == DateTimeSection::Millisecond ) { + const auto section = findSection( mActiveSection ); + if ( section && section->displayDigits < 3 ) + value *= section->displayDigits == 1 ? 100 : 10; + } + mPendingDigits.clear(); + const bool changed = setSectionNumericValue( mActiveSection, value ); + if ( advance ) + moveActiveSection( 1 ); + else + selectActiveSection(); + return changed; +} + +bool UIDateTimeEdit::processDigit( char digit ) { + if ( mActiveSection == DateTimeSection::None ) { + const auto order = getSectionOrder(); + if ( order.empty() ) + return false; + mActiveSection = order.front(); + } + if ( mActiveSection == DateTimeSection::AmPm ) + return false; + initializeValueForEditing(); + auto section = findSection( mActiveSection ); + if ( !section ) + return false; + if ( mPendingDigitClock.getElapsedTime() >= mPendingDigitTimeout ) + mPendingDigits.clear(); + mPendingDigitClock.restart(); + mPendingDigits.push_back( digit ); + Int32 value = 0; + String::fromString( value, mPendingDigits ); + const Uint32 targetDigits = section->type == DateTimeSection::Year ? 4 : section->displayDigits; + const bool full = mPendingDigits.size() >= targetDigits; + const bool canContinue = mPendingDigits.size() < targetDigits && + static_cast( value ) * 10 <= section->maxValue; + if ( full ) { + if ( value >= section->minValue && value <= section->maxValue ) + return commitPendingDigits( true ); + const char retry = mPendingDigits.back(); + mPendingDigits.pop_back(); + if ( mPendingDigits.empty() ) { + resetPendingDigits(); + return true; + } + commitPendingDigits( true ); + return processDigit( retry ); + } + if ( !canContinue ) + return commitPendingDigits( true ); + updatePendingText(); + return true; +} + +bool UIDateTimeEdit::processAmPm( char value ) { + if ( mActiveSection != DateTimeSection::AmPm ) + return false; + const char lower = static_cast( std::tolower( static_cast( value ) ) ); + if ( lower != 'a' && lower != 'p' ) + return false; + initializeValueForEditing(); + const bool wantPm = lower == 'p'; + if ( mEditMode == DateTimeEditMode::Time && mTime ) { + TimeOfDay valueTime = *mTime; + const bool isPm = valueTime.hour >= 12; + if ( isPm != wantPm ) + valueTime = valueTime.addHours( 12 ); + setTime( valueTime ); + } else if ( mEditMode == DateTimeEditMode::DateTime && mDateTime ) { + LocalDateTime valueDateTime = *mDateTime; + const bool isPm = valueDateTime.time.hour >= 12; + if ( isPm != wantPm ) + valueDateTime.time = valueDateTime.time.addHours( 12 ); + setDateTime( valueDateTime ); + } + moveActiveSection( 1 ); + return true; +} + +bool UIDateTimeEdit::setSectionNumericValue( DateTimeSection section, Int32 value ) { + initializeValueForEditing(); + auto setDateSection = [&]( CalendarDate& date ) -> bool { + switch ( section ) { + case DateTimeSection::Day: + date.day = static_cast( std::clamp( value, 1, date.daysInMonth() ) ); + break; + case DateTimeSection::Month: { + date.month = static_cast( std::clamp( value, 1, 12 ) ); + date.day = static_cast( std::min( date.day, date.daysInMonth() ) ); + break; + } + case DateTimeSection::Year: + date.year = std::clamp( value, 1, 9999 ); + date.day = static_cast( std::min( date.day, date.daysInMonth() ) ); + break; + default: + return false; + } + return true; + }; + if ( mEditMode == DateTimeEditMode::Date && mDate ) { + CalendarDate date = *mDate; + if ( !setDateSection( date ) ) + return false; + auto old = mDate; + setDate( date ); + return old != mDate; + } + + auto setTimeSection = [&]( TimeOfDay& time ) -> bool { + switch ( section ) { + case DateTimeSection::Hour: { + const bool hour12 = std::any_of( + mDisplayFormat.getTokens().begin(), mDisplayFormat.getTokens().end(), + []( const DateTimeFormatToken& token ) { + return token.type == DateTimeFormatTokenType::Hour12; + } ); + if ( hour12 ) { + const bool pm = time.hour >= 12; + Int32 h = std::clamp( value, 1, 12 ); + time.hour = static_cast( ( h % 12 ) + ( pm ? 12 : 0 ) ); + } else { + time.hour = static_cast( std::clamp( value, 0, 23 ) ); + } + return true; + } + case DateTimeSection::Minute: + time.minute = static_cast( std::clamp( value, 0, 59 ) ); + return true; + case DateTimeSection::Second: + time.second = static_cast( std::clamp( value, 0, 59 ) ); + return true; + case DateTimeSection::Millisecond: + time.millisecond = static_cast( std::clamp( value, 0, 999 ) ); + return true; + default: + return false; + } + }; + + if ( mEditMode == DateTimeEditMode::Time && mTime ) { + TimeOfDay time = *mTime; + if ( !setTimeSection( time ) ) + return false; + auto old = mTime; + setTime( time ); + return old != mTime; + } + if ( mEditMode == DateTimeEditMode::DateTime && mDateTime ) { + LocalDateTime dateTime = *mDateTime; + if ( !setDateSection( dateTime.date ) && !setTimeSection( dateTime.time ) ) + return false; + auto old = mDateTime; + setDateTime( dateTime ); + return old != mDateTime; + } + return false; +} + +std::optional +UIDateTimeEdit::findSection( DateTimeSection section ) const { + auto it = std::find_if( mSections.begin(), mSections.end(), + [section]( const Section& value ) { return value.type == section; } ); + return it == mSections.end() ? std::nullopt : std::optional
{ *it }; +} + +SmallVector UIDateTimeEdit::getSectionOrder() const { + SmallVector result; + for ( const auto& section : mSections ) + result.push_back( section.type ); + return result; +} + +bool UIDateTimeEdit::parseAndSetDisplayText( std::string_view text ) { + switch ( mEditMode ) { + case DateTimeEditMode::Date: { + auto value = DateTimeFormatter::parseDateTolerant( text, mDisplayFormat, mLocale ); + if ( !value ) + return false; + setDate( *value ); + return true; + } + case DateTimeEditMode::Time: { + auto value = DateTimeFormatter::parseTimeTolerant( text, mDisplayFormat, mLocale ); + if ( !value ) + return false; + setTime( *value ); + return true; + } + case DateTimeEditMode::DateTime: { + auto value = DateTimeFormatter::parseDateTimeTolerant( text, mDisplayFormat, mLocale ); + if ( !value ) + return false; + setDateTime( *value ); + return true; + } + } + return false; +} + +bool UIDateTimeEdit::parseAndSetSerializedValue( std::string_view text ) { + if ( text.empty() ) { + clear(); + return true; + } + return parseAndSetDisplayText( text ); +} + +void UIDateTimeEdit::initializeValueForEditing() { + if ( hasValue() ) + return; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + setDate( CalendarDate::today() ); + break; + case DateTimeEditMode::Time: { + TimeOfDay value = TimeOfDay::now(); + value.second = 0; + value.millisecond = 0; + setTime( value ); + break; + } + case DateTimeEditMode::DateTime: { + LocalDateTime value = LocalDateTime::now(); + value.time.second = 0; + value.time.millisecond = 0; + setDateTime( value ); + break; + } + } +} + +void UIDateTimeEdit::clampCurrentValue() { + if ( !mClampToBounds ) + return; + switch ( mEditMode ) { + case DateTimeEditMode::Date: + if ( mDate ) + setDate( mDate ); + break; + case DateTimeEditMode::Time: + if ( mTime ) + setTime( mTime ); + break; + case DateTimeEditMode::DateTime: + if ( mDateTime ) + setDateTime( mDateTime ); + break; + } +} + +DateTimeSection UIDateTimeEdit::tokenToSection( DateTimeFormatTokenType type ) { + switch ( type ) { + case DateTimeFormatTokenType::Day: + return DateTimeSection::Day; + case DateTimeFormatTokenType::Month: + return DateTimeSection::Month; + case DateTimeFormatTokenType::Year: + return DateTimeSection::Year; + case DateTimeFormatTokenType::Hour24: + case DateTimeFormatTokenType::Hour12: + return DateTimeSection::Hour; + case DateTimeFormatTokenType::Minute: + return DateTimeSection::Minute; + case DateTimeFormatTokenType::Second: + return DateTimeSection::Second; + case DateTimeFormatTokenType::Millisecond: + return DateTimeSection::Millisecond; + case DateTimeFormatTokenType::AmPm: + return DateTimeSection::AmPm; + case DateTimeFormatTokenType::Literal: + return DateTimeSection::None; + } + return DateTimeSection::None; +} + +const char* UIDateTimeEdit::modeToString( DateTimeEditMode mode ) { + switch ( mode ) { + case DateTimeEditMode::Date: + return "date"; + case DateTimeEditMode::Time: + return "time"; + case DateTimeEditMode::DateTime: + return "datetime"; + } + return "date"; +} + +std::optional UIDateTimeEdit::modeFromString( std::string_view mode ) { + const auto value = lowerAscii( mode ); + if ( value == "date" ) + return DateTimeEditMode::Date; + if ( value == "time" ) + return DateTimeEditMode::Time; + if ( value == "datetime" || value == "date-time" || value == "datetime-local" ) + return DateTimeEditMode::DateTime; + return std::nullopt; +} + +const char* UIDateTimeEdit::hourCycleToString( HourCycle cycle ) { + switch ( cycle ) { + case HourCycle::Locale: + return "locale"; + case HourCycle::H12: + return "12"; + case HourCycle::H24: + return "24"; + } + return "locale"; +} + +Uint32 UIDateTimeEdit::onKeyDown( const KeyEvent& event ) { + if ( !isEditingAllowed() ) + return UITextInput::onKeyDown( event ); + if ( event.getSanitizedMod() == 0 ) { + switch ( event.getKeyCode() ) { + case KEY_LEFT: + moveActiveSection( -1 ); + return 1; + case KEY_RIGHT: + moveActiveSection( 1 ); + return 1; + case KEY_HOME: { + const auto order = getSectionOrder(); + if ( !order.empty() ) + setActiveSection( order.front() ); + return 1; + } + case KEY_END: { + const auto order = getSectionOrder(); + if ( !order.empty() ) + setActiveSection( order.back() ); + return 1; + } + case KEY_DELETE: + case KEY_BACKSPACE: + resetPendingDigits(); + clear(); + return 1; + case KEY_UP: + stepActiveSection( 1 ); + return 1; + case KEY_DOWN: + stepActiveSection( -1 ); + return 1; + case KEY_ESCAPE: + if ( !mPendingDigits.empty() ) { + resetPendingDigits(); + selectActiveSection(); + return 1; + } + break; + case KEY_RETURN: + case KEY_KP_ENTER: + commitPendingDigits( false ); + break; + default: + break; + } + } + return UITextInput::onKeyDown( event ); +} + +Uint32 UIDateTimeEdit::onTextInput( const TextInputEvent& event ) { + if ( !isEditingAllowed() ) + return 0; + if ( !event.isValid( getInput() ) ) + return 0; + const String& text = event.getText(); + if ( text.size() == 1 ) { + const auto chr = text[0]; + if ( chr >= '0' && chr <= '9' ) + return processDigit( static_cast( chr ) ) ? 1 : 0; + if ( ( chr == 'a' || chr == 'A' || chr == 'p' || chr == 'P' ) && + processAmPm( static_cast( chr ) ) ) + return 1; + } + if ( text.size() > 1 ) + return parseAndSetDisplayText( text.toUtf8() ) ? 1 : 0; + return 0; +} + +Uint32 UIDateTimeEdit::onMouseClick( const Vector2i& position, const Uint32& flags ) { + const Uint32 result = UITextInput::onMouseClick( position, flags ); + if ( hasValue() && ( flags & EE_BUTTON_LMASK ) && + !( getInput()->getModState() & KEYMOD_SHIFT ) ) { + // Focus and mouse-down handling can replace the document selection before this event. + // Hit-test the click itself, including the blank space after the formatted value. + Vector2f textPosition( position.asFloat() ); + worldToNode( textPosition ); + textPosition = PixelDensity::dpToPx( textPosition ) - mRealAlignOffset - + Vector2f( mPaddingPx.Left, mPaddingPx.Top ); + const Int32 column = getVisibleTextCache().findCharacterFromPos( + Vector2i( std::max( 0.f, textPosition.x ), 0 ) ); + if ( column >= 0 ) + selectSectionAtColumn( column ); + } + return result; +} + +Uint32 UIDateTimeEdit::onMouseWheel( const Vector2f& offset, bool flipped ) { + if ( !mWheelEditing || !hasFocus() || offset.y == 0.f ) + return UITextInput::onMouseWheel( offset, flipped ); + const Float value = flipped ? -offset.y : offset.y; + return stepActiveSection( value > 0 ? 1 : -1 ) ? 1 : 0; +} + +void UIDateTimeEdit::onTextChanged() { + // UITextView treats text itself as the value and therefore emits OnValueChange here. + // For UIDateTimeEdit the canonical typed date/time value is authoritative, so formatting + // changes must only emit OnTextChanged; typed setters emit OnValueChange exactly once. + sendCommonEvent( Event::OnTextChanged ); + invalidateDraw(); +} + +void UIDateTimeEdit::onDocumentTextChanged( const DocumentContentChange& change ) { + UITextInput::onDocumentTextChanged( change ); + if ( mUpdatingText ) + return; + const std::string attempted = getText().toUtf8(); + if ( attempted.empty() ) { + clear(); + updateTextFromValue(); + return; + } + if ( !parseAndSetDisplayText( attempted ) ) + updateTextFromValue(); +} + +Uint32 UIDateTimeEdit::onFocus( NodeFocusReason reason ) { + const Uint32 result = UITextInput::onFocus( reason ); + if ( mActiveSection == DateTimeSection::None ) { + const auto order = getSectionOrder(); + if ( !order.empty() ) + mActiveSection = order.front(); + } + selectActiveSection(); + return result; +} + +void UIDateTimeEdit::scheduledUpdate( const Time& time ) { + UITextInput::scheduledUpdate( time ); + if ( !mPendingDigits.empty() && mPendingDigitClock.getElapsedTime() >= mPendingDigitTimeout ) + commitPendingDigits( true ); +} + +bool UIDateTimeEdit::applyProperty( const StyleSheetProperty& attribute ) { + if ( !checkPropertyDefinition( attribute ) ) + return false; + switch ( attribute.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::ActiveSection: { + for ( Uint8 i = 0; i < sizeof( SectionNames ) / sizeof( SectionNames[0] ); ++i ) { + if ( attribute.getValue() == SectionNames[i] ) { + setActiveSection( static_cast( i ) ); + return true; + } + } + return false; + } + case PropertyId::Value: + return parseAndSetSerializedValue( attribute.asString() ); + case PropertyId::DateTimeMode: { + auto mode = modeFromString( attribute.asString() ); + if ( mode ) + setEditMode( *mode ); + return mode.has_value(); + } + case PropertyId::AllowEmpty: + setAllowEmpty( attribute.asBool() ); + return true; + case PropertyId::DateFormat: + setDateFormat( attribute.asString() ); + return true; + case PropertyId::TimeFormat: + setTimeFormat( attribute.asString() ); + return true; + case PropertyId::HourCycle: { + const auto value = lowerAscii( attribute.asString() ); + setHourCycle( value == "12" || value == "h12" ? HourCycle::H12 + : value == "24" || value == "h24" ? HourCycle::H24 + : HourCycle::Locale ); + return true; + } + case PropertyId::ShowSeconds: + setShowSeconds( attribute.asBool() ); + return true; + case PropertyId::ShowMilliseconds: + setShowMilliseconds( attribute.asBool() ); + return true; + case PropertyId::WheelEditing: + setWheelEditingEnabled( attribute.asBool() ); + return true; + case PropertyId::HourStep: + setHourStep( attribute.asUint() ); + return true; + case PropertyId::MinuteStep: + setMinuteStep( attribute.asUint() ); + return true; + case PropertyId::SecondStep: + setSecondStep( attribute.asUint() ); + return true; + case PropertyId::MinDate: { + if ( attribute.getValue().empty() ) { + setMinDate( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseDateTolerant( + attribute.asString(), DateTimeFormat( "yyyy-MM-dd" ), mLocale ); + if ( value ) + setMinDate( *value ); + return value.has_value(); + } + case PropertyId::MaxDate: { + if ( attribute.getValue().empty() ) { + setMaxDate( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseDateTolerant( + attribute.asString(), DateTimeFormat( "yyyy-MM-dd" ), mLocale ); + if ( value ) + setMaxDate( *value ); + return value.has_value(); + } + case PropertyId::MinTime: { + if ( attribute.getValue().empty() ) { + setMinTime( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseTimeTolerant( + attribute.asString(), DateTimeFormat( "HH:mm:ss" ), mLocale ); + if ( value ) + setMinTime( *value ); + return value.has_value(); + } + case PropertyId::MaxTime: { + if ( attribute.getValue().empty() ) { + setMaxTime( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseTimeTolerant( + attribute.asString(), DateTimeFormat( "HH:mm:ss" ), mLocale ); + if ( value ) + setMaxTime( *value ); + return value.has_value(); + } + case PropertyId::MinDateTime: { + if ( attribute.getValue().empty() ) { + setMinDateTime( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseDateTimeTolerant( + attribute.asString(), DateTimeFormat( "yyyy-MM-dd'T'HH:mm:ss" ), mLocale ); + if ( value ) + setMinDateTime( *value ); + return value.has_value(); + } + case PropertyId::MaxDateTime: { + if ( attribute.getValue().empty() ) { + setMaxDateTime( std::nullopt ); + return true; + } + auto value = DateTimeFormatter::parseDateTimeTolerant( + attribute.asString(), DateTimeFormat( "yyyy-MM-dd'T'HH:mm:ss" ), mLocale ); + if ( value ) + setMaxDateTime( *value ); + return value.has_value(); + } + default: + return UITextInput::applyProperty( attribute ); + } +} + +std::string UIDateTimeEdit::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( !propertyDef ) + return ""; + switch ( propertyDef->getPropertyId() ) { + case PropertyId::ActiveSection: + return SectionNames[static_cast( mActiveSection )]; + case PropertyId::Value: + return getSerializedValue(); + case PropertyId::DateTimeMode: + return modeToString( mEditMode ); + case PropertyId::AllowEmpty: + return mAllowEmpty ? "true" : "false"; + case PropertyId::DateFormat: + return mDateFormat.empty() ? "locale" : mDateFormat; + case PropertyId::TimeFormat: + return mTimeFormat.empty() ? "locale" : mTimeFormat; + case PropertyId::HourCycle: + return hourCycleToString( mHourCycle ); + case PropertyId::ShowSeconds: + return mShowSeconds ? "true" : "false"; + case PropertyId::ShowMilliseconds: + return mShowMilliseconds ? "true" : "false"; + case PropertyId::WheelEditing: + return mWheelEditing ? "true" : "false"; + case PropertyId::HourStep: + return String::toString( mHourStep ); + case PropertyId::MinuteStep: + return String::toString( mMinuteStep ); + case PropertyId::SecondStep: + return String::toString( mSecondStep ); + case PropertyId::MinDate: + return mMinDate ? DateTimeFormatter::toISODate( *mMinDate ) : ""; + case PropertyId::MaxDate: + return mMaxDate ? DateTimeFormatter::toISODate( *mMaxDate ) : ""; + case PropertyId::MinTime: + return mMinTime ? DateTimeFormatter::toISOTime( *mMinTime, mMinTime->millisecond != 0 ) + : ""; + case PropertyId::MaxTime: + return mMaxTime ? DateTimeFormatter::toISOTime( *mMaxTime, mMaxTime->millisecond != 0 ) + : ""; + case PropertyId::MinDateTime: + return mMinDateTime ? DateTimeFormatter::toISOLocalDateTime( + *mMinDateTime, mMinDateTime->time.millisecond != 0 ) + : ""; + case PropertyId::MaxDateTime: + return mMaxDateTime ? DateTimeFormatter::toISOLocalDateTime( + *mMaxDateTime, mMaxDateTime->time.millisecond != 0 ) + : ""; + default: + return UITextInput::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UIDateTimeEdit::getPropertiesImplemented() const { + auto props = UITextInput::getPropertiesImplemented(); + const std::initializer_list local = { + PropertyId::ActiveSection, PropertyId::DateTimeMode, PropertyId::AllowEmpty, + PropertyId::DateFormat, PropertyId::TimeFormat, PropertyId::HourCycle, + PropertyId::ShowSeconds, PropertyId::ShowMilliseconds, PropertyId::WheelEditing, + PropertyId::HourStep, PropertyId::MinuteStep, PropertyId::SecondStep, + PropertyId::MinDate, PropertyId::MaxDate, PropertyId::MinTime, + PropertyId::MaxTime, PropertyId::MinDateTime, PropertyId::MaxDateTime }; + props.insert( props.end(), local.begin(), local.end() ); + return props; +} + +void UIDateTimeEdit::pasteValue() { + if ( !isEditingAllowed() || !getUISceneNode() ) + return; + resetPendingDigits(); + const std::string text = getUISceneNode()->getWindow()->getClipboard()->getText(); + if ( text.empty() ) + clear(); + else + parseAndSetDisplayText( text ); + sendCommonEvent( Event::OnTextPasted ); +} + +Uint32 UIDateTimeEdit::onFocusLoss() { + commitPendingDigits( false ); + return UITextInput::onFocusLoss(); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uidatetimepicker.cpp b/src/eepp/ui/uidatetimepicker.cpp new file mode 100644 index 000000000..22916c02e --- /dev/null +++ b/src/eepp/ui/uidatetimepicker.cpp @@ -0,0 +1,460 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +namespace { + +class PopupTimePicker; + +class DateTimePopup : public UIWidget { + public: + DateTimePopup( UICalendar* calendar, PopupTimePicker* time ); + + ~DateTimePopup(); + + void updateLayout(); + + protected: + void onAutoSize() override { updateLayout(); } + + void onSizeChange() override { + UIWidget::onSizeChange(); + updateLayout(); + } + + void onPaddingChange() override { + UIWidget::onPaddingChange(); + updateLayout(); + } + + private: + UICalendar* mCalendar; + PopupTimePicker* mTime; + bool mUpdatingLayout{ false }; + EventConnection mCalendarSizeConnection; + EventConnection mCalendarCloseConnection; + EventConnection mTimeCloseConnection; +}; + +class PopupTimePicker : public UITimePicker { + public: + explicit PopupTimePicker( UIDateTimePicker* owner ) : mOwner( owner ) { + setElementTag( "datetimepicker::time" ); + unsetFlags( UI_AUTO_SIZE ); + setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::WrapContent ); + setClipType( ClipType::BorderBox ); + setTextAlign( UI_HALIGN_CENTER | UI_VALIGN_CENTER ); + } + + void setPopup( DateTimePopup* popup ) { mPopup = popup; } + + void detachOwner() { mOwner = nullptr; } + + void commit() { commitPendingDigits( false ); } + + Sizef getPreferredSize() { + return fitMinMaxSizeDp( + PixelDensity::pxToDp( + Sizef( getTextWidth() + mPaddingPx.getWidth(), + eemax( getTextHeight(), static_cast( mTextCache.getLineSpacing() ) ) + + mPaddingPx.getHeight() ) ) + .ceil() ); + } + + void updateArrows() { + if ( mUpdatingArrows || !mTextCache.getFont() ) + return; + mUpdatingArrows = true; + for ( size_t i = 0; i < Sections.size(); ++i ) { + const auto section = findSection( Sections[i] ); + if ( section && !mButtons[i][0] ) + createButtons( i ); + for ( size_t direction = 0; direction < 2; ++direction ) { + auto* button = mButtons[i][direction]; + if ( !button ) + continue; + button->setVisible( section.has_value() ); + button->setEnabled( section.has_value() && isEditingAllowed() ); + if ( !section ) + continue; + const Float start = mTextCache.findCharacterPos( section->start ).x; + const Float end = mTextCache.findCharacterPos( section->start + section->length ).x; + const Float center = PixelDensity::pxToDp( mPaddingPx.Left + mRealAlignOffset.x + + ( start + end ) * 0.5f ); + const Float y = + direction == 0 + ? ( getPadding().Top - button->getSize().getHeight() ) * 0.5f + : getSize().getHeight() - getPadding().Bottom + + ( getPadding().Bottom - button->getSize().getHeight() ) * 0.5f; + button->setPosition( center - button->getSize().getWidth() * 0.5f, y ); + } + } + mUpdatingArrows = false; + } + + protected: + Uint32 onKeyDown( const KeyEvent& event ) override { + if ( !mOwner ) + return UITimePicker::onKeyDown( event ); + if ( event.getKeyCode() == KEY_ESCAPE ) { + resetPendingDigits(); + updateTextFromValue(); + mOwner->hideCalendar(); + return 1; + } + if ( event.getKeyCode() == KEY_RETURN || event.getKeyCode() == KEY_KP_ENTER ) { + commit(); + mOwner->hideCalendar(); + return 1; + } + return UITimePicker::onKeyDown( event ); + } + + void draw() override { + // Reserve top/bottom padding for the child arrows while clipping the inherited text. + clipSmartEnable( mScreenPos.x + mPaddingPx.Left, mScreenPos.y + mPaddingPx.Top, + eemax( 0.f, mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right ), + eemax( 0.f, mSize.getHeight() - mPaddingPx.Top - mPaddingPx.Bottom ) ); + UITimePicker::draw(); + clipSmartDisable(); + } + + void onAutoSize() override { + if ( mPopup ) + mPopup->updateLayout(); + else + UITimePicker::onAutoSize(); + updateArrows(); + } + + void onSizeChange() override { + UITimePicker::onSizeChange(); + if ( mPopup ) + mPopup->updateLayout(); + updateArrows(); + } + + void onFontChanged() override { + UITimePicker::onFontChanged(); + onAutoSize(); + } + + void onFontStyleChanged() override { + UITimePicker::onFontStyleChanged(); + onAutoSize(); + } + + void onPaddingChange() override { + UITimePicker::onPaddingChange(); + onAutoSize(); + } + + void alignFix() override { + UITimePicker::alignFix(); + // The input retains its scrolling offset. Honor CSS alignment when the whole time fits. + if ( getTextWidth() <= mSize.getWidth() - mPaddingPx.getWidth() ) + UITextView::alignFix(); + updateArrows(); + } + + void onDocumentSelectionChange( const TextRange& range ) override { + UITimePicker::onDocumentSelectionChange( range ); + updateArrows(); + } + + private: + void createButtons( size_t index ) { + const auto section = Sections[index]; + for ( size_t direction = 0; direction < 2; ++direction ) { + auto* button = UIPushButton::NewWithTag( direction == 0 ? "datetimepicker::time-up" + : "datetimepicker::time-down" ); + button->addClass( SectionNames[index] ); + button->unsetFlags( UI_AUTO_SIZE ); + button->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + button->setSize( 24, 22 ); + button->setParent( this ); + const Int32 delta = direction == 0 ? 1 : -1; + mButtonConnections[index][direction] = + button->connect( Event::MouseClick, [this, section, delta]( const Event* event ) { + if ( !( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) || !mOwner || + !mOwner->isEnabled() || !mOwner->isEditingAllowed() || + !mOwner->isCalendarVisible() ) + return; + setActiveSection( section ); + stepActiveSection( delta ); + setFocus(); + } ); + mButtons[index][direction] = button; + } + } + + static constexpr std::array Sections{ + DateTimeSection::Hour, DateTimeSection::Minute, DateTimeSection::Second, + DateTimeSection::Millisecond, DateTimeSection::AmPm }; + static constexpr std::array SectionNames{ "hour", "minute", "second", + "millisecond", "am-pm" }; + UIDateTimePicker* mOwner; + DateTimePopup* mPopup{ nullptr }; + std::array, 5> mButtons{}; + bool mUpdatingArrows{ false }; + std::array, 5> mButtonConnections; +}; + +DateTimePopup::DateTimePopup( UICalendar* calendar, PopupTimePicker* time ) : + UIWidget( "datetimepicker::popup" ), mCalendar( calendar ), mTime( time ) { + setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ); + mCalendar->setParent( this ); + mTime->setParent( this ); + mCalendarSizeConnection = + mCalendar->connect( Event::OnSizeChange, [this]( const Event* ) { updateLayout(); } ); + mCalendarCloseConnection = + mCalendar->connect( Event::OnClose, [this]( const Event* ) { mCalendar = nullptr; } ); + mTimeCloseConnection = + mTime->connect( Event::OnClose, [this]( const Event* ) { mTime = nullptr; } ); + mTime->setPopup( this ); + updateLayout(); +} + +DateTimePopup::~DateTimePopup() { + // Children are deleted by Node after this derived object has finished destruction. + mCalendarSizeConnection.disconnect(); + mCalendarCloseConnection.disconnect(); + mTimeCloseConnection.disconnect(); + if ( mTime ) { + mTime->setPopup( nullptr ); + } +} + +void DateTimePopup::updateLayout() { + if ( mUpdatingLayout || !mCalendar || !mTime || isClosing() ) + return; + mUpdatingLayout = true; + const Sizef timeSize( mTime->getPreferredSize() ); + const Float width = eemax( mCalendar->getSize().getWidth(), timeSize.getWidth() ); + const Float height = mCalendar->getSize().getHeight() + timeSize.getHeight(); + const Sizef size = fitMinMaxSizeDp( + { getLayoutWidthPolicy() == SizePolicy::WrapContent ? width + getPadding().getWidth() + : getSize().getWidth(), + getLayoutHeightPolicy() == SizePolicy::WrapContent ? height + getPadding().getHeight() + : getSize().getHeight() } ); + setSize( size ); + const Float contentWidth = eemax( 0.f, size.getWidth() - getPadding().getWidth() ); + mCalendar->setPosition( getPadding().Left + + ( contentWidth - mCalendar->getSize().getWidth() ) * 0.5f, + getPadding().Top ); + mTime->setSize( contentWidth, timeSize.getHeight() ); + mTime->setPosition( getPadding().Left, getPadding().Top + mCalendar->getSize().getHeight() ); + mTime->updateArrows(); + mUpdatingLayout = false; +} + +} // namespace + +UIDateTimePicker* UIDateTimePicker::New() { + return eeNew( UIDateTimePicker, () ); +} + +UIDateTimePicker::UIDateTimePicker() : + UIDatePicker( DateTimeEditMode::DateTime, "datetimepicker" ) { + mConnections[0] = + connect( Event::OnValueChange, [this]( const Event* ) { synchronizeTimePopup(); } ); + mConnections[1] = + connect( Event::OnTextChanged, [this]( const Event* ) { synchronizeTimePopup(); } ); +} + +UIDateTimePicker::~UIDateTimePicker() { + // The popup may be parented to a window/root and therefore needs explicit owner cleanup. + auto* popup = mPopup; + auto* time = static_cast( mPopupTimePicker ); + const bool deferred = + popup && ( popup->isClosing() || ( mCalendar && mCalendar->isClosing() ) || + ( time && time->isClosing() ) ); + mCalendarVisible = false; + mConnections = {}; + mCalendarConnections = {}; + mTimeConnections = {}; + mPopupConnections = {}; + if ( time ) { + time->detachOwner(); + } + mPopup = nullptr; + mPopupTimePicker = nullptr; + if ( popup ) { + mCalendar = nullptr; + // Deleting a queued child or popup here would leave its pointer in the scene's close + // list. Let the scene finish that deletion, with all owner callbacks disconnected. + if ( deferred ) { + popup->setEnabled( false ); + popup->setVisible( false ); + if ( !popup->isClosing() ) + popup->close(); + } else { + eeDelete( popup ); + } + } +} + +Uint32 UIDateTimePicker::getType() const { + return UI_TYPE_DATETIMEPICKER; +} + +bool UIDateTimePicker::isType( const Uint32& type ) const { + return type == UI_TYPE_DATETIMEPICKER || UIDatePicker::isType( type ); +} + +UIWidget* UIDateTimePicker::getCalendarPopup() const { + return mPopup; +} + +UITimePicker* UIDateTimePicker::getPopupTimePicker() const { + return mPopupTimePicker; +} + +void UIDateTimePicker::prepareCalendarPopup() { + if ( mPopup && + ( !mPopupTimePicker || mCalendar->getParent() != mPopup || mPopup->isClosing() ) ) { + // A closed child calendar/time editor must not leave a stale composite on the next open. + mCalendar->setParent( this ); + auto* popup = mPopup; + mPopup = nullptr; + mPopupTimePicker = nullptr; + eeDelete( popup ); + } + if ( !mPopup ) { + auto* time = eeNew( PopupTimePicker, ( this ) ); + mPopupTimePicker = time; + mPopup = eeNew( DateTimePopup, ( mCalendar, time ) ); + mPopup->setEnabled( false ); + mPopup->setVisible( false ); + mPopup->setParent( this ); + mPopupConnections[0] = + mPopup->connect( Event::OnClose, [this, popup = mPopup]( const Event* ) { + if ( mPopup != popup ) + return; + mCalendar = nullptr; + mPopup = nullptr; + mPopupTimePicker = nullptr; + mCalendarVisible = false; + mSceneSizeConnection.disconnect(); + } ); + mPopupConnections[1] = mPopup->connect( Event::OnSizeChange, [this]( const Event* ) { + if ( mCalendarVisible ) + UIPopUp::align( this, mPopup, mPopUpToRoot ); + } ); + mPopupConnections[2] = mPopup->connect( Event::OnFocusWithinLoss, + [this]( const Event* ) { closeOnFocusLoss(); } ); + mPopupConnections[3] = mPopup->connect( Event::KeyDown, [this]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_ESCAPE ) + hideCalendar(); + } ); + mTimeConnections[0] = + time->connect( Event::OnValueChange, [this]( const Event* ) { selectPopupTime(); } ); + mTimeConnections[1] = time->connect( Event::OnClose, [this, time]( const Event* ) { + if ( mPopupTimePicker == time ) { + mPopupTimePicker = nullptr; + hideCalendar( false ); + } + } ); + } + mCalendar->setEnabled( true ); + mCalendar->setVisible( true ); + synchronizeTimePopup(); +} + +void UIDateTimePicker::synchronizeTimePopup() { + if ( !mPopup || !mPopupTimePicker || !mCalendar || mSynchronizingPopup ) + return; + mSynchronizingPopup = true; + auto* time = static_cast( mPopupTimePicker ); + // Only the time-relevant locale fields affect this editor. Avoid copying/recompiling + // the complete locale on each value change. + if ( time->getLocale().hourCycle != getLocale().hourCycle || + time->getLocale().am != getLocale().am || time->getLocale().pm != getLocale().pm ) + time->setLocale( getLocale() ); + time->setHourCycle( getHourCycle() ); + time->setShowSeconds( getShowSeconds() ); + time->setShowMilliseconds( getShowMilliseconds() ); + time->setTimeFormat( getTimeFormat() ); + time->setHourStep( getHourStep() ); + time->setMinuteStep( getMinuteStep() ); + time->setSecondStep( getSecondStep() ); + time->setPendingDigitTimeout( getPendingDigitTimeout() ); + time->setAllowEmpty( false ); + time->setAllowEditing( isEditingAllowed() && isEnabled() ); + const CalendarDate date = getDateTime() ? getDateTime()->date : mCalendar->getFocusedDate(); + const std::optional minimum = + getMinDateTime() && getMinDateTime()->date == date + ? std::optional( getMinDateTime()->time ) + : std::nullopt; + const std::optional maximum = + getMaxDateTime() && getMaxDateTime()->date == date + ? std::optional( getMaxDateTime()->time ) + : std::nullopt; + if ( time->getMinTime() != minimum || time->getMaxTime() != maximum ) { + time->setMinTime( std::nullopt ); + time->setMaxTime( std::nullopt ); + time->setMinTime( minimum ); + time->setMaxTime( maximum ); + } + TimeOfDay value = getDateTime() ? getDateTime()->time : TimeOfDay{}; + if ( minimum && value < *minimum ) + value = *minimum; + if ( maximum && value > *maximum ) + value = *maximum; + if ( time->getTime() != value ) + time->setTime( value ); + if ( getDateTime() && mCalendar->getSelectedDate() != getDateTime()->date ) + mCalendar->setSelectedDate( getDateTime()->date ); + else if ( !getDateTime() && mCalendar->getSelectedDate() ) + mCalendar->clearSelection(); + time->updateArrows(); + static_cast( mPopup )->updateLayout(); + mSynchronizingPopup = false; +} + +void UIDateTimePicker::onConstraintsChange() { + UIDatePicker::onConstraintsChange(); + synchronizeTimePopup(); +} + +void UIDateTimePicker::onCalendarFocusedDateChange() { + synchronizeTimePopup(); +} + +void UIDateTimePicker::onConfigurationChange() { + UIDatePicker::onConfigurationChange(); + synchronizeTimePopup(); +} + +void UIDateTimePicker::selectCalendarDate() { + if ( !mCalendar || !mCalendar->getSelectedDate() || !isEnabled() || !isEditingAllowed() ) + return; + setDateTime( LocalDateTime{ *mCalendar->getSelectedDate(), + getDateTime() ? getDateTime()->time : TimeOfDay{} } ); + synchronizeTimePopup(); +} + +void UIDateTimePicker::selectPopupTime() { + if ( mSynchronizingPopup || !mCalendar || !mPopupTimePicker ) + return; + if ( isCalendarVisible() && isEnabled() && isEditingAllowed() && mPopupTimePicker->getTime() ) { + const CalendarDate date = getDateTime() ? getDateTime()->date : mCalendar->getFocusedDate(); + setDateTime( LocalDateTime{ date, *mPopupTimePicker->getTime() } ); + } + synchronizeTimePopup(); +} + +UIDateTimePicker* UIDateTimePicker::hideCalendar( bool restoreFocus ) { + if ( mPopupTimePicker && !mSynchronizingPopup ) + static_cast( mPopupTimePicker )->commit(); + UIDatePicker::hideCalendar( restoreFocus ); + return this; +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uidropdown.cpp b/src/eepp/ui/uidropdown.cpp index 6e276a7cb..d47a76336 100644 --- a/src/eepp/ui/uidropdown.cpp +++ b/src/eepp/ui/uidropdown.cpp @@ -1,10 +1,8 @@ -#include #include #include #include #include -#include -#include +#include namespace EE { namespace UI { @@ -129,50 +127,9 @@ Float UIDropDown::getPopUpWidth( Float contentsWidth ) const { } void UIDropDown::alignPopUp( UIWidget* widget ) { - if ( !mStyleConfig.PopUpToRoot ) - widget->setParent( getWindowContainer() ); - else - widget->setParent( getUISceneNode()->getRoot() ); - - widget->toFront(); - - bool center = mStyleConfig.menuWidthRule == MenuWidthMode::ContentsCentered || - mStyleConfig.menuWidthRule == MenuWidthMode::ExpandIfNeededCentered; - - // The placement is decided in screen pixels, where the field, the popup and the scene bounds - // are directly comparable. Deriving it from the node's own dp position previously mixed - // coordinate spaces (the candidate point was expressed in the parent's space but converted - // through the field's own nodeToWorld, shifting the test rectangle by the field's offset), so - // the "fits below" check failed for any field away from its parent's origin and the popup was - // flipped above the field even when there was no room there. - const Rectf field( getScreenRect() ); - const Sizef popUpSize( widget->getPixelsSize() ); - const Rectf sceneBounds( getUISceneNode()->getWorldBounds() ); - - Float x = center ? field.Left + eefloor( ( field.getWidth() - popUpSize.getWidth() ) * 0.5f ) - : field.Left; - - // Prefer below the field, fall back to above it, and only then clamp: the list is never placed - // partially off screen when the scene has room for it on either side. - Float y = field.Bottom; - if ( y + popUpSize.getHeight() > sceneBounds.Bottom ) { - Float above = field.Top - popUpSize.getHeight(); - y = above >= sceneBounds.Top - ? above - : eemax( sceneBounds.Top, sceneBounds.Bottom - popUpSize.getHeight() ); - } - - // Keep the popup inside the scene horizontally as well; a list wider than its field used to - // run off the right edge. - x = eeclamp( x, sceneBounds.Left, - eemax( sceneBounds.Left, sceneBounds.Right - popUpSize.getWidth() ) ); - - // World coordinates are pixels and worldToNode already converts back to dp, which is what - // setPosition expects; applying the density a second time would shift the popup. - Vector2f pos( x, y ); - widget->getParent()->worldToNode( pos ); - - widget->setPosition( pos ); + const bool center = mStyleConfig.menuWidthRule == MenuWidthMode::ContentsCentered || + mStyleConfig.menuWidthRule == MenuWidthMode::ExpandIfNeededCentered; + UIPopUp::align( this, widget, mStyleConfig.PopUpToRoot, center ); show(); widget->setFocus(); } @@ -234,7 +191,7 @@ void UIDropDown::onPopUpFocusLoss() { std::find( mRelatedWidgets.begin(), mRelatedWidgets.end(), getEventDispatcher()->getFocusNode() ) != mRelatedWidgets.end(); - if ( getEventDispatcher()->getFocusNode() != this && !isChildFocus && !friendIsFocus && + if ( !UIPopUp::hasFocus( this, getPopUpWidget() ) && !isChildFocus && !friendIsFocus && !isRelatedWidget ) { hide(); } @@ -248,36 +205,11 @@ void UIDropDown::onItemClicked( const Event* ) { void UIDropDown::onItemSelected( const Event* ) {} void UIDropDown::show() { - UIWidget* widget = getPopUpWidget(); - if ( NULL == widget ) - return; - - widget->setEnabled( true ); - widget->setVisible( true ); - - if ( NULL != getUISceneNode() && - getUISceneNode()->getUIThemeManager()->getDefaultEffectsEnabled() ) { - widget->runAction( Actions::Sequence::New( - Actions::Fade::New( 255.f == widget->getAlpha() ? 0.f : widget->getAlpha(), 255.f, - getUISceneNode()->getUIThemeManager()->getWidgetsFadeOutTime() ), - Actions::Spawn::New( Actions::Enable::New(), Actions::Visible::New( true ) ) ) ); - } + UIPopUp::show( getPopUpWidget() ); } void UIDropDown::hide() { - UIWidget* widget = getPopUpWidget(); - if ( NULL == widget ) - return; - - if ( NULL != getUISceneNode() && - getUISceneNode()->getUIThemeManager()->getDefaultEffectsEnabled() ) { - widget->runAction( Actions::Sequence::New( - Actions::FadeOut::New( getUISceneNode()->getUIThemeManager()->getWidgetsFadeOutTime() ), - Actions::Spawn::New( Actions::Disable::New(), Actions::Visible::New( false ) ) ) ); - } else { - widget->setEnabled( false ); - widget->setVisible( false ); - } + UIPopUp::hide( getPopUpWidget() ); } Uint32 UIDropDown::onMouseOver( const Vector2i& position, const Uint32& flags ) { diff --git a/src/eepp/ui/uigridlayout.cpp b/src/eepp/ui/uigridlayout.cpp index 242accf0b..08c15e456 100644 --- a/src/eepp/ui/uigridlayout.cpp +++ b/src/eepp/ui/uigridlayout.cpp @@ -1,5 +1,6 @@ #include #include +#include namespace EE { namespace UI { @@ -149,6 +150,10 @@ void UIGridLayout::updateLayout() { pos.x = initX; } + // Fractional column weights accumulate rounding error. Treat subpixel differences at the + // right edge as a fit so a seven-column grid does not unexpectedly wrap after six cells. + const Float edgeTolerance = + std::max( 1.f, getSize().getWidth() ) * std::numeric_limits::epsilon() * 8; bool usedLastRow = true; while ( NULL != ChildLoop ) { @@ -168,9 +173,10 @@ void UIGridLayout::updateLayout() { pos.x += getHorizontalAlign() == UI_HALIGN_RIGHT ? -targetSize.getWidth() : targetSize.getWidth(); - if ( pos.x < mPadding.Left || - pos.x + targetSize.x > getSize().getWidth() - mPadding.Right || - pos.x + targetSize.x + mBoxMargin.x > getSize().getWidth() - mPadding.Right ) { + if ( pos.x < mPadding.Left - edgeTolerance || + pos.x + targetSize.x > getSize().getWidth() - mPadding.Right + edgeTolerance || + pos.x + targetSize.x + mBoxMargin.x > + getSize().getWidth() - mPadding.Right + edgeTolerance ) { if ( getHorizontalAlign() == UI_HALIGN_CENTER ) { pos.x = initX; diff --git a/src/eepp/ui/uihtmlform.cpp b/src/eepp/ui/uihtmlform.cpp index 5b6ca9912..680f04869 100644 --- a/src/eepp/ui/uihtmlform.cpp +++ b/src/eepp/ui/uihtmlform.cpp @@ -76,6 +76,31 @@ std::vector UIHTMLForm::getPropertiesImplemented() const { return props; } +namespace { + +bool checkFormControls( const Node* node ) { + if ( node->isType( UI_TYPE_HTML_INPUT ) ) + return static_cast( node )->checkValidity(); + for ( const Node* child = node->getFirstChild(); child; child = child->getNextNode() ) { + if ( !checkFormControls( child ) ) + return false; + } + return true; +} + +} // namespace + +bool UIHTMLForm::checkValidity() const { + return checkFormControls( this ); +} + +bool UIHTMLForm::requestSubmit() { + if ( !checkValidity() ) + return false; + submit(); + return true; +} + void UIHTMLForm::submit() { std::vector> fields; collectFormData( this, fields ); @@ -132,8 +157,6 @@ void UIHTMLForm::submit() { static String getWidgetFormValue( UIWidget* widget ) { if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) return static_cast( widget )->getFormValue(); - if ( widget->isType( UI_TYPE_HTML_INPUT ) ) - return static_cast( widget )->getFormValue(); if ( widget->isType( UI_TYPE_TEXTINPUT ) ) return static_cast( widget )->getText(); if ( widget->isType( UI_TYPE_TEXTEDIT ) ) @@ -146,7 +169,9 @@ void UIHTMLForm::collectFormData( Node* node, if ( !node ) return; - UIWidget* widget = node->asType(); + if ( node->isType( UI_TYPE_HTML_INPUT ) && !node->isEnabled() ) + return; + UIWidget* widget = node->isWidget() ? node->asType() : nullptr; if ( widget ) { std::string name; UIStyle* style = widget->getUIStyle(); @@ -159,8 +184,13 @@ void UIHTMLForm::collectFormData( Node* node, name = widget->getPropertyString( "name" ); if ( !name.empty() ) { String value = getWidgetFormValue( widget ); - if ( !value.empty() ) + const auto* input = widget->isType( UI_TYPE_HTML_INPUT ) + ? static_cast( widget ) + : nullptr; + if ( !value.empty() || ( input && input->getChildWidget() && + input->getChildWidget()->isType( UI_TYPE_DATETIMEEDIT ) ) ) { fields.emplace_back( name, value.toUtf8() ); + } } } @@ -173,7 +203,7 @@ void UIHTMLForm::collectFormData( Node* node, Uint32 UIHTMLForm::onMessage( const NodeMessage* msg ) { if ( msg->getMsg() == NodeMessage::MouseClick && isSubmitTrigger( msg->getSender() ) ) { - submit(); + requestSubmit(); return 1; } return UIRichText::onMessage( msg ); diff --git a/src/eepp/ui/uihtmlinput.cpp b/src/eepp/ui/uihtmlinput.cpp index 62293e3f1..ec3cbaf58 100644 --- a/src/eepp/ui/uihtmlinput.cpp +++ b/src/eepp/ui/uihtmlinput.cpp @@ -1,5 +1,9 @@ +#include +#include #include #include +#include +#include #include #include #include @@ -8,11 +12,109 @@ #include #include #include +#include +#include namespace EE { namespace UI { +using namespace EE::System; + namespace { +static void markAnonymousControlTree( Node* node ) { + if ( node->isWidget() ) + node->asType()->setFlags( UI_IGNORE_GLOBAL_CSS ); + for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) + markAnonymousControlTree( child ); +} + +// HTML temporal values have stricter syntax than the native editor's tolerant paste parser. +// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#dates-and-times +static std::optional parseHTMLDate( std::string_view text ) { + const size_t dash = text.find( '-' ); + if ( dash < 4 || dash == std::string_view::npos || text.size() != dash + 6 || + text[dash + 3] != '-' ) + return std::nullopt; + for ( size_t i = 0; i < text.size(); ++i ) { + if ( i != dash && i != dash + 3 && ( text[i] < '0' || text[i] > '9' ) ) + return std::nullopt; + } + Int32 year; + const auto parsed = std::from_chars( text.data(), text.data() + dash, year ); + if ( parsed.ec != std::errc() || year < 1 ) + return std::nullopt; + const auto pair = [&]( size_t pos ) { return ( text[pos] - '0' ) * 10 + text[pos + 1] - '0'; }; + CalendarDate value{ year, static_cast( pair( dash + 1 ) ), + static_cast( pair( dash + 4 ) ) }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +static std::optional parseHTMLTime( std::string_view text ) { + if ( !( text.size() == 5 || text.size() == 8 || ( text.size() >= 10 && text.size() <= 12 ) ) || + text[2] != ':' || ( text.size() > 5 && text[5] != ':' ) || + ( text.size() > 8 && text[8] != '.' ) ) + return std::nullopt; + for ( size_t i = 0; i < text.size(); ++i ) { + if ( i != 2 && i != 5 && i != 8 && ( text[i] < '0' || text[i] > '9' ) ) + return std::nullopt; + } + const auto pair = [&]( size_t pos ) { return ( text[pos] - '0' ) * 10 + text[pos + 1] - '0'; }; + Uint16 ms = 0; + for ( size_t i = 9; i < text.size(); ++i ) + ms = ms * 10 + text[i] - '0'; + for ( size_t i = text.size(); i > 8 && i < 12; ++i ) + ms *= 10; + TimeOfDay value{ static_cast( pair( 0 ) ), static_cast( pair( 3 ) ), + static_cast( text.size() > 5 ? pair( 6 ) : 0 ), ms }; + return value.isValid() ? std::optional( value ) : std::nullopt; +} + +static std::optional parseHTMLTemporal( std::string_view text, + const std::string& type ) { + if ( type == "date" ) { + if ( auto date = parseHTMLDate( text ) ) + return LocalDateTime{ *date, {} }; + } else if ( type == "time" ) { + if ( auto time = parseHTMLTime( text ) ) + return LocalDateTime{ {}, *time }; + } else { + const size_t separator = text.find_first_of( "T " ); + if ( separator != std::string_view::npos ) { + const auto date = parseHTMLDate( text.substr( 0, separator ) ); + const auto time = parseHTMLTime( text.substr( separator + 1 ) ); + if ( date && time ) + return LocalDateTime{ *date, *time }; + } + } + return std::nullopt; +} + +static std::string formatHTMLTime( const TimeOfDay& time ) { + std::string value = DateTimeFormatter::toISOTime( time, time.millisecond != 0 ); + if ( time.millisecond ) { + while ( value.back() == '0' ) + value.pop_back(); + } else if ( !time.second ) { + value.resize( 5 ); + } + return value; +} + +static long double htmlTemporalNumber( const LocalDateTime& value ) { + return static_cast( value.date.daysSinceEpoch() ) * 86400000.L + + ( value.time.hour * 3600 + value.time.minute * 60 + value.time.second ) * 1000.L + + value.time.millisecond; +} + +static double htmlTemporalStep( const std::string& text, bool date ) { + double step; + const auto parsed = std::from_chars( text.data(), text.data() + text.size(), step ); + return parsed.ec == std::errc() && parsed.ptr == text.data() + text.size() && + std::isfinite( step ) && step > 0 + ? step + : ( date ? 1. : 60. ); +} + static bool htmlBoolAttributeIsTrue( const StyleSheetProperty& property ) { if ( property.value().empty() ) return true; @@ -77,13 +179,6 @@ static bool implementationPropertyAffectsIntrinsicSize( PropertyId id ) { } } -static void markAnonymousControlTree( Node* node ) { - if ( node->isWidget() ) - node->asType()->setFlags( UI_IGNORE_GLOBAL_CSS ); - for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) - markAnonymousControlTree( child ); -} - } // namespace UIHTMLInput* UIHTMLInput::New() { @@ -123,10 +218,31 @@ bool UIHTMLInput::applyProperty( const StyleSheetProperty& attribute ) { return UIHTMLWidget::applyProperty( attribute ); case PropertyId::Value: case PropertyId::Text: + mImplementationProperties[PropertyId::Value] = attribute; + if ( isTemporalInput() ) { + setTemporalValue( attribute.value() ); + syncTemporalConfiguration(); + return true; + } mValue = attribute.value(); if ( mChildWidget && !( mInputType == "checkbox" || mInputType == "radio" ) ) mChildWidget->applyProperty( attribute ); return UIHTMLWidget::applyProperty( attribute ); + case PropertyId::Min: + case PropertyId::Max: + case PropertyId::Step: + case PropertyId::Required: + case PropertyId::ReadOnly: + case PropertyId::Disabled: + mImplementationProperties[id] = attribute; + if ( id == PropertyId::Disabled ) + setEnabled( false ); + if ( isTemporalInput() ) + syncTemporalConfiguration(); + else if ( id == PropertyId::ReadOnly && mChildWidget && + mChildWidget->isType( UI_TYPE_TEXTINPUT ) ) + mChildWidget->asType()->setAllowEditing( false ); + return true; case PropertyId::Type: setInputType( attribute.value() ); return true; @@ -150,7 +266,7 @@ std::string UIHTMLInput::getPropertyString( const PropertyDefinition* propertyDe switch ( propertyDef->getPropertyId() ) { case PropertyId::Value: - return mValue; + return isTemporalInput() ? getFormValue().toUtf8() : mValue.toUtf8(); case PropertyId::Selected: return mChecked ? "true" : "false"; case PropertyId::Type: @@ -159,6 +275,16 @@ std::string UIHTMLInput::getPropertyString( const PropertyDefinition* propertyDe break; } + if ( auto it = mImplementationProperties.find( propertyDef->getPropertyId() ); + it != mImplementationProperties.end() && + ( propertyDef->getPropertyId() == PropertyId::Min || + propertyDef->getPropertyId() == PropertyId::Max || + propertyDef->getPropertyId() == PropertyId::Step || + propertyDef->getPropertyId() == PropertyId::Required || + propertyDef->getPropertyId() == PropertyId::ReadOnly || + propertyDef->getPropertyId() == PropertyId::Disabled ) ) + return it->second.value(); + if ( mChildWidget ) { std::string val = mChildWidget->getPropertyString( propertyDef, propertyIndex ); if ( !val.empty() ) @@ -173,6 +299,9 @@ std::vector UIHTMLInput::getPropertiesImplemented() const { props.push_back( PropertyId::Type ); props.push_back( PropertyId::Value ); props.push_back( PropertyId::Checked ); + for ( const auto id : { PropertyId::Min, PropertyId::Max, PropertyId::Step, + PropertyId::Required, PropertyId::ReadOnly, PropertyId::Disabled } ) + props.push_back( id ); return props; } @@ -215,6 +344,8 @@ UIWidget* UIHTMLInput::getChildWidget() const { } void UIHTMLInput::createChildWidget() { + mControlChildrenConnection.disconnect(); + mPopupTimeChildrenConnection.disconnect(); if ( mChildWidget ) { mChildWidget->close(); mChildWidget = nullptr; @@ -234,6 +365,12 @@ void UIHTMLInput::createChildWidget() { setVisible( false ); setEnabled( false ); mDisplay = CSSDisplay::None; + } else if ( mInputType == "date" ) { + mChildWidget = UIDatePicker::New(); + } else if ( mInputType == "time" ) { + mChildWidget = UITimePicker::New(); + } else if ( mInputType == "datetime-local" ) { + mChildWidget = UIDateTimePicker::New(); } else if ( mInputType == "number" ) { mChildWidget = UISpinBox::New(); } else if ( mInputType == "password" ) { @@ -261,6 +398,27 @@ void UIHTMLInput::createChildWidget() { void UIHTMLInput::configureChildWidget() { mChildWidget->setParent( this ); markAnonymousControlTree( mChildWidget ); + if ( isTemporalInput() ) { + // Anonymous popups remain in the document scene and use its private UA defaults. + mControlChildrenConnection = + mChildWidget->connect( Event::OnChildCountChanged, [this]( const Event* event ) { + const auto* change = static_cast( event ); + if ( change->removed() ) + return; + markAnonymousControlTree( change->child() ); + if ( auto* time = change->child()->findByType( UI_TYPE_TIMEPICKER ) ) { + // Seconds and milliseconds can create new arrows after the popup is open. + mPopupTimeChildrenConnection = + time->connect( Event::OnChildCountChanged, []( const Event* event ) { + const auto* change = + static_cast( event ); + if ( !change->removed() ) + markAnonymousControlTree( change->child() ); + } ); + } + } ); + static_cast( mChildWidget )->setClampToBounds( false ); + } mChildWidget->setLayoutWidthPolicy( SizePolicy::WrapContent ); mChildWidget->setLayoutHeightPolicy( SizePolicy::WrapContent ); @@ -304,11 +462,23 @@ void UIHTMLInput::syncImplementationState() { if ( !mChildWidget ) return; - for ( const auto& prop : mImplementationProperties ) - mChildWidget->applyProperty( prop.second ); + for ( const auto& [id, property] : mImplementationProperties ) { + if ( id != PropertyId::Value && id != PropertyId::Min && id != PropertyId::Max && + id != PropertyId::Step && id != PropertyId::Required && id != PropertyId::ReadOnly && + id != PropertyId::Disabled ) + mChildWidget->applyProperty( property ); + } - if ( !( mInputType == "checkbox" || mInputType == "radio" ) ) + if ( isTemporalInput() ) { + setTemporalValue( mValue ); + syncTemporalConfiguration(); + } else if ( !( mInputType == "checkbox" || mInputType == "radio" ) ) { mChildWidget->applyProperty( StyleSheetProperty( "value", mValue ) ); + if ( mImplementationProperties.count( PropertyId::ReadOnly ) && + mChildWidget->isType( UI_TYPE_TEXTINPUT ) ) { + mChildWidget->asType()->setAllowEditing( false ); + } + } syncCheckedState(); updateHostGeometry(); mChildWidget->setLayoutWidthPolicy( SizePolicy::Fixed ); @@ -335,9 +505,12 @@ void UIHTMLInput::updateHostGeometry() { const Rectf contentOffset = getPixelsContentOffset(); Sizef size = getPixelsSize(); - if ( getLayoutWidthPolicy() == SizePolicy::WrapContent ) - size.setWidth( mChildWidget->getPixelsSize().getWidth() + contentOffset.Left + - contentOffset.Right ); + if ( getLayoutWidthPolicy() == SizePolicy::WrapContent ) { + // Temporal editors may already have a fixed used size when their font or value changes. + const Float width = isTemporalInput() ? mChildWidget->getMaxIntrinsicWidth() + : mChildWidget->getPixelsSize().getWidth(); + size.setWidth( width + contentOffset.Left + contentOffset.Right ); + } if ( getLayoutHeightPolicy() == SizePolicy::WrapContent || !mHasSpecifiedHeight ) { Float contentHeight = mChildWidget->getPixelsSize().getHeight(); if ( mChildWidget->isType( UI_TYPE_HTML_TEXTINPUT ) ) @@ -352,9 +525,17 @@ void UIHTMLInput::updateHostGeometry() { } Float UIHTMLInput::getReplacedElementBaseline() const { - if ( mChildWidget && mChildWidget->isType( UI_TYPE_HTML_TEXTINPUT ) ) + if ( mChildWidget && mChildWidget->isType( UI_TYPE_HTML_TEXTINPUT ) ) { return getPixelsContentOffset().Top + mChildWidget->asType()->getTextBaseline(); + } + if ( isTemporalInput() && mChildWidget ) { + const auto* edit = static_cast( mChildWidget ); + if ( edit->getFont() ) { + return getPixelsContentOffset().Top + edit->getPixelsPadding().Top + + edit->getRealAlignOffset().y + edit->getFont()->getAscent( edit->getFontSize() ); + } + } return getPixelsSize().getHeight(); } @@ -401,12 +582,168 @@ String UIHTMLInput::getFormValue() const { if ( mInputType == "button" || mInputType == "submit" ) return static_cast( mChildWidget )->getText(); + if ( isTemporalInput() ) { + auto* edit = static_cast( mChildWidget ); + const auto rawValue = parseHTMLTemporal( mValue.toUtf8(), mInputType ); + if ( mInputType != "datetime-local" && rawValue && rawValue == getTemporalValue() ) + return mValue; + if ( mInputType == "date" ) { + return edit->getDate() && edit->getDate()->year > 0 + ? DateTimeFormatter::toISODate( *edit->getDate() ) + : ""; + } + if ( mInputType == "time" ) + return edit->getTime() ? formatHTMLTime( *edit->getTime() ) : ""; + return edit->getDateTime() && edit->getDateTime()->date.year > 0 + ? DateTimeFormatter::toISODate( edit->getDateTime()->date ) + "T" + + formatHTMLTime( edit->getDateTime()->time ) + : ""; + } + if ( mChildWidget->isType( UI_TYPE_TEXTINPUT ) ) return static_cast( mChildWidget )->getText(); return mValue; } +bool UIHTMLInput::isTemporalInput() const { + return mInputType == "date" || mInputType == "time" || mInputType == "datetime-local"; +} + +const std::string& UIHTMLInput::temporalAttribute( PropertyId id ) const { + static const std::string empty; + const auto it = mImplementationProperties.find( id ); + return it == mImplementationProperties.end() ? empty : it->second.value(); +} + +std::optional UIHTMLInput::getTemporalValue() const { + if ( !mChildWidget || !isTemporalInput() ) + return std::nullopt; + const auto* edit = static_cast( mChildWidget ); + if ( mInputType == "date" ) { + if ( edit->getDate() && edit->getDate()->year > 0 ) + return LocalDateTime{ *edit->getDate(), {} }; + } else if ( mInputType == "time" ) { + if ( edit->getTime() ) + return LocalDateTime{ {}, *edit->getTime() }; + } else if ( edit->getDateTime() && edit->getDateTime()->date.year > 0 ) { + return edit->getDateTime(); + } + return std::nullopt; +} + +void UIHTMLInput::setTemporalValue( const String& text ) { + const auto value = parseHTMLTemporal( text.toUtf8(), mInputType ); + auto* edit = static_cast( mChildWidget ); + if ( mInputType == "date" ) + edit->setDate( value ? std::optional( value->date ) : std::nullopt ); + else if ( mInputType == "time" ) + edit->setTime( value ? std::optional( value->time ) : std::nullopt ); + else + edit->setDateTime( value ); + mValue = value ? text : String(); + if ( mInputType == "datetime-local" ) + mValue = getFormValue(); +} + +void UIHTMLInput::syncTemporalConfiguration() { + auto* edit = static_cast( mChildWidget ); + const auto minimum = parseHTMLTemporal( temporalAttribute( PropertyId::Min ), mInputType ); + const auto maximum = parseHTMLTemporal( temporalAttribute( PropertyId::Max ), mInputType ); + // HTML ranges validate without clamping. Native reversed time bounds are not periodic, so + // leave those bounds open and validate the wrapping interval here instead. + if ( mInputType == "date" ) { + edit->setMinDate( std::nullopt ); + edit->setMaxDate( std::nullopt ); + if ( !minimum || !maximum || *minimum <= *maximum ) { + edit->setMinDate( minimum ? minimum->date : CalendarDate{ 1, 1, 1 } ); + edit->setMaxDate( maximum ? std::optional( maximum->date ) + : std::nullopt ); + } + } else if ( mInputType == "time" ) { + edit->setMinTime( std::nullopt ); + edit->setMaxTime( std::nullopt ); + if ( !minimum || !maximum || *minimum <= *maximum ) { + edit->setMinTime( minimum ? std::optional( minimum->time ) : std::nullopt ); + edit->setMaxTime( maximum ? std::optional( maximum->time ) : std::nullopt ); + } + } else { + edit->setMinDateTime( std::nullopt ); + edit->setMaxDateTime( std::nullopt ); + if ( !minimum || !maximum || *minimum <= *maximum ) { + edit->setMinDateTime( minimum.value_or( LocalDateTime{ { 1, 1, 1 }, {} } ) ); + edit->setMaxDateTime( maximum ); + } + } + const auto allowEditing = mImplementationProperties.find( PropertyId::AllowEditing ); + edit->setAllowEditing( + mImplementationProperties.count( PropertyId::ReadOnly ) == 0 && + ( allowEditing == mImplementationProperties.end() || allowEditing->second.asBool() ) ); + const double step = + htmlTemporalStep( temporalAttribute( PropertyId::Step ), mInputType == "date" ); + const auto nativeStep = []( double value ) { + return static_cast( + std::clamp( value, 1., static_cast( std::numeric_limits::max() ) ) ); + }; + edit->setDayStep( nativeStep( step ) ); + edit->setMinuteStep( std::fmod( step, 60. ) == 0 ? nativeStep( step / 60. ) : 1 ); + edit->setSecondStep( nativeStep( step ) ); + const auto value = getTemporalValue(); + const bool milliseconds = + ( value && value->time.millisecond ) || ( minimum && minimum->time.millisecond ) || + ( maximum && maximum->time.millisecond ) || std::floor( step ) != step; + const bool seconds = milliseconds || ( value && value->time.second ) || + ( minimum && minimum->time.second ) || + ( maximum && maximum->time.second ) || std::fmod( step, 60. ) != 0; + edit->setShowMilliseconds( milliseconds ); + edit->setShowSeconds( seconds ); +} + +bool UIHTMLInput::willValidate() const { + return isTemporalInput() && isEnabled() && + mImplementationProperties.count( PropertyId::ReadOnly ) == 0; +} + +HTMLInputValidity UIHTMLInput::getValidity() const { + HTMLInputValidity validity; + if ( !isTemporalInput() || !mChildWidget ) + return validity; + const auto value = getTemporalValue(); + validity.valueMissing = !value && + mImplementationProperties.count( PropertyId::Required ) != 0 && + mImplementationProperties.count( PropertyId::ReadOnly ) == 0; + validity.badInput = !value && static_cast( mChildWidget )->hasValue(); + if ( !value ) + return validity; + const auto minimum = parseHTMLTemporal( temporalAttribute( PropertyId::Min ), mInputType ); + const auto maximum = parseHTMLTemporal( temporalAttribute( PropertyId::Max ), mInputType ); + validity.rangeUnderflow = minimum && *value < *minimum; + validity.rangeOverflow = maximum && *value > *maximum; + if ( mInputType == "time" && minimum && maximum && *minimum > *maximum ) { + validity.rangeUnderflow = validity.rangeOverflow = *value > *maximum && *value < *minimum; + } + const std::string& stepText = temporalAttribute( PropertyId::Step ); + if ( !String::iequals( stepText, "any" ) ) { + const auto initial = + parseHTMLTemporal( temporalAttribute( PropertyId::Value ), mInputType ); + const long double base = minimum ? htmlTemporalNumber( *minimum ) + : initial ? htmlTemporalNumber( *initial ) + : 0.L; + const long double step = htmlTemporalStep( stepText, mInputType == "date" ) * + ( mInputType == "date" ? 86400000.L : 1000.L ); + const long double offset = htmlTemporalNumber( *value ) - base; + const long double remainder = std::abs( std::remainder( offset, step ) ); + validity.stepMismatch = + remainder > std::max( 0.000001L, std::abs( offset ) * + std::numeric_limits::epsilon() * 8 ); + } + return validity; +} + +bool UIHTMLInput::checkValidity() const { + return !willValidate() || getValidity().valid(); +} + void UIHTMLInput::onSizeChange() { UIHTMLWidget::onSizeChange(); updateChildGeometry(); diff --git a/src/eepp/ui/uimenu.cpp b/src/eepp/ui/uimenu.cpp index 9f14c8c81..32344bb37 100644 --- a/src/eepp/ui/uimenu.cpp +++ b/src/eepp/ui/uimenu.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -731,169 +732,82 @@ void UIMenu::findBestMenuPos( Vector2f& pos, UIWidget* menu, UIMenu* parent, UIM if ( nullptr == sceneNode ) return; + if ( trigger || !parent || !subMenu ) { + pos = UIPopUp::findBestPosition( menu, pos, trigger, true ); + return; + } + + // Cascading menus also avoid overlapping the previous menu in the chain. Rectf qScreen( 0.f, 0.f, sceneNode->getPixelsSize().getWidth(), sceneNode->getPixelsSize().getHeight() ); - Vector2f oriPos( pos ); - Rectf qPos( pos.x, pos.y, pos.x + menu->getPixelsSize().getWidth(), - pos.y + menu->getPixelsSize().getHeight() ); + Rectf qPos( pos, menu->getPixelsSize() ); + Rectf qPrevMenu; + bool clipMenu = parent->getOwnerNode() && parent->getOwnerNode()->getParent() && + parent->getOwnerNode()->getParent()->isType( UI_TYPE_MENU ); - if ( nullptr != trigger ) { - Rectf qTrigger = trigger->getScreenRect(); - // Try to position below the trigger - pos.y = qTrigger.Bottom; + Vector2f sPos = subMenu->getPixelsPosition(); + subMenu->nodeToWorldTranslation( sPos ); + + Vector2f pPos = parent->getPixelsPosition(); + parent->nodeToWorldTranslation( pPos ); + + if ( clipMenu ) { + UIMenu* parentOwner = parent->getOwnerNode()->getParent()->asType(); + Vector2f poPos = parentOwner->getPixelsPosition(); + parentOwner->nodeToWorldTranslation( poPos ); + qPrevMenu = Rectf( poPos.x, poPos.y, poPos.x + parentOwner->getPixelsSize().getWidth(), + poPos.y + parentOwner->getPixelsSize().getHeight() ); + } + + Rectf qParent( pPos.x, pPos.y, pPos.x + parent->getPixelsSize().getWidth(), + pPos.y + parent->getPixelsSize().getHeight() ); + + pos.x = qParent.Right; + pos.y = sPos.y; + qPos.Left = pos.x; + qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); + qPos.Top = pos.y; + qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); + + if ( !qScreen.contains( qPos ) || ( clipMenu && qPrevMenu.overlap( qPos ) ) ) { + pos.y = sPos.y + subMenu->getPixelsSize().getHeight() - menu->getPixelsSize().getHeight(); qPos.Top = pos.y; qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - // Try to position above the trigger - pos.y = qTrigger.Top - menu->getPixelsSize().getHeight(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - // Try to position to the right of the trigger - pos.x = qTrigger.Right; - pos.y = qTrigger.Top; - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - // Try to position to the left of the trigger - pos.x = qTrigger.Left - menu->getPixelsSize().getWidth(); - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - - if ( !qScreen.contains( qPos ) ) { - // Reset to original position if no better position found - pos = oriPos; - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - } - } - } - } - } else if ( nullptr != parent && nullptr != subMenu ) { - Rectf qPrevMenu; - bool clipMenu = parent->getOwnerNode() && parent->getOwnerNode()->getParent() && - parent->getOwnerNode()->getParent()->isType( UI_TYPE_MENU ); - - Vector2f sPos = subMenu->getPixelsPosition(); - subMenu->nodeToWorldTranslation( sPos ); - - Vector2f pPos = parent->getPixelsPosition(); - parent->nodeToWorldTranslation( pPos ); - - if ( clipMenu ) { - UIMenu* parentOwner = parent->getOwnerNode()->getParent()->asType(); - Vector2f poPos = parentOwner->getPixelsPosition(); - parentOwner->nodeToWorldTranslation( poPos ); - qPrevMenu = Rectf( poPos.x, poPos.y, poPos.x + parentOwner->getPixelsSize().getWidth(), - poPos.y + parentOwner->getPixelsSize().getHeight() ); - } - - Rectf qParent( pPos.x, pPos.y, pPos.x + parent->getPixelsSize().getWidth(), - pPos.y + parent->getPixelsSize().getHeight() ); - - pos.x = qParent.Right; - pos.y = sPos.y; - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - if ( !qScreen.contains( qPos ) || ( clipMenu && qPrevMenu.overlap( qPos ) ) ) { - pos.y = - sPos.y + subMenu->getPixelsSize().getHeight() - menu->getPixelsSize().getHeight(); + pos.x = qParent.Left - menu->getPixelsSize().getWidth(); + pos.y = sPos.y; + qPos.Left = pos.x; + qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); qPos.Top = pos.y; qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); + if ( !qScreen.contains( qPos ) || ( clipMenu && qPrevMenu.overlap( qPos ) ) ) { - pos.x = qParent.Left - menu->getPixelsSize().getWidth(); - pos.y = sPos.y; - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); + pos.y = sPos.y + subMenu->getPixelsSize().getHeight() - + menu->getPixelsSize().getHeight(); qPos.Top = pos.y; qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - if ( !qScreen.contains( qPos ) || ( clipMenu && qPrevMenu.overlap( qPos ) ) ) { - pos.y = sPos.y + subMenu->getPixelsSize().getHeight() - - menu->getPixelsSize().getHeight(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - if ( menu->getPixelsSize().getHeight() <= qScreen.getHeight() ) { - pos = { pos.x, eefloor( ( qScreen.getHeight() - - menu->getPixelsSize().getHeight() ) * - 0.5f ) }; - } else { - pos = { pos.x, 0 }; - } - } - - if ( pos.x < 0 ) - pos.x = 0; - if ( pos.y < 0 ) - pos.y = 0; - - qPos.Left = qParent.Right; - qPos.Top = pos.y; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - if ( qScreen.contains( qPos ) && ( !clipMenu || !qPrevMenu.overlap( qPos ) ) ) { - pos.x = qPos.Left; + if ( !qScreen.contains( qPos ) ) { + if ( menu->getPixelsSize().getHeight() <= qScreen.getHeight() ) { + pos = { pos.x, eefloor( ( qScreen.getHeight() - + menu->getPixelsSize().getHeight() ) * + 0.5f ) }; + } else { + pos = { pos.x, 0 }; } } - } - } - } else { - if ( !qScreen.contains( qPos ) ) { - pos.y -= menu->getPixelsSize().getHeight(); - qPos.Top -= menu->getPixelsSize().getHeight(); - qPos.Bottom -= menu->getPixelsSize().getHeight(); - if ( !qScreen.contains( qPos ) ) { - pos.x -= menu->getPixelsSize().getWidth(); - qPos.Left -= menu->getPixelsSize().getWidth(); - qPos.Right -= menu->getPixelsSize().getWidth(); + if ( pos.x < 0 ) + pos.x = 0; + if ( pos.y < 0 ) + pos.y = 0; - if ( !qScreen.contains( qPos ) ) { - pos.y += menu->getPixelsSize().getHeight(); - qPos.Top += menu->getPixelsSize().getHeight(); - qPos.Bottom += menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - pos = oriPos; - pos.y -= menu->getPixelsSize().getHeight(); - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( !qScreen.contains( qPos ) ) { - pos.y = qScreen.Bottom - menu->getPixelsSize().getHeight(); - qPos.Left = pos.x; - qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); - - if ( qPos.Right > qScreen.Right ) { - qPos.Right = qScreen.Right; - qPos.Left = qPos.Right - menu->getPixelsSize().getWidth(); - } - - qPos.Top = pos.y; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - - if ( qPos.Top < qScreen.Top ) { - qPos.Top = qScreen.Top; - qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); - } - - pos = qPos.getPosition(); - } - } + qPos.Left = qParent.Right; + qPos.Top = pos.y; + qPos.Right = qPos.Left + menu->getPixelsSize().getWidth(); + qPos.Bottom = qPos.Top + menu->getPixelsSize().getHeight(); + if ( qScreen.contains( qPos ) && ( !clipMenu || !qPrevMenu.overlap( qPos ) ) ) { + pos.x = qPos.Left; } } } diff --git a/src/eepp/ui/uinode.cpp b/src/eepp/ui/uinode.cpp index 8c5260189..32850ab39 100644 --- a/src/eepp/ui/uinode.cpp +++ b/src/eepp/ui/uinode.cpp @@ -977,10 +977,6 @@ Float UINode::getBorderWidth() const { return NULL != mBorder ? mBorder->getLineWidth() : 1.f; } -const Uint64& UINode::getFlags() const { - return mFlags; -} - UINode* UINode::setFlags( const Uint64& flags ) { if ( NULL == mBackground && ( flags & UI_FILL_BACKGROUND ) ) setBackgroundFillEnabled( true ); diff --git a/src/eepp/ui/uipopup.cpp b/src/eepp/ui/uipopup.cpp new file mode 100644 index 000000000..2bb4c6682 --- /dev/null +++ b/src/eepp/ui/uipopup.cpp @@ -0,0 +1,147 @@ +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +namespace { + +Vector2f clampPosition( Vector2f position, const Sizef& size, const Rectf& bounds ) { + position.x = + eeclamp( position.x, bounds.Left, eemax( bounds.Left, bounds.Right - size.getWidth() ) ); + position.y = + eeclamp( position.y, bounds.Top, eemax( bounds.Top, bounds.Bottom - size.getHeight() ) ); + return position; +} + +} // namespace + +Vector2f UIPopUp::findBestPosition( UIWidget* popup, const Vector2f& position, Node* anchor, + bool trySides ) { + if ( !popup || !popup->getSceneNode() ) + return position; + + const Sizef size( popup->getPixelsSize() ); + const Rectf bounds( popup->getUISceneNode()->getVisibleWorldBounds() ); + if ( anchor ) { + const Rectf field( anchor->getScreenRect() ); + // Field popups stay vertically attached even when their left edge must be clamped. + const Float x = trySides ? position.x : clampPosition( position, size, bounds ).x; + const Vector2f candidates[] = { { x, field.Bottom }, + { x, field.Top - size.getHeight() }, + { field.Right, field.Top }, + { field.Left - size.getWidth(), field.Top } }; + for ( size_t i = 0; i < ( trySides ? 4u : 2u ); ++i ) { + const Rectf candidate( candidates[i], size ); + // Dropdowns clamp horizontally independently of the below/above choice. Even a + // list wider than the scene must remain below the field when its height fits. + const bool fits = + trySides ? bounds.contains( candidate ) + : candidate.Top >= bounds.Top && candidate.Bottom <= bounds.Bottom; + if ( fits ) + return candidates[i]; + } + // Trigger menus retain their requested point if no side fits; field popups use the + // bottom scene edge when neither below nor above has enough space. + return clampPosition( trySides ? position : Vector2f( x, bounds.Bottom - size.getHeight() ), + size, bounds ); + } + + // Cursor menus prefer down/right, up/right, up/left, then down/left. + const Vector2f candidates[] = { position, + { position.x, position.y - size.getHeight() }, + { position.x - size.getWidth(), position.y - size.getHeight() }, + { position.x - size.getWidth(), position.y } }; + for ( const auto& candidate : candidates ) { + if ( bounds.contains( Rectf( candidate, size ) ) ) + return candidate; + } + return clampPosition( { position.x, bounds.Bottom - size.getHeight() }, size, bounds ); +} + +void UIPopUp::align( UIWidget* anchor, UIWidget* popup, bool popUpToRoot, bool centered ) { + if ( nullptr == anchor || nullptr == popup || nullptr == anchor->getUISceneNode() ) + return; + + if ( !popUpToRoot ) + popup->setParent( anchor->getWindowContainer() ); + else + popup->setParent( anchor->getUISceneNode()->getRoot() ); + + popup->toFront(); + + // The placement is decided in screen pixels, where the field, the popup and the scene bounds + // are directly comparable. Deriving it from the anchor's own dp position previously mixed + // coordinate spaces (the candidate point was expressed in the parent's space but converted + // through the field's own nodeToWorld, shifting the test rectangle by the field's offset), so + // the "fits below" check failed for any field away from its parent's origin and the popup was + // flipped above the field even when there was no room there. + + const Rectf field( anchor->getScreenRect() ); + const Sizef popUpSize( popup->getPixelsSize() ); + const Float x = centered + ? field.Left + eefloor( ( field.getWidth() - popUpSize.getWidth() ) * 0.5f ) + : field.Left; + Vector2f pos = findBestPosition( popup, { x, field.Bottom }, anchor ); + // worldToNode already converts world pixels back to parent dp; do not apply density again. + popup->getParent()->worldToNode( pos ); + popup->setPosition( pos ); +} + +bool UIPopUp::hasFocus( UIWidget* anchor, UIWidget* popup ) { + if ( !anchor || !anchor->getEventDispatcher() ) + return false; + // Focus-loss events are emitted before the old node clears its focus flag. The dispatcher + // already names the new node, so test its ancestry instead of cached hasFocus() flags. + const auto* focus = anchor->getEventDispatcher()->getFocusNode(); + return focus && ( focus == anchor || anchor->isParentOf( focus ) || + ( popup && ( focus == popup || popup->isParentOf( focus ) ) ) ); +} + +void UIPopUp::show( UIWidget* popup ) { + if ( nullptr == popup ) + return; + + popup->removeActionsByTag( String::hash( "eepp-popup-visibility" ) ); + popup->setEnabled( true ); + popup->setVisible( true ); + + if ( nullptr != popup->getUISceneNode() && + popup->getUISceneNode()->getUIThemeManager()->getDefaultEffectsEnabled() ) { + auto* action = Actions::Sequence::New( + Actions::Fade::New( + 255.f == popup->getAlpha() ? 0.f : popup->getAlpha(), 255.f, + popup->getUISceneNode()->getUIThemeManager()->getWidgetsFadeOutTime() ), + Actions::Spawn::New( Actions::Enable::New(), Actions::Visible::New( true ) ) ); + action->setTag( String::hash( "eepp-popup-visibility" ) ); + popup->runAction( action ); + } else { + // A previous fade-out can leave the popup transparent after effects are disabled. + popup->setAlpha( 255.f ); + } +} + +void UIPopUp::hide( UIWidget* popup ) { + if ( nullptr == popup ) + return; + popup->removeActionsByTag( String::hash( "eepp-popup-visibility" ) ); + popup->setEnabled( false ); + + if ( nullptr != popup->getUISceneNode() && + popup->getUISceneNode()->getUIThemeManager()->getDefaultEffectsEnabled() ) { + auto* action = Actions::Sequence::New( + Actions::FadeOut::New( + popup->getUISceneNode()->getUIThemeManager()->getWidgetsFadeOutTime() ), + Actions::Spawn::New( Actions::Disable::New(), Actions::Visible::New( false ) ) ); + action->setTag( String::hash( "eepp-popup-visibility" ) ); + popup->runAction( action ); + } else { + popup->setEnabled( false ); + popup->setVisible( false ); + } +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uitextinput.cpp b/src/eepp/ui/uitextinput.cpp index 2c9df8a27..281939572 100644 --- a/src/eepp/ui/uitextinput.cpp +++ b/src/eepp/ui/uitextinput.cpp @@ -294,6 +294,12 @@ void UITextInput::autoPadding() { UITextInput* UITextInput::setAllowEditing( const bool& allow ) { if ( allow != mAllowEditing ) { mAllowEditing = allow; + sendCommonEvent( Event::OnAllowEditingChange ); + // Attribute selectors can style the input and its children from allow-editing. + if ( getUISceneNode() ) { + getUISceneNode()->invalidateStyle( this ); + getUISceneNode()->invalidateStyleState( this ); + } invalidateDraw(); } return this; @@ -479,7 +485,9 @@ Uint32 UITextInput::onMouseDoubleClick( const Vector2i& Pos, const Uint32& Flags } Uint32 UITextInput::onMouseOver( const Vector2i& position, const Uint32& flags ) { - if ( NULL != mSceneNode ) + // Mouse-over also bubbles from children; keep the cursor selected for the hovered child. + auto* dispatcher = getEventDispatcher(); + if ( mSceneNode && dispatcher && dispatcher->getMouseOverNode() == this ) mSceneNode->setCursor( Cursor::IBeam ); return UITextView::onMouseOver( position, flags ); diff --git a/src/eepp/ui/uitimepicker.cpp b/src/eepp/ui/uitimepicker.cpp new file mode 100644 index 000000000..62254e238 --- /dev/null +++ b/src/eepp/ui/uitimepicker.cpp @@ -0,0 +1,19 @@ +#include + +namespace EE { namespace UI { + +UITimePicker* UITimePicker::New() { + return eeNew( UITimePicker, () ); +} + +UITimePicker::UITimePicker() : UIDateTimeEdit( DateTimeEditMode::Time, "timepicker" ) {} + +Uint32 UITimePicker::getType() const { + return UI_TYPE_TIMEPICKER; +} + +bool UITimePicker::isType( const Uint32& type ) const { + return type == UI_TYPE_TIMEPICKER || UIDateTimeEdit::isType( type ); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index df0202034..9a79222c7 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -4,10 +4,14 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -50,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -104,6 +109,7 @@ dd { margin-left: 40dp; } summary { cursor: pointer; padding-left: 20dp; list-style-type: disclosure-closed; } +form { margin-top: 0em; margin-bottom: 1em; } )css"; } @@ -120,7 +126,10 @@ textarea { border-width: 1dp; border-style: solid; border-color: #767676; backgr input[type="text"], input[type="password"], -input[type="number"] { +input[type="number"], +input[type="date"], +input[type="time"], +input[type="datetime-local"] { border-width: 1dp; border-style: solid; border-color: #767676; @@ -209,6 +218,103 @@ RadioButton::active { foreground-position: 6dp 6dp; } +/* Anonymous temporal controls use browser-like light defaults within the document. */ +Calendar { + color: black; + font-size: 12dp; + background-color: white; + border-color: #767676; + border-width: 1dp; + width: auto; + height: auto; + min-width: 160dp; + min-height: 160dp; +} +Calendar::previous, Calendar::next, Calendar::title, Calendar::today, Calendar::day { + color: black; + padding: 0; + border-width: 0; + background-color: transparent; +} +Calendar::previous { + foreground-image: url("data:image/svg,"); +} +Calendar::next { + foreground-image: url("data:image/svg,"); +} +Calendar::previous, Calendar::next { + cursor: arrow; + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: black; +} +Calendar::weekday, Calendar::day.outside-month { color: #767676; } +Calendar::day.selected, Calendar::day:hover { background-color: #0078d7; color: white; } +Calendar::day.today { border-color: #767676; border-width: 1dp; } +Calendar::day.focused { border-color: black; border-width: 1dp; } +Calendar::day:disabled { opacity: 0.35; } +DatePicker::button, DateTimePicker::button { + foreground-image: url("data:image/svg,"); + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: black; + padding: 0; + cursor: arrow; + border-width: 0; + background-color: transparent; +} +DatePicker:disabled > DatePicker::button, DateTimePicker:disabled > DateTimePicker::button, +DatePicker[allow-editing=false] > DatePicker::button, +DateTimePicker[allow-editing=false] > DateTimePicker::button { + foreground-tint: #767676; + background-color: transparent; + cursor: arrow; +} + +/* Combined popup: a reusable calendar followed by a segmented time editor. */ +DateTimePicker::popup { + width: auto; + height: auto; + background-color: white; + border-color: #767676; + border-width: 1dp; + border-radius: 2dp; +} +DateTimePicker::popup > Calendar { + border-width: 0; + background-color: transparent; +} +DateTimePicker::time { + text-align: center; + font-size: 18dp; + padding: 24dp 8dp; + min-height: 72dp; + color: black; + background-color: transparent; + border-color: #767676; + border-width: 1dp 0 0 0; +} +DateTimePicker::time-up, DateTimePicker::time-down { + foreground-size: 12dp 12dp; + foreground-position: center; + foreground-repeat: no-repeat; + foreground-tint: black; + background-color: transparent; + border-width: 0; + padding: 0; + width: 24dp; + height: 22dp; + cursor: arrow; +} +DateTimePicker::time-up { + foreground-image: url("data:image/svg,"); +} +DateTimePicker::time-down { + foreground-image: url("data:image/svg,"); +} + )css"; } @@ -239,6 +345,11 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["tabwidget"] = UITabWidget::New; registeredWidget["textedit"] = UITextEdit::New; registeredWidget["textinput"] = UITextInput::New; + registeredWidget["datetimeedit"] = []() -> UIWidget* { return UIDateTimeEdit::New(); }; + registeredWidget["calendar"] = UICalendar::New; + registeredWidget["datepicker"] = UIDatePicker::New; + registeredWidget["timepicker"] = UITimePicker::New; + registeredWidget["datetimepicker"] = UIDateTimePicker::New; registeredWidget["loader"] = UILoader::New; registeredWidget["selectbutton"] = UISelectButton::New; registeredWidget["window"] = UIWindow::New; diff --git a/src/examples/ui_date_time_picker/ui_date_time_picker.cpp b/src/examples/ui_date_time_picker/ui_date_time_picker.cpp new file mode 100644 index 000000000..8a16081e9 --- /dev/null +++ b/src/examples/ui_date_time_picker/ui_date_time_picker.cpp @@ -0,0 +1,69 @@ +#include + +EE_MAIN_FUNC int main( int argc, char** argv ) { + UIApplication app( { 1020, 720, "eepp - Date and Time Pickers" } ); + if ( !app.getWindow() || !app.getWindow()->isOpen() ) + return EXIT_FAILURE; + bool usLocale = false; + bool narrow = false; + std::string fontSize; + auto* ui = app.getUI(); + for ( int i = 1; i < argc; ++i ) { + const std::string_view option( argv[i] ); + if ( option == "--us" ) + usLocale = true; + else if ( option == "--light" ) + ui->setColorSchemePreference( ColorSchemePreference::Light ); + else if ( option == "--large-font" ) + fontSize = "24dp"; + else if ( option == "--small-font" ) + fontSize = "10dp"; + else if ( option == "--narrow" ) + narrow = true; + } + ui->loadLayoutFromString( R"xml( + + + + + + + + + + + + + + + + + + + + )xml" ); + if ( !fontSize.empty() ) + ui->combineStyleSheet( "* { font-size: " + fontSize + "; }" ); + if ( narrow ) + ui->combineStyleSheet( "datepicker, timepicker, datetimepicker { layout-width: 160dp; }" ); + EventConnectionList connections; + const auto locale = DateTimeLocale::fromLocaleName( usLocale ? "en_US" : "es_AR" ); + for ( const char* tag : { "datepicker", "timepicker", "datetimepicker" } ) { + for ( auto* widget : ui->getRoot()->querySelectorAll( tag ) ) { + auto* edit = widget->asType(); + edit->setLocale( locale ); + connections += edit->connect( Event::OnValueChange, [ui, edit]( const Event* ) { + ui->find( "value" )->setText( edit->getSerializedValue() ); + } ); + } + } + connections += ui->connect( Event::KeyUp, [ui]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_F11 ) + UIWidgetInspector::create( ui ); + } ); + return app.run(); +} diff --git a/src/tests/unit_tests/datetime_tests.cpp b/src/tests/unit_tests/datetime_tests.cpp new file mode 100644 index 000000000..c208cc08a --- /dev/null +++ b/src/tests/unit_tests/datetime_tests.cpp @@ -0,0 +1,473 @@ +#include +#if EE_PLATFORM == EE_PLATFORM_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +#include "utest.hpp" +#include +#include + +using namespace EE; +using namespace EE::System; + +UTEST( DateTime, CalendarDateValidityAndLeapYears ) { + EXPECT_TRUE( CalendarDate::isLeapYear( 2000 ) ); + EXPECT_FALSE( CalendarDate::isLeapYear( 1900 ) ); + EXPECT_TRUE( CalendarDate::isLeapYear( 2024 ) ); + EXPECT_FALSE( CalendarDate::isLeapYear( 2025 ) ); + + EXPECT_TRUE( ( CalendarDate{ 2000, 2, 29 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 1900, 2, 29 } ).isValid() ); + EXPECT_TRUE( ( CalendarDate{ 2024, 2, 29 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 2025, 2, 29 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 2026, 0, 1 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 2026, 13, 1 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 2026, 4, 31 } ).isValid() ); + EXPECT_FALSE( ( CalendarDate{ 2026, 1, 0 } ).isValid() ); +} + +UTEST( DateTime, CalendarDateMonthLengths ) { + const int expected[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + for ( Uint8 month = 1; month <= 12; ++month ) + EXPECT_EQ( CalendarDate::daysInMonth( 2025, month ), expected[month - 1] ); + EXPECT_EQ( CalendarDate::daysInMonth( 2024, 2 ), 29 ); + EXPECT_EQ( CalendarDate::daysInMonth( 2024, 0 ), 0 ); + EXPECT_EQ( CalendarDate::daysInMonth( 2024, 13 ), 0 ); +} + +UTEST( DateTime, CalendarDateDayArithmetic ) { + EXPECT_TRUE( ( CalendarDate{ 2026, 1, 31 }.addDays( 1 ) == CalendarDate{ 2026, 2, 1 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2026, 3, 1 }.addDays( -1 ) == CalendarDate{ 2026, 2, 28 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2024, 3, 1 }.addDays( -1 ) == CalendarDate{ 2024, 2, 29 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2026, 12, 31 }.addDays( 1 ) == CalendarDate{ 2027, 1, 1 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2027, 1, 1 }.addDays( -1 ) == CalendarDate{ 2026, 12, 31 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2000, 1, 1 }.addDays( 10000 ).addDays( -10000 ) == + CalendarDate{ 2000, 1, 1 } ) ); +} + +UTEST( DateTime, CalendarDateMonthAndYearArithmeticClamps ) { + EXPECT_TRUE( ( CalendarDate{ 2026, 1, 31 }.addMonths( 1 ) == CalendarDate{ 2026, 2, 28 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2024, 1, 31 }.addMonths( 1 ) == CalendarDate{ 2024, 2, 29 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2026, 3, 31 }.addMonths( -1 ) == CalendarDate{ 2026, 2, 28 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2026, 1, 31 }.addMonths( -1 ) == CalendarDate{ 2025, 12, 31 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2024, 2, 29 }.addYears( 1 ) == CalendarDate{ 2025, 2, 28 } ) ); + EXPECT_TRUE( ( CalendarDate{ 2024, 2, 29 }.addYears( 4 ) == CalendarDate{ 2028, 2, 29 } ) ); +} + +UTEST( DateTime, CalendarDateOrderingAndRange ) { + const CalendarDate start{ 2026, 9, 1 }; + const CalendarDate middle{ 2026, 9, 15 }; + const CalendarDate end{ 2026, 9, 30 }; + EXPECT_TRUE( start < middle ); + EXPECT_TRUE( middle < end ); + EXPECT_TRUE( ( DateRange{ start, end } ).isValid() ); + EXPECT_TRUE( ( DateRange{ start, end } ).contains( start ) ); + EXPECT_TRUE( ( DateRange{ start, end } ).contains( middle ) ); + EXPECT_TRUE( ( DateRange{ start, end } ).contains( end ) ); + EXPECT_FALSE( ( DateRange{ start, end } ).contains( CalendarDate{ 2026, 10, 1 } ) ); + EXPECT_FALSE( ( DateRange{ end, start } ).isValid() ); +} + +UTEST( DateTime, TimeOfDayValidityAndWrapping ) { + EXPECT_TRUE( ( TimeOfDay{ 0, 0, 0, 0 } ).isValid() ); + EXPECT_TRUE( ( TimeOfDay{ 23, 59, 59, 999 } ).isValid() ); + EXPECT_FALSE( ( TimeOfDay{ 24, 0, 0, 0 } ).isValid() ); + EXPECT_FALSE( ( TimeOfDay{ 12, 60, 0, 0 } ).isValid() ); + EXPECT_FALSE( ( TimeOfDay{ 12, 0, 60, 0 } ).isValid() ); + EXPECT_FALSE( ( TimeOfDay{ 12, 0, 0, 1000 } ).isValid() ); + + EXPECT_TRUE( ( TimeOfDay{ 23, 30, 0, 7 }.addHours( 1 ) == TimeOfDay{ 0, 30, 0, 7 } ) ); + EXPECT_TRUE( ( TimeOfDay{ 0, 30, 0, 7 }.addHours( -1 ) == TimeOfDay{ 23, 30, 0, 7 } ) ); + EXPECT_TRUE( ( TimeOfDay{ 23, 59, 0, 7 }.addMinutes( 2 ) == TimeOfDay{ 0, 1, 0, 7 } ) ); + EXPECT_TRUE( ( TimeOfDay{ 0, 0, 1, 7 }.addSeconds( -2 ) == TimeOfDay{ 23, 59, 59, 7 } ) ); +} + +UTEST( DateTime, LocalDateTimeCarriesAcrossDates ) { + LocalDateTime value{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 23, 30, 45, 123 } }; + EXPECT_TRUE( value.isValid() ); + EXPECT_TRUE( ( value.addHours( 1 ) == + LocalDateTime{ CalendarDate{ 2026, 9, 29 }, TimeOfDay{ 0, 30, 45, 123 } } ) ); + EXPECT_TRUE( ( value.addMinutes( 60 ) == value.addHours( 1 ) ) ); + EXPECT_TRUE( + ( LocalDateTime{ CalendarDate{ 2026, 1, 1 }, TimeOfDay{ 0, 0, 0, 0 } }.addSeconds( -1 ) == + LocalDateTime{ CalendarDate{ 2025, 12, 31 }, TimeOfDay{ 23, 59, 59, 0 } } ) ); +} + +#include + +UTEST( DateTime, FormatCompilerProducesSectionsAndLiterals ) { + DateTimeFormat format( "dd/MM/yyyy HH:mm:ss.SSS" ); + ASSERT_TRUE( format.isValid() ); + const auto& tokens = format.getTokens(); + ASSERT_EQ( tokens.size(), static_cast( 13 ) ); + EXPECT_TRUE( tokens[0].type == DateTimeFormatTokenType::Day ); + EXPECT_TRUE( tokens[1].type == DateTimeFormatTokenType::Literal ); + EXPECT_TRUE( tokens[1].literal == "/" ); + EXPECT_TRUE( tokens[2].type == DateTimeFormatTokenType::Month ); + EXPECT_TRUE( tokens[4].type == DateTimeFormatTokenType::Year ); + EXPECT_TRUE( tokens[6].type == DateTimeFormatTokenType::Hour24 ); + EXPECT_TRUE( tokens[8].type == DateTimeFormatTokenType::Minute ); + EXPECT_TRUE( tokens[10].type == DateTimeFormatTokenType::Second ); + EXPECT_TRUE( tokens[12].type == DateTimeFormatTokenType::Millisecond ); + EXPECT_FALSE( DateTimeFormat( "ddd/MM/yyyy" ).isValid() ); + EXPECT_FALSE( DateTimeFormat( "dd/QQ/yyyy" ).isValid() ); + EXPECT_TRUE( DateTimeFormat( "yyyy-MM-dd'T'HH:mm" ).isValid() ); +} + +UTEST( DateTime, FormattingAndTokenRanges ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const LocalDateTime value{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 20, 14, 32, 7 } }; + const DateTimeFormat format( "dd/MM/yyyy HH:mm:ss.SSS" ); + const auto formatted = DateTimeFormatter::formatDateTimeDetailed( value, format, locale ); + EXPECT_TRUE( formatted.text == "28/09/2026 20:14:32.007" ); + ASSERT_EQ( formatted.ranges.size(), format.getTokens().size() ); + EXPECT_EQ( formatted.ranges[0].start, static_cast( 0 ) ); + EXPECT_EQ( formatted.ranges[0].length, static_cast( 2 ) ); + EXPECT_EQ( formatted.ranges[4].start, static_cast( 6 ) ); + EXPECT_EQ( formatted.ranges[4].length, static_cast( 4 ) ); + EXPECT_EQ( formatted.ranges[8].start, static_cast( 14 ) ); +} + +UTEST( DateTime, TwelveHourFormattingAndParsing ) { + DateTimeLocale locale = DateTimeLocale::fromLocaleName( "en_US" ); + DateTimeFormat format( "hh:mm a" ); + EXPECT_TRUE( DateTimeFormatter::formatTime( TimeOfDay{ 0, 5, 0, 0 }, format, locale ) == + "12:05 AM" ); + EXPECT_TRUE( DateTimeFormatter::formatTime( TimeOfDay{ 20, 14, 0, 0 }, format, locale ) == + "08:14 PM" ); + const auto parsed = DateTimeFormatter::parseTime( "08:14 PM", format, locale ); + ASSERT_TRUE( parsed.has_value() ); + EXPECT_TRUE( *parsed == ( TimeOfDay{ 20, 14, 0, 0 } ) ); +} + +UTEST( DateTime, LocalePatternFallbacks ) { + const auto argentina = DateTimeLocale::fromLocaleName( "es_AR.UTF-8" ); + EXPECT_TRUE( argentina.dateOrder == DateOrder::DMY ); + EXPECT_TRUE( argentina.datePattern() == "dd/MM/yyyy" ); + EXPECT_TRUE( argentina.hourCycle == HourCycle::H24 ); + EXPECT_EQ( argentina.firstDayOfWeek, static_cast( 1 ) ); + + const auto us = DateTimeLocale::fromLocaleName( "en_US.UTF-8" ); + EXPECT_TRUE( us.dateOrder == DateOrder::MDY ); + EXPECT_TRUE( us.datePattern() == "MM/dd/yyyy" ); + EXPECT_TRUE( us.hourCycle == HourCycle::H12 ); + EXPECT_EQ( us.firstDayOfWeek, static_cast( 0 ) ); + + const auto japan = DateTimeLocale::fromLocaleName( "ja_JP.UTF-8" ); + EXPECT_TRUE( japan.dateOrder == DateOrder::YMD ); + EXPECT_TRUE( japan.datePattern() == "yyyy-MM-dd" ); +} + +UTEST( DateTime, ExactDateAndTimeParsing ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const auto date = + DateTimeFormatter::parseDate( "28/09/2026", DateTimeFormat( "dd/MM/yyyy" ), locale ); + ASSERT_TRUE( date.has_value() ); + EXPECT_TRUE( *date == ( CalendarDate{ 2026, 9, 28 } ) ); + EXPECT_FALSE( + DateTimeFormatter::parseDate( "31/02/2026", DateTimeFormat( "dd/MM/yyyy" ), locale ) ); + EXPECT_FALSE( + DateTimeFormatter::parseDate( "29/02/2025", DateTimeFormat( "dd/MM/yyyy" ), locale ) ); + + const auto time = + DateTimeFormatter::parseTime( "20:14:32", DateTimeFormat( "HH:mm:ss" ), locale ); + ASSERT_TRUE( time.has_value() ); + EXPECT_TRUE( *time == ( TimeOfDay{ 20, 14, 32, 0 } ) ); + EXPECT_FALSE( + DateTimeFormatter::parseTime( "24:00:00", DateTimeFormat( "HH:mm:ss" ), locale ) ); + EXPECT_FALSE( + DateTimeFormatter::parseTime( "12:60:00", DateTimeFormat( "HH:mm:ss" ), locale ) ); +} + +UTEST( DateTime, TolerantDateParsingAcceptsISOAndAlternateSeparators ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const DateTimeFormat display( "dd/MM/yyyy" ); + for ( const char* input : { "28/09/2026", "28-09-2026", "28.09.2026", "2026-09-28" } ) { + const auto value = DateTimeFormatter::parseDateTolerant( input, display, locale ); + ASSERT_TRUE( value.has_value() ); + EXPECT_TRUE( *value == ( CalendarDate{ 2026, 9, 28 } ) ); + } + EXPECT_FALSE( DateTimeFormatter::parseDateTolerant( "31-02-2026", display, locale ) ); +} + +UTEST( DateTime, TolerantDateParsingRespectsDisplayOrder ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "en_US" ); + const DateTimeFormat display( "MM/dd/yyyy" ); + const auto value = DateTimeFormatter::parseDateTolerant( "09-28-2026", display, locale ); + ASSERT_TRUE( value.has_value() ); + EXPECT_TRUE( *value == ( CalendarDate{ 2026, 9, 28 } ) ); + const auto iso = DateTimeFormatter::parseDateTolerant( "2026/09/28", display, locale ); + ASSERT_TRUE( iso.has_value() ); + EXPECT_TRUE( *iso == ( CalendarDate{ 2026, 9, 28 } ) ); +} + +UTEST( DateTime, TolerantTimeAndDateTimeParsing ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const auto time = + DateTimeFormatter::parseTimeTolerant( "20:14:32.125", DateTimeFormat( "HH:mm" ), locale ); + ASSERT_TRUE( time.has_value() ); + EXPECT_TRUE( *time == ( TimeOfDay{ 20, 14, 32, 125 } ) ); + + const auto dateTime = DateTimeFormatter::parseDateTimeTolerant( + "2026-09-28T20:14:32", DateTimeFormat( "dd/MM/yyyy HH:mm" ), locale ); + ASSERT_TRUE( dateTime.has_value() ); + EXPECT_TRUE( *dateTime == + ( LocalDateTime{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 20, 14, 32, 0 } } ) ); +} + +UTEST( DateTime, ISOFormattingIsLocaleIndependent ) { + EXPECT_TRUE( DateTimeFormatter::toISODate( CalendarDate{ 2026, 9, 28 } ) == "2026-09-28" ); + EXPECT_TRUE( DateTimeFormatter::toISOTime( TimeOfDay{ 20, 14, 0, 125 } ) == "20:14:00" ); + EXPECT_TRUE( DateTimeFormatter::toISOTime( TimeOfDay{ 20, 14, 0, 125 }, true ) == + "20:14:00.125" ); + EXPECT_TRUE( DateTimeFormatter::toISOLocalDateTime( + LocalDateTime{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 20, 14, 0, 0 } } ) == + "2026-09-28T20:14:00" ); +} + +UTEST( DateTime, SerializedYearsRoundTripAcrossTheRepresentableRange ) { + const auto locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const DateTimeFormat display( "dd/MM/yyyy" ); + for ( Int32 year : { std::numeric_limits::min(), -1, 0, 9999, 12026, + std::numeric_limits::max() } ) { + const CalendarDate date{ year, 9, 28 }; + EXPECT_TRUE( DateTimeFormatter::parseDateTolerant( DateTimeFormatter::toISODate( date ), + display, locale ) == date ); + EXPECT_TRUE( + DateTimeFormatter::parseDate( DateTimeFormatter::formatDate( date, display, locale ), + display, locale ) == date ); + } + const DateTimeFormat iso( "yyyy-MM-dd" ); + EXPECT_FALSE( DateTimeFormatter::parseDate( "2147483648-09-28", iso, locale ) ); + EXPECT_FALSE( DateTimeFormatter::parseDate( "-2147483649-09-28", iso, locale ) ); + EXPECT_TRUE( DateTimeFormatter::parseDate( "20260928", DateTimeFormat( "yyyyMMdd" ), locale ) == + ( CalendarDate{ 2026, 9, 28 } ) ); +} + +UTEST( DateTime, TolerantParsingRejectsOverflowAndTrailingGarbage ) { + const DateTimeLocale locale = DateTimeLocale::fromLocaleName( "es_AR" ); + const DateTimeFormat dateFormat( "dd/MM/yyyy" ); + for ( const char* input : { "257/09/2026", "28/265/2026", "999999999999999999/1/2026", + "2026-09-28junk", "2026-09-28T00:00:00Z", "garbage 28/9/2026" } ) { + EXPECT_FALSE( DateTimeFormatter::parseDateTolerant( input, dateFormat, locale ) ); + } + const DateTimeFormat timeFormat( "HH:mm" ); + for ( const char* input : { "256:00", "00:256", "12:00:256", "12:00 garbage", "12:00Z", + "999999999999999999:00", "12:00:00.1234" } ) { + EXPECT_FALSE( DateTimeFormatter::parseTimeTolerant( input, timeFormat, locale ) ); + } +} + +UTEST( DateTime, GregorianArithmeticRoundTripsAcrossFourHundredYears ) { + CalendarDate date{ 1800, 1, 1 }; + for ( int day = 0; day < 146097; ++day ) { + const auto next = date.addDays( 1 ); + ASSERT_TRUE( next.isValid() ); + ASSERT_TRUE( next.addDays( -1 ) == date ); + date = next; + } + EXPECT_TRUE( date == ( CalendarDate{ 2200, 1, 1 } ) ); +} + +UTEST( DateTime, HugeArithmeticStepsDoNotOverflow ) { + const auto maximum = std::numeric_limits::max(); + const auto minimum = std::numeric_limits::min(); + const CalendarDate date{ 2026, 9, 28 }; + for ( auto step : { minimum, maximum } ) { + EXPECT_TRUE( date.addDays( step ) == date ); + EXPECT_TRUE( date.addMonths( step ) == date ); + EXPECT_TRUE( date.addYears( step ) == date ); + const TimeOfDay time{ 23, 59, 59, 999 }; + EXPECT_TRUE( time.addHours( step ).isValid() ); + EXPECT_TRUE( time.addMinutes( step ).isValid() ); + EXPECT_TRUE( time.addSeconds( step ).isValid() ); + const LocalDateTime dateTime{ date, time }; + EXPECT_TRUE( dateTime.addHours( step ) == dateTime ); + EXPECT_TRUE( dateTime.addMinutes( step ) == dateTime ); + EXPECT_TRUE( dateTime.addSeconds( step ) == dateTime ); + } +} + +UTEST( DateTime, FractionalSecondPatternRoundTrip ) { + const DateTimeLocale locale; + for ( const char* pattern : { "HH:mm:ss.S", "HH:mm:ss.SS", "HH:mm:ss.SSS" } ) { + const DateTimeFormat format( pattern ); + const TimeOfDay value{ 1, 2, 3, 400 }; + const auto parsed = DateTimeFormatter::parseTime( + DateTimeFormatter::formatTime( value, format, locale ), format, locale ); + ASSERT_TRUE( parsed ); + EXPECT_TRUE( *parsed == value ); + } +} + +UTEST( DateTime, IsoPasteTakesPrecedenceOverAmbiguousCustomFormat ) { + DateTimeFormat custom( "yyyy-dd-MM" ); + auto date = DateTimeFormatter::parseDateTolerant( "2026-09-10", custom ); + ASSERT_TRUE( date ); + EXPECT_TRUE( *date == ( CalendarDate{ 2026, 9, 10 } ) ); +} + +UTEST( DateTime, IsoDateTimePasteTakesPrecedenceOverAmbiguousCustomFormat ) { + DateTimeFormat custom( "yyyy-dd-MM'T'HH:mm:ss" ); + auto dateTime = DateTimeFormatter::parseDateTimeTolerant( "2026-09-10T20:14:32.12", custom ); + ASSERT_TRUE( dateTime ); + EXPECT_TRUE( dateTime->date == ( CalendarDate{ 2026, 9, 10 } ) ); + EXPECT_TRUE( dateTime->time == ( TimeOfDay{ 20, 14, 32, 120 } ) ); +} + +UTEST( DateTime, EpochDaysAreGregorianAndTimezoneIndependent ) { + EXPECT_EQ( ( CalendarDate{ 1970, 1, 1 } ).daysSinceEpoch(), 0 ); + EXPECT_EQ( ( CalendarDate{ 1969, 12, 31 } ).daysSinceEpoch(), -1 ); + EXPECT_EQ( ( CalendarDate{ 2000, 3, 1 } ).daysSinceEpoch() - + ( CalendarDate{ 2000, 2, 28 } ).daysSinceEpoch(), + 2 ); +} + +UTEST( DateTime, CalendarDateUnixTimestampsUseMidnightUTC ) { + EXPECT_TRUE( ( CalendarDate{ 1970, 1, 1 } ).toUnixTimestamp() == 0 ); + EXPECT_TRUE( ( CalendarDate{ 1969, 12, 31 } ).toUnixTimestamp() == -86400 ); + EXPECT_TRUE( ( CalendarDate{ 2000, 2, 29 } ).toUnixTimestamp() == 951782400 ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( 0 ) == ( CalendarDate{ 1970, 1, 1 } ) ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( 86399 ) == ( CalendarDate{ 1970, 1, 1 } ) ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( -1 ) == ( CalendarDate{ 1969, 12, 31 } ) ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( -86401 ) == ( CalendarDate{ 1969, 12, 30 } ) ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( 951782400 ) == ( CalendarDate{ 2000, 2, 29 } ) ); + EXPECT_FALSE( ( CalendarDate{ 2025, 2, 29 } ).toUnixTimestamp() ); +} + +UTEST( DateTime, CalendarDateUnixTimestampYearBoundaries ) { + const CalendarDate minimum{ std::numeric_limits::min(), 1, 1 }; + const CalendarDate maximum{ std::numeric_limits::max(), 12, 31 }; + for ( auto date : { minimum, CalendarDate{ -1, 12, 31 }, CalendarDate{ 0, 1, 1 }, maximum } ) { + const auto timestamp = date.toUnixTimestamp(); + ASSERT_TRUE( timestamp ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( *timestamp ) == date ); + } + EXPECT_FALSE( CalendarDate::fromUnixTimestamp( *minimum.toUnixTimestamp() - 1 ) ); + EXPECT_TRUE( CalendarDate::fromUnixTimestamp( *maximum.toUnixTimestamp() + 86399 ) == maximum ); + EXPECT_FALSE( CalendarDate::fromUnixTimestamp( *maximum.toUnixTimestamp() + 86400 ) ); + EXPECT_FALSE( CalendarDate::fromUnixTimestamp( std::numeric_limits::min() ) ); + EXPECT_FALSE( CalendarDate::fromUnixTimestamp( std::numeric_limits::max() ) ); +} + +UTEST( DateTime, LocalDateTimeUnixTimestampsAndNegativeFractions ) { + const LocalDateTime epoch{ { 1970, 1, 1 }, {} }; + EXPECT_TRUE( epoch.toUnixTimestamp() == 0 ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 0 ) == epoch ); + const LocalDateTime before{ { 1969, 12, 31 }, { 23, 59, 59, 125 } }; + EXPECT_TRUE( before.toUnixTimestamp( 0 ) == -1 ); + EXPECT_TRUE( before.toUnixTimestampMilliseconds() == -875 ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestampMilliseconds( -875 ) == before ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( -1, 0 ) == + ( LocalDateTime{ { 1969, 12, 31 }, { 23, 59, 59, 0 } } ) ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 951782400, 0 ) == + ( LocalDateTime{ { 2000, 2, 29 }, {} } ) ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 2147483648LL, 0 ) == + ( LocalDateTime{ { 2038, 1, 19 }, { 3, 14, 8, 0 } } ) ); +} + +UTEST( DateTime, UnixTimestampOffsetsCarryAcrossDateBoundaries ) { + const LocalDateTime west{ { 1969, 12, 31 }, { 21, 0, 0, 0 } }; + const LocalDateTime east{ { 1970, 1, 1 }, { 5, 45, 0, 0 } }; + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 0, -10800 ) == west ); + EXPECT_TRUE( west.toUnixTimestamp( -10800 ) == 0 ); + EXPECT_TRUE( west.toUnixTimestamp( 0 ) == -10800 ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 0, 20700 ) == east ); + EXPECT_TRUE( east.toUnixTimestamp( 20700 ) == 0 ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( -1, 30 ) == + ( LocalDateTime{ { 1970, 1, 1 }, { 0, 0, 29, 0 } } ) ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( 0, -30 ) == + ( LocalDateTime{ { 1969, 12, 31 }, { 23, 59, 30, 0 } } ) ); +} + +UTEST( DateTime, UnixMillisecondsRoundTripIncludingInt64EndpointsAndOffsets ) { + const Int64 minimum = std::numeric_limits::min(); + const Int64 maximum = std::numeric_limits::max(); + const Int64 timestamps[] = { minimum, minimum + 1, -86400001, -86400000, -1001, -1000, + -999, -1, 0, 1, 999, 1000, + 951782400125LL, maximum - 1, maximum }; + const Int32 offsets[] = { 0, -10800, 20700, std::numeric_limits::min(), + std::numeric_limits::max() }; + for ( Int64 timestamp : timestamps ) { + for ( Int32 offset : offsets ) { + const auto value = LocalDateTime::fromUnixTimestampMilliseconds( timestamp, offset ); + ASSERT_TRUE( value ); + EXPECT_TRUE( value->isValid() ); + EXPECT_TRUE( value->toUnixTimestampMilliseconds( offset ) == timestamp ); + } + } + const auto first = LocalDateTime::fromUnixTimestampMilliseconds( minimum, 0 ); + const auto last = LocalDateTime::fromUnixTimestampMilliseconds( maximum, 0 ); + ASSERT_TRUE( first ); + ASSERT_TRUE( last ); + EXPECT_FALSE( first->addSeconds( -1 ).toUnixTimestampMilliseconds( 0 ) ); + EXPECT_FALSE( last->addSeconds( 1 ).toUnixTimestampMilliseconds( 0 ) ); + const LocalDateTime below{ first->date, + { first->time.hour, first->time.minute, first->time.second, 191 } }; + const LocalDateTime above{ last->date, + { last->time.hour, last->time.minute, last->time.second, 808 } }; + EXPECT_FALSE( below.toUnixTimestampMilliseconds( 0 ) ); + EXPECT_FALSE( above.toUnixTimestampMilliseconds( 0 ) ); +} + +UTEST( DateTime, UnixTimestampConversionsRejectInvalidValuesAndHandleYearLimits ) { + for ( const LocalDateTime value : + { LocalDateTime{ { 2025, 2, 29 }, {} }, LocalDateTime{ { 1970, 1, 1 }, { 24, 0, 0, 0 } }, + LocalDateTime{ { 1970, 1, 1 }, { 0, 0, 0, 1000 } } } ) { + EXPECT_FALSE( value.toUnixTimestamp( 0 ) ); + EXPECT_FALSE( value.toUnixTimestampMilliseconds( 0 ) ); + } + for ( Int64 timestamp : + { std::numeric_limits::min(), std::numeric_limits::max() } ) { + for ( Int32 offset : + { 0, std::numeric_limits::min(), std::numeric_limits::max() } ) + EXPECT_FALSE( LocalDateTime::fromUnixTimestamp( timestamp, offset ) ); + } + for ( const LocalDateTime value : + { LocalDateTime{ { std::numeric_limits::min(), 1, 1 }, {} }, + LocalDateTime{ { std::numeric_limits::max(), 12, 31 }, { 23, 59, 59, 0 } } } ) { + for ( Int32 offset : + { 0, std::numeric_limits::min(), std::numeric_limits::max() } ) { + const auto timestamp = value.toUnixTimestamp( offset ); + ASSERT_TRUE( timestamp ); + EXPECT_TRUE( LocalDateTime::fromUnixTimestamp( *timestamp, offset ) == value ); + EXPECT_FALSE( value.toUnixTimestampMilliseconds( offset ) ); + } + } +} + +#if EE_PLATFORM == EE_PLATFORM_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +UTEST( DateTime, SystemLocaleMatchesWindowsRegionalSettings ) { + const auto locale = DateTimeLocale::system(); + DWORD order = 0, hourCycle = 0, firstDay = 0; + ASSERT_TRUE( GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, LOCALE_IDATE | LOCALE_RETURN_NUMBER, + reinterpret_cast( &order ), + sizeof( order ) / sizeof( wchar_t ) ) > 0 ); + ASSERT_TRUE( GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, LOCALE_ITIME | LOCALE_RETURN_NUMBER, + reinterpret_cast( &hourCycle ), + sizeof( hourCycle ) / sizeof( wchar_t ) ) > 0 ); + ASSERT_TRUE( GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, + LOCALE_IFIRSTDAYOFWEEK | LOCALE_RETURN_NUMBER, + reinterpret_cast( &firstDay ), + sizeof( firstDay ) / sizeof( wchar_t ) ) > 0 ); + EXPECT_TRUE( locale.dateOrder == ( order == 0 ? DateOrder::MDY + : order == 1 ? DateOrder::DMY + : DateOrder::YMD ) ); + EXPECT_TRUE( locale.hourCycle == ( hourCycle == 0 ? HourCycle::H12 : HourCycle::H24 ) ); + EXPECT_EQ( locale.firstDayOfWeek, static_cast( ( firstDay + 1 ) % 7 ) ); + wchar_t month[128]{}, day[128]{}; + ASSERT_TRUE( GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, LOCALE_SMONTHNAME1, month, 128 ) > 0 ); + ASSERT_TRUE( GetLocaleInfoEx( LOCALE_NAME_USER_DEFAULT, LOCALE_SDAYNAME7, day, 128 ) > 0 ); + EXPECT_TRUE( locale.monthNames[0] == String::fromWide( month ).toUtf8() ); + EXPECT_TRUE( locale.weekdayNames[0] == String::fromWide( day ).toUtf8() ); +} +#endif diff --git a/src/tests/unit_tests/propertyidset_tests.cpp b/src/tests/unit_tests/propertyidset_tests.cpp index ea4f40eef..55a0ee17c 100644 --- a/src/tests/unit_tests/propertyidset_tests.cpp +++ b/src/tests/unit_tests/propertyidset_tests.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include using namespace EE; @@ -227,3 +228,58 @@ UTEST( IdNameMap, failedFinalizationDoesNotEnableCustomIds ) { EXPECT_TRUE( ids.finalizeBuiltins() ); EXPECT_EQ( static_cast( ids.getOrCreateId( "custom" ) ), 3u ); } + +UTEST( IdNameMap, ownsNamesRegisteredFromBoundedViews ) { + IdNameMap ids; + const std::string expected = "first-canonical-property"; + std::string storage = expected + "ignored-suffix"; + const std::string_view name( storage.data(), expected.size() ); + ASSERT_TRUE( ids.addBuiltin( TestDenseId::One, name ) ); + EXPECT_FALSE( ids.addBuiltin( TestDenseId::Two, name ) ); + ASSERT_TRUE( ids.addBuiltin( TestDenseId::Two, "second-canonical-property" ) ); + ASSERT_TRUE( ids.finalizeBuiltins() ); + EXPECT_TRUE( ids.getId( name ) == TestDenseId::One ); + EXPECT_TRUE( ids.contains( name ) ); + EXPECT_TRUE( ids.getId( storage ) == TestDenseId::Invalid ); + storage.assign( storage.size(), 'x' ); + EXPECT_TRUE( ids.getName( TestDenseId::One ) == expected ); + EXPECT_TRUE( ids.getId( expected ) == TestDenseId::One ); + + const std::string customExpected = "custom-canonical-property"; + storage = customExpected + "ignored-suffix"; + const std::string_view customName( storage.data(), customExpected.size() ); + const auto customId = ids.getOrCreateId( customName ); + EXPECT_TRUE( customId == TestDenseId::FirstCustomId ); + EXPECT_TRUE( ids.getOrCreateId( customName ) == customId ); + storage.clear(); + EXPECT_TRUE( ids.getName( customId ) == customExpected ); + EXPECT_TRUE( ids.getId( std::string_view( customExpected ) ) == customId ); +} + +UTEST( PropertyRegistration, ownsNamesAndDefaultsPassedAsViews ) { + auto* specification = StyleSheetSpecification::instance(); + const std::string expectedName = "view-backed-custom-property"; + const std::string expectedValue = "a-default-value-longer-than-sso"; + std::string nameStorage = expectedName + "ignored-suffix"; + std::string valueStorage = expectedValue + "ignored-suffix"; + const std::string_view name( nameStorage.data(), expectedName.size() ); + const std::string_view value( valueStorage.data(), expectedValue.size() ); + auto* definition = specification->registerProperty( name, value, true ); + ASSERT_TRUE( definition ); + EXPECT_TRUE( specification->getProperty( name ) == definition ); + EXPECT_TRUE( specification->getProperty( nameStorage ) == nullptr ); + nameStorage.assign( nameStorage.size(), 'x' ); + valueStorage.assign( valueStorage.size(), 'x' ); + EXPECT_TRUE( definition->getName() == expectedName ); + EXPECT_TRUE( definition->getDefaultValue() == expectedValue ); + EXPECT_TRUE( definition->getId() == String::hash( expectedName ) ); + EXPECT_TRUE( definition->isInherited() ); + EXPECT_TRUE( specification->getProperty( "viewbackedcustomproperty" ) == definition ); + EXPECT_TRUE( specification->registerProperty( std::string_view( expectedName ), "changed" ) == + definition ); + EXPECT_TRUE( definition->getDefaultValue() == expectedValue ); + + const PropertyDefinition empty( PropertyId::Invalid, std::string_view{}, std::string_view{} ); + EXPECT_TRUE( empty.getName().empty() ); + EXPECT_TRUE( empty.getDefaultValue().empty() ); +} diff --git a/src/tests/unit_tests/ui_pixel_density_tests.cpp b/src/tests/unit_tests/ui_pixel_density_tests.cpp index ff8e3bcaa..23d912ce0 100644 --- a/src/tests/unit_tests/ui_pixel_density_tests.cpp +++ b/src/tests/unit_tests/ui_pixel_density_tests.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -206,6 +207,64 @@ UTEST( PixelDensityRegression, FontPickerUsesLogicalSceneInset ) { EXPECT_EQ( dialog->getMinWindowSize().y, scene.y - 32.f ); } +UTEST( PixelDensityRegression, CodeEditorMatchesDefaultTextFontSize ) { + RestoreDensity restoreDensity; + for ( Float density : { 1.f, 1.5f, 2.f } ) { + UIApplication app( + WindowSettings( 1200, 1000, "Editor Default Font Size", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), + density ) ); + auto* ui = app.getUI(); + ui->setStyleSheet( "" ); + auto* manager = ui->getUIThemeManager(); + const auto verifyDefault = [&]() { + auto* text = UITextView::New(); + auto* editor = UICodeEditor::New(); + text->setParent( ui->getRoot() ); + editor->setParent( ui->getRoot() ); + ui->update( Time::Zero ); + EXPECT_EQ( editor->getFontSize(), static_cast( text->getFontSize() ) ); + editor->fontSizeGrow(); + editor->fontSizeReset(); + EXPECT_EQ( editor->getFontSize(), static_cast( text->getFontSize() ) ); + editor->applyProperty( StyleSheetProperty( "font-size", "14dp" ) ); + EXPECT_EQ( editor->getFontSize(), PixelDensity::dpToPx( 14.f ) ); + eeDelete( editor ); + eeDelete( text ); + }; + verifyDefault(); + // Theme defaults take priority over the manager fallback, just as for UITextView. + manager->setDefaultFontSize( PixelDensity::dpToPx( 20.f ) ); + manager->getDefaultTheme()->setDefaultFontSize( PixelDensity::dpToPx( 10.f ) ); + verifyDefault(); + manager->setDefaultTheme( UIThemePtr{} ); + verifyDefault(); + } +} + +UTEST( PixelDensityRegression, BreezePreservesConfiguredCodeEditorFontSize ) { + DensityApplication fixture; + auto* ui = fixture.app.getUI(); + auto* editor = UICodeEditor::New(); + editor->setParent( ui->getRoot() ); + // ecode applies its configured size after creation, before deferred CSS is resolved. + const Float configuredSize = PixelDensity::dpToPx( 17.f ); + editor->setFontSize( configuredSize ); + ui->update( Time::Zero ); + EXPECT_EQ( editor->getFontSize(), configuredSize ); + editor->reloadStyle( true, true, true, true, true ); + ui->update( Time::Zero ); + EXPECT_EQ( editor->getFontSize(), configuredSize ); + // Settings changes must also survive subsequent theme/style reloads. + const Float changedSize = PixelDensity::dpToPx( 19.f ); + editor->setFontSize( changedSize ); + editor->reloadStyle( true, true, true, true, true ); + ui->update( Time::Zero ); + EXPECT_EQ( editor->getFontSize(), changedSize ); + eeDelete( editor ); +} + UTEST( PixelDensityRegression, EditorLineNumberPaddingScalesExactlyOnce ) { DensityApplication fixture; auto* editor = UICodeEditor::New(); diff --git a/src/tests/unit_tests/uidatetimeedit_tests.cpp b/src/tests/unit_tests/uidatetimeedit_tests.cpp new file mode 100644 index 000000000..7badafc1e --- /dev/null +++ b/src/tests/unit_tests/uidatetimeedit_tests.cpp @@ -0,0 +1,1348 @@ +#include "utest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; + +namespace { + +class TestableDateTimeEdit : public UIDateTimeEdit { + public: + static TestableDateTimeEdit* New( DateTimeEditMode mode ) { + return eeNew( TestableDateTimeEdit, ( mode ) ); + } + + bool typeDigit( char digit ) { return processDigit( digit ); } + + bool typeAmPm( char value ) { return processAmPm( value ); } + + void clickAt( const Vector2f& localPosition ) { + Vector2f worldPosition = localPosition; + nodeToWorld( worldPosition ); + onMouseClick( worldPosition.asInt(), EE_BUTTON_LMASK ); + } + + Vector2f sectionClickPosition( Int32 column ) { + return PixelDensity::pxToDp( mTextCache.findCharacterPos( column ) + mRealAlignOffset + + Vector2f( mPaddingPx.Left, mPaddingPx.Top ) ); + } + + Uint32 key( Keycode code, Uint32 mod = 0 ) { + return onKeyDown( KeyEvent( this, Event::KeyDown, code, SCANCODE_UNKNOWN, 0, mod ) ); + } + + protected: + explicit TestableDateTimeEdit( DateTimeEditMode mode ) : UIDateTimeEdit( mode ) {} +}; + +class TestableCalendar : public UICalendar { + public: + static TestableCalendar* New() { return eeNew( TestableCalendar, () ); } + + Uint32 key( Keycode keyCode, Uint32 mod = 0 ) { + KeyEvent event( this, Event::KeyDown, keyCode, SCANCODE_UNKNOWN, 0, mod ); + + return onKeyDown( event ); + } + + protected: + TestableCalendar() : UICalendar() {} +}; + +UIApplication testApp() { + // Keep app teardown within each test, before process-static text caches are destroyed. + return UIApplication( + WindowSettings{ 640, 480, "eepp - date time edit tests" }, + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); +} + +} // namespace + +UTEST( UIDateTimeEdit, DateValueFormattingAndSerialization ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + EXPECT_TRUE( edit->getText().toUtf8() == "28/09/2026" ); + EXPECT_TRUE( edit->getSerializedValue() == "2026-09-28" ); + EXPECT_TRUE( edit->hasValue() ); + + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, MillisecondSteppingCarriesAndRespectsYearLimits ) { + auto app = testApp(); + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Time ); + edit->setShowMilliseconds( true ); + edit->setTime( TimeOfDay{ 23, 59, 59, 999 } ); + edit->setActiveSection( DateTimeSection::Millisecond ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + EXPECT_TRUE( edit->getTime() == ( TimeOfDay{ 0, 0, 0, 0 } ) ); + EXPECT_TRUE( edit->stepActiveSection( -1 ) ); + EXPECT_TRUE( edit->getTime() == ( TimeOfDay{ 23, 59, 59, 999 } ) ); + edit->setEditMode( DateTimeEditMode::DateTime ); + edit->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 23, 59, 59, 999 } } ); + edit->setActiveSection( DateTimeSection::Millisecond ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + EXPECT_TRUE( edit->getDateTime() == ( LocalDateTime{ { 2026, 9, 29 }, {} } ) ); + const LocalDateTime maximum{ { std::numeric_limits::max(), 12, 31 }, + { 23, 59, 59, 999 } }; + edit->setDateTime( maximum ); + EXPECT_FALSE( edit->stepActiveSection( 1 ) ); + EXPECT_TRUE( edit->getDateTime() == maximum ); + const LocalDateTime minimum{ { std::numeric_limits::min(), 1, 1 }, {} }; + edit->setDateTime( minimum ); + EXPECT_FALSE( edit->stepActiveSection( -1 ) ); + EXPECT_TRUE( edit->getDateTime() == minimum ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, EmptyStateAndClear ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + EXPECT_FALSE( edit->hasValue() ); + EXPECT_TRUE( edit->getText().empty() ); + EXPECT_TRUE( edit->getHint().toUtf8() == "dd/mm/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->clear(); + EXPECT_FALSE( edit->hasValue() ); + EXPECT_TRUE( edit->getSerializedValue().empty() ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, DateSectionSteppingUsesCalendarArithmetic ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 1, 31 } ); + edit->setActiveSection( DateTimeSection::Month ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + ASSERT_TRUE( edit->getDate().has_value() ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2026, 2, 28 } ) ); + edit->setDate( CalendarDate{ 2024, 2, 29 } ); + edit->setActiveSection( DateTimeSection::Year ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2025, 2, 28 } ) ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, DateTimeHourSteppingCarriesDate ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::DateTime ); + edit->setDateFormat( "yyyy-MM-dd" ); + edit->setTimeFormat( "HH:mm" ); + edit->setDateTime( LocalDateTime{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 23, 30, 0, 0 } } ); + edit->setActiveSection( DateTimeSection::Hour ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + ASSERT_TRUE( edit->getDateTime().has_value() ); + EXPECT_TRUE( *edit->getDateTime() == + ( LocalDateTime{ CalendarDate{ 2026, 9, 29 }, TimeOfDay{ 0, 30, 0, 0 } } ) ); + + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, ConstraintsClampAllProgrammaticMutations ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "yyyy-MM-dd" ); + edit->setMinDate( CalendarDate{ 2026, 9, 10 } ); + edit->setMaxDate( CalendarDate{ 2026, 9, 20 } ); + edit->setDate( CalendarDate{ 2026, 9, 1 } ); + ASSERT_TRUE( edit->getDate().has_value() ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2026, 9, 10 } ) ); + edit->setDate( CalendarDate{ 2026, 9, 30 } ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2026, 9, 20 } ) ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, TolerantSetTextAndInvalidTextPreservesValue ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setText( "2027-01-31" ); + ASSERT_TRUE( edit->getDate().has_value() ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2027, 1, 31 } ) ); + edit->setText( "31/02/2027" ); + EXPECT_TRUE( *edit->getDate() == ( CalendarDate{ 2027, 1, 31 } ) ); + EXPECT_TRUE( edit->getText().toUtf8() == "31/01/2027" ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, NumericEntryCommitsAndAdvances ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setActiveSection( DateTimeSection::Month ); + EXPECT_TRUE( edit->typeDigit( '1' ) ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Month ); + EXPECT_TRUE( edit->typeDigit( '2' ) ); + ASSERT_TRUE( edit->getDate().has_value() ); + EXPECT_TRUE( edit->getDate()->month == 12 ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Year ); + edit->setActiveSection( DateTimeSection::Day ); + EXPECT_TRUE( edit->typeDigit( '4' ) ); + EXPECT_TRUE( edit->getDate()->day == 4 ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Month ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, TwelveHourEntryKeepsAmPmAndAmPmTypingToggles ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Time ); + edit->setHourCycle( HourCycle::H12 ); + edit->setTimeFormat( "hh:mm a" ); + edit->setTime( TimeOfDay{ 20, 14, 0, 0 } ); + edit->setActiveSection( DateTimeSection::Hour ); + EXPECT_TRUE( edit->typeDigit( '9' ) ); + ASSERT_TRUE( edit->getTime().has_value() ); + EXPECT_TRUE( edit->getTime()->hour == 21 ); + edit->setActiveSection( DateTimeSection::AmPm ); + EXPECT_TRUE( edit->typeAmPm( 'a' ) ); + EXPECT_TRUE( edit->getTime()->hour == 9 ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, ValueChangeOnlyEmitsForCanonicalChanges ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + int changes = 0; + auto valueConnection = + edit->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setDateFormat( "yyyy-MM-dd" ); + EXPECT_EQ( changes, 1 ); + edit->setDate( CalendarDate{ 2026, 9, 29 } ); + EXPECT_EQ( changes, 2 ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, TimeStepsAndWheelDefault ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Time ); + EXPECT_FALSE( edit->isWheelEditingEnabled() ); + edit->setTimeFormat( "HH:mm:ss" ); + edit->setMinuteStep( 15 ); + edit->setTime( TimeOfDay{ 23, 50, 0, 0 } ); + edit->setActiveSection( DateTimeSection::Minute ); + EXPECT_TRUE( edit->stepActiveSection( 1 ) ); + ASSERT_TRUE( edit->getTime().has_value() ); + EXPECT_TRUE( *edit->getTime() == ( TimeOfDay{ 0, 5, 0, 0 } ) ); + eeDelete( edit ); +} + +UTEST( UICalendar, FixedGridAndAdjacentMonthDates ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + calendar->setDisplayedMonth( CalendarDate{ 2026, 9, 1 } ); + EXPECT_EQ( calendar->getDayCellCount(), static_cast( 42 ) ); + // Locale may be Sunday- or Monday-first, but September 1st must occur in the first week. + const CalendarDate first = calendar->getFirstVisibleDate(); + EXPECT_TRUE( first <= ( CalendarDate{ 2026, 9, 1 } ) ); + EXPECT_TRUE( first.addDays( 6 ) >= ( CalendarDate{ 2026, 9, 1 } ) ); + EXPECT_TRUE( calendar->getDayCellDate( 41 ) == first.addDays( 41 ) ); + eeDelete( calendar ); +} + +UTEST( UICalendar, MonthNavigationDoesNotChangeSelection ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + calendar->setSelectedDate( CalendarDate{ 2026, 9, 28 } ); + int changes = 0; + auto valueConnection = + calendar->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + calendar->next(); + EXPECT_TRUE( calendar->getDisplayedMonth() == ( CalendarDate{ 2026, 10, 1 } ) ); + ASSERT_TRUE( calendar->getSelectedDate().has_value() ); + EXPECT_TRUE( *calendar->getSelectedDate() == ( CalendarDate{ 2026, 9, 28 } ) ); + EXPECT_EQ( changes, 0 ); + calendar->previous(); + EXPECT_TRUE( calendar->getDisplayedMonth() == ( CalendarDate{ 2026, 9, 1 } ) ); + + eeDelete( calendar ); +} + +UTEST( UICalendar, ConstraintsClampSelectionAndKeyboardNavigation ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + calendar->setMinDate( CalendarDate{ 2026, 9, 10 } ); + calendar->setMaxDate( CalendarDate{ 2026, 9, 20 } ); + calendar->setSelectedDate( CalendarDate{ 2026, 9, 1 } ); + ASSERT_TRUE( calendar->getSelectedDate().has_value() ); + EXPECT_TRUE( *calendar->getSelectedDate() == ( CalendarDate{ 2026, 9, 10 } ) ); + calendar->setFocusedDate( CalendarDate{ 2026, 9, 20 } ); + EXPECT_EQ( calendar->key( KEY_RIGHT ), static_cast( 1 ) ); + EXPECT_TRUE( calendar->getFocusedDate() == ( CalendarDate{ 2026, 9, 20 } ) ); + calendar->setFocusedDate( CalendarDate{ 2026, 9, 10 } ); + EXPECT_EQ( calendar->key( KEY_LEFT ), static_cast( 1 ) ); + EXPECT_TRUE( calendar->getFocusedDate() == ( CalendarDate{ 2026, 9, 10 } ) ); + + eeDelete( calendar ); +} + +UTEST( UICalendar, KeyboardMovementAndSelection ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + calendar->setFocusedDate( CalendarDate{ 2026, 9, 28 } ); + calendar->clearSelection(); + EXPECT_EQ( calendar->key( KEY_RIGHT ), static_cast( 1 ) ); + EXPECT_TRUE( calendar->getFocusedDate() == ( CalendarDate{ 2026, 9, 29 } ) ); + EXPECT_EQ( calendar->key( KEY_DOWN ), static_cast( 1 ) ); + EXPECT_TRUE( calendar->getFocusedDate() == ( CalendarDate{ 2026, 10, 6 } ) ); + EXPECT_FALSE( calendar->getSelectedDate().has_value() ); + EXPECT_EQ( calendar->key( KEY_RETURN ), static_cast( 1 ) ); + ASSERT_TRUE( calendar->getSelectedDate().has_value() ); + EXPECT_TRUE( *calendar->getSelectedDate() == ( CalendarDate{ 2026, 10, 6 } ) ); + eeDelete( calendar ); +} + +UTEST( UICalendar, MonthAndYearViewsNavigateDisplayOnly ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + calendar->setSelectedDate( CalendarDate{ 2026, 9, 28 } ); + calendar->setView( CalendarView::Months ); + calendar->next(); + EXPECT_TRUE( calendar->getDisplayedMonth() == ( CalendarDate{ 2027, 9, 1 } ) ); + ASSERT_TRUE( calendar->getSelectedDate().has_value() ); + EXPECT_TRUE( *calendar->getSelectedDate() == ( CalendarDate{ 2026, 9, 28 } ) ); + calendar->setView( CalendarView::Years ); + calendar->next(); + EXPECT_TRUE( calendar->getView() == CalendarView::Years ); + EXPECT_TRUE( *calendar->getSelectedDate() == ( CalendarDate{ 2026, 9, 28 } ) ); + eeDelete( calendar ); +} + +UTEST( UICalendar, ValueChangeOnlyOnExplicitSelection ) { + auto app = testApp(); + + auto* calendar = TestableCalendar::New(); + int changes = 0; + auto valueConnection = + calendar->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + calendar->setDisplayedMonth( CalendarDate{ 2026, 9, 1 } ); + calendar->setFocusedDate( CalendarDate{ 2026, 9, 28 } ); + calendar->next(); + calendar->previous(); + EXPECT_EQ( changes, 0 ); + calendar->setFocusedDate( CalendarDate{ 2026, 9, 28 } ); + EXPECT_TRUE( calendar->selectFocusedDate() ); + EXPECT_EQ( changes, 1 ); + EXPECT_TRUE( calendar->selectFocusedDate() ); + EXPECT_EQ( changes, 1 ); + eeDelete( calendar ); +} + +UTEST( UIDateTimeEdit, KeyboardNavigationPendingDigitsAndCancel ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setActiveSection( DateTimeSection::Month ); + edit->typeDigit( '1' ); + EXPECT_TRUE( edit->getText().toUtf8() == "28/1_/2026" ); + EXPECT_TRUE( edit->getDate()->month == 9 ); + edit->key( KEY_ESCAPE ); + EXPECT_TRUE( edit->getText().toUtf8() == "28/09/2026" ); + edit->typeDigit( '1' ); + edit->key( KEY_RIGHT ); + EXPECT_TRUE( edit->getDate()->month == 1 ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Year ); + edit->key( KEY_HOME ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Day ); + edit->key( KEY_LEFT ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Day ); + edit->key( KEY_END ); + edit->key( KEY_RIGHT ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Year ); + edit->key( KEY_DELETE ); + EXPECT_FALSE( edit->hasValue() ); + + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, PendingDigitTimeoutCommitsWithoutSleeping ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setActiveSection( DateTimeSection::Month ); + edit->setPendingDigitTimeout( Time::Zero ); + edit->typeDigit( '1' ); + edit->scheduledUpdate( Time::Zero ); + EXPECT_TRUE( edit->getDate()->month == 1 ); + EXPECT_TRUE( edit->getText().toUtf8() == "28/01/2026" ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, PasteReplacesWholeValueFromSelectedSection ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + edit->setActiveSection( DateTimeSection::Month ); + + const std::string previous = app.getWindow()->getClipboard()->getText(); + app.getWindow()->getClipboard()->setText( "2027-01-31" ); + edit->getDocument().execute( "paste" ); + EXPECT_TRUE( edit->getSerializedValue() == "2027-01-31" ); + + app.getWindow()->getClipboard()->setText( "31/02/2027" ); + edit->getDocument().execute( "paste" ); + EXPECT_TRUE( edit->getSerializedValue() == "2027-01-31" ); + app.getWindow()->getClipboard()->setText( previous ); + + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, ReadOnlyKeysAndWheelDoNotMutate ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Time ); + edit->setTime( TimeOfDay{ 12, 30 } ); + edit->setActiveSection( DateTimeSection::Hour ); + edit->setAllowEditing( false ); + edit->key( KEY_UP ); + edit->key( KEY_DELETE ); + EXPECT_FALSE( edit->stepActiveSection( 1 ) ); + EXPECT_TRUE( edit->getTime() == std::optional( TimeOfDay{ 12, 30 } ) ); + + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, LocaleAndFormatValidationAndPrecision ) { + auto app = testApp(); + + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setLocale( DateTimeLocale::fromLocaleName( "es_AR" ) ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + EXPECT_TRUE( edit->getText().toUtf8() == "28/09/2026" ); + edit->setLocale( DateTimeLocale::fromLocaleName( "en_US" ) ); + EXPECT_TRUE( edit->getText().toUtf8() == "09/28/2026" ); + edit->setDateFormat( "HH:mm" ); + EXPECT_TRUE( edit->getText().toUtf8() == "09/28/2026" ); + edit->setDateFormat( "d/M/yyyy" ); + edit->setActiveSection( DateTimeSection::Month ); + edit->typeDigit( '1' ); + edit->typeDigit( '2' ); + EXPECT_TRUE( edit->getDate()->month == 12 ); + edit->setEditMode( DateTimeEditMode::Time ); + edit->setTime( TimeOfDay{ 20, 14, 32, 125 } ); + edit->setTimeFormat( "HH:mm" ); + EXPECT_TRUE( edit->getSerializedValue() == "20:14:32.125" ); + edit->setTimeFormat( "dd/MM/yyyy" ); + EXPECT_TRUE( edit->getTimeFormat() == "HH:mm" ); + eeDelete( edit ); +} + +UTEST( UIDateTimeEdit, ContradictoryBoundsResolveAndEmptyPropertyRemovesConstraint ) { + auto app = testApp(); + + auto* edit = UIDateTimeEdit::New(); + edit->setMinDate( CalendarDate{ 2026, 9, 20 } ); + edit->setMaxDate( CalendarDate{ 2026, 9, 10 } ); + EXPECT_TRUE( edit->getMinDate() == edit->getMaxDate() ); + edit->setDate( CalendarDate{ 2026, 9, 1 } ); + EXPECT_TRUE( edit->getDate()->day == 10 ); + EXPECT_TRUE( edit->applyProperty( StyleSheetProperty( "min-date", "" ) ) ); + EXPECT_FALSE( edit->getMinDate() ); + edit->setAllowEmpty( false ); + edit->getDocument().setSelection( + { { 0, 0 }, { 0, static_cast( edit->getText().size() ) } } ); + edit->getDocument().deleteSelection(); + EXPECT_TRUE( edit->hasValue() ); + EXPECT_FALSE( edit->getText().empty() ); + eeDelete( edit ); +} + +UTEST( UICalendar, LocaleAlignmentAndBoundChangeEvents ) { + auto app = testApp(); + + auto* calendar = UICalendar::New(); + calendar->setFirstDayOfWeek( 1 )->setDisplayedMonth( CalendarDate{ 2026, 9, 1 } ); + EXPECT_TRUE( calendar->getFirstVisibleDate() == ( CalendarDate{ 2026, 8, 31 } ) ); + calendar->setFirstDayOfWeek( 0 ); + EXPECT_TRUE( calendar->getFirstVisibleDate() == ( CalendarDate{ 2026, 8, 30 } ) ); + calendar->setSelectedDate( CalendarDate{ 2026, 9, 28 } ); + int changes = 0; + auto valueConnection = + calendar->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + calendar->setMaxDate( CalendarDate{ 2026, 9, 20 } ); + EXPECT_TRUE( calendar->getSelectedDate()->day == 20 ); + EXPECT_EQ( changes, 1 ); + int selections = 0; + auto selectionConnection = + calendar->connect( Event::OnItemSelected, [&selections]( const Event* ) { ++selections; } ); + calendar->selectFocusedDate(); + EXPECT_EQ( changes, 1 ); + EXPECT_EQ( selections, 1 ); + + eeDelete( calendar ); +} + +UTEST( UIDatePicker, EmptyOpeningAndEscapePreserveValue ) { + auto app = testApp(); + + auto* picker = UIDatePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 200, 30 ); + EXPECT_TRUE( picker->getCalendar() == nullptr ); + picker->showCalendar(); + ASSERT_TRUE( picker->getCalendar() ); + EXPECT_TRUE( picker->isCalendarVisible() ); + EXPECT_FALSE( picker->hasValue() ); + const auto today = CalendarDate::today(); + EXPECT_TRUE( picker->getCalendar()->getDisplayedMonth() == + ( CalendarDate{ today.year, today.month, 1 } ) ); + picker->getCalendar()->forceKeyDown( + KeyEvent( picker->getCalendar(), Event::KeyDown, KEY_ESCAPE, SCANCODE_UNKNOWN, 0, 0 ) ); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_FALSE( picker->hasValue() ); + EXPECT_TRUE( picker->hasFocus() ); + eeDelete( picker ); +} + +UTEST( UIDatePicker, SelectionClosesAndReSelectingSameDateAlsoCloses ) { + auto app = testApp(); + + auto* picker = UIDatePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setDate( CalendarDate{ 2026, 9, 28 } ); + int changes = 0; + auto valueConnection = + picker->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + picker->showCalendar(); + EXPECT_TRUE( picker->getCalendar()->getDisplayedMonth() == ( CalendarDate{ 2026, 9, 1 } ) ); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2026, 10, 6 } )->selectFocusedDate(); + EXPECT_TRUE( picker->getSerializedValue() == "2026-10-06" ); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_TRUE( picker->hasFocus() ); + EXPECT_EQ( changes, 1 ); + picker->showCalendar(); + picker->getCalendar()->selectFocusedDate(); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_EQ( changes, 1 ); + + eeDelete( picker ); +} + +UTEST( UIDatePicker, PopupPlacementFocusLossAndLifetime ) { + auto app = testApp(); + + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* picker = UIDatePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 200, 30 ); + picker->setPosition( 430, 440 ); + picker->showCalendar(); + auto* calendar = picker->getCalendar(); + EXPECT_TRUE( calendar->getScreenRect().Bottom <= picker->getScreenRect().Top ); + EXPECT_LE( calendar->getScreenRect().Right, app.getUI()->getWorldBounds().Right ); + EXPECT_TRUE( calendar->getParent() == picker->getWindowContainer() ); + auto* other = UITextInput::New(); + other->setParent( app.getUI()->getRoot() ); + other->setFocus(); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_FALSE( calendar->isEnabled() ); + picker->applyProperty( StyleSheetProperty( "popup-to-root", "true" ) ); + picker->showCalendar(); + EXPECT_TRUE( calendar->getParent() == app.getUI()->getRoot() ); + picker->setEnabled( false ); + EXPECT_FALSE( picker->isCalendarVisible() ); + picker->setEnabled( true ); + picker->showCalendar(); + calendar->close(); + app.getUI()->update( Milliseconds( 16 ) ); + EXPECT_TRUE( picker->getCalendar() == nullptr ); + EXPECT_FALSE( picker->isCalendarVisible() ); + + eeDelete( picker ); + eeDelete( other ); +} + +UTEST( UIDatePicker, OwnerAndPopupCanCloseInTheSameUpdate ) { + auto app = testApp(); + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + for ( int closingPart = 0; closingPart < 4; ++closingPart ) { + auto* picker = closingPart ? UIDateTimePicker::New() : UIDatePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setSize( 200, 30 ); + picker->showCalendar(); + Node* popup = picker->getCalendar(); + if ( closingPart == 1 ) + popup = + static_cast( picker )->getPopupTimePicker()->getParentWidget(); + else if ( closingPart == 3 ) + popup = static_cast( picker )->getPopupTimePicker(); + const size_t count = app.getUI()->getRoot()->getChildCount() - 2; + popup->close(); + picker->showCalendar(); // A queued popup cannot be reused before its deletion completes. + picker->close(); + app.getUI()->update( Time::Zero ); + app.getUI()->update( Time::Zero ); + EXPECT_EQ( app.getUI()->getRoot()->getChildCount(), count ); + } +} + +UTEST( UIDatePicker, ButtonPaddingDoesNotGrowWhenOtherPaddingChanges ) { + auto app = testApp(); + + auto* picker = UIDatePicker::New(); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 200, 30 ); + picker->setPaddingRight( 5 ); + + const auto right = picker->getPadding().Right; + picker->setPaddingTop( 7 ); + picker->setPaddingBottom( 8 ); + picker->setPaddingLeft( 9 ); + EXPECT_EQ( picker->getPadding().Right, right ); + picker->setSize( 200, 40 ); + EXPECT_EQ( picker->getPadding().Right, right + 10 ); + EXPECT_EQ( picker->getCalendarButton()->getPosition().x, 160.f ); + EXPECT_TRUE( picker->getCalendarButton()->getSize() == Sizef( 40, 40 ) ); + + eeDelete( picker ); +} + +UTEST( UIDatePicker, ReadOnlyCalendarIconTracksEditingPermission ) { + for ( int theme = 0; theme < 3; ++theme ) { + auto app = testApp(); + auto* ui = app.getUI(); + ui->setColorSchemePreference( theme == 1 ? ColorSchemePreference::Light + : ColorSchemePreference::Dark ); + std::string css; + ASSERT_TRUE( FileSystem::fileGet( Sys::getProcessPath() + "../assets/ui/" + + ( theme == 2 ? "uitheme.css" : "breeze.css" ), + css ) ); + ui->setStyleSheet( css ); + auto* root = ui->loadLayoutFromString( R"xml( + + + + )xml" ); + ASSERT_TRUE( root ); + ui->update( Time::Zero ); + const Color muted = Color::fromString( theme == 2 ? "#969696" : "#727679" ); + for ( const auto* id : { "#date", "#datetime" } ) { + auto* picker = root->querySelector( id )->asType(); + auto* button = picker->getCalendarButton(); + const auto textColor = picker->getFontColor(); + EXPECT_TRUE( button->getForegroundTint( 0 ) == muted ); + EXPECT_EQ( button->getCursor(), Cursor::Arrow ); + button->pushState( UIState::StateHover ); + ui->update( Milliseconds( 250 ) ); + EXPECT_TRUE( button->getForegroundTint( 0 ) == muted ); + EXPECT_EQ( button->getBackgroundColor().a, 0 ); + picker->setAllowEditing( true ); + ui->update( Milliseconds( 250 ) ); + EXPECT_FALSE( button->getForegroundTint( 0 ) == muted ); + EXPECT_EQ( button->getCursor(), Cursor::Arrow ); + picker->setAllowEditing( false ); + ui->update( Milliseconds( 250 ) ); + EXPECT_TRUE( button->getForegroundTint( 0 ) == muted ); + EXPECT_EQ( button->getBackgroundColor().a, 0 ); + EXPECT_TRUE( picker->getFontColor() == textColor ); + EXPECT_TRUE( picker->isEnabled() ); + } + } +} + +UTEST( UIDateTimePicker, ChildButtonsKeepArrowCursorThroughMouseRouting ) { + for ( int theme = 0; theme < 3; ++theme ) { + auto app = testApp(); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( false ); + ui->setColorSchemePreference( theme == 1 ? ColorSchemePreference::Light + : ColorSchemePreference::Dark ); + std::string css; + ASSERT_TRUE( FileSystem::fileGet( Sys::getProcessPath() + "../assets/ui/" + + ( theme == 2 ? "uitheme.css" : "breeze.css" ), + css ) ); + ui->setStyleSheet( css ); + auto* picker = UIDateTimePicker::New(); + picker->setParent( ui->getRoot() ); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 240, 30 ); + picker->setPosition( 10, 10 ); + picker->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 20, 14 } } ); + ui->update( Time::Zero ); + auto* dispatcher = ui->getEventDispatcher(); + auto* cursors = app.getWindow()->getCursorManager(); + const auto hover = [&]( UIWidget* widget, Cursor::SysType cursor ) { + const Vector2f point = widget->getScreenRect().getCenter(); + dispatcher->getInput()->setMousePos( + app.getWindow()->mapCoordsToPixel( point, app.getWindow()->getDefaultView() ) ); + dispatcher->update( Time::Zero ); + EXPECT_TRUE( dispatcher->getMouseOverNode() == widget ); + // Check the OS cursor, not just the widget's stored CSS property. + EXPECT_EQ( cursors->getCurrentSysCursor(), cursor ); + ui->update( Time::Zero ); + EXPECT_EQ( cursors->getCurrentSysCursor(), cursor ); + }; + hover( picker, Cursor::SysIBeam ); + hover( picker->getCalendarButton(), Cursor::SysArrow ); + hover( picker, Cursor::SysIBeam ); + picker->setAllowEditing( false ); + ui->update( Time::Zero ); + hover( picker->getCalendarButton(), Cursor::SysArrow ); + picker->setAllowEditing( true ); + ui->update( Time::Zero ); + picker->showCalendar(); + ui->update( Time::Zero ); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + for ( const auto* selector : { "Calendar::previous", "Calendar::next" } ) { + auto* button = picker->getCalendar()->querySelector( selector ); + ASSERT_TRUE( button ); + hover( button, Cursor::SysArrow ); + } + for ( const auto* selector : + { "DateTimePicker::time-up.minute", "DateTimePicker::time-down.minute" } ) { + auto* button = time->querySelector( selector ); + ASSERT_TRUE( button ); + hover( time, Cursor::SysIBeam ); + hover( button, Cursor::SysArrow ); + hover( time, Cursor::SysIBeam ); + } + picker->hideCalendar(); + hover( picker, Cursor::SysIBeam ); + hover( picker->getCalendarButton(), Cursor::SysArrow ); + eeDelete( picker ); + } +} + +UTEST( UIDateTimePicker, CalendarPreservesTimeAndEmptySelectionUsesMidnight ) { + auto app = testApp(); + + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setDateTime( + LocalDateTime{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 20, 14, 32, 125 } } ); + picker->showCalendar(); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2027, 1, 31 } )->selectFocusedDate(); + ASSERT_TRUE( picker->getDateTime() ); + EXPECT_TRUE( picker->getDateTime()->time == ( TimeOfDay{ 20, 14, 32, 125 } ) ); + EXPECT_TRUE( picker->getSerializedValue() == "2027-01-31T20:14:32.125" ); + picker->clear(); + picker->showCalendar(); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2027, 2, 1 } )->selectFocusedDate(); + EXPECT_TRUE( picker->getDateTime()->time == TimeOfDay{} ); + picker->setMinDateTime( LocalDateTime{ CalendarDate{ 2027, 2, 1 }, TimeOfDay{ 9, 30 } } ); + picker->clear(); + picker->showCalendar(); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2027, 2, 1 } )->selectFocusedDate(); + EXPECT_TRUE( picker->getDateTime()->time == ( TimeOfDay{ 9, 30 } ) ); + + eeDelete( picker ); +} + +UTEST( UIDateTimePicker, SecondsCanBeShownWithoutMilliseconds ) { + auto app = testApp(); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setLocale( DateTimeLocale::fromLocaleName( "es_AR" ) ); + const LocalDateTime value{ { 2026, 9, 28 }, { 20, 14, 32, 125 } }; + picker->setDateTime( value ); + EXPECT_FALSE( picker->getShowSeconds() ); + EXPECT_FALSE( picker->getShowMilliseconds() ); + EXPECT_TRUE( picker->getText().toUtf8() == "28/09/2026 20:14" ); + picker->showCalendar(); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + EXPECT_TRUE( time->getText().toUtf8() == "20:14" ); + picker->applyProperty( StyleSheetProperty( "show-seconds", "true" ) ); + EXPECT_TRUE( picker->getShowSeconds() ); + EXPECT_FALSE( picker->getShowMilliseconds() ); + EXPECT_TRUE( picker->getText().toUtf8() == "28/09/2026 20:14:32" ); + EXPECT_TRUE( time->getText().toUtf8() == "20:14:32" ); + auto* seconds = time->querySelector( "DateTimePicker::time-up.second" ); + ASSERT_TRUE( seconds && seconds->isVisible() ); + EXPECT_FALSE( time->querySelector( "DateTimePicker::time-up.millisecond" ) ); + picker->setShowMilliseconds( true ); + EXPECT_TRUE( time->getText().toUtf8() == "20:14:32.125" ); + auto* milliseconds = time->querySelector( "DateTimePicker::time-up.millisecond" ); + ASSERT_TRUE( milliseconds && milliseconds->isVisible() ); + picker->setShowMilliseconds( false ); + EXPECT_TRUE( picker->getShowSeconds() ); + EXPECT_TRUE( time->getText().toUtf8() == "20:14:32" ); + EXPECT_TRUE( seconds->isVisible() ); + EXPECT_FALSE( milliseconds->isVisible() ); + picker->setShowSeconds( false ); + EXPECT_TRUE( time->getText().toUtf8() == "20:14" ); + EXPECT_FALSE( seconds->isVisible() ); + EXPECT_TRUE( picker->getDateTime() == value ); + EXPECT_TRUE( time->getTime() == value.time ); + eeDelete( picker ); +} + +UTEST( UIDateTimePicker, PopupTimeSteppersPreserveDateAndPrecision ) { + auto app = testApp(); + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setShowSeconds( true ); + picker->setShowMilliseconds( true ); + picker->setMinuteStep( 15 ); + picker->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 20, 14, 32, 125 } } ); + int changes = 0; + auto valueConnection = + picker->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + EXPECT_TRUE( picker->getPopupTimePicker() == nullptr ); + picker->showCalendar(); + app.getUI()->update( Time::Zero ); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + EXPECT_EQ( changes, 0 ); + EXPECT_TRUE( time->getTime() == ( TimeOfDay{ 20, 14, 32, 125 } ) ); + EXPECT_TRUE( picker->getCalendar()->isEnabled() ); + EXPECT_TRUE( picker->getCalendar()->isVisible() ); + EXPECT_TRUE( time->getParent() == picker->getCalendar()->getParent() ); + EXPECT_GE( time->getScreenRect().Top, picker->getCalendar()->getScreenRect().Bottom ); + auto* minuteUp = time->querySelector( "datetimepicker::time-up.minute" ); + ASSERT_TRUE( minuteUp && minuteUp->isVisible() ); + EXPECT_EQ( minuteUp->getCursor(), Cursor::Arrow ); + app.getUI()->getEventDispatcher()->sendMouseClick( + minuteUp, minuteUp->getScreenRect().getCenter().asInt(), EE_BUTTON_LMASK ); + EXPECT_TRUE( picker->getDateTime() == + ( LocalDateTime{ { 2026, 9, 28 }, { 20, 29, 32, 125 } } ) ); + EXPECT_EQ( changes, 1 ); + EXPECT_TRUE( picker->isCalendarVisible() ); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2026, 10, 6 } )->selectFocusedDate(); + EXPECT_TRUE( picker->getDateTime() == + ( LocalDateTime{ { 2026, 10, 6 }, { 20, 29, 32, 125 } } ) ); + EXPECT_TRUE( picker->isCalendarVisible() ); + time->setActiveSection( DateTimeSection::Hour ); + time->setFocus(); + app.getUI()->getEventDispatcher()->sendKeyDown( KEY_UP, SCANCODE_UNKNOWN, 0, 0 ); + EXPECT_TRUE( picker->getDateTime()->time == ( TimeOfDay{ 21, 29, 32, 125 } ) ); + app.getUI()->getEventDispatcher()->sendKeyDown( KEY_RETURN, SCANCODE_UNKNOWN, 0, 0 ); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_TRUE( picker->hasFocus() ); + picker->showCalendar(); + EXPECT_TRUE( picker->getPopupTimePicker() == time ); + EXPECT_TRUE( time->getTime() == picker->getDateTime()->time ); + eeDelete( picker ); +} + +UTEST( UIDateTimePicker, EmptyPopupAndBoundaryTimeConstraints ) { + auto app = testApp(); + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setMinDateTime( LocalDateTime{ { 2026, 9, 28 }, { 9, 30 } } ); + picker->setMaxDateTime( LocalDateTime{ { 2026, 9, 28 }, { 17, 0 } } ); + int changes = 0; + auto valueConnection = + picker->connect( Event::OnValueChange, [&changes]( const Event* ) { ++changes; } ); + picker->showCalendar(); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + EXPECT_FALSE( picker->hasValue() ); + EXPECT_EQ( changes, 0 ); + EXPECT_TRUE( time->getMinTime() == ( TimeOfDay{ 9, 30 } ) ); + EXPECT_TRUE( time->getMaxTime() == ( TimeOfDay{ 17, 0 } ) ); + time->setActiveSection( DateTimeSection::Minute ); + time->stepActiveSection( 1 ); + EXPECT_TRUE( picker->getDateTime() == ( LocalDateTime{ { 2026, 9, 28 }, { 9, 31 } } ) ); + EXPECT_EQ( changes, 1 ); + time->setTime( TimeOfDay{ 23, 0 } ); + EXPECT_TRUE( picker->getDateTime()->time == ( TimeOfDay{ 17, 0 } ) ); + picker->setMaxDateTime( LocalDateTime{ { 2026, 9, 29 }, { 18, 0 } } ); + picker->getCalendar()->setFocusedDate( CalendarDate{ 2026, 9, 29 } )->selectFocusedDate(); + EXPECT_FALSE( time->getMinTime() ); + EXPECT_TRUE( time->getMaxTime() == ( TimeOfDay{ 18, 0 } ) ); + picker->clear(); + EXPECT_FALSE( picker->hasValue() ); + EXPECT_FALSE( picker->getCalendar()->getSelectedDate() ); + picker->hideCalendar(); + EXPECT_FALSE( picker->hasValue() ); + eeDelete( picker ); +} + +UTEST( UIDateTimePicker, FirstOpeningFitsStyledTimeFooter ) { + for ( Float density : { 1.f, 1.5f, 2.f } ) { + for ( int theme = 0; theme < 3; ++theme ) { + UIApplication app( + WindowSettings{ 1020, 720, "eepp - date time popup layout tests" }, + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), + density ) ); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( false ); + ui->setColorSchemePreference( theme == 1 ? ColorSchemePreference::Light + : ColorSchemePreference::Dark ); + std::string css; + ASSERT_TRUE( FileSystem::fileGet( Sys::getProcessPath() + "../assets/ui/" + + ( theme == 2 ? "uitheme.css" : "breeze.css" ), + css ) ); + ui->setStyleSheet( css ); + auto* picker = UIDateTimePicker::New(); + picker->setParent( ui->getRoot() ); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 320, 30 ); + picker->setPosition( 16, PixelDensity::pxToDp( 298.f ) ); + picker->setShowMilliseconds( true ); + picker->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 20, 14, 32, 125 } } ); + ui->update( Time::Zero ); + picker->showCalendar(); + ui->update( Time::Zero ); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + auto* popup = time->getParent(); + const auto verifyBounds = [&]() { + EXPECT_LE( popup->getScreenRect().Bottom, ui->getWorldBounds().Bottom ); + EXPECT_GE( popup->getScreenRect().Top, ui->getWorldBounds().Top ); + EXPECT_LE( time->getScreenRect().Bottom, popup->getScreenRect().Bottom ); + EXPECT_EQ( time->getScreenRect().Top, + picker->getCalendar()->getScreenRect().Bottom ); + EXPECT_GE( time->getSize().getHeight(), 72.f ); + for ( auto* arrow : time->querySelectorAll( "DateTimePicker::time-down" ) ) { + if ( arrow->isVisible() ) + EXPECT_LE( arrow->getScreenRect().Bottom, popup->getScreenRect().Bottom ); + } + }; + verifyBounds(); + // Changing style metrics must resize and realign without stepping a time section. + time->applyProperty( StyleSheetProperty( "font-size", "24dp" ) ); + time->setPaddingTop( 30 ); + time->setPaddingBottom( 30 ); + verifyBounds(); + eeDelete( picker ); + } + } +} + +UTEST( UIDateTimePicker, PopupFormatFocusPlacementAndLifetime ) { + auto app = testApp(); + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + picker->setSize( 200, 30 ); + picker->setPosition( 430, 440 ); + picker->setLocale( DateTimeLocale::fromLocaleName( "en_US" ) ); + picker->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 20, 14 } } ); + picker->showCalendar(); + app.getUI()->update( Time::Zero ); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + EXPECT_TRUE( time->getText().toUtf8() == "08:14 PM" ); + auto* popup = time->getParent(); + EXPECT_LE( popup->getScreenRect().Bottom, picker->getScreenRect().Top ); + EXPECT_LE( popup->getScreenRect().Right, app.getUI()->getWorldBounds().Right ); + auto* amPm = time->querySelector( "datetimepicker::time-up.am-pm" ); + ASSERT_TRUE( amPm && amPm->isVisible() ); + time->setFocus(); + EXPECT_TRUE( picker->isCalendarVisible() ); + const auto value = picker->getDateTime(); + time->setActiveSection( DateTimeSection::Minute ); + app.getUI()->getEventDispatcher()->sendTextInput( '1', 0 ); + app.getUI()->getEventDispatcher()->sendKeyDown( KEY_ESCAPE, SCANCODE_UNKNOWN, 0, 0 ); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_TRUE( picker->getDateTime() == value ); + picker->showCalendar(); + picker->getCalendar()->close(); + app.getUI()->update( Time::Zero ); + EXPECT_TRUE( picker->getCalendar() == nullptr ); + EXPECT_FALSE( picker->isCalendarVisible() ); + picker->showCalendar(); + EXPECT_TRUE( picker->getCalendar()->getParent() == picker->getPopupTimePicker()->getParent() ); + picker->getPopupTimePicker()->close(); + app.getUI()->update( Time::Zero ); + EXPECT_TRUE( picker->getPopupTimePicker() == nullptr ); + picker->showCalendar(); + ASSERT_TRUE( picker->getPopupTimePicker() ); + auto* other = UITextInput::New(); + other->setParent( app.getUI()->getRoot() ); + picker->getPopupTimePicker()->setFocus(); + other->setFocus(); + EXPECT_FALSE( picker->isCalendarVisible() ); + picker->showCalendar(); + picker->setEnabled( false ); + EXPECT_FALSE( picker->isCalendarVisible() ); + eeDelete( picker ); + eeDelete( other ); +} + +UTEST( DateTimeWidgets, NativeXmlRegistrationAndTypedValueProperties ) { + auto app = testApp(); + + auto* root = app.getUI()->loadLayoutFromString( R"xml( + + + + + + + )xml" ); + ASSERT_TRUE( root ); + auto* date = root->querySelector( "#date" ); + + auto* time = root->querySelector( "#time" ); + auto* datetime = root->querySelector( "#datetime" ); + ASSERT_TRUE( date && time && datetime ); + EXPECT_TRUE( date->isType( UI_TYPE_DATEPICKER ) ); + EXPECT_TRUE( date->isType( UI_TYPE_DATETIMEEDIT ) ); + EXPECT_TRUE( date->isType( UI_TYPE_TEXTINPUT ) ); + EXPECT_TRUE( time->isType( UI_TYPE_TIMEPICKER ) ); + EXPECT_TRUE( datetime->isType( UI_TYPE_DATETIMEPICKER ) ); + + const auto* value = StyleSheetSpecification::instance()->getProperty( "value" ); + EXPECT_TRUE( date->getPropertyString( value ) == "1990-06-17" ); + EXPECT_TRUE( time->getPropertyString( value ) == "20:30:00" ); + EXPECT_TRUE( datetime->getPropertyString( value ) == "2026-09-28T20:30:00" ); + EXPECT_TRUE( root->querySelector( "#edit" )->getPropertyString( value ) == "12:00:00" ); + eeDelete( root ); +} + +UTEST( DateTimeWidgets, BindingUsesIsoAndPreservesHiddenPrecision ) { + auto app = testApp(); + auto* picker = UIDateTimePicker::New(); + picker->setLocale( DateTimeLocale::fromLocaleName( "es_AR" ) ); + { + UIProperty value( std::string( "2026-09-28T20:14:32.125" ), picker ); + EXPECT_TRUE( picker->getSerializedValue() == "2026-09-28T20:14:32.125" ); + picker->setDateTime( + LocalDateTime{ CalendarDate{ 2027, 1, 31 }, TimeOfDay{ 9, 15, 12, 250 } } ); + EXPECT_TRUE( value.value() == "2027-01-31T09:15:12.250" ); + picker->setDateFormat( "MM/dd/yyyy" ); + EXPECT_TRUE( value.value() == "2027-01-31T09:15:12.250" ); + value = std::string( "2028-02-29T23:59:59.999" ); + EXPECT_TRUE( picker->getDateTime()->date == ( CalendarDate{ 2028, 2, 29 } ) ); + picker->clear(); + EXPECT_TRUE( value.value().empty() ); + } + eeDelete( picker ); +} + +UTEST( UICalendar, InvalidBoundsPreserveConstraintsAndYearPagesStayRepresentable ) { + auto app = testApp(); + auto* calendar = UICalendar::New(); + calendar->setMinDate( CalendarDate{ 2026, 1, 1 } ); + EXPECT_FALSE( calendar->applyProperty( StyleSheetProperty( "min-date", "2026-02-31" ) ) ); + EXPECT_TRUE( calendar->getMinDate() == ( CalendarDate{ 2026, 1, 1 } ) ); + calendar->setMinDate( std::nullopt ); + for ( const Int32 year : + { std::numeric_limits::min(), std::numeric_limits::max() } ) { + calendar->setDisplayedMonth( CalendarDate{ year, 6, 1 } ); + calendar->setView( CalendarView::Years ); + if ( year == std::numeric_limits::min() ) + calendar->previous()->previous(); + else + calendar->next()->next(); + EXPECT_TRUE( !calendar->querySelectorAll( "calendar::day:disabled" ).empty() ); + calendar->setView( CalendarView::Days ); + } + eeDelete( calendar ); +} + +UTEST( UIDateTimeEdit, ModeChangePreservesNonemptyPolicy ) { + auto app = testApp(); + auto* edit = UIDateTimeEdit::New(); + edit->setAllowEmpty( false ); + edit->setEditMode( DateTimeEditMode::Time ); + EXPECT_TRUE( edit->getTime().has_value() ); + edit->setEditMode( DateTimeEditMode::DateTime ); + EXPECT_TRUE( edit->getDateTime().has_value() ); + eeDelete( edit ); +} + +UTEST( UICalendar, DefaultSizeTracksFontMetricsAndFixedSizeRemainsExplicit ) { + auto app = testApp(); + auto* calendar = UICalendar::New(); + calendar->setParent( app.getUI()->getRoot() ); + app.getUI()->update( Milliseconds( 16 ) ); + const auto initialSize = calendar->getSize(); + for ( const char* tag : + { "calendar::day::text", "calendar::title::text", "calendar::today::text" } ) { + for ( auto* text : calendar->querySelectorAll( tag ) ) + text->asType()->setFontSize( PixelDensity::dpToPx( 24 ) ); + } + for ( auto* text : calendar->querySelectorAll( "calendar::weekday" ) ) + text->asType()->setFontSize( PixelDensity::dpToPx( 24 ) ); + app.getUI()->update( Milliseconds( 16 ) ); + EXPECT_GT( calendar->getSize().getWidth(), initialSize.getWidth() ); + EXPECT_GT( calendar->getSize().getHeight(), initialSize.getHeight() ); + for ( auto* widget : calendar->querySelectorAll( "calendar::weekday" ) ) { + auto* label = widget->asType(); + EXPECT_GE( label->getPixelsSize().getWidth(), label->getTextWidth() ); + EXPECT_GE( label->getPixelsSize().getHeight(), label->getTextHeight() ); + } + calendar->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + calendar->setSize( 500, 500 ); + calendar->setDisplayedMonth( CalendarDate{ 2026, 9, 1 } ); + EXPECT_TRUE( calendar->getSize() == Sizef( 500, 500 ) ); + eeDelete( calendar ); +} + +UTEST( UICalendar, FractionalGridWidthsKeepSevenColumnsAndSixRows ) { + auto app = testApp(); + auto* calendar = UICalendar::New(); + calendar->setParent( app.getUI()->getRoot() ); + calendar->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + for ( const Float width : { 240.f, 320.f, 480.f, 481.f } ) { + calendar->setSize( width, 480 ); + app.getUI()->update( Milliseconds( 16 ) ); + auto cells = calendar->querySelectorAll( "calendar::day" ); + ASSERT_GE( cells.size(), 42u ); + const Float top = cells[0]->getPosition().y; + for ( size_t i = 1; i < 7; ++i ) + EXPECT_EQ( cells[i]->getPosition().y, top ); + EXPECT_GT( cells[7]->getPosition().y, top ); + EXPECT_EQ( cells[35]->getPosition().y, cells[41]->getPosition().y ); + const auto* grid = cells[0]->getParent(); + EXPECT_LE( cells[41]->getPosition().y + cells[41]->getSize().getHeight(), + grid->getSize().getHeight() + 0.01f ); + } + eeDelete( calendar ); +} + +UTEST( UIDateTimeEdit, ClickSelectsNearestSectionIndependentOfExistingSelection ) { + auto app = testApp(); + auto* edit = TestableDateTimeEdit::New( DateTimeEditMode::Date ); + edit->setParent( app.getUI()->getRoot() ); + edit->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + edit->setSize( 400, 40 ); + edit->setPosition( 30, 50 ); + edit->setDateFormat( "dd/MM/yyyy" ); + edit->setDate( CalendarDate{ 2026, 9, 28 } ); + app.getUI()->update( Milliseconds( 16 ) ); + + edit->setActiveSection( DateTimeSection::Day ); + edit->clickAt( { 200, 20 } ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Year ); + EXPECT_EQ( edit->getDocument().getSelection().start().column(), 6 ); + EXPECT_EQ( edit->getDocument().getSelection().end().column(), 10 ); + + edit->clickAt( { 0, 20 } ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Day ); + edit->clickAt( edit->sectionClickPosition( 4 ) ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Month ); + edit->clickAt( edit->sectionClickPosition( 8 ) ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Year ); + edit->clickAt( edit->sectionClickPosition( 1 ) ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Day ); + + edit->setEditMode( DateTimeEditMode::Time ); + edit->setTimeFormat( "HH:mm:ss" ); + edit->setTime( TimeOfDay{ 20, 14, 32 } ); + edit->setActiveSection( DateTimeSection::Hour ); + edit->clickAt( { 200, 20 } ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Second ); + edit->clickAt( { 0, 20 } ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Hour ); + + edit->setEditMode( DateTimeEditMode::DateTime ); + edit->setTimeFormat( "HH:mm:ss.SSS" ); + edit->setShowMilliseconds( true ); + edit->setDateTime( LocalDateTime{ CalendarDate{ 2026, 9, 28 }, TimeOfDay{ 20, 14, 32, 125 } } ); + edit->setActiveSection( DateTimeSection::Day ); + edit->clickAt( { 380, 20 } ); + EXPECT_TRUE( edit->getActiveSection() == DateTimeSection::Millisecond ); + eeDelete( edit ); +} + +UTEST( UIDateTimePicker, LivePopupConfiguration ) { + auto app = testApp(); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setDateTime( LocalDateTime{ { 2026, 9, 28 }, { 20, 14 } } ); + picker->showCalendar(); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + picker->setHourStep( 2 ); + picker->setMinuteStep( 15 ); + picker->setSecondStep( 10 ); + picker->setPendingDigitTimeout( Milliseconds( 250 ) ); + EXPECT_EQ( time->getHourStep(), 2u ); + EXPECT_EQ( time->getMinuteStep(), 15u ); + EXPECT_EQ( time->getSecondStep(), 10u ); + EXPECT_TRUE( time->getPendingDigitTimeout() == Milliseconds( 250 ) ); + picker->setShowSeconds( true ); + EXPECT_TRUE( time->getShowSeconds() ); + picker->setShowMilliseconds( true ); + EXPECT_TRUE( time->getShowMilliseconds() ); + auto locale = DateTimeLocale::fromLocaleName( "en_US" ); + locale.monthNames[8] = "Localized September"; + picker->setLocale( locale ); + EXPECT_TRUE( picker->getCalendar()->getLocale().monthNames[8] == "Localized September" ); + EXPECT_TRUE( time->getHourCycle() == picker->getHourCycle() ); + time->setActiveSection( DateTimeSection::Minute ); + time->stepActiveSection( 1 ); + EXPECT_EQ( picker->getDateTime()->time.minute, 29 ); + // Test the nonvirtual base API too: read-only changes cannot bypass popup synchronization. + static_cast( picker )->setAllowEditing( false ); + EXPECT_FALSE( picker->isCalendarVisible() ); + EXPECT_FALSE( time->isEditingAllowed() ); + picker->setAllowEditing( true ); + picker->showCalendar(); + EXPECT_TRUE( picker->isCalendarVisible() ); + EXPECT_TRUE( time->isEditingAllowed() ); + eeDelete( picker ); +} + +UTEST( UIDateTimePicker, EmptyPopupNavigationUpdatesBoundaryTime ) { + auto app = testApp(); + auto* picker = UIDateTimePicker::New(); + picker->setParent( app.getUI()->getRoot() ); + picker->setMinDateTime( LocalDateTime{ { 2026, 9, 28 }, { 10, 30 } } ); + picker->setMaxDateTime( LocalDateTime{ { 2026, 9, 30 }, { 18, 45 } } ); + picker->showCalendar(); + picker->getCalendar()->setFocusedDate( { 2026, 9, 28 } ); + EXPECT_TRUE( picker->getPopupTimePicker()->getMinTime() == ( TimeOfDay{ 10, 30 } ) ); + picker->getCalendar()->setFocusedDate( { 2026, 9, 29 } ); + EXPECT_FALSE( picker->getPopupTimePicker()->getMinTime() ); + picker->getCalendar()->setFocusedDate( { 2026, 9, 30 } ); + EXPECT_TRUE( picker->getPopupTimePicker()->getMaxTime() == ( TimeOfDay{ 18, 45 } ) ); + EXPECT_FALSE( picker->hasValue() ); + eeDelete( picker ); +} + +UTEST( UIDatePicker, PopupTracksAncestorMovementAndVisibleSceneBounds ) { + UIApplication app( + WindowSettings{ 1020, + 900, + "Picker movement tests", + WindowStyle::Default, + WindowBackend::Default, + 32, + {}, + 1, + false, + true }, + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + app.getUI()->getUIThemeManager()->setDefaultEffectsEnabled( false ); + for ( const float density : { 1.f, 1.5f, 2.f } ) { + PixelDensity::setPixelDensity( density ); + auto* container = UIWidget::New(); + container->setParent( app.getUI()->getRoot() ); + container->setPosition( 20, 20 ); + auto* picker = UIDatePicker::New(); + picker->setParent( container ); + picker->setSize( 150, 24 ); + picker->setPosition( 10, 10 ); + picker->applyProperty( StyleSheetProperty( "popup-to-root", "true" ) ); + picker->showCalendar(); + const Vector2f before = picker->getCalendar()->getScreenRect().getPosition(); + container->setPosition( 40, 50 ); + picker + ->getScreenRect(); // Screen-position invalidation also covers scroll-container motion. + const Vector2f after = picker->getCalendar()->getScreenRect().getPosition(); + EXPECT_NEAR( after.x - before.x, 20 * density, 0.1f ); + EXPECT_NEAR( after.y - before.y, 30 * density, 0.1f ); + picker->applyProperty( StyleSheetProperty( "popup-to-root", "false" ) ); + EXPECT_TRUE( picker->isCalendarVisible() ); + container->setPosition( 500 / density, 400 / density ); + picker->getScreenRect(); + EXPECT_TRUE( app.getUI()->getVisibleWorldBounds().contains( + picker->getCalendar()->getScreenRect() ) ); + eeDelete( container ); + } + PixelDensity::setPixelDensity( 1 ); +} + +UTEST( DateTimeWidgets, BuiltInPropertyAndShorthandInventoryIsComplete ) { + auto* spec = StyleSheetSpecification::instance(); + for ( Uint16 id = 1; id < static_cast( PropertyId::NumDefinedIds ); ++id ) + EXPECT_TRUE( spec->getProperty( static_cast( id ) ) != nullptr ); + for ( Uint8 id = 1; id < static_cast( ShorthandId::NumDefinedIds ); ++id ) + EXPECT_TRUE( spec->getShorthand( static_cast( id ) ) != nullptr ); +} + +UTEST( UIDatePicker, PopupTracksScrollingAndMovingWindows ) { + UIApplication app( + WindowSettings{ 1020, + 900, + "Picker scrolling tests", + WindowStyle::Default, + WindowBackend::Default, + 32, + {}, + 1, + false, + true }, + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* window = UIWindow::New(); + window->setParent( ui->getRoot() ); + window->setSize( 400, 350 ); + window->setPosition( 50, 40 ); + auto* scroll = UIScrollView::New(); + scroll->setParent( window->getContainer() ); + scroll->setSize( 350, 250 ); + auto* content = UIWidget::New(); + content->setSize( 320, 800 ); + content->setParent( scroll ); + auto* picker = UIDatePicker::New(); + picker->setParent( content ); + picker->setSize( 200, 30 ); + picker->setPosition( 20, 120 ); + ui->flushDirtyStyleAndLayout(); + ASSERT_TRUE( scroll->getVerticalScrollBar()->isEnabled() ); + for ( bool toRoot : { false, true } ) { + picker->applyProperty( StyleSheetProperty( "popup-to-root", toRoot ? "true" : "false" ) ); + picker->showCalendar(); + ui->flushDirtyStyleAndLayout(); + EXPECT_TRUE( picker->getCalendar()->getParent() == + ( toRoot ? static_cast( ui->getRoot() ) : window->getContainer() ) ); + const auto before = picker->getCalendar()->getScreenRect(); + const auto fieldBefore = picker->getScreenRect(); + scroll->getVerticalScrollBar()->setValue( toRoot ? 0.1f : 0.05f ); + const auto fieldAfter = picker->getScreenRect(); + const auto after = picker->getCalendar()->getScreenRect(); + EXPECT_LT( fieldAfter.Top, fieldBefore.Top ); + EXPECT_NEAR( after.Top - before.Top, fieldAfter.Top - fieldBefore.Top, 0.1f ); + window->setPosition( window->getPosition() + Vector2f( 30, 20 ) ); + picker->getScreenRect(); + EXPECT_NEAR( picker->getCalendar()->getScreenRect().Left - after.Left, 30.f, 0.1f ); + EXPECT_NEAR( picker->getCalendar()->getScreenRect().Top - after.Top, 20.f, 0.1f ); + picker->hideCalendar(); + } + ui->setPixelsSize( 300, 250 ); + picker->showCalendar(); + EXPECT_TRUE( ui->getVisibleWorldBounds().contains( picker->getCalendar()->getScreenRect() ) ); +} diff --git a/src/tests/unit_tests/uidropdownmodellist_tests.cpp b/src/tests/unit_tests/uidropdownmodellist_tests.cpp index 1f5fd75c2..9593b7eaa 100644 --- a/src/tests/unit_tests/uidropdownmodellist_tests.cpp +++ b/src/tests/unit_tests/uidropdownmodellist_tests.cpp @@ -1,13 +1,17 @@ #include "utest.h" +#include #include #include #include #include #include +#include #include #include #include +#include +#include #include using namespace EE; @@ -50,3 +54,161 @@ UTEST( UIDropDownModelList, basicFunctionality ) { EXPECT_EQ( dropDown->getMaxNumVisibleItems(), 2ul ); EXPECT_TRUE( dropDown->getListView()->getVerticalScrollBar()->isVisible() ); } + +UTEST( UIDropDown, PlacementParentingAndFocusCompatibility ) { + for ( Float density : { 1.f, 2.f } ) { + UIApplication app( + WindowSettings( 640, 480, "eepp - Dropdown Placement Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), + density ) ); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( false ); + auto* window = UIWindow::New(); + window->setParent( ui->getRoot() ); + window->setPosition( 20, 15 ); + window->setSize( 250, 180 ); + for ( bool modelBased : { false, true } ) { + UIDropDown* dropdown; + UIWidget* popup; + if ( modelBased ) { + auto* modelDropdown = UIDropDownModelList::New(); + modelDropdown->setModel( ItemListOwnerModel::create( + { "A reasonably wide dropdown item", "Second item", "Third item" } ) ); + modelDropdown->getListView()->setRowHeight( 20 ); + dropdown = modelDropdown; + popup = modelDropdown->getListView(); + } else { + auto* listDropdown = UIDropDownList::New(); + listDropdown->getListBox()->addListBoxItems( + { "A reasonably wide dropdown item", "Second item", "Third item" } ); + listDropdown->getListBox()->setRowHeight( 20 ); + dropdown = listDropdown; + popup = listDropdown->getListBox(); + } + dropdown->setParent( window->getContainer() ); + dropdown->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + dropdown->setSize( 100, 20 ); + dropdown->setPosition( 30, 30 ); + dropdown->setMaxNumVisibleItems( 3 ); + const Rectf bounds( ui->getWorldBounds() ); + for ( bool toRoot : { false, true } ) { + dropdown->setPopUpToRoot( toRoot ); + for ( auto mode : + { UIDropDown::MenuWidthMode::DropDown, UIDropDown::MenuWidthMode::Contents, + UIDropDown::MenuWidthMode::ContentsCentered, + UIDropDown::MenuWidthMode::ExpandIfNeeded, + UIDropDown::MenuWidthMode::ExpandIfNeededCentered } ) { + dropdown->setMenuWidthMode( mode ); + dropdown->showList(); + const Rectf field( dropdown->getScreenRect() ); + const Float width = popup->getPixelsSize().getWidth(); + const bool centered = mode == UIDropDown::MenuWidthMode::ContentsCentered || + mode == UIDropDown::MenuWidthMode::ExpandIfNeededCentered; + Float x = centered ? field.Left + eefloor( ( field.getWidth() - width ) / 2 ) + : field.Left; + x = eeclamp( x, bounds.Left, eemax( bounds.Left, bounds.Right - width ) ); + EXPECT_TRUE( popup->getScreenPos() == Vector2f( x, field.Bottom ) ); + EXPECT_TRUE( popup->getParent() == + ( toRoot ? ui->getRoot() : window->getContainer() ) ); + EXPECT_TRUE( popup->isVisible() ); + EXPECT_TRUE( popup->isEnabled() ); + EXPECT_TRUE( popup->hasFocus() ); + if ( mode == UIDropDown::MenuWidthMode::DropDown ) + EXPECT_EQ( width, field.getWidth() ); + popup->setVisible( false ); + } + } + // A list wider than the scene still belongs below the field when its height fits. + dropdown->setMenuWidthMode( UIDropDown::MenuWidthMode::DropDown ); + dropdown->setPixelsSize( bounds.getWidth() + 100, 40 ); + dropdown->showList(); + EXPECT_TRUE( popup->getScreenPos() == + Vector2f( bounds.Left, dropdown->getScreenRect().Bottom ) ); + popup->setVisible( false ); + dropdown->setSize( 100, 20 ); + Vector2f bottomPosition( bounds.Right - dropdown->getPixelsSize().getWidth(), + bounds.Bottom - dropdown->getPixelsSize().getHeight() ); + dropdown->getParent()->worldToNode( bottomPosition ); + dropdown->setPosition( bottomPosition ); + dropdown->showList(); + EXPECT_EQ( popup->getScreenRect().Bottom, dropdown->getScreenRect().Top ); + EXPECT_EQ( popup->getScreenRect().Right, bounds.Right ); + popup->getFirstChild()->setFocus(); + EXPECT_TRUE( popup->isVisible() ); + dropdown->setFocus(); + EXPECT_TRUE( popup->isVisible() ); + popup->setFocus(); + auto* other = UITextInput::New(); + other->setParent( ui->getRoot() ); + other->setFocus(); + EXPECT_FALSE( popup->isVisible() ); + EXPECT_FALSE( popup->isEnabled() ); + eeDelete( dropdown ); + eeDelete( other ); + } + } +} + +UTEST( UIDropDown, ReopenDuringFadeDoesNotHideTheNewPopup ) { + UIApplication app( + WindowSettings( 640, 480, "eepp - Dropdown Fade Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( true ); + ui->getUIThemeManager()->setWidgetsFadeOutTime( Milliseconds( 50 ) ); + auto* dropdown = UIDropDownList::New(); + dropdown->setParent( ui->getRoot() ); + dropdown->setSize( 100, 20 ); + dropdown->getListBox()->addListBoxItems( { "First item", "Second item" } ); + auto* popup = dropdown->getListBox(); + auto* other = UITextInput::New(); + other->setParent( ui->getRoot() ); + dropdown->showList(); + EXPECT_TRUE( popup->isVisible() ); + other->setFocus(); + EXPECT_FALSE( popup->isEnabled() ); + EXPECT_TRUE( popup->isVisible() ); + dropdown->showList(); + // A visible fading popup first toggles closed; reopen after its fade completes. + for ( int i = 0; i < 3; ++i ) + ui->getActionManager()->update( Milliseconds( 100 ) ); + dropdown->showList(); + other->setFocus(); + popup->setVisible( false ); + dropdown->showList(); + for ( int i = 0; i < 3; ++i ) + ui->getActionManager()->update( Milliseconds( 100 ) ); + EXPECT_TRUE( popup->isVisible() ); + EXPECT_TRUE( popup->isEnabled() ); + EXPECT_EQ( popup->getAlpha(), 255.f ); +} + +UTEST( UIDropDown, ReopenAfterDisablingEffectsRestoresOpacity ) { + UIApplication app( + WindowSettings( 640, 480, "eepp - Dropdown Effects Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* ui = app.getUI(); + ui->getUIThemeManager()->setDefaultEffectsEnabled( true ); + ui->getUIThemeManager()->setWidgetsFadeOutTime( Milliseconds( 50 ) ); + auto* dropdown = UIDropDownList::New(); + dropdown->setParent( ui->getRoot() ); + dropdown->setSize( 100, 20 ); + dropdown->getListBox()->addListBoxItems( { "First item", "Second item" } ); + auto* other = UITextInput::New(); + other->setParent( ui->getRoot() ); + dropdown->showList(); + other->setFocus(); + for ( int i = 0; i < 3; ++i ) + ui->getActionManager()->update( Milliseconds( 100 ) ); + auto* popup = dropdown->getListBox(); + EXPECT_FALSE( popup->isVisible() ); + EXPECT_EQ( popup->getAlpha(), 0.f ); + ui->getUIThemeManager()->setDefaultEffectsEnabled( false ); + dropdown->showList(); + EXPECT_TRUE( popup->isVisible() ); + EXPECT_TRUE( popup->isEnabled() ); + EXPECT_EQ( popup->getAlpha(), 255.f ); +} diff --git a/src/tests/unit_tests/uihtmltemporal_tests.cpp b/src/tests/unit_tests/uihtmltemporal_tests.cpp new file mode 100644 index 000000000..9bf7220e3 --- /dev/null +++ b/src/tests/unit_tests/uihtmltemporal_tests.cpp @@ -0,0 +1,380 @@ +#include "utest.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Tools; + +namespace { + +UIApplication temporalApp() { + return UIApplication( + WindowSettings{ 800, + 650, + "HTML temporal input tests", + WindowStyle::Default, + WindowBackend::Default, + 32, + {}, + 1, + false, + true }, + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); +} + +} // namespace + +UTEST( UIHTMLTemporal, NativeMappingAndStrictValueSanitization ) { + auto app = temporalApp(); + struct Case { + const char* type; + const char* value; + const char* normalized; + }; + const Case cases[] = { + { "date", "2024-02-29", "2024-02-29" }, + { "date", "0001-01-01", "0001-01-01" }, + { "date", "00001-01-01", "00001-01-01" }, + { "date", "12026-09-28", "12026-09-28" }, + { "date", "2025-02-29", "" }, + { "date", "0000-01-01", "" }, + { "date", "28/09/2026", "" }, + { "date", "2026-9-28", "" }, + { "date", " 2026-09-28", "" }, + { "time", "20:14", "20:14" }, + { "time", "20:14:00", "20:14:00" }, + { "time", "20:14:32.120", "20:14:32.120" }, + { "time", "20:14:32.1", "20:14:32.1" }, + { "time", "24:00", "" }, + { "time", "8:14 PM", "" }, + { "time", "20:14:32.1234", "" }, + { "datetime-local", "2026-09-28 20:14:00.000", "2026-09-28T20:14" }, + { "datetime-local", "2026-09-28T20:14:32.007", "2026-09-28T20:14:32.007" }, + { "datetime-local", "2026-09-28T20:14Z", "" }, + { "datetime-local", "2026-09-28T20:14+01:00", "" } }; + for ( const auto& test : cases ) { + auto* input = UIHTMLInput::New(); + input->setParent( app.getUI()->getRoot() ); + input->setInputType( test.type ); + const Uint32 type = std::string_view( test.type ) == "date" ? UI_TYPE_DATEPICKER + : std::string_view( test.type ) == "time" ? UI_TYPE_TIMEPICKER + : UI_TYPE_DATETIMEPICKER; + EXPECT_TRUE( input->getChildWidget()->isType( type ) ); + input->applyProperty( StyleSheetProperty( "value", test.value, false ) ); + EXPECT_TRUE( input->getFormValue().toUtf8() == test.normalized ); + auto* edit = static_cast( input->getChildWidget() ); + edit->setLocale( DateTimeLocale::fromLocaleName( "en_US" ) ); + EXPECT_TRUE( input->getFormValue().toUtf8() == test.normalized ); + edit->setLocale( DateTimeLocale::fromLocaleName( "es_AR" ) ); + EXPECT_TRUE( input->getFormValue().toUtf8() == test.normalized ); + input->close(); + app.getUI()->update( Time::Zero ); + } +} + +UTEST( UIHTMLTemporal, RangeRequiredAndStepValidation ) { + auto app = temporalApp(); + auto* input = UIHTMLInput::New(); + input->setParent( app.getUI()->getRoot() ); + input->setInputType( "date" ); + input->applyProperty( StyleSheetProperty( "required", "required" ) ); + EXPECT_TRUE( input->getValidity().valueMissing ); + EXPECT_FALSE( input->checkValidity() ); + input->applyProperty( StyleSheetProperty( "min", "2026-09-01" ) ); + input->applyProperty( StyleSheetProperty( "max", "2026-09-30" ) ); + input->applyProperty( StyleSheetProperty( "step", "2" ) ); + input->applyProperty( StyleSheetProperty( "value", "2026-09-02" ) ); + EXPECT_TRUE( input->getValidity().stepMismatch ); + input->applyProperty( StyleSheetProperty( "value", "2026-09-03" ) ); + EXPECT_TRUE( input->checkValidity() ); + EXPECT_EQ( static_cast( input->getChildWidget() )->getDayStep(), 2u ); + input->applyProperty( StyleSheetProperty( "value", "2026-10-01" ) ); + EXPECT_TRUE( input->getValidity().rangeOverflow ); + EXPECT_TRUE( input->getFormValue() == "2026-10-01" ); // Validation never clamps the DOM value. + input->applyProperty( StyleSheetProperty( "min", "malformed" ) ); + input->applyProperty( StyleSheetProperty( "max", "malformed" ) ); + input->applyProperty( StyleSheetProperty( "step", "any" ) ); + EXPECT_TRUE( input->checkValidity() ); + input->setInputType( "time" ); + input->applyProperty( StyleSheetProperty( "min", "22:00" ) ); + input->applyProperty( StyleSheetProperty( "max", "02:00" ) ); + input->applyProperty( StyleSheetProperty( "value", "23:30" ) ); + EXPECT_TRUE( input->checkValidity() ); + input->applyProperty( StyleSheetProperty( "value", "01:30" ) ); + EXPECT_TRUE( input->checkValidity() ); + input->applyProperty( StyleSheetProperty( "value", "12:00" ) ); + EXPECT_TRUE( input->getValidity().rangeUnderflow ); + EXPECT_TRUE( input->getValidity().rangeOverflow ); + input->applyProperty( StyleSheetProperty( "min", "00:00:00.005" ) ); + input->applyProperty( StyleSheetProperty( "max", "23:59:59.999" ) ); + input->applyProperty( StyleSheetProperty( "step", "0.01" ) ); + input->applyProperty( StyleSheetProperty( "value", "00:00:00.015" ) ); + EXPECT_TRUE( input->checkValidity() ); + input->applyProperty( StyleSheetProperty( "value", "00:00:00.016" ) ); + EXPECT_TRUE( input->getValidity().stepMismatch ); + input->applyProperty( + StyleSheetProperty( "readonly", "false" ) ); // HTML boolean: presence is true. + EXPECT_FALSE( input->willValidate() ); + EXPECT_TRUE( input->checkValidity() ); + EXPECT_FALSE( static_cast( input->getChildWidget() )->isEditingAllowed() ); + input->close(); + app.getUI()->update( Time::Zero ); +} + +UTEST( UIHTMLTemporal, ReadOnlySurvivesInputTypeChanges ) { + auto app = temporalApp(); + auto* input = UIHTMLInput::New(); + input->setParent( app.getUI()->getRoot() ); + input->applyProperty( StyleSheetProperty( "readonly", "readonly" ) ); + for ( const char* type : { "password", "date", "time", "datetime-local", "text" } ) { + input->setInputType( type ); + ASSERT_TRUE( input->getChildWidget()->isType( UI_TYPE_TEXTINPUT ) ); + EXPECT_FALSE( input->getChildWidget()->asType()->isEditingAllowed() ); + app.getUI()->update( Time::Zero ); + } +} + +UTEST( UIHTMLTemporal, DefaultStepsAndValueAttributeStepBase ) { + auto app = temporalApp(); + auto* input = UIHTMLInput::New(); + input->setParent( app.getUI()->getRoot() ); + input->setInputType( "time" ); + auto* edit = static_cast( input->getChildWidget() ); + edit->setTime( { 12, 0, 1 } ); + EXPECT_TRUE( input->getValidity().stepMismatch ); + input->applyProperty( StyleSheetProperty( "value", "12:00:01" ) ); + EXPECT_TRUE( input->checkValidity() ); + edit->setTime( { 12, 1, 1 } ); + EXPECT_TRUE( input->checkValidity() ); + edit->setTime( { 12, 1, 2 } ); + EXPECT_TRUE( input->getValidity().stepMismatch ); + input->applyProperty( StyleSheetProperty( "step", "900" ) ); + EXPECT_EQ( edit->getMinuteStep(), 15u ); + edit->setTime( { 12, 15, 1 } ); + EXPECT_TRUE( input->checkValidity() ); + input->setInputType( "datetime-local" ); + input->applyProperty( StyleSheetProperty( "min", "2026-09-28T23:50" ) ); + input->applyProperty( StyleSheetProperty( "step", "1200" ) ); + input->applyProperty( StyleSheetProperty( "value", "2026-09-29T00:10" ) ); + EXPECT_TRUE( input->checkValidity() ); + input->applyProperty( StyleSheetProperty( "value", "2026-09-29T00:11" ) ); + EXPECT_TRUE( input->getValidity().stepMismatch ); + input->close(); + app.getUI()->update( Time::Zero ); +} + +UTEST( UIHTMLTemporal, FormSerializationAndInteractiveValidation ) { + auto app = temporalApp(); + auto* ui = app.getUI(); + ui->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
+ + + + + +
)html" ) ); + ui->update( Time::Zero ); + auto* form = ui->getRoot()->find( "form" )->asType(); + ASSERT_TRUE( form ); + int requests = 0; + std::string body; + ui->setNavigationInterceptorCb( [&]( const NavigationRequest& request ) { + ++requests; + body = request.body; + return true; + } ); + EXPECT_FALSE( form->requestSubmit() ); + EXPECT_EQ( requests, 0 ); + auto* input = ui->getRoot()->find( "date" )->asType(); + input->applyProperty( StyleSheetProperty( "value", "2026-09-28" ) ); + EXPECT_TRUE( form->requestSubmit() ); + EXPECT_EQ( requests, 1 ); + EXPECT_TRUE( body == + "date=2026-09-28&time=20%3A14%3A00&datetime=2026-09-28T20%3A14%3A32.12&empty=" ); + input->applyProperty( StyleSheetProperty( "value", "invalid" ) ); + form->submit(); // The direct HTML submit API bypasses constraint validation. + EXPECT_EQ( requests, 2 ); +} + +UTEST( UIHTMLTemporal, WebViewPopupRespectsPrivateDefaultsAndVisibleViewport ) { + auto app = temporalApp(); + auto* web = UIWebView::New(); + web->setParent( app.getUI()->getRoot() ); + web->setPosition( 30, 20 ); + web->setSize( 600, 550 ); + auto* document = web->getDocumentSceneNode(); + ASSERT_TRUE( document ); + document->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + +
+ )html" ), + web->getDocumentContainer() ); + web->refreshDocumentLayout(); + app.getUI()->update( Time::Zero ); + document->update( Time::Zero ); + auto* input = document->getRoot()->find( "date" )->asType(); + ASSERT_TRUE( input ); + auto* picker = static_cast( input->getChildWidget() ); + picker->showCalendar(); + document->update( Time::Zero ); + auto* calendar = picker->getCalendar(); + ASSERT_TRUE( calendar ); + EXPECT_TRUE( calendar->getUISceneNode() == document ); + EXPECT_TRUE( calendar->getParent() == document->getRoot() ); + EXPECT_TRUE( document->getVisibleWorldBounds().contains( calendar->getScreenRect() ) ); + EXPECT_LT( static_cast( calendar->querySelector( "calendar::day" ) ) + ->getTextView() + ->getFontSize(), + 80u ); + EXPECT_LT( picker->getCalendarButton()->getTextView()->getFontSize(), 80u ); + ASSERT_TRUE( picker->getCalendarButton()->hasForeground() ); + ASSERT_TRUE( picker->getCalendarButton()->getForeground()->getLayer( 0 ) ); + EXPECT_TRUE( picker->getCalendarButton()->getForeground()->getLayer( 0 )->getDrawable() != + nullptr ); + EXPECT_NE( calendar->getBackgroundColor().a, 0 ); + EXPECT_FALSE( calendar->getBackgroundColor() == Color::fromString( "#00ff00" ) ); + EXPECT_FALSE( picker->getCalendarButton()->getForegroundTint( 0 ) == Color::Red ); + const auto before = calendar->getScreenRect().getPosition(); + web->setPosition( 50, 40 ); + picker->getScreenRect(); + EXPECT_NEAR( calendar->getScreenRect().Left - before.x, 20.f, 0.1f ); + EXPECT_NEAR( calendar->getScreenRect().Top - before.y, 20.f, 0.1f ); +} + +UTEST( UIHTMLTemporal, AnonymousCheckboxRetainsUserAgentDefaults ) { + for ( int engine = 0; engine < 2; ++engine ) { + auto app = temporalApp(); + auto* ui = app.getUI(); + ui->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + )html" ) ); + ui->update( Time::Zero ); + auto* input = ui->getRoot()->find( "checkbox" )->asType(); + ASSERT_TRUE( input ); + auto* button = input->getChildWidget()->querySelector( "checkbox::active" ); + ASSERT_TRUE( button ); + EXPECT_NEAR( button->getSize().getWidth(), 12.f, 0.1f ); + EXPECT_TRUE( button->hasForeground() ); + } +} + +UTEST( UIHTMLTemporal, DateTimePopupKeepsPrivateDefaultsForLateTimeButtons ) { + auto app = temporalApp(); + auto* web = UIWebView::New(); + web->setParent( app.getUI()->getRoot() ); + web->setSize( 700, 600 ); + auto* document = web->getDocumentSceneNode(); + document->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + + )html" ), + web->getDocumentContainer() ); + web->refreshDocumentLayout(); + app.getUI()->update( Time::Zero ); + document->update( Time::Zero ); + auto* input = document->getRoot()->find( "datetime" )->asType(); + ASSERT_TRUE( input ); + EXPECT_TRUE( input->getBackgroundColor() == Color::fromString( "#123456" ) ); + auto* picker = static_cast( input->getChildWidget() ); + picker->showCalendar(); + document->update( Time::Zero ); + auto* time = picker->getPopupTimePicker(); + ASSERT_TRUE( time ); + EXPECT_TRUE( time->getParent()->getParent() == document->getRoot() ); + EXPECT_TRUE( time->getParent()->asType()->getFlags() & UI_IGNORE_GLOBAL_CSS ); + picker->setShowSeconds( true ); + picker->setShowMilliseconds( true ); + document->update( Time::Zero ); + for ( const char* selector : + { "datetimepicker::time-up.hour", "datetimepicker::time-down.minute", + "datetimepicker::time-up.second", "datetimepicker::time-down.millisecond" } ) { + auto* button = time->querySelector( selector ); + ASSERT_TRUE( button ); + EXPECT_TRUE( button->getFlags() & UI_IGNORE_GLOBAL_CSS ); + EXPECT_LT( button->getSize().getWidth(), 80.f ); + EXPECT_FALSE( button->getForegroundTint( 0 ) == Color::Red ); + ASSERT_TRUE( button->hasForeground() ); + ASSERT_TRUE( button->getForeground()->getLayer( 0 ) ); + EXPECT_TRUE( button->getForeground()->getLayer( 0 )->getDrawable() ); + } + // Replacing a control disconnects its scoped popup-attachment listener. + input->setInputType( "text" ); + document->update( Time::Zero ); + input->setInputType( "date" ); + auto* date = static_cast( input->getChildWidget() ); + date->showCalendar(); + document->update( Time::Zero ); + EXPECT_TRUE( date->getCalendar()->getFlags() & UI_IGNORE_GLOBAL_CSS ); +} + +UTEST( UIHTMLTemporal, BareControlsHaveIntrinsicSizeAndLightDefaultsWithoutNativeTheme ) { + auto app = temporalApp(); + auto* web = UIWebView::New(); + web->setParent( app.getUI()->getRoot() ); + web->setSize( 700, 600 ); + auto* document = web->getDocumentSceneNode(); + document->getUIThemeManager()->setDefaultTheme( UIThemePtr{} ); + document->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + + + + + + )html" ), + web->getDocumentContainer() ); + web->refreshDocumentLayout(); + app.getUI()->update( Time::Zero ); + document->update( Time::Zero ); + for ( const char* id : { "filled", "empty", "time", "datetime", "emptydatetime" } ) { + auto* input = document->getRoot()->find( id )->asType(); + ASSERT_TRUE( input ); + auto* edit = static_cast( input->getChildWidget() ); + EXPECT_GT( input->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( input->getPixelsSize().getHeight(), 0.f ); + EXPECT_GE( edit->getPixelsSize().getWidth(), + edit->getTextWidth() + edit->getPixelsPadding().getWidth() ); + EXPECT_TRUE( input->getBackgroundColor() == Color::White ); + EXPECT_TRUE( edit->getFontColor() == Color::Black ); + if ( edit->isType( UI_TYPE_DATEPICKER ) ) { + auto* picker = static_cast( edit ); + auto* button = picker->getCalendarButton(); + EXPECT_GT( button->getPixelsSize().getWidth(), 0.f ); + ASSERT_TRUE( button->hasForeground() ); + ASSERT_TRUE( button->getForeground()->getLayer( 0 ) ); + EXPECT_TRUE( button->getForeground()->getLayer( 0 )->getDrawable() ); + picker->showCalendar(); + document->update( Time::Zero ); + EXPECT_TRUE( document->getVisibleWorldBounds().contains( + picker->getCalendar()->getScreenRect() ) ); + auto* title = + picker->getCalendar()->querySelector( "calendar::title" )->asType(); + EXPECT_TRUE( title->getTextView()->getFontColor() == Color::Black ); + picker->hideCalendar(); + } + } +} diff --git a/src/tests/unit_tests/uiinspector_tests.cpp b/src/tests/unit_tests/uiinspector_tests.cpp index 12b6784ed..582900ad1 100644 --- a/src/tests/unit_tests/uiinspector_tests.cpp +++ b/src/tests/unit_tests/uiinspector_tests.cpp @@ -748,7 +748,7 @@ UTEST( UIInspector, KeyBindingsKeepAliasesAndSceneScopeInReadOnlyMode ) { EXPECT_TRUE( result["bindings"][1]["command"] == "reload" ); EXPECT_EQ( result["bindings"][0]["keycode"].get(), KEY_F5 ); EXPECT_EQ( result["bindings"][1]["keycode"].get(), KEY_R ); - EXPECT_EQ( result["bindings"][1]["mod"].get(), KEYMOD_CTRL ); + EXPECT_EQ( result["bindings"][1]["mod"].get(), (Uint32)KEYMOD_CTRL ); EXPECT_STDSTREQ( result["bindings"][1]["shortcut"].get(), scene->getKeyBindings().getShortcutString( { KEY_R, KEYMOD_CTRL } ) ); client.send( diff --git a/src/tests/unit_tests/uimenu_tests.cpp b/src/tests/unit_tests/uimenu_tests.cpp index 6c08e7373..ece6fa8b1 100644 --- a/src/tests/unit_tests/uimenu_tests.cpp +++ b/src/tests/unit_tests/uimenu_tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -135,6 +136,95 @@ UTEST( UIMenu, NestedMenuBarAndSubmenuAtHighDensity ) { PixelDensity::setPixelDensity( previousDensity ); } +UTEST( UIMenu, SharedPopupPlacementAtSceneEdges ) { + UIApplication app( + WindowSettings( 320, 240, "eepp - Popup Placement Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* anchor = UIWidget::New(); + anchor->setParent( app.getUI()->getRoot() ); + anchor->setPixelsSize( 40, 20 ); + anchor->setPixelsPosition( 100, 100 ); + auto* popup = UIWidget::New(); + popup->setParent( app.getUI()->getRoot() ); + popup->setPixelsSize( 100, 80 ); + Vector2f position( 100, 100 ); + UIMenu::findBestMenuPos( position, popup, nullptr, nullptr, anchor ); + EXPECT_TRUE( position == Vector2f( 100, 120 ) ); + UIPopUp::align( anchor, popup ); + EXPECT_TRUE( popup->getScreenPos() == position ); + + anchor->setPixelsPosition( 100, 220 ); + position = Vector2f( 100, 220 ); + UIMenu::findBestMenuPos( position, popup, nullptr, nullptr, anchor ); + EXPECT_TRUE( position == Vector2f( 100, 140 ) ); + UIPopUp::align( anchor, popup ); + EXPECT_TRUE( popup->getScreenPos() == position ); + + popup->setPixelsSize( 100, 140 ); + anchor->setPixelsPosition( 100, 90 ); + position = Vector2f( 100, 90 ); + UIMenu::findBestMenuPos( position, popup, nullptr, nullptr, anchor ); + EXPECT_TRUE( position == Vector2f( 140, 90 ) ); + // Field popups retain vertical attachment instead of moving beside the field. + UIPopUp::align( anchor, popup ); + EXPECT_TRUE( popup->getScreenPos() == Vector2f( 100, 100 ) ); + anchor->setPixelsPosition( 280, 90 ); + position = Vector2f( 280, 90 ); + UIMenu::findBestMenuPos( position, popup, nullptr, nullptr, anchor ); + EXPECT_TRUE( position == Vector2f( 180, 90 ) ); + UIPopUp::align( anchor, popup ); + EXPECT_TRUE( popup->getScreenPos() == Vector2f( 220, 100 ) ); + + popup->setPixelsSize( 100, 80 ); + position = Vector2f( 300, 230 ); + UIMenu::findBestMenuPos( position, popup ); + EXPECT_TRUE( position == Vector2f( 200, 150 ) ); + position = Vector2f( 300, 10 ); + UIMenu::findBestMenuPos( position, popup ); + EXPECT_TRUE( position == Vector2f( 200, 10 ) ); + popup->setPixelsSize( 400, 300 ); + position = Vector2f( 300, 230 ); + UIMenu::findBestMenuPos( position, popup ); + EXPECT_TRUE( position == Vector2f::Zero ); +} + +UTEST( UIMenu, SharedPopupPlacementUsesWorldPixelsAtHighDensity ) { + UIApplication app( + WindowSettings( 640, 480, "eepp - Popup World Coordinates Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + const Float previousDensity = PixelDensity::getPixelDensity(); + PixelDensity::setPixelDensity( 2.f ); + app.getUI()->setPosition( 30, 40 ); + auto* container = UIWidget::New(); + container->setParent( app.getUI()->getRoot() ); + container->setPosition( 20, 30 ); + auto* anchor = UIWidget::New(); + anchor->setParent( container ); + anchor->setPosition( 10, 15 ); + anchor->setSize( 50, 20 ); + auto* popup = UIWidget::New(); + popup->setParent( app.getUI()->getRoot() ); + popup->setSize( 80, 60 ); + const Rectf field( anchor->getScreenRect() ); + Vector2f position( field.Left, field.Top ); + UIMenu::findBestMenuPos( position, popup, nullptr, nullptr, anchor ); + EXPECT_TRUE( position == Vector2f( field.Left, field.Bottom ) ); + UIPopUp::align( anchor, popup, true ); + EXPECT_TRUE( popup->getScreenPos() == position ); + UIPopUp::align( anchor, popup, true, true ); + EXPECT_TRUE( + popup->getScreenPos() == + Vector2f( field.Left + ( field.getWidth() - popup->getPixelsSize().getWidth() ) / 2, + field.Bottom ) ); + popup->setPixelsSize( 800, 600 ); + position = Vector2f( -100, -100 ); + UIMenu::findBestMenuPos( position, popup ); + EXPECT_TRUE( position == app.getUI()->getWorldBounds().getPosition() ); + PixelDensity::setPixelDensity( previousDensity ); +} + namespace { class DensityTestCheckBox : public UICheckBox {