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 = ->(dt) { puts dt.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
Two of their knobs are claimed by this field and must not be reassigned: each half's HasValue#on_value_change (that is how the composite hears them) and each half's #bg_color (see the well rule below).
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. 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 #default_bg_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 BG_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 included from HasValidation
Attributes included from HasValue
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. -
#attributable? ⇒ Boolean
@return — whether a half is holding input its own value cannot represent, and so wears the error itself.
-
#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
The ink rule in the class doc, as an expression.
-
#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 #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=(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 ⇒ void
-
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. - #handle_half_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. -
#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=(new_value) ⇒ 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.
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, #handle_child_visibility_changed, #inner_rect, #main_extent, #main_sizes, #percent_of, #place_children, #placement, #rect=, #relayout, #remove, #shown_children, #validate_align, #validate_cross, #validate_main, #validate_spacing
Constructor Details
#initialize ⇒ DateTimeField
Returns a new instance of DateTimeField.
122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/tuile/component/date_time_field.rb', line 122 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 { _1.on_value_change = ->(_) { handle_half_change } } end |
Instance Attribute Details
#date_field ⇒ DateField (readonly)
@return — the left half; tune it, never replace it.
136 137 138 |
# File 'lib/tuile/component/date_time_field.rb', line 136 def date_field @date_field end |
#time_field ⇒ TimeField (readonly)
@return — the right half; tune it, never replace it.
139 140 141 |
# File 'lib/tuile/component/date_time_field.rb', line 139 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
210 211 212 213 214 |
# File 'lib/tuile/component/date_time_field.rb', line 210 def active=(flag) was = active? super sync_half_wells unless was == active? 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.
258 259 260 261 262 263 |
# File 'lib/tuile/component/date_time_field.rb', line 258 def @applying = true yield ensure @applying = false end |
#attributable? ⇒ Boolean
@return — whether a half is holding input its own value cannot represent, and so wears the error itself.
235 |
# File 'lib/tuile/component/date_time_field.rb', line 235 def attributable? = date_field.bad_input? || time_field.bad_input? |
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
10564 |
# File 'sig/tuile.rbs', line 10564
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.
191 192 193 194 195 196 |
# File 'lib/tuile/component/date_time_field.rb', line 191 def attributed = date_field. || time_field. return attributed unless attributed.nil? date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil end |
#bad_input_settled? ⇒ Boolean
The ink rule in the class doc, as an expression.
No latch ivar, deliberately: every input here is a fact something
announces, which is what lets the well sync have a complete call list. A
half's bad_input? moves with every keystroke and announces nothing at
all by design, so a latch of this field's own could not follow it.
229 |
# File 'lib/tuile/component/date_time_field.rb', line 229 def bad_input_settled? = !attributable? && !active? |
#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).
181 182 183 184 185 186 |
# File 'lib/tuile/component/date_time_field.rb', line 181 def clear { [date_field, time_field].each(&:clear) } # Announced even though the halves hold their own notice: emptying is # not a half-typed prefix. fire_if_changed 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.
10591 |
# File 'sig/tuile.rbs', line 10591
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.
176 |
# File 'lib/tuile/component/date_time_field.rb', line 176 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).
10580 |
# File 'sig/tuile.rbs', line 10580
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.
10569 |
# File 'sig/tuile.rbs', line 10569
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
10573 |
# File 'sig/tuile.rbs', line 10573
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
201 202 203 204 |
# File 'lib/tuile/component/date_time_field.rb', line 201 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).
218 |
# File 'lib/tuile/component/date_time_field.rb', line 218 def extent = Size.new(rect.width, 1) |
#fire_if_changed ⇒ void
This method returns an undefined value.
266 267 268 269 270 271 272 |
# File 'lib/tuile/component/date_time_field.rb', line 266 def fire_if_changed v = value return if v == @last_value @last_value = v on_value_change&.call(v) 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).
10598 |
# File 'sig/tuile.rbs', line 10598
def focusable?: () -> bool
|
#handle_half_change ⇒ void
This method returns an undefined value.
250 251 252 253 |
# File 'lib/tuile/component/date_time_field.rb', line 250 def handle_half_change sync_half_wells fire_if_changed 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.
10584 |
# File 'sig/tuile.rbs', line 10584
def inspect_details: () -> ::Array[String]
|
#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.
244 245 246 247 |
# File 'lib/tuile/component/date_time_field.rb', line 244 def sync_half_wells ink = error_ink? [date_field, time_field].each { _1.bg_color = ink ? BG_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.
143 144 145 146 147 148 149 |
# File 'lib/tuile/component/date_time_field.rb', line 143 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=(new_value) ⇒ 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.
@param new_value — anything carrying both a civil date and a time of day; nil empties both halves.
160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/tuile/component/date_time_field.rb', line 160 def value=(new_value) 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 fire_if_changed end |