From d49fe3fdc8c4fe21bc6df65511a684a24d852aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Tue, 6 Oct 2026 01:13:44 -0300 Subject: [PATCH] Add date and time pickers with calendar popups and HTML mappings - Add CalendarDate, TimeOfDay, LocalDateTime and DateRange with Gregorian arithmetic, locale-aware formatting/parsing, ISO serialization and Unix timestamp conversions with explicit UTC offsets. - Add UIDateTimeEdit, UIDatePicker, UITimePicker, UIDateTimePicker and standalone UICalendar, with XML/CSS registration and data binding. - Support segmented keyboard editing, numeric entry, ISO paste, configurable steps, bounds, empty values and independent seconds/milliseconds display. Default time display to hours and minutes while preserving stored precision. - Add day/month/year calendar navigation and a combined date-time popup with time steppers, live configuration updates and scoped event connections. - Add Breeze and fallback theme styles with CSS-defined SVG icons, dp sizing, font-aware calendar dimensions and muted read-only/disabled controls. - Map HTML date, time and datetime-local inputs to native controls with strict value sanitization, min/max/step/required/readonly/disabled handling, constraint validation and normalized form serialization. - Provide private light HTML control defaults and intrinsic temporal sizing; prevent author CSS from styling anonymous implementation parts. - Share popup placement and visibility helpers across pickers, dropdowns and trigger/cursor menus while retaining cascading-menu placement rules. - Fix fractional grid wrapping and input cursor bubbling over child buttons; notify and refresh styles when editing permission changes. - Reduce CSS registration allocations with string_view parameters and heterogeneous name lookup; inline frequently used node and CSS getters. - Align CodeEditor default font sizing with text widgets without overriding application preferences. - Add HTML form margin defaults and update the affected rendering baseline. - Upgrade Windows ecode nightly builds to Ubuntu 24.04 and newer MinGW. - Add picker documentation, an example and regression coverage for date math, locales, editing, popup placement/lifetime, HTML controls and pixel density --- .github/workflows/ecode-nightly.yml | 12 +- bin/assets/ui/breeze.css | 136 +- bin/assets/ui/uitheme.css | 100 ++ .../eepp-uihtmltable-complex-layout-2.webp | Bin 27508 -> 27584 bytes docs/articles/css_specification.md | 449 ++++- docs/articles/ui_date_time_pickers.md | 211 +++ docs/articles/ui_html_compatibility.md | 16 + include/eepp/scene/event.hpp | 2 + include/eepp/system.hpp | 2 + include/eepp/system/datetime.hpp | 119 ++ include/eepp/system/datetimeformat.hpp | 166 ++ include/eepp/ui.hpp | 5 + include/eepp/ui/css/idnamemap.hpp | 52 +- include/eepp/ui/css/propertydefinition.hpp | 5 +- include/eepp/ui/css/propertyids.hpp | 29 + include/eepp/ui/css/propertyspecification.hpp | 8 +- include/eepp/ui/css/stylesheetselector.hpp | 7 +- .../eepp/ui/css/stylesheetspecification.hpp | 8 +- include/eepp/ui/css/stylesheetstyle.hpp | 6 +- include/eepp/ui/uicalendar.hpp | 169 ++ include/eepp/ui/uidatepicker.hpp | 98 + include/eepp/ui/uidatetimeedit.hpp | 301 ++++ include/eepp/ui/uidatetimepicker.hpp | 57 + include/eepp/ui/uihelper.hpp | 5 + include/eepp/ui/uihtmlform.hpp | 11 +- include/eepp/ui/uihtmlinput.hpp | 48 +- include/eepp/ui/uinode.hpp | 2 +- include/eepp/ui/uipopup.hpp | 44 + include/eepp/ui/uitimepicker.hpp | 23 + premake4.lua | 6 + premake5.lua | 6 + src/eepp/system/datetime.cpp | 320 ++++ src/eepp/system/datetimeformat.cpp | 980 ++++++++++ src/eepp/ui/css/propertydefinition.cpp | 4 +- src/eepp/ui/css/propertyspecification.cpp | 22 +- src/eepp/ui/css/stylesheet.cpp | 11 +- src/eepp/ui/css/stylesheetselector.cpp | 8 - src/eepp/ui/css/stylesheetspecification.cpp | 50 +- src/eepp/ui/css/stylesheetstyle.cpp | 12 - src/eepp/ui/uicalendar.cpp | 791 ++++++++ src/eepp/ui/uicodeeditor.cpp | 6 +- src/eepp/ui/uidatepicker.cpp | 299 ++++ src/eepp/ui/uidatetimeedit.cpp | 1592 +++++++++++++++++ src/eepp/ui/uidatetimepicker.cpp | 460 +++++ src/eepp/ui/uidropdown.cpp | 82 +- src/eepp/ui/uigridlayout.cpp | 12 +- src/eepp/ui/uihtmlform.cpp | 40 +- src/eepp/ui/uihtmlinput.cpp | 367 +++- src/eepp/ui/uimenu.cpp | 208 +-- src/eepp/ui/uinode.cpp | 4 - src/eepp/ui/uipopup.cpp | 147 ++ src/eepp/ui/uitextinput.cpp | 10 +- src/eepp/ui/uitimepicker.cpp | 19 + src/eepp/ui/uiwidgetcreator.cpp | 113 +- .../ui_date_time_picker.cpp | 69 + src/tests/unit_tests/datetime_tests.cpp | 473 +++++ src/tests/unit_tests/propertyidset_tests.cpp | 56 + .../unit_tests/ui_pixel_density_tests.cpp | 59 + src/tests/unit_tests/uidatetimeedit_tests.cpp | 1348 ++++++++++++++ .../unit_tests/uidropdownmodellist_tests.cpp | 162 ++ src/tests/unit_tests/uihtmltemporal_tests.cpp | 380 ++++ src/tests/unit_tests/uiinspector_tests.cpp | 2 +- src/tests/unit_tests/uimenu_tests.cpp | 90 + 63 files changed, 9949 insertions(+), 350 deletions(-) create mode 100644 docs/articles/ui_date_time_pickers.md create mode 100644 include/eepp/system/datetime.hpp create mode 100644 include/eepp/system/datetimeformat.hpp create mode 100644 include/eepp/ui/uicalendar.hpp create mode 100644 include/eepp/ui/uidatepicker.hpp create mode 100644 include/eepp/ui/uidatetimeedit.hpp create mode 100644 include/eepp/ui/uidatetimepicker.hpp create mode 100644 include/eepp/ui/uipopup.hpp create mode 100644 include/eepp/ui/uitimepicker.hpp create mode 100644 src/eepp/system/datetime.cpp create mode 100644 src/eepp/system/datetimeformat.cpp create mode 100644 src/eepp/ui/uicalendar.cpp create mode 100644 src/eepp/ui/uidatepicker.cpp create mode 100644 src/eepp/ui/uidatetimeedit.cpp create mode 100644 src/eepp/ui/uidatetimepicker.cpp create mode 100644 src/eepp/ui/uipopup.cpp create mode 100644 src/eepp/ui/uitimepicker.cpp create mode 100644 src/examples/ui_date_time_picker/ui_date_time_picker.cpp create mode 100644 src/tests/unit_tests/datetime_tests.cpp create mode 100644 src/tests/unit_tests/uidatetimeedit_tests.cpp create mode 100644 src/tests/unit_tests/uihtmltemporal_tests.cpp 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 56fdbadce8299352caae8e4bbde1e28fda93c19c..55498bf1fd693b80279055824cc80c7f2a76d06f 100644 GIT binary patch literal 27584 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

o~1z$V_ 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 Ivs-}w0f_9XiU0rr literal 27508 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 f`GMeb8tV6du0Oq;=gw!3CNy;YIUU9MQuY4` 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 {