Class: Tuile::Component::TimeField

Inherits:
AbstractWrappingField show all
Includes:
HasBadInput
Defined in:
lib/tuile/component/time_field.rb,
sig/tuile.rbs

Overview

A single-line field whose #value is a time of day — a wall-clock reading with no date and no zone, carried as a Time pinned to MIDNIGHT. Give it a single-row #rect:

field = Component::TimeField.new
field.on_value_change { |e| puts e.value&.strftime("%H:%M") }
field.set_to(13, 45)   # field shows "13:45"
field.placeholder      # => "hh:mm"
field.value            # => 2000-01-01 13:45:00 UTC

Up/Down step by #step and PageUp/PageDown by an hour (an empty field steps to now), wrapping at midnight — a clock has no day to carry into.

The value is an instant, and that is a real cost

There is no civil-time class in Ruby, so the value is a Time on a fixed epoch date in UTC — which means it is a perfectly good Time that is wrong anywhere an instant was meant:

field.value = Time.now      # takes the wall clock it reads
field.value == Time.now     # => false — different date, different zone

An app that forgets to combine it with a date gets the year 2000 in its output, which is visible rather than subtly wrong. Combine it with a date at your own boundary; MIDNIGHT names the epoch.

Precision is the step, not a format

Seconds are shown exactly when #step is under a minute, and there is no per-field format setter at all:

field.step = 1                   # now shows "13:45:00", Up walks a second
field.step = 60                  # back to "13:45" — the default
field.formats                    # a report of the list in force

The spelling — separator, digit order, 12- or 24-hour — comes from Screen#locale and survives that switch, so a Finnish user sees 13.45 and 13.45.00 rather than a colon either way. An app wanting a spelling the probe did not find sets it for the session, and every field follows:

screen.locale = Locale::ISO.with(time_formats: ["%I:%M:%S %p"])

Input the field cannot parse is reported, not filtered

A time's grammar is not prefix-closed ("1" is a prefix of "13:45" and is not a time), 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 time is bad input, 1, 13, 13: 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 time can also parse cleanly: typing 13:45 passes through 13:4, a perfectly good four minutes past one. 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 those intermediate readings.

#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 time on screen. Nor does a change nobody had to type — a AbstractWrappingField#value=, a #set_to, an arrow-key step, a AbstractWrappingField#clear and a reparse under a new #step 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 #step= and a Screen#locale= 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.
  • Narrowing #step= does not truncate. 13:45:30 stays in the buffer when the step widens back past a minute, and simply reads as bad input — the field never discards a second a user meant. 13:45:00 does narrow, because dropping a zero discards nothing.
  • 24:00 is rejected, though ISO 8601 permits it as an end-of-day: Time cannot hold it, and Time.utc(…, 24, 0, 0) is silently the next day. So is a 60th second (Locale::TimeFormats.parse).
  • Canonicalizing fires no HasValue#on_value_change — the spelling changed, not the value. Up/Down canonicalize too, since they go through AbstractWrappingField#value=.

UI-thread-confined, like every component (see Screen).

Constant Summary collapse

MIDNIGHT =

The epoch every value sits on, midnight UTC — matching what Rails' time column casts to, so an ActiveRecord round-trip is exact.

UTC, not local, and that is not a detail: a local epoch would put every value on a date whose offset the zone can change, making some wall times unrepresentable or silently shifted — the whole DST class, removed.

Returns:

  • (Time)
Time.utc(2000, 1, 1).freeze
SECONDS_PER_DAY =

Returns:

  • (Integer)
86_400
SECONDS_PER_HOUR =

Returns the PageUp/PageDown stride.

Returns:

  • (Integer) —

    the PageUp/PageDown stride.

3600
SECONDS_VISIBLE_BELOW =

The stride below which the field shows seconds. See #step=.

Returns:

  • (Integer)
60
DEFAULT_STEP =

Returns:

  • (Integer)
60
BAD_INPUT_MESSAGE =

Returns what HasBadInput#bad_input_message reports for a buffer no format in force parses.

Returns:

"not a valid time"
MAX_TEXT_LENGTH =

No time format renders past ~20 columns, so this caps nothing a locale supplies — it stops a pasted novel from sitting in the buffer.

Returns:

  • (Integer)
64

Instance Attribute Summary collapse

Attributes inherited from AbstractWrappingField

#editor

Class Method Summary collapse

Instance Method Summary collapse

Methods included from HasBadInput

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

Methods inherited from AbstractWrappingField

