Class: Tuile::Component::DateField

Inherits:
AbstractWrappingField show all
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.firstthe 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.

Returns:

"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.

Returns:

  • (Integer)
64

Instance Attribute Summary

Attributes included from HasValidation

#on_error_message_change

Attributes inherited from AbstractWrappingField

#editor, #on_enter

Attributes included from HasValue

#on_value_change

Instance Method Summary collapse

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

#placeholder

Methods included from HasValue

#clear, #empty?, #focusable?

Constructor Details

#initializeDateField

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.

Returns:

  • (Boolean)


7978
# File 'sig/tuile.rbs', line 7978

def bad_input?: () -> bool

#bad_input_messageString?

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.

Returns:

  • (String, nil)


249
# File 'lib/tuile/component/date_field.rb', line 249

def bad_input_message = 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).

Returns:

  • (Boolean)


275
# File 'lib/tuile/component/date_field.rb', line 275

def bad_input_settled? = @settled

#calendar_startNumeric

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).

Returns:

  • (Numeric)


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.

Parameters:

  • start (Numeric, nil)


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

#commitvoid

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_placeholderString?

@return — the hint for the primary format, or nil when it holds a directive the humanizer cannot translate exactly — never a half-translated one.

Returns:

  • (String, nil)


344
# File 'lib/tuile/component/date_field.rb', line 344

def derived_placeholder = Locale::DateFormats.humanize(formats.first)

#empty_valuevoid

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_colorColor?

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).

Returns:



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.

Returns:

  • (Boolean)


7983
# File 'sig/tuile.rbs', line 7983

def error_ink?: () -> bool

#error_messageStyledString?

@return — why the field is invalid, or nil when it is not; nil until something sets it.

Returns:



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

Parameters:



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.

Returns:

  • (::Array[String])


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.

Parameters:

  • list (String, ::Array[String], nil)


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_changevoid

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_changedvoid

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.

Returns:

  • (::Array[String])


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.

Returns:

  • (Boolean)


267
# File 'lib/tuile/component/date_field.rb', line 267

def notify_on_edit? = false

#parse(text, format) ⇒ Date?

@param text

@param format

@returnnil 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.

Parameters:

  • text (String)
  • format (String)

Returns:

  • (Date, nil)


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 textnil restores the derived hint, "" suppresses it.

Parameters:

  • text (String, nil)


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

Parameters:

  • flag (Boolean)


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

Parameters:

  • delta (Integer)


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_placeholdervoid

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

#valueDate?

@return — the buffer parsed by the first format that matches it whole; nil when the buffer is empty or no format parses it.

Returns:

  • (Date, nil)


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_valuenil empties the field.

Parameters:

  • new_value (Date, nil)


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