Class: Tuile::Component::DateTimeField
- Inherits:
-
Layout::Horizontal
- Object
- Layout
- Layout::Box
- Layout::Horizontal
- Tuile::Component::DateTimeField
- Includes:
- HasBadInput, HasValue
- Defined in:
- lib/tuile/component/date_time_field.rb,
sig/tuile.rbs
Overview
A one-row field pairing a DateField and a TimeField behind a single
DateTime. Give it a single-row #rect, 16 columns or wider:
[2026-09-14] [13:45]
↑ the blank column is {Layout::Box#spacing}, not a component
f = Component::DateTimeField.new
f.on_value_change { |e| puts e.value.inspect } # DateTime or nil, per commit
f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
f.clear # empties both halves
Neither half is labelled: each paints the hint derived from its own format
(yyyy-mm-dd, hh:mm), which names it while it is empty — the moment
naming matters. The caption ("Starts at") belongs to the layout around
the field, as it does for every field (design/decisions.md
D_caption_ownership).
Tune the halves; don't replace them
They are exposed read-only, so everything they configure is reached directly rather than forwarded through a second set of names:
f.date_field.formats = "%d.%m.%Y" # ambiguous if it were `f.formats=`
f.date_field.calendar_start = Date::ITALY
f.time_field.step = 900 # Up/Down walk a quarter hour
What this field owns on them: each half's #bg_color (the well rule below), and one listener on each half's HasValue#on_value_change and HasBadInput#on_bad_input_change — that is how the composite hears them, so append your own and never remove those.
The value is a DateTime at +00:00
Both halves feed it with no adapter, and the offset is a placeholder
rather than a zone — TimeField's epoch cost, taken the same way: a value
that is visibly wrong where an instant was meant beats one that is subtly
wrong. Combine it with a zone at your own boundary (f.value&.to_time).
Lenient in, strict out, so an input carrying more than the halves can hold does not round-trip:
f.value = Time.now # takes today's date and the wall clock
f.value == DateTime.now # => false — the zone went, and the seconds with it
Three states, and only one of them is this field's own fault
HasValue#value is non-nil iff both halves parse, so a half going bad nils the whole value (HasBadInput: a field holds bad input or a value, never both). Who reddens follows from whether the fault is attributable:
date half time half value bad_input? red
2026-09-14 13:45 DateTime no nobody
(empty) (empty) nil no — empty is not bad input nobody
2026-99-99 13:45 nil "not a valid date" the date half
2026-09-14 (empty) nil "needs both a date and a time" this field
A half's bad input is the half's to paint, on its own latch, and this field paints nothing — but it does report: HasBadInput#bad_input_message relays the guilty half's message and HasBadInput#on_bad_input_change fires on that half's latch, so whoever has cells beside the pair prints the words the half has nowhere to put. Half-filled is nobody else's, so this field reddens whole — but only while it is not active: it judges you when you leave and goes quiet when you come back to fix it. A validator's verdict (HasValidation#error_message=) is by definition not attributable either, and reddens whole with no latch at all.
The one cost: ENTER does not redden this field, where it reddens a half. A save gate on ENTER over a date with no time still reads HasBadInput#bad_input? true and gets the message; only the ink waits for the blur.
Implementation details
- The halves keep their own wells, and this field's ink is synced onto
them.
error_bg_colorsits at the top of the background chain, so a child answering Tuile::ComponentBackground#default_color — every field does — never inherits an ancestor's error level, so marking only this field would leave the halves untouched and reach no cell at all. So the halves are marked Tuile::ComponentBackground::INHERIT exactly while this field inks, andnilotherwise. A guilty half's own error well still beats the mark, which is what keeps the ink rule free of arithmetic. - The spacing column is nobody's surface — #clear_inside_extent blanks it in the ambient background, so the two wells read as two fields rather than one long one and each half keeps its own focus highlight.
- A half announces from its own
value=and its Up/Down step — the other half of AbstractWrappingField#notify_on_edit?'s contract — so writing a value into both halves would announce a half-assembledDateTime. Suppressed while applying, and announced once from this field's own diff. - Nothing else is wired. Focus forwards through Layout#handle_focus, the mouse routes down through Mouse::Router, each half commits on its own blur (Tab between them canonicalizes the date and leaves this field active), and ENTER commits inside the half and keeps bubbling to the scope's default button.
UI-thread-confined, like every component (see Screen).
Constant Summary collapse
- HALF_FILLED_MESSAGE =
Returns what HasBadInput#bad_input_message reports when one half holds a value and the other is empty.
"needs both a date and a time"- CIVIL_PARTS =
What #value= needs off whatever it is handed — the two halves' own leniencies, checked together so a rejected value writes neither.
%i[strftime hour min sec].freeze
- DATE_WEIGHT =
The content ratio, which decides this field's minimum width rather than merely its looks:
2026-09-14is 10 columns and13:45is 5, so at 16 the 2:1 split lands exactly 10 / 5. A constant rather than a measurement, so a locale spelling dates longer simply reaches its own minimum later (design/decisions.mdD_date_time_field). 2- TIME_WEIGHT =
1- DEFAULT_PLACEMENT =
- ALIGNMENTS =
Instance Attribute Summary collapse
-
#date_field ⇒ DateField
readonly
@return — the left half; tune it, never replace it.
-
#time_field ⇒ TimeField
readonly
@return — the right half; tune it, never replace it.
Attributes inherited from Layout::Box
Instance Method Summary collapse
-
#active=(flag) ⇒ void
Syncs the halves' wells on both focus edges — this field inks its half-filled fault only once you have left it.
-
#applying ⇒ void
Runs
blockwith the halves' notices suppressed, so a value written into both is announced once rather than half-assembled. -
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
-
#bad_input_message ⇒ String?
The guilty half's own report, the date's first when both are bad; else the one fault no half can wear, a half-filled pair.
-
#bad_input_settled? ⇒ Boolean
Relayed from the half whose message this field is carrying, so the report reaches a listener at the instant that half reddens rather than a focus edge later; the fault no half can wear settles on leaving this field.
-
#clear ⇒ void
Empties the input of both halves, not just the value — either may be holding glyphs no parse could use (HasBadInput).
-
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports
truewhile the user is looking at glyphs it could not use, so ask HasBadInput#bad_input? first. -
#empty_value ⇒ void
nil, not a pair of nils: a field with no parseable date and time 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 it is showable, and unless a child wears it instead.
-
#error_message ⇒ StyledString?
@return — why the field is invalid, or
nilwhen it is not;niluntil something sets it. -
#error_message=(new_message) ⇒ void
Sets the verdict and syncs the halves' wells onto it.
-
#extent ⇒ Size
@return — the full width, one row — so a taller rect gets the ambient background rather than this field's well (#extent).
-
#fire_if_changed(from_user:) ⇒ void
@param
from_user. -
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#guilty_half ⇒ AbstractWrappingField?
@return — the half holding input its own value cannot represent, the date's first — it owns the message this field relays, and the latch relayed with it.
-
#handle_editor_change ⇒ void
Rides AbstractWrappingField's edit funnel, so every includer wrapping an editor announces its report with no wiring of its own.
-
#handle_half_change(from_user:) ⇒ void
@param
from_user— what the half's write declared. - #handle_half_report_change ⇒ void
-
#initialize ⇒ DateTimeField
constructor
A new instance of DateTimeField.
-
#inspect_details ⇒ ::Array[String]
Adds
error_message=…to #inspect, omitted while valid — so a Testing.get failure dump says which field is already flagged. -
#on_bad_input_change ⇒ Listeners
Fired with a HasBadInput::BadInputChangeEvent whenever the showable report changes — see the class doc for what that is and why it is not #bad_input?.
-
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set.
-
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
-
#set_value(new_value, from_user:) ⇒ void
Writes the date into one half and the time of day into the other, firing HasValue#on_value_change once if the value actually changed.
-
#shown_message ⇒ StyledString, ...
The field's own report when it is showable, else whatever HasValidation#shown_message has — bad input outranks a verdict.
-
#sync_bad_input ⇒ void
Fires #on_bad_input_change when the showable report has really changed — the sole writer of
@last_bad_input, called from wherever an input of that expression moves. -
#sync_half_wells ⇒ void
One idempotent sync over one condition, this field the sole writer of its halves' #bg_color — the shape a hook-owned resource takes.
-
#value ⇒ DateTime?
@return — the two halves assembled, on the calendar Tuile::Component::DateField#calendar_start parsed the date in;
nilunless both parse. -
#value= ⇒ void
A programmatic write: #set_value with
from_user: false. -
#wears_bad_input_ink? ⇒ Boolean
falsewhile a half is guilty: that half paints the fault where it happened, and a well over the pair would say the other half was wrong too.
Methods included from HasBadInput
Methods included from Listeners::Declare
Methods included from HasValidation
Methods included from HasValue
Methods inherited from Layout::Horizontal
#build_rect, #cross_extent, #main_extent
Methods inherited from Layout::Box
#add, #align_offset, #build_rect, #constrain, #cross_extent, #cross_placement, #distribute_expand, #inner_rect, #main_extent, #main_sizes, #place_children, #placement, #relayout, #remove, #shown_children, #validate_align, #validate_cross, #validate_main, #validate_spacing
Constructor Details
#initialize ⇒ DateTimeField
Returns a new instance of DateTimeField.
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/tuile/component/date_time_field.rb', line 125 def initialize super(spacing: 1) @date_field = DateField.new @time_field = TimeField.new @last_value = empty_value @applying = false # cross: Fixed[1] is load-bearing — neither half declares an extent, so # one handed a three-row rect paints a three-row well. add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1]) add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1]) [@date_field, @time_field].each do |half| half.on_value_change { |e| handle_half_change(from_user: e.from_user?) } # A half's report moves without its value — garbage and an empty # buffer both read `nil` — and this field relays it. The report # only: that event carries no origin to announce a value with, and # every change of a half's value reaches the slot above anyway. half.on_bad_input_change { handle_half_report_change } end end |
Instance Attribute Details
#date_field ⇒ DateField (readonly)
@return — the left half; tune it, never replace it.
146 147 148 |
# File 'lib/tuile/component/date_time_field.rb', line 146 def date_field @date_field end |
#time_field ⇒ TimeField (readonly)
@return — the right half; tune it, never replace it.
149 150 151 |
# File 'lib/tuile/component/date_time_field.rb', line 149 def time_field @time_field end |
Instance Method Details
#active=(flag) ⇒ void
This method returns an undefined value.
Syncs the halves' wells on both focus edges — this field inks its half-filled fault only once you have left it.
@param flag
241 242 243 244 245 246 247 248 |
# File 'lib/tuile/component/date_time_field.rb', line 241 def active=(flag) was = active? super return if was == active? sync_half_wells sync_bad_input end |
#applying ⇒ void
This method returns an undefined value.
Runs block with the halves' notices suppressed, so a value written
into both is announced once rather than half-assembled.
301 302 303 304 305 306 |
# File 'lib/tuile/component/date_time_field.rb', line 301 def @applying = true yield ensure @applying = false end |
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
13395 |
# File 'sig/tuile.rbs', line 13395
def bad_input?: () -> bool
|
#bad_input_message ⇒ String?
The guilty half's own report, the date's first when both are bad; else the one fault no half can wear, a half-filled pair.
210 211 212 213 214 215 |
# File 'lib/tuile/component/date_time_field.rb', line 210 def attributed = guilty_half&. return attributed unless attributed.nil? date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil end |
#bad_input_settled? ⇒ Boolean
Relayed from the half whose message this field is carrying, so the report reaches a listener at the instant that half reddens rather than a focus edge later; the fault no half can wear settles on leaving this field.
No latch ivar, deliberately: every input here is a fact something announces, which is what lets the sync sites be a complete list.
224 225 226 227 |
# File 'lib/tuile/component/date_time_field.rb', line 224 def bad_input_settled? half = guilty_half half ? half.bad_input_settled? : !active? end |
#clear ⇒ void
This method returns an undefined value.
Empties the input of both halves, not just the value — either may be holding glyphs no parse could use (HasBadInput).
199 200 201 202 203 204 205 |
# File 'lib/tuile/component/date_time_field.rb', line 199 def clear { [date_field, time_field].each(&:clear) } # Announced even though the halves hold their own notice: emptying is # not a half-typed prefix. sync_bad_input fire_if_changed(from_user: false) end |
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports true while the
user is looking at glyphs it could not use, so ask
HasBadInput#bad_input? first.
@return — true iff #value equals #empty_value.
13455 |
# File 'sig/tuile.rbs', line 13455
def empty?: () -> bool
|
#empty_value ⇒ void
This method returns an undefined value.
nil, not a pair of nils: a field with no parseable date and time is
empty.
194 |
# File 'lib/tuile/component/date_time_field.rb', line 194 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).
13433 |
# File 'sig/tuile.rbs', line 13433
def error_bg_color: () -> Color?
|
#error_ink? ⇒ Boolean
Widens HasValidation#error_ink?: bad input paints the invalid well too, with no verdict written — once it is showable, and unless a child wears it instead.
13404 |
# File 'sig/tuile.rbs', line 13404
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
13426 |
# File 'sig/tuile.rbs', line 13426
def error_message: () -> StyledString?
|
#error_message=(new_message) ⇒ void
This method returns an undefined value.
Sets the verdict and syncs the halves' wells onto it.
@param new_message
232 233 234 235 |
# File 'lib/tuile/component/date_time_field.rb', line 232 def () super sync_half_wells end |
#extent ⇒ Size
@return — the full width, one row — so a taller rect gets the ambient background rather than this field's well (Tuile::Component#extent).
252 |
# File 'lib/tuile/component/date_time_field.rb', line 252 def extent = Size.new(rect.width, 1) |
#fire_if_changed(from_user:) ⇒ void
This method returns an undefined value.
@param from_user
310 311 312 313 314 315 316 |
# File 'lib/tuile/component/date_time_field.rb', line 310 def fire_if_changed(from_user:) v = value return if v == @last_value @last_value = v on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:)) end |
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides Tuile::Component#focusable?);
a read-only display field could override back to false. Only
focusable? lives here — tab_stop? diverges between leaf fields and
composing wrappers, so it stays per-class (design/decisions.md
D_integer_field).
13462 |
# File 'sig/tuile.rbs', line 13462
def focusable?: () -> bool
|
#guilty_half ⇒ AbstractWrappingField?
@return — the half holding input its own value cannot represent, the date's first — it owns the message this field relays, and the latch relayed with it.
268 |
# File 'lib/tuile/component/date_time_field.rb', line 268 def guilty_half = [date_field, time_field].find(&:bad_input?) |
#handle_editor_change ⇒ void
This method returns an undefined value.
Rides AbstractWrappingField's edit funnel, so every includer wrapping an editor announces its report with no wiring of its own.
super is guarded because an includer need not wrap an editor —
Tuile::Component::DateTimeField is a Layout::Horizontal and never calls this.
13411 |
# File 'sig/tuile.rbs', line 13411
def handle_editor_change: () -> void
|
#handle_half_change(from_user:) ⇒ void
This method returns an undefined value.
@param from_user — what the half's write declared.
284 285 286 287 288 289 290 |
# File 'lib/tuile/component/date_time_field.rb', line 284 def handle_half_change(from_user:) sync_half_wells return if @applying sync_bad_input fire_if_changed(from_user:) end |
#handle_half_report_change ⇒ void
This method returns an undefined value.
293 294 295 296 |
# File 'lib/tuile/component/date_time_field.rb', line 293 def handle_half_report_change sync_half_wells sync_bad_input unless @applying 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.
13437 |
# File 'sig/tuile.rbs', line 13437
def inspect_details: () -> ::Array[String]
|
#on_bad_input_change ⇒ Listeners
Fired with a HasBadInput::BadInputChangeEvent whenever the showable report changes — see the class doc for what that is and why it is not #bad_input?.
Empty means nobody outside the field is showing the report, which is the common case: the red well needs no notice, since it reads the pull on every paint.
13391 |
# File 'sig/tuile.rbs', line 13391
def on_bad_input_change: () -> Listeners
|
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set. The container that paints the message registers here, and so may an app painting its own: the list takes both, which a single slot could not.
13422 |
# File 'sig/tuile.rbs', line 13422
def on_error_message_change: () -> Listeners
|
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
13441 |
# File 'sig/tuile.rbs', line 13441
def on_value_change: () -> Listeners
|
#set_value(new_value, from_user:) ⇒ void
This method returns an undefined value.
Writes the date into one half and the time of day into the other, firing
HasValue#on_value_change once if the value actually changed. The
halves are written programmatically whatever from_user says — their
notices are muted — and this field's own event carries it.
An app never needs this with from_user: true to reach a half: each
half is a Tuile::Component::DateField / TimeField with its own #set_value, and a
user's edit there already arrives here as the user's.
@param new_value — anything carrying both a civil date and a time of day; nil empties both halves.
@param from_user — see HasValue#set_value.
177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/tuile/component/date_time_field.rb', line 177 def set_value(new_value, from_user:) unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) } raise TypeError, "expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}" end do date_field.value = new_value time_field.value = new_value end sync_bad_input fire_if_changed(from_user:) end |
#shown_message ⇒ StyledString, ...
The field's own report when it is showable, else whatever HasValidation#shown_message has — bad input outranks a verdict.
13399 |
# File 'sig/tuile.rbs', line 13399
def shown_message: () -> (StyledString | String)?
|
#sync_bad_input ⇒ void
This method returns an undefined value.
Fires #on_bad_input_change when the showable report has really
changed — the sole writer of @last_bad_input, called from wherever an
input of that expression moves.
13416 |
# File 'sig/tuile.rbs', line 13416
def sync_bad_input: () -> void
|
#sync_half_wells ⇒ void
This method returns an undefined value.
One idempotent sync over one condition, this field the sole writer of its halves' Tuile::Component#bg_color — the shape a hook-owned resource takes. Called from the three places HasValidation#error_ink? can change: a verdict, a focus edge, and a half's announcement. Leave one out and this field stops inking while its halves stay marked, i.e. both halves flat with their wells gone.
277 278 279 280 |
# File 'lib/tuile/component/date_time_field.rb', line 277 def sync_half_wells ink = error_ink? [date_field, time_field].each { _1.bg_color = ink ? ComponentBackground::INHERIT : nil } end |
#value ⇒ DateTime?
@return — the two halves assembled, on the calendar
Tuile::Component::DateField#calendar_start parsed the date in; nil unless both parse.
153 154 155 156 157 158 159 |
# File 'lib/tuile/component/date_time_field.rb', line 153 def value date = date_field.value time = time_field.value return nil if date.nil? || time.nil? DateTime.new(date.year, date.month, date.day, time.hour, time.min, time.sec, 0, date.start) end |
#value= ⇒ void
This method returns an undefined value.
A programmatic write: #set_value with from_user: false. Defined
here once and never overridden — override #set_value instead, since a
setter has no call syntax for the keyword.
@param new_value
13448 |
# File 'sig/tuile.rbs', line 13448
def value=: (Object new_value) -> void
|
#wears_bad_input_ink? ⇒ Boolean
false while a half is guilty: that half paints the fault where it
happened, and a well over the pair would say the other half was wrong
too. This field reddens for the half-filled fault and for a verdict, the
two nobody else can wear.
261 |
# File 'lib/tuile/component/date_time_field.rb', line 261 def wears_bad_input_ink? = guilty_half.nil? |