#active=, #clear, #commit_and_notify, #cursor_position, #empty?, #fire_if_changed, #focusable?, #handle_focus, #on_enter, #on_value_change, #placeholder, #relayout, #value=

Methods included from HasPlaceholder

#placeholder

Methods included from HasValue

#clear, #empty?, #focusable?, listener, #on_value_change, #value=

Constructor Details

#initialize ⇒ TimeField

Returns a new instance of TimeField.



155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/tuile/component/time_field.rb', line 155

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_by(@step) }
  editor.on_key_down { step_by(-@step) }
  @settled = false
  @placeholder_override = nil
  @step = DEFAULT_STEP
  sync_placeholder
end

Instance Attribute Details

#step ⇒ Integer

@return — how many seconds Up/Down move the value, and — under a minute — the reason seconds are shown at all. See #step=.

Returns:

  • (Integer)


235
236
237
# File 'lib/tuile/component/time_field.rb', line 235

def step
  @step
end

Class Method Details

.time_of_day(hour, minute, second = 0) ⇒ Time

Builds a value, not a field: a Time to compare one a field handed back against, without hand-writing the epoch.

Component::TimeField.time_of_day(13, 45)   # => 2000-01-01 13:45:00 UTC
field.value == Component::TimeField.time_of_day(9, 30)

To set a field, #set_to is shorter and reads as the mutation it is.

@param hour — 0..23.

@param minute — 0..59.

@param second — 0..59.

@return — on MIDNIGHT's date, in UTC.

Parameters:

  • hour (Integer)
  • minute (Integer)
  • second (Integer) (defaults to: 0)

Returns:

  • (Time)


146
147
148
149
150
151
152
153
# File 'lib/tuile/component/time_field.rb', line 146

def self.time_of_day(hour, minute, second = 0)
  unless Locale::TimeFormats.in_range?(hour, minute, second)
    raise ArgumentError,
          "expected a time of day (0..23, 0..59, 0..59), got #{[hour, minute, second].inspect}"
  end

  Time.utc(MIDNIGHT.year, MIDNIGHT.month, MIDNIGHT.day, hour, minute, second)
end

Instance Method Details

#advance(time, delta) ⇒ Time

@param time

@param delta — seconds, either sign.

@return — wrapped into the day: 23:59 + a minute is 00:00.

Parameters:

  • time (Time)
  • delta (Integer)

Returns:

  • (Time)


450
# File 'lib/tuile/component/time_field.rb', line 450

def advance(time, delta) = MIDNIGHT + (((time - MIDNIGHT).to_i + delta) % SECONDS_PER_DAY)

#bad_input? ⇒ Boolean

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

Returns:

  • (Boolean)


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

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 time, and everything that is simply not one.

Returns:

  • (String, nil)


322
# File 'lib/tuile/component/time_field.rb', line 322

def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil

#bad_input_settled? ⇒ Boolean

Every prefix of a time is bad input, so the report is latched to the commit gestures instead of shown per keystroke: 1, 13, 13: on the way to 13:45 never redden and announce nothing, and a time the field cannot parse reddens the moment the user leaves the field or presses ENTER (HasBadInput).

Returns:

  • (Boolean)


330
# File 'lib/tuile/component/time_field.rb', line 330

def bad_input_settled? = @settled

#coerce(new_value) ⇒ Time

@param new_value

Parameters:

  • new_value (Object)

Returns:

  • (Time)


430
431
432
433
434
435
436
# File 'lib/tuile/component/time_field.rb', line 430

def coerce(new_value)
  unless %i[hour min sec].all? { new_value.respond_to?(_1) }
    raise TypeError, "expected a time of day answering hour/min/sec, got #{new_value.inspect}"
  end

  self.class.time_of_day(new_value.hour, new_value.min, new_value.sec)
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.



352
353
354
355
356
357
# File 'lib/tuile/component/time_field.rb', line 352

def commit
  time = value
  # The user's: a commit gesture, and a held notice announces here.
  set_value(time, from_user: true) unless time.nil? # …which unsettles, hence the order
  settle(true)
end

#derive_formats(current) ⇒ ::Array[String]

@param current

@return — frozen.

Parameters:

Returns:

  • (::Array[String])


412
413
414
415
416
417
418
419
420
421
422
# File 'lib/tuile/component/time_field.rb', line 412

