Class: Tuile::Component::DateTimeField

Inherits:
Layout::Horizontal show all
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_color sits 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, and nil otherwise. 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-assembled DateTime. 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.

Returns:

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

Returns:

  • (Array<Symbol>)
%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-14 is 10 columns and 13:45 is 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.md D_date_time_field).

Returns:

  • (Integer)
2
TIME_WEIGHT =

Returns:

  • (Integer)
1
DEFAULT_PLACEMENT =

Returns:

  • (::Hash[Symbol, Object])
ALIGNMENTS =

Returns:

  • (::Array[Symbol])

Instance Attribute Summary collapse

Attributes inherited from Layout::Box

#padding, #spacing

Instance Method Summary collapse

Methods included from HasBadInput

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

Methods included from HasValue

listener

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.

Returns:



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.

Returns:



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

Parameters:

  • flag (Boolean)


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
  @applying = true
  yield
ensure
  @applying = false
end

#bad_input? ⇒ Boolean

@return — true iff the field is holding input its value cannot represent.

Returns:

  • (Boolean)


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.

Returns:

  • (String, nil)


210
211
212
213
214
215
# File 'lib/tuile/component/date_time_field.rb', line 210

def bad_input_message
  attributed = guilty_half&.bad_input_message
  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.

Returns:

  • (Boolean)


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
  applying { [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.

Returns:

  • (Boolean)


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

Returns:



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.

Returns:

  • (Boolean)


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.

Returns:



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

Parameters:



232
233
234
235
# File 'lib/tuile/component/date_time_field.rb', line 232

def error_message=(new_message)
  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).

Returns:



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

Parameters:

  • from_user: (Boolean)


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

Returns:

  • (Boolean)


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.

Returns:



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.

Parameters:

  • from_user: (Boolean)


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.

Returns:

  • (::Array[String])


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.

Returns:



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.

Returns:



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.

Returns:



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.

Parameters:

  • new_value (DateTime, Time, nil)
  • from_user: (Boolean)


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

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

Returns:



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.

Returns:

  • (DateTime, nil)


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

Parameters:

  • new_value (Object)


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.

Returns:

  • (Boolean)


261
# File 'lib/tuile/component/date_time_field.rb', line 261

def wears_bad_input_ink? = guilty_half.nil?