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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- 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
|