def derive_formats(current)
  spellings = current.time_formats
  stripped = spellings.map { Locale::TimeFormats.strip_seconds(_1) }.uniq
  return stripped.freeze if seconds_hidden?

  full = spellings.select { Locale::TimeFormats.seconds?(_1) }
  # A locale whose own spelling stops at minutes has no seconds form to
  # widen into, and splicing a separator would be inventing one.
  full = [Locale::ISO.time_formats.first] if full.empty?
  (full + stripped).uniq.freeze
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.

Returns:

  • (String, nil)


481
# File 'lib/tuile/component/time_field.rb', line 481

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

#empty_value ⇒ void

This method returns an undefined value.

nil, not "": a time field with no parseable time is empty.



231
# File 'lib/tuile/component/time_field.rb', line 231

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:



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

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)


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

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:



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

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:



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

def error_message=: ((String | StyledString)? new_message) -> void

#formats ⇒ ::Array[String]

The formats in force, primary first — the locale's spelling (Locale#time_formats) reduced to this field's precision, with the seconds-bearing forms in front when #step shows seconds so that typing 13:45 still parses and widens to 13:45:00.

field.formats   # => ["%H:%M"]                 at the default step
field.step = 1
field.formats   # => ["%H:%M:%S", "%H:%M"]

A report, not a request — there is deliberately no writer. The spelling is a session convention (Screen#locale=) and the precision is #step; a per-field override would be a third authority over one fact.

@return — frozen.

Returns:

  • (::Array[String])


288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/tuile/component/time_field.rb', line 288

def formats
  current = locale
  # Keyed on both inputs rather than snapshotted, since this is read on
  # every repaint (through HasBadInput#error_ink?) and derives its answer
  # with a StringScanner per entry.
  if !@formats_locale.equal?(current) || @formats_step != @step
    @formats_locale = current
    @formats_step = @step
    @formats = derive_formats(current)
  end
  @formats
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.



368
369
370
371
372
373
# File 'lib/tuile/component/time_field.rb', line 368

def handle_editor_change
  # Unsettled before the sync riding `super`, which would otherwise read
  # the new buffer against the old latch.
  settle(false)
  super
end

#handle_key?(key) ⇒ Boolean

Claims PageUp/PageDown for the hour step; they reach this field only because the editor declines them.

@param key

@return — true for the two page keys, else whatever super returns.

Parameters:

  • key (String)

Returns:

  • (Boolean)


337
338
339
340
341
342
343
344
# File 'lib/tuile/component/time_field.rb', line 337

def handle_key?(key)
  case key
  when Keys::PAGE_UP then step_by(SECONDS_PER_HOUR)
  when Keys::PAGE_DOWN then step_by(-SECONDS_PER_HOUR)
  else return super
  end
  true
end

#handle_locale_changed ⇒ void

This method returns an undefined value.



376
377
378
379
# File 'lib/tuile/component/time_field.rb', line 376

def handle_locale_changed
  super
  reformat
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])


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

def inspect_details: () -> ::Array[String]

#losslessly(time) ⇒ Time?

A narrowing #step= must not discard seconds the user typed — but dropping a zero second discards nothing, and reddening 13:45:00 for switching to minute display would be a bug rather than a report. So a value the new primary can still write exactly is carried across.

@param time

@return — time if the new primary round-trips it, else nil.

Parameters:

  • time (Time, nil)

Returns:

  • (Time, nil)


403
404
405
406
407
408
# File 'lib/tuile/component/time_field.rb', line 403

def losslessly(time)
  return nil if time.nil?

  primary = formats.first
  Locale::TimeFormats.parse(time.strftime(primary), primary, MIDNIGHT) == time ? time : nil
end

#notify_on_edit? ⇒ Boolean

false: a prefix of a time can parse cleanly (13:4 for 13:45), so the notice settles onto the commit gestures, exactly as the ink does. The class docs carry the case.

Returns:

  • (Boolean)


363
# File 'lib/tuile/component/time_field.rb', line 363

def notify_on_edit? = false

#now ⇒ Time

@return — the local wall clock, truncated to this field's precision — landing on now rather than a stride away from it.

Returns:

  • (Time)


454
455
456
457
# File 'lib/tuile/component/time_field.rb', line 454

def now
  wall = Time.now
  self.class.time_of_day(wall.hour, wall.min, seconds_hidden? ? 0 : wall.sec)
end

#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:



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

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:



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

def on_error_message_change: () -> Listeners

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

Parameters:

  • text (String, nil)


311
312
313
314
315
# File 'lib/tuile/component/time_field.rb', line 311

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

#reformat(carried = nil) ⇒ 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 — the one path a #step= and a Screen#locale= share, since both mean "the format list changed under a buffer".

