Class: Tuile::Component::DateField
- Inherits:
-
AbstractWrappingField
- Object
- Component
- AbstractWrappingField
- Tuile::Component::DateField
- Includes:
- HasBadInput
- Defined in:
- lib/tuile/component/date_field.rb,
sig/tuile.rbs
Overview
A single-line field whose #value is a stdlib Date (or nil when
empty). Give it a single-row #rect:
field = Component::DateField.new
field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
field.placeholder # => "yyyy-mm-dd"
field.clear # empties it; value => nil
Up/Down step a day (an empty field steps to today), and the hint an empty
field paints is derived from the format — set AbstractWrappingField#placeholder to override
it, or "" to suppress it.
Several formats in, one format out
#formats is a list of strftime patterns. Parsing tries them in order and
the first match wins, while formats.first — the primary — is what
#value= writes and what a loosely typed buffer is rewritten into once
the user leaves the field or presses ENTER. So it is lenient about what it
accepts and strict about what it shows, and the list is the leniency knob:
field.formats = ["%d.%m.%Y", "%Y-%m-%d"]
# the user types "2026-9-4" and Tabs away; the field shows "04.09.2026"
The order is the disambiguation, and it is the app's call: "%m/%d/%Y"
and "%d/%m/%Y" both match 04/09/2026 and disagree about what it means,
which no validation can detect — a wrong value that saves cleanly is worse
than input the user can see is bad. An app setting a list owns that call;
the detected list invents nothing, carrying what the system said (with a
two-digit year widened) and ISO behind it.
The conventions come from the session, unless you say otherwise
#formats and #calendar_start follow Screen#locale until they are assigned, so a stock field spells dates the way the user's environment says to, and an app wanting one spelling everywhere sets it once:
screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"]) # session-wide
field.formats = "%d.%m.%Y" # this field only
field.formats = nil # follow again
A mid-session Screen#locale= reaches an inheriting field: the hint is re-derived, and a buffer that still parses is rewritten in the new primary format. One that no longer parses is left exactly as typed and reads as bad input.
Input the field cannot parse is reported, not filtered
A date's grammar is not prefix-closed ("2020-13-45" is well-formed at
every character), so nothing is filtered: every character is admitted,
typed or pasted, and the residue is reported through HasBadInput. A form
asks HasBadInput#bad_input? before HasValue#empty?, since a field
full of garbage reads nil:
field.value # => nil
field.empty? # => true — empty of *value*
field.bad_input? # => true
The red well, unlike that report, waits for a commit gesture: since
every prefix of a date is bad input, painting it per keystroke would hold
the field red for the whole time the user types a correct one. So 2,
20, 202 stay quiet, leaving the field (or pressing ENTER) reddens what
did not parse, and the next edit clears it again.
The value notice waits for the same gesture
A prefix of a date can also parse cleanly: typing 1.1.2024 into a
%d.%m.%Y field passes through 1.1.2, a perfectly good 1st of January
in the year 2. So HasValue#on_value_change does not fire per keystroke,
but when the user leaves the field or presses ENTER — and a form
recalculating from it never sees that year 2.
#value does not wait: it is a live parse of the buffer at every moment, so a save gate reached without leaving the field reads the date on screen. Nor does a change nobody had to type — a #value=, an Up/Down step, a AbstractWrappingField#clear and a reparse under new #formats all fire as they happen.
Implementation details
- The buffer is the single source of truth. #value is a parse of it, recomputed on read — so #formats= and #calendar_start= can change the value with no edit, and a buffer the field cannot parse is left exactly as typed, because the user has to see what they wrote in order to fix it.
- Canonicalizing fires no HasValue#on_value_change — the spelling changed, not the value. Up/Down canonicalize too, since they go through #value=, so Up-then-Down does not restore the text you typed.
- The calendar is proleptic Gregorian, not Ruby's
Date::ITALY, which matters for dates near and before the 1582 reform: #calendar_start. - A format is checked when it is assigned, not at the first keystroke: #formats=.
UI-thread-confined, like every component (see Screen).
Constant Summary collapse
- BAD_INPUT_MESSAGE =
Returns what HasBadInput#bad_input_message reports for a buffer no configured format parses.
"not a valid date"- MAX_TEXT_LENGTH =
No format renders a date past ~30 columns ("Wednesday, 04 September 2026"), so this caps nothing an app configured — it stops a pasted novel from sitting in the buffer.
64
Instance Attribute Summary
Attributes included from HasValidation
Attributes inherited from AbstractWrappingField
Attributes included from HasValue
Instance Method Summary collapse
-
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
-
#bad_input_message ⇒ String?
Nothing a format parses is bad input, and an empty buffer is empty rather than bad (HasBadInput) — so this reports the residue of a grammar that cannot be filtered as it is typed: every prefix of a date, and everything that is simply not one.
-
#bad_input_settled? ⇒ Boolean
Every prefix of a date is bad input, so the well is latched to the commit gestures instead of painted per keystroke:
2,20,202on the way to2026-09-04never redden, and a date the field cannot parse reddens the moment the user leaves the field or presses ENTER (HasBadInput). -
#calendar_start ⇒ Numeric
When the Gregorian calendar takes over from the Julian one, as a Julian Day Number —
Date::GREGORIAN(proleptic Gregorian) by default, not Ruby'sDate::ITALY. -
#calendar_start=(start) ⇒ void
Sets the calendar and fires HasValue#on_value_change if the buffer now parses to a different date; the buffer itself is left alone.
-
#commit ⇒ void
Rewrites a buffer that parses in the primary format, leaving one that does not exactly as the user typed it — and settles the field either way, so input it could not parse starts painting the well.
-
#derived_placeholder ⇒ String?
@return — the hint for the primary format, or
nilwhen it holds a directive the humanizer cannot translate exactly — never a half-translated one. -
#empty_value ⇒ void
nil, not"": a date field with no parseable date is empty. -
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides #error_bg_color).
-
#error_ink? ⇒ Boolean
Widens HasValidation#error_ink?: bad input paints the invalid well too, with no verdict written — once #bad_input_settled? says the report may be shown.
-
#error_message ⇒ StyledString?
@return — why the field is invalid, or
nilwhen it is not;niluntil something sets it. -
#error_message= ⇒ void
Sets the verdict and repaints the field in Theme#error_color;
nilclears it. -
#formats ⇒ ::Array[String]
The accepted formats, primary first — this field's own if one was set, otherwise the screen's (Locale#date_formats), which is what makes a stock field follow the session's conventions with no configuration.
-
#formats=(list) ⇒ void
Sets the formats, re-derives the AbstractWrappingField#placeholder, and fires HasValue#on_value_change if the buffer now parses differently.
-
#handle_editor_change ⇒ void
An edit is the user having another go, so the well goes quiet again until the next commit gesture.
-
#handle_locale_changed ⇒ void
Re-derives the hint (which was pushed into the editor, so a repaint alone would keep the old one) and rewrites a buffer that still parses.
-
#initialize ⇒ DateField
constructor
A new instance of DateField.
-
#inspect_details ⇒ ::Array[String]
Adds
error_message=…to #inspect, omitted while valid — so a Testing.get failure dump says which field is already flagged. -
#notify_on_edit? ⇒ Boolean
false: a prefix of a date can parse cleanly (1.1.2for1.1.2024), so the notice settles onto the commit gestures, exactly as the ink does. -
#parse(text, format) ⇒ Date?
@param
text. -
#placeholder=(text) ⇒ void
Overrides the hint derived from the primary format.
-
#settle(flag) ⇒ void
@param
flag. -
#step(delta) ⇒ void
Steps #value by
deltadays; an empty or unparseable field steps to today, which is what a calendar would have opened on. - #sync_placeholder ⇒ void
-
#value ⇒ Date?
@return — the buffer parsed by the first format that matches it whole;
nilwhen the buffer is empty or no format parses it. -
#value=(new_value) ⇒ void
Writes
new_valueinto the buffer in the primary format and parks the caret at its end; fires HasValue#on_value_change only if the value actually changed.
Methods inherited from AbstractWrappingField
#active=, #clear, #commit_and_notify, #cursor_position, #default_bg_color, #empty?, #fire_if_changed, #focusable?, #handle_focus, #handle_key?, #layout, #placeholder, #rect=
Methods included from HasPlaceholder
Methods included from HasValue
Constructor Details
#initialize ⇒ DateField
Returns a new instance of DateField.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/tuile/component/date_field.rb', line 108 def initialize super(TextField.new) editor.max_text_length = MAX_TEXT_LENGTH # Claiming the editor's two arrow slots, not the general interceptor: # that one stays free for the app. editor.on_key_up = -> { step(1) } editor.on_key_down = -> { step(-1) } @settled = false @placeholder_override = nil # Both nil: follow the screen's locale until an app overrides them. @formats = nil @calendar_start = nil sync_placeholder end |
Instance Method Details
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
7978 |
# File 'sig/tuile.rbs', line 7978
def bad_input?: () -> bool
|
#bad_input_message ⇒ String?
Nothing a format parses is bad input, and an empty buffer is empty rather than bad (HasBadInput) — so this reports the residue of a grammar that cannot be filtered as it is typed: every prefix of a date, and everything that is simply not one.
249 |
# File 'lib/tuile/component/date_field.rb', line 249 def = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil |
#bad_input_settled? ⇒ Boolean
Every prefix of a date is bad input, so the well is latched to the
commit gestures instead of painted per keystroke: 2, 20, 202 on
the way to 2026-09-04 never redden, and a date the field cannot parse
reddens the moment the user leaves the field or presses ENTER
(HasBadInput).
275 |
# File 'lib/tuile/component/date_field.rb', line 275 def bad_input_settled? = @settled |
#calendar_start ⇒ Numeric
When the Gregorian calendar takes over from the Julian one, as a Julian
Day Number — Date::GREGORIAN (proleptic Gregorian) by default, not
Ruby's Date::ITALY. That makes 1582-10-10 an ordinary date instead
of a hole the user cannot type their way out of, and makes ISO output
mean the ISO 8601 date, which mandates proleptic Gregorian.
The cost, since it is real: the round-trip is exact only while the
field's calendar matches that of the Dates the app hands it, and
Date.new(1500, 1, 1) in app code is ITALY. So a pre-1582 date set
that way comes back nine days off once the field canonicalizes the
buffer. Set this to Date::ITALY if that is the app's world — or set it
once for the whole session, since it is a Locale member that this
reader falls back to:
screen.locale = Locale::ISO.with(calendar_start: Date::ITALY).
210 |
# File 'lib/tuile/component/date_field.rb', line 210 def calendar_start = @calendar_start || locale.calendar_start |
#calendar_start=(start) ⇒ void
This method returns an undefined value.
Sets the calendar and fires HasValue#on_value_change if the buffer now parses to a different date; the buffer itself is left alone.
@param start — a Julian Day Number, one of Date::ITALY / Date::ENGLAND / Date::GREGORIAN / Date::JULIAN, or nil to inherit Screen#locale again.
219 220 221 222 223 224 225 226 |
# File 'lib/tuile/component/date_field.rb', line 219 def calendar_start=(start) unless start.nil? || start.is_a?(Numeric) raise TypeError, "expected a Numeric day of calendar reform or nil, got #{start.inspect}" end @calendar_start = start fire_if_changed end |
#commit ⇒ void
This method returns an undefined value.
Rewrites a buffer that parses in the primary format, leaving one that does not exactly as the user typed it — and settles the field either way, so input it could not parse starts painting the well.
257 258 259 260 261 |
# File 'lib/tuile/component/date_field.rb', line 257 def commit date = value self.value = date unless date.nil? # …which unsettles, hence the order settle(true) end |
#derived_placeholder ⇒ String?
@return — the hint for the primary format, or nil when it
holds a directive the humanizer cannot translate exactly — never a
half-translated one.
344 |
# File 'lib/tuile/component/date_field.rb', line 344 def derived_placeholder = Locale::DateFormats.humanize(formats.first) |
#empty_value ⇒ void
This method returns an undefined value.
nil, not "": a date field with no parseable date is empty.
158 |
# File 'lib/tuile/component/date_field.rb', line 158 def empty_value = nil |
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides Tuile::Component#error_bg_color).
8004 |
# File 'sig/tuile.rbs', line 8004
def error_bg_color: () -> Color?
|
#error_ink? ⇒ Boolean
Widens HasValidation#error_ink?: bad input paints the invalid well too, with no verdict written — once #bad_input_settled? says the report may be shown.
7983 |
# File 'sig/tuile.rbs', line 7983
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
7987 |
# File 'sig/tuile.rbs', line 7987
def error_message: () -> StyledString?
|
#error_message= ⇒ void
This method returns an undefined value.
Sets the verdict and repaints the field in Theme#error_color; nil
clears it. No-op (no repaint, no listener) when unchanged. A String is
parsed via StyledString.parse, as HasCaption#caption= does.
Safe on a detached field — an app validates a form it assembled but has not mounted, and Tuile::Component#invalidate is already a no-op there.
@param new_message
7997 |
# File 'sig/tuile.rbs', line 7997
def error_message=: ((String | StyledString)? new_message) -> void
|
#formats ⇒ ::Array[String]
The accepted formats, primary first — this field's own if one was set, otherwise the screen's (Locale#date_formats), which is what makes a stock field follow the session's conventions with no configuration. Frozen either way: assign a new list rather than pushing onto this one, or the validator and the derived AbstractWrappingField#placeholder are both bypassed.
166 |
# File 'lib/tuile/component/date_field.rb', line 166 def formats = @formats || locale.date_formats |
#formats=(list) ⇒ void
This method returns an undefined value.
Sets the formats, re-derives the AbstractWrappingField#placeholder, and fires HasValue#on_value_change if the buffer now parses differently.
field.formats = "%d.%m.%Y" # the one-format shorthand
field.formats = ["%d.%m.%Y", "%Y-%m-%d"] # lenient in, first one out
field.formats = nil # back to following the locale
Only the primary must round-trip; every later entry only ever parses, which is what lets a lenient list carry a two-digit-year pattern behind a widened one (Locale::DateFormats.validate).
A non-empty buffer is left alone: it is text, and it reparses under the new list on the next read.
@param list — one format, several, or nil to inherit Screen#locale again.
189 190 191 192 193 |
# File 'lib/tuile/component/date_field.rb', line 189 def formats=(list) @formats = list.nil? ? nil : Locale::DateFormats.validate(list) sync_placeholder fire_if_changed end |
#handle_editor_change ⇒ void
This method returns an undefined value.
An edit is the user having another go, so the well goes quiet again until the next commit gesture.
280 281 282 283 |
# File 'lib/tuile/component/date_field.rb', line 280 def handle_editor_change super settle(false) end |
#handle_locale_changed ⇒ void
This method returns an undefined value.
Re-derives the hint (which was pushed into the editor, so a repaint alone would keep the old one) and rewrites a buffer that still parses.
288 289 290 291 292 293 294 295 296 297 |
# File 'lib/tuile/component/date_field.rb', line 288 def handle_locale_changed super # Both overridden: this field follows no session convention. return if @formats && @calendar_start sync_placeholder date = value self.value = date unless date.nil? fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it end |
#inspect_details ⇒ ::Array[String]
Adds error_message=… to Tuile::Component#inspect, omitted while valid — so
a Testing.get failure dump says which field is already flagged.
8008 |
# File 'sig/tuile.rbs', line 8008
def inspect_details: () -> ::Array[String]
|
#notify_on_edit? ⇒ Boolean
false: a prefix of a date can parse cleanly (1.1.2 for 1.1.2024),
so the notice settles onto the commit gestures, exactly as the ink
does. The class docs carry the case.
267 |
# File 'lib/tuile/component/date_field.rb', line 267 def notify_on_edit? = false |
#parse(text, format) ⇒ Date?
@param text
@param format
@return — nil unless format consumes text whole and
the fields it yields are a real date — Date._strptime checks
neither, happily ignoring a trailing "junk" and accepting
February 30th.
318 319 320 321 322 323 324 325 |
# File 'lib/tuile/component/date_field.rb', line 318 def parse(text, format) parsed = Date._strptime(text, format) return nil if parsed.nil? || !parsed[:leftover].to_s.empty? Date.strptime(text, format, calendar_start) rescue ArgumentError # Date::Error is one nil end |
#placeholder=(text) ⇒ void
This method returns an undefined value.
Overrides the hint derived from the primary format.
field.placeholder = "when it happened" # a hint of your own
field.placeholder = "" # no hint at all
field.placeholder = nil # back to the derived one
@param text — nil restores the derived hint, "" suppresses it.
238 239 240 241 242 |
# File 'lib/tuile/component/date_field.rb', line 238 def placeholder=(text) # The editor validates the type, so a bad one raises before it is stored. editor.placeholder = text || derived_placeholder @placeholder_override = text end |
#settle(flag) ⇒ void
This method returns an undefined value.
@param flag
303 304 305 306 307 308 309 310 |
# File 'lib/tuile/component/date_field.rb', line 303 def settle(flag) return if @settled == flag @settled = flag # Nothing else painted: an ENTER on an untouched buffer writes no cells, # and neither does leaving the field with bad input in it. invalidate end |
#step(delta) ⇒ void
This method returns an undefined value.
Steps #value by delta days; an empty or unparseable field steps to
today, which is what a calendar would have opened on.
@param delta
331 332 333 334 |
# File 'lib/tuile/component/date_field.rb', line 331 def step(delta) date = value self.value = date.nil? ? Date.today : date + delta end |
#sync_placeholder ⇒ void
This method returns an undefined value.
337 338 339 |
# File 'lib/tuile/component/date_field.rb', line 337 def sync_placeholder editor.placeholder = @placeholder_override || derived_placeholder end |
#value ⇒ Date?
@return — the buffer parsed by the first format that matches
it whole; nil when the buffer is empty or no format parses it.
125 126 127 128 129 130 131 132 133 134 |
# File 'lib/tuile/component/date_field.rb', line 125 def value text = editor.text return nil if text.empty? formats.each do |format| date = parse(text, format) return date unless date.nil? end nil end |
#value=(new_value) ⇒ void
This method returns an undefined value.
Writes new_value into the buffer in the primary format and parks the
caret at its end; fires HasValue#on_value_change only if the value
actually changed.
Thin and lenient, deliberately: anything answering strftime is taken
and truncated to its civil date, the same lenient-in/strict-out shape as
the format list itself.
field.value = Time.now # shows today; reads back a Date, time dropped
@param new_value — nil empties the field.
148 149 150 151 152 153 154 |
# File 'lib/tuile/component/date_field.rb', line 148 def value=(new_value) editor.text = new_value.nil? ? "" : new_value.strftime(formats.first) editor.caret = editor.text.length # The edit above announced nothing ({#notify_on_edit?}); a date written # rather than typed has no prefix to be mistaken for a value. fire_if_changed end |