tuile 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A single-line field whose {#value} is a stdlib `Date` (or `nil` when
6
+ # empty). Give it a single-row {#rect}:
7
+ #
8
+ # field = Component::DateField.new
9
+ # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
10
+ # field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
11
+ # field.placeholder # => "yyyy-mm-dd"
12
+ # field.clear # empties it; value => nil
13
+ #
14
+ # Up/Down step a day (an empty field steps to today), and the hint an empty
15
+ # field paints is derived from the format — set {#placeholder} to override
16
+ # it, or `""` to suppress it.
17
+ #
18
+ # == Several formats in, one format out
19
+ # {#formats} is a list of strftime patterns. Parsing tries them in order and
20
+ # the first match wins, while `formats.first` — *the primary* — is what
21
+ # {#value=} writes and what a loosely typed buffer is rewritten into once
22
+ # the user leaves the field or presses ENTER. So it is lenient about what it
23
+ # accepts and strict about what it shows, and the list is the leniency knob:
24
+ #
25
+ # field.formats = ["%d.%m.%Y", "%Y-%m-%d"]
26
+ # # the user types "2026-9-4" and Tabs away; the field shows "04.09.2026"
27
+ #
28
+ # **The order is the disambiguation, and it is the app's call**: `"%m/%d/%Y"`
29
+ # and `"%d/%m/%Y"` both match `04/09/2026` and disagree about what it means,
30
+ # which no validation can detect — a wrong value that saves cleanly is worse
31
+ # than input the user can see is bad. An app setting a list owns that call;
32
+ # the *detected* list invents nothing, carrying what the system said (with a
33
+ # two-digit year widened) and ISO behind it.
34
+ #
35
+ # == The conventions come from the session, unless you say otherwise
36
+ # {#formats} and {#calendar_start} follow {Screen#locale} until they are
37
+ # assigned, so a stock field spells dates the way the user's environment
38
+ # says to, and an app wanting one spelling everywhere sets it once:
39
+ #
40
+ # screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"]) # session-wide
41
+ # field.formats = "%d.%m.%Y" # this field only
42
+ # field.formats = nil # follow again
43
+ #
44
+ # A mid-session {Screen#locale=} reaches an inheriting field: the hint is
45
+ # re-derived, and a buffer that still parses is rewritten in the new primary
46
+ # format. One that no longer parses is left exactly as typed and reads as
47
+ # bad input.
48
+ #
49
+ # == Input the field cannot parse is *reported*, not filtered
50
+ # A date's grammar is not prefix-closed (`"2020-13-45"` is well-formed at
51
+ # every character), so nothing is filtered: every character is admitted,
52
+ # typed or pasted, and the residue is reported through {HasBadInput}. A form
53
+ # asks {HasBadInput#bad_input?} *before* {HasValue#empty?}, since a field
54
+ # full of garbage reads `nil`:
55
+ #
56
+ # field.value # => nil
57
+ # field.empty? # => true — empty of *value*
58
+ # field.bad_input? # => true
59
+ #
60
+ # The red **well**, unlike that report, waits for a commit gesture: since
61
+ # every prefix of a date is bad input, painting it per keystroke would hold
62
+ # the field red for the whole time the user types a correct one. So `2`,
63
+ # `20`, `202` stay quiet, leaving the field (or pressing ENTER) reddens what
64
+ # did not parse, and the next edit clears it again.
65
+ #
66
+ # == The value notice waits for the same gesture
67
+ # A prefix of a date can also parse *cleanly*: typing `1.1.2024` into a
68
+ # `%d.%m.%Y` field passes through `1.1.2`, a perfectly good 1st of January
69
+ # in the year 2. So {HasValue#on_value_change} does not fire per keystroke,
70
+ # but when the user leaves the field or presses ENTER — and a form
71
+ # recalculating from it never sees that year 2.
72
+ #
73
+ # {#value} does *not* wait: it is a live parse of the buffer at every
74
+ # moment, so a save gate reached without leaving the field reads the date
75
+ # on screen. Nor does a change nobody had to type — a {#value=}, an Up/Down
76
+ # step, a {#clear} and a reparse under new {#formats} all fire as they
77
+ # happen.
78
+ #
79
+ # == Implementation details
80
+ # - **The buffer is the single source of truth.** {#value} is a parse of it,
81
+ # recomputed on read — so {#formats=} and {#calendar_start=} can change the
82
+ # value with no edit, and a buffer the field cannot parse is left exactly
83
+ # as typed, because the user has to see what they wrote in order to fix it.
84
+ # - **Canonicalizing fires no {HasValue#on_value_change}** — the spelling
85
+ # changed, not the value. Up/Down canonicalize too, since they go through
86
+ # {#value=}, so Up-then-Down does not restore the text you typed.
87
+ # - **The calendar is proleptic Gregorian, not Ruby's `Date::ITALY`**, which
88
+ # matters for dates near and before the 1582 reform: {#calendar_start}.
89
+ # - **A format is checked when it is assigned**, not at the first keystroke:
90
+ # {#formats=}.
91
+ #
92
+ # UI-thread-confined, like every component (see {Screen}).
93
+ class DateField < AbstractWrappingField
94
+ include HasBadInput
95
+
96
+ # @return [String] what {HasBadInput#bad_input_message} reports for a
97
+ # buffer no configured format parses.
98
+ BAD_INPUT_MESSAGE = "not a valid date"
99
+ private_constant :BAD_INPUT_MESSAGE
100
+
101
+ # No format renders a date past ~30 columns ("Wednesday, 04 September
102
+ # 2026"), so this caps nothing an app configured — it stops a pasted
103
+ # novel from sitting in the buffer.
104
+ # @return [Integer]
105
+ MAX_TEXT_LENGTH = 64
106
+ private_constant :MAX_TEXT_LENGTH
107
+
108
+ def initialize
109
+ super(TextField.new)
110
+ editor.max_text_length = MAX_TEXT_LENGTH
111
+ # Claiming the editor's two arrow slots, not the general interceptor:
112
+ # that one stays free for the app.
113
+ editor.on_key_up = -> { step(1) }
114
+ editor.on_key_down = -> { step(-1) }
115
+ @settled = false
116
+ @placeholder_override = nil
117
+ # Both nil: follow the screen's locale until an app overrides them.
118
+ @formats = nil
119
+ @calendar_start = nil
120
+ sync_placeholder
121
+ end
122
+
123
+ # @return [Date, nil] the buffer parsed by the first format that matches
124
+ # it whole; `nil` when the buffer is empty or no format parses it.
125
+ def value
126
+ text = editor.text
127
+ return nil if text.empty?
128
+
129
+ formats.each do |format|
130
+ date = parse(text, format)
131
+ return date unless date.nil?
132
+ end
133
+ nil
134
+ end
135
+
136
+ # Writes `new_value` into the buffer in the primary format and parks the
137
+ # caret at its end; fires {HasValue#on_value_change} only if the value
138
+ # actually changed.
139
+ #
140
+ # Thin and lenient, deliberately: anything answering `strftime` is taken
141
+ # and truncated to its civil date, the same lenient-in/strict-out shape as
142
+ # the format list itself.
143
+ #
144
+ # field.value = Time.now # shows today; reads back a Date, time dropped
145
+ #
146
+ # @param new_value [Date, nil] `nil` empties the field.
147
+ # @return [void]
148
+ def value=(new_value)
149
+ editor.text = new_value.nil? ? "" : new_value.strftime(formats.first)
150
+ editor.caret = editor.text.length
151
+ # The edit above announced nothing ({#notify_on_edit?}); a date written
152
+ # rather than typed has no prefix to be mistaken for a value.
153
+ fire_if_changed
154
+ end
155
+
156
+ # `nil`, not `""`: a date field with no parseable date is empty.
157
+ # @return [nil]
158
+ def empty_value = nil
159
+
160
+ # The accepted formats, primary first — this field's own if one was set,
161
+ # otherwise the screen's ({Locale#date_formats}), which is what makes a
162
+ # stock field follow the session's conventions with no configuration.
163
+ # Frozen either way: assign a new list rather than pushing onto this one,
164
+ # or the validator and the derived {#placeholder} are both bypassed.
165
+ # @return [Array<String>]
166
+ def formats = @formats || locale.date_formats
167
+
168
+ # Sets the formats, re-derives the {#placeholder}, and fires
169
+ # {HasValue#on_value_change} if the buffer now parses differently.
170
+ #
171
+ # field.formats = "%d.%m.%Y" # the one-format shorthand
172
+ # field.formats = ["%d.%m.%Y", "%Y-%m-%d"] # lenient in, first one out
173
+ # field.formats = nil # back to following the locale
174
+ #
175
+ # Only the primary must round-trip; every later entry only ever parses,
176
+ # which is what lets a lenient list carry a two-digit-year pattern behind
177
+ # a widened one ({Locale::DateFormats.validate}).
178
+ #
179
+ # A non-empty buffer is left alone: it is text, and it reparses under the
180
+ # new list on the next read.
181
+ # @param list [String, Array<String>, nil] one format, several, or `nil`
182
+ # to inherit {Screen#locale} again.
183
+ # @return [void]
184
+ # @raise [TypeError] on anything but a String, an Array of Strings or nil.
185
+ # @raise [ArgumentError] on an empty list, `%x`/`%X`/`%c`, a primary that
186
+ # does not survive a `strftime`/`strptime` round-trip — notably one
187
+ # carrying `%y`, which cannot carry a century — or any entry `strptime`
188
+ # cannot use.
189
+ def formats=(list)
190
+ @formats = list.nil? ? nil : Locale::DateFormats.validate(list)
191
+ sync_placeholder
192
+ fire_if_changed
193
+ end
194
+
195
+ # When the Gregorian calendar takes over from the Julian one, as a Julian
196
+ # Day Number — `Date::GREGORIAN` (proleptic Gregorian) by default, *not*
197
+ # Ruby's `Date::ITALY`. That makes `1582-10-10` an ordinary date instead
198
+ # of a hole the user cannot type their way out of, and makes ISO output
199
+ # mean the ISO 8601 date, which mandates proleptic Gregorian.
200
+ #
201
+ # The cost, since it is real: the round-trip is exact only while the
202
+ # field's calendar matches that of the `Date`s the app hands it, and
203
+ # `Date.new(1500, 1, 1)` in app code is `ITALY`. So a pre-1582 date set
204
+ # that way comes back nine days off once the field canonicalizes the
205
+ # buffer. Set this to `Date::ITALY` if that is the app's world — or set it
206
+ # once for the whole session, since it is a {Locale} member that this
207
+ # reader falls back to:
208
+ # `screen.locale = Locale::ISO.with(calendar_start: Date::ITALY)`.
209
+ # @return [Numeric]
210
+ def calendar_start = @calendar_start || locale.calendar_start
211
+
212
+ # Sets the calendar and fires {HasValue#on_value_change} if the buffer now
213
+ # parses to a different date; the buffer itself is left alone.
214
+ # @param start [Numeric, nil] a Julian Day Number, one of `Date::ITALY` /
215
+ # `Date::ENGLAND` / `Date::GREGORIAN` / `Date::JULIAN`, or `nil` to
216
+ # inherit {Screen#locale} again.
217
+ # @return [void]
218
+ # @raise [TypeError] unless `start` is Numeric or nil.
219
+ def calendar_start=(start)
220
+ unless start.nil? || start.is_a?(Numeric)
221
+ raise TypeError, "expected a Numeric day of calendar reform or nil, got #{start.inspect}"
222
+ end
223
+
224
+ @calendar_start = start
225
+ fire_if_changed
226
+ end
227
+
228
+ # Overrides the hint derived from the primary format.
229
+ #
230
+ # field.placeholder = "when it happened" # a hint of your own
231
+ # field.placeholder = "" # no hint at all
232
+ # field.placeholder = nil # back to the derived one
233
+ #
234
+ # @param text [String, nil] `nil` restores the derived hint, `""`
235
+ # suppresses it.
236
+ # @return [void]
237
+ # @raise [TypeError] unless `text` is a String or nil.
238
+ def placeholder=(text)
239
+ # The editor validates the type, so a bad one raises before it is stored.
240
+ editor.placeholder = text || derived_placeholder
241
+ @placeholder_override = text
242
+ end
243
+
244
+ # Nothing a format parses is bad input, and an *empty* buffer is empty
245
+ # rather than bad ({HasBadInput}) — so this reports the residue of a
246
+ # grammar that cannot be filtered as it is typed: every prefix of a date,
247
+ # and everything that is simply not one.
248
+ # @return [String, nil]
249
+ def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
250
+
251
+ protected
252
+
253
+ # Rewrites a buffer that parses in the primary format, leaving one that
254
+ # does not exactly as the user typed it — and settles the field either
255
+ # way, so input it could not parse starts painting the well.
256
+ # @return [void]
257
+ def commit
258
+ date = value
259
+ self.value = date unless date.nil? # …which unsettles, hence the order
260
+ settle(true)
261
+ end
262
+
263
+ # `false`: a prefix of a date can parse cleanly (`1.1.2` for `1.1.2024`),
264
+ # so the notice settles onto the commit gestures, exactly as the ink
265
+ # does. The class docs carry the case.
266
+ # @return [Boolean]
267
+ def notify_on_edit? = false
268
+
269
+ # Every prefix of a date is bad input, so the well is latched to the
270
+ # commit gestures instead of painted per keystroke: `2`, `20`, `202` on
271
+ # the way to `2026-09-04` never redden, and a date the field cannot parse
272
+ # reddens the moment the user leaves the field or presses ENTER
273
+ # ({HasBadInput}).
274
+ # @return [Boolean]
275
+ def bad_input_settled? = @settled
276
+
277
+ # An edit is the user having another go, so the well goes quiet again
278
+ # until the next commit gesture.
279
+ # @return [void]
280
+ def handle_editor_change
281
+ super
282
+ settle(false)
283
+ end
284
+
285
+ # Re-derives the hint (which was *pushed* into the editor, so a repaint
286
+ # alone would keep the old one) and rewrites a buffer that still parses.
287
+ # @return [void]
288
+ def handle_locale_changed
289
+ super
290
+ # Both overridden: this field follows no session convention.
291
+ return if @formats && @calendar_start
292
+
293
+ sync_placeholder
294
+ date = value
295
+ self.value = date unless date.nil?
296
+ fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
297
+ end
298
+
299
+ private
300
+
301
+ # @param flag [Boolean]
302
+ # @return [void]
303
+ def settle(flag)
304
+ return if @settled == flag
305
+
306
+ @settled = flag
307
+ # Nothing else painted: an ENTER on an untouched buffer writes no cells,
308
+ # and neither does leaving the field with bad input in it.
309
+ invalidate
310
+ end
311
+
312
+ # @param text [String]
313
+ # @param format [String]
314
+ # @return [Date, nil] `nil` unless `format` consumes `text` whole *and*
315
+ # the fields it yields are a real date — `Date._strptime` checks
316
+ # neither, happily ignoring a trailing `"junk"` and accepting
317
+ # February 30th.
318
+ def parse(text, format)
319
+ parsed = Date._strptime(text, format)
320
+ return nil if parsed.nil? || !parsed[:leftover].to_s.empty?
321
+
322
+ Date.strptime(text, format, calendar_start)
323
+ rescue ArgumentError # Date::Error is one
324
+ nil
325
+ end
326
+
327
+ # Steps {#value} by `delta` days; an empty or unparseable field steps to
328
+ # today, which is what a calendar would have opened on.
329
+ # @param delta [Integer]
330
+ # @return [void]
331
+ def step(delta)
332
+ date = value
333
+ self.value = date.nil? ? Date.today : date + delta
334
+ end
335
+
336
+ # @return [void]
337
+ def sync_placeholder
338
+ editor.placeholder = @placeholder_override || derived_placeholder
339
+ end
340
+
341
+ # @return [String, nil] the hint for the primary format, or `nil` when it
342
+ # holds a directive the humanizer cannot translate exactly — never a
343
+ # half-translated one.
344
+ def derived_placeholder = Locale::DateFormats.humanize(formats.first)
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row field pairing a {DateField} and a {TimeField} behind a single
6
+ # `DateTime`. Give it a single-row {#rect}, 16 columns or wider:
7
+ #
8
+ # [2026-09-14] [13:45]
9
+ # ↑ the blank column is {Layout::Box#spacing}, not a component
10
+ #
11
+ # f = Component::DateTimeField.new
12
+ # f.on_value_change = ->(dt) { puts dt.inspect } # DateTime or nil, per commit
13
+ # f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
14
+ # f.clear # empties both halves
15
+ #
16
+ # Neither half is labelled: each paints the hint derived from its own format
17
+ # (`yyyy-mm-dd`, `hh:mm`), which names it while it is empty — the moment
18
+ # naming matters. The *caption* ("Starts at") belongs to the layout around
19
+ # the field, as it does for every field (`design/decisions.md`
20
+ # `D_caption_ownership`).
21
+ #
22
+ # == Tune the halves; don't replace them
23
+ # They are exposed read-only, so everything they configure is reached
24
+ # directly rather than forwarded through a second set of names:
25
+ #
26
+ # f.date_field.formats = "%d.%m.%Y" # ambiguous if it were `f.formats=`
27
+ # f.date_field.calendar_start = Date::ITALY
28
+ # f.time_field.step = 900 # Up/Down walk a quarter hour
29
+ #
30
+ # Two of their knobs are **claimed** by this field and must not be
31
+ # reassigned: each half's {HasValue#on_value_change} (that is how the
32
+ # composite hears them) and each half's {Component#bg_color} (see the well
33
+ # rule below).
34
+ #
35
+ # == The value is a `DateTime` at +00:00
36
+ # Both halves feed it with no adapter, and the offset is a placeholder
37
+ # rather than a zone — {TimeField}'s epoch cost, taken the same way: a value
38
+ # that is visibly wrong where an instant was meant beats one that is subtly
39
+ # wrong. Combine it with a zone at your own boundary (`f.value&.to_time`).
40
+ #
41
+ # Lenient in, strict out, so an input carrying more than the halves can hold
42
+ # does not round-trip:
43
+ #
44
+ # f.value = Time.now # takes today's date and the wall clock
45
+ # f.value == DateTime.now # => false — the zone went, and the seconds with it
46
+ #
47
+ # == Three states, and only one of them is this field's own fault
48
+ # {HasValue#value} is non-nil **iff both halves parse**, so a half going bad
49
+ # nils the whole value ({HasBadInput}: a field holds bad input *or* a value,
50
+ # never both). Who reddens follows from whether the fault is attributable:
51
+ #
52
+ # date half time half value bad_input? red
53
+ # 2026-09-14 13:45 DateTime no nobody
54
+ # (empty) (empty) nil no — empty is not bad input nobody
55
+ # 2026-99-99 13:45 nil "not a valid date" the date half
56
+ # 2026-09-14 (empty) nil "needs both a date and a time" this field
57
+ #
58
+ # A half's bad input is the half's to paint, on its own latch, and this
59
+ # field paints nothing. Half-filled is nobody else's, so this field reddens
60
+ # whole — but **only while it is not active**: it judges you when you leave
61
+ # and goes quiet when you come back to fix it. A validator's verdict
62
+ # ({HasValidation#error_message=}) is by definition not attributable either,
63
+ # and reddens whole with no latch at all.
64
+ #
65
+ # The one cost: **ENTER does not redden this field**, where it reddens a
66
+ # half. A save gate on ENTER over a date with no time still reads
67
+ # {HasBadInput#bad_input?} true and gets the message; only the ink waits for
68
+ # the blur.
69
+ #
70
+ # == Implementation details
71
+ # - **The halves keep their own wells, and this field's ink is *synced* onto
72
+ # them.** `error_bg_color` sits at the top of the background chain, so a
73
+ # child answering {Component#default_bg_color} — every field does — never
74
+ # inherits an ancestor's error level, so marking only this field would
75
+ # leave the halves untouched and reach no cell at all. So the halves are
76
+ # marked {Component::BG_INHERIT} exactly while this field inks, and `nil`
77
+ # otherwise. A guilty half's *own* error well still beats the mark, which
78
+ # is what keeps the ink rule free of arithmetic.
79
+ # - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
80
+ # blanks it in the ambient background, so the two wells read as two fields
81
+ # rather than one long one and each half keeps its own focus highlight.
82
+ # - **A half announces from its own `value=` and its Up/Down step** — the
83
+ # other half of {AbstractWrappingField#notify_on_edit?}'s contract — so
84
+ # writing a value into both halves would announce a half-assembled
85
+ # `DateTime`. Suppressed while applying, and announced once from this
86
+ # field's own diff.
87
+ # - **Nothing else is wired.** Focus forwards through {Layout#handle_focus},
88
+ # the mouse routes down through {Mouse::Router}, each half commits
89
+ # on its own blur (Tab between them canonicalizes the date and leaves this
90
+ # field active), and ENTER commits inside the half and keeps bubbling to
91
+ # the scope's default button.
92
+ #
93
+ # UI-thread-confined, like every component (see {Screen}).
94
+ class DateTimeField < Layout::Horizontal
95
+ include HasValue
96
+ include HasBadInput
97
+
98
+ # @return [String] what {HasBadInput#bad_input_message} reports when one
99
+ # half holds a value and the other is empty.
100
+ HALF_FILLED_MESSAGE = "needs both a date and a time"
101
+ private_constant :HALF_FILLED_MESSAGE
102
+
103
+ # What {#value=} needs off whatever it is handed — the two halves' own
104
+ # leniencies, checked together so a rejected value writes neither.
105
+ # @return [Array<Symbol>]
106
+ CIVIL_PARTS = %i[strftime hour min sec].freeze
107
+ private_constant :CIVIL_PARTS
108
+
109
+ # The content ratio, which decides this field's minimum width rather than
110
+ # merely its looks: `2026-09-14` is 10 columns and `13:45` is 5, so at 16
111
+ # the 2:1 split lands exactly 10 / 5. A constant rather than a measurement,
112
+ # so a locale spelling dates longer simply reaches its own minimum later
113
+ # (`design/decisions.md` `D_date_time_field`).
114
+ # @return [Integer]
115
+ DATE_WEIGHT = 2
116
+ private_constant :DATE_WEIGHT
117
+
118
+ # @return [Integer]
119
+ TIME_WEIGHT = 1
120
+ private_constant :TIME_WEIGHT
121
+
122
+ def initialize
123
+ super(spacing: 1)
124
+ @date_field = DateField.new
125
+ @time_field = TimeField.new
126
+ @last_value = empty_value
127
+ @applying = false
128
+ # cross: Fixed[1] is load-bearing — neither half declares an extent, so
129
+ # one handed a three-row rect paints a three-row well.
130
+ add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1])
131
+ add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1])
132
+ [@date_field, @time_field].each { _1.on_value_change = ->(_) { handle_half_change } }
133
+ end
134
+
135
+ # @return [DateField] the left half; tune it, never replace it.
136
+ attr_reader :date_field
137
+
138
+ # @return [TimeField] the right half; tune it, never replace it.
139
+ attr_reader :time_field
140
+
141
+ # @return [DateTime, nil] the two halves assembled, on the calendar
142
+ # {DateField#calendar_start} parsed the date in; `nil` unless both parse.
143
+ def value
144
+ date = date_field.value
145
+ time = time_field.value
146
+ return nil if date.nil? || time.nil?
147
+
148
+ DateTime.new(date.year, date.month, date.day, time.hour, time.min, time.sec, 0, date.start)
149
+ end
150
+
151
+ # Writes the date into one half and the time of day into the other, firing
152
+ # {HasValue#on_value_change} once if the value actually changed.
153
+ #
154
+ # @param new_value [DateTime, Time, nil] anything carrying both a civil
155
+ # date and a time of day; `nil` empties both halves.
156
+ # @return [void]
157
+ # @raise [TypeError] on a `Date` (it has no hour, and midnight would be
158
+ # invented) or anything else missing one of the two — checked before
159
+ # either half is written, so a rejected value leaves the field as it was.
160
+ def value=(new_value)
161
+ unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) }
162
+ raise TypeError,
163
+ "expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}"
164
+ end
165
+
166
+ applying do
167
+ date_field.value = new_value
168
+ time_field.value = new_value
169
+ end
170
+ fire_if_changed
171
+ end
172
+
173
+ # `nil`, not a pair of nils: a field with no parseable date *and* time is
174
+ # empty.
175
+ # @return [nil]
176
+ def empty_value = nil
177
+
178
+ # Empties the *input* of both halves, not just the value — either may be
179
+ # holding glyphs no parse could use ({HasBadInput}).
180
+ # @return [void]
181
+ def clear
182
+ applying { [date_field, time_field].each(&:clear) }
183
+ # Announced even though the halves hold their own notice: emptying is
184
+ # not a half-typed prefix.
185
+ fire_if_changed
186
+ end
187
+
188
+ # The guilty half's own report, the date's first when both are bad; else
189
+ # the one fault no half can wear, a half-filled pair.
190
+ # @return [String, nil]
191
+ def bad_input_message
192
+ attributed = date_field.bad_input_message || time_field.bad_input_message
193
+ return attributed unless attributed.nil?
194
+
195
+ date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil
196
+ end
197
+
198
+ # Sets the verdict and syncs the halves' wells onto it.
199
+ # @param new_message [String, StyledString, nil]
200
+ # @return [void]
201
+ def error_message=(new_message)
202
+ super
203
+ sync_half_wells
204
+ end
205
+
206
+ # Syncs the halves' wells on both focus edges — this field inks its
207
+ # half-filled fault only once you have left it.
208
+ # @param flag [Boolean]
209
+ # @return [void]
210
+ def active=(flag)
211
+ was = active?
212
+ super
213
+ sync_half_wells unless was == active?
214
+ end
215
+
216
+ # @return [Size] the full width, one row — so a taller rect gets the
217
+ # ambient background rather than this field's well ({Component#extent}).
218
+ def extent = Size.new(rect.width, 1)
219
+
220
+ protected
221
+
222
+ # The ink rule in the class doc, as an expression.
223
+ #
224
+ # No latch ivar, deliberately: every input here is a fact something
225
+ # announces, which is what lets the well sync have a complete call list. A
226
+ # half's `bad_input?` moves with every keystroke and announces nothing at
227
+ # all by design, so a latch of this field's own could not follow it.
228
+ # @return [Boolean]
229
+ def bad_input_settled? = !attributable? && !active?
230
+
231
+ private
232
+
233
+ # @return [Boolean] whether a half is holding input its own value cannot
234
+ # represent, and so wears the error itself.
235
+ def attributable? = date_field.bad_input? || time_field.bad_input?
236
+
237
+ # One idempotent sync over one condition, this field the sole writer of
238
+ # its halves' {Component#bg_color} — the shape a hook-owned resource takes.
239
+ # Called from the three places {HasValidation#error_ink?} can change: a
240
+ # verdict, a focus edge, and a half's announcement. Leave one out and this
241
+ # field stops inking while its halves stay marked, i.e. both halves flat
242
+ # with their wells gone.
243
+ # @return [void]
244
+ def sync_half_wells
245
+ ink = error_ink?
246
+ [date_field, time_field].each { _1.bg_color = ink ? BG_INHERIT : nil }
247
+ end
248
+
249
+ # @return [void]
250
+ def handle_half_change
251
+ sync_half_wells
252
+ fire_if_changed unless @applying
253
+ end
254
+
255
+ # Runs `block` with the halves' notices suppressed, so a value written
256
+ # into both is announced once rather than half-assembled.
257
+ # @return [void]
258
+ def applying
259
+ @applying = true
260
+ yield
261
+ ensure
262
+ @applying = false
263
+ end
264
+
265
+ # @return [void]
266
+ def fire_if_changed
267
+ v = value
268
+ return if v == @last_value
269
+
270
+ @last_value = v
271
+ on_value_change&.call(v)
272
+ end
273
+ end
274
+ end
275
+ end