@param carried — what the buffer meant under the outgoing formats, where the caller could still read it; only #step= can.

Parameters:

  • carried (Time, nil) (defaults to: nil)


390
391
392
393
394
395
# File 'lib/tuile/component/time_field.rb', line 390

def reformat(carried = nil)
  sync_placeholder
  time = value || losslessly(carried)
  self.value = time unless time.nil?
  fire_if_changed(from_user: false) # for the buffer that just *stopped* parsing: nothing above touched it
end

#seconds_hidden? ⇒ Boolean

Returns:

  • (Boolean)


425
# File 'lib/tuile/component/time_field.rb', line 425

def seconds_hidden? = @step >= SECONDS_VISIBLE_BELOW

#set_to(hour, minute, second = 0) ⇒ void

This method returns an undefined value.

Sets the value from its parts, so nothing assembles a Time on the epoch only to hand it straight back.

field.set_to(13, 45)      # shows "13:45"
field.set_to(9, 30, 15)   # the seconds show only while step < 60

@param hour — 0..23.

@param minute — 0..59.

@param second — 0..59.

Parameters:

  • hour (Integer)
  • minute (Integer)
  • second (Integer) (defaults to: 0)


215
216
217
# File 'lib/tuile/component/time_field.rb', line 215

def set_to(hour, minute, second = 0)
  self.value = self.class.time_of_day(hour, minute, second)
end

#set_to_now ⇒ void

This method returns an undefined value.

Sets the value to the local wall clock, truncated to this field's precision — the same place Up/Down land an empty field.

field.set_to_now   # "13:45" at the default step, "13:45:37" under a minute


225
226
227
# File 'lib/tuile/component/time_field.rb', line 225

def set_to_now
  self.value = now
end

#set_value(new_value, from_user:) ⇒ 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.

Lenient about what it takes: the receiver's own hour / min / sec are read and rebuilt on MIDNIGHT, so a Time, a DateTime and a Sequel::SQLTime all work and any date, zone or fraction of a second they carried is dropped.

@param new_value — nil empties the field.

@param from_user — see HasValue#set_value.

Parameters:

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


196
197
198
199
200
201
202
# File 'lib/tuile/component/time_field.rb', line 196

def set_value(new_value, from_user:)
  editor.set_value(new_value.nil? ? "" : coerce(new_value).strftime(formats.first), from_user:)
  editor.caret = editor.text.length
  # The edit above announced nothing ({#notify_on_edit?}); a time written
  # rather than typed has no prefix to be mistaken for a value.
  fire_if_changed(from_user:)
end

#settle(flag) ⇒ void

This method returns an undefined value.

@param flag

Parameters:

  • flag (Boolean)


461
462
463
464
465
466
467
468
469
470
471
# File 'lib/tuile/component/time_field.rb', line 461

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
  # The latch moves without the buffer, so this is the one input to the
  # showable report that {HasBadInput}'s edit funnel cannot see.
  sync_bad_input
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:



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

def shown_message: () -> (StyledString | String)?

#step_by(delta) ⇒ void

This method returns an undefined value.

Steps #value by delta seconds; an empty or unparseable field steps to now (as #set_to_now would) instead, and delta is ignored.

@param delta — seconds, either sign.

Parameters:

  • delta (Integer)


442
443
444
445
# File 'lib/tuile/component/time_field.rb', line 442

def step_by(delta)
  time = value
  set_value(time.nil? ? now : advance(time, delta), from_user: true)
end

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



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

def sync_bad_input: () -> void

#sync_placeholder ⇒ void

This method returns an undefined value.



474
475
476
# File 'lib/tuile/component/time_field.rb', line 474

def sync_placeholder
  editor.placeholder = @placeholder_override || derived_placeholder
end

#value ⇒ Time?

@return — the buffer parsed by the first format that matches it whole, on MIDNIGHT's date; nil when the buffer is empty or no format parses it.

Returns:

  • (Time, nil)


171
172
173
174
175
176
177
178
179
180
# File 'lib/tuile/component/time_field.rb', line 171

def value
  text = editor.text
  return nil if text.empty?

  formats.each do |format|
    time = Locale::TimeFormats.parse(text, format, MIDNIGHT)
    return time unless time.nil?
  end
  nil
end

#wears_bad_input_ink? ⇒ Boolean

Whether this component paints the well for its own report. true here; a composite relaying a child's report answers false while that child is the one holding it, so the fault reddens where it happened rather than across the whole widget (DateTimeField).

Returns:

  • (Boolean)


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

def wears_bad_input_ink?: () -> bool