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 = ->(t) { puts t&.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 #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 #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 included from HasValidation

#on_error_message_change

Attributes inherited from AbstractWrappingField

#editor, #on_enter

Attributes included from HasValue

#on_value_change

Class Method Summary collapse

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, #layout, #placeholder, #rect=

Methods included from HasPlaceholder

#placeholder

Methods included from HasValue

#clear, #empty?, #focusable?

Constructor Details

#initializeTimeField

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

#stepInteger

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

Returns:

  • (Integer)


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

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)


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

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)


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

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

Returns:

  • (String, nil)


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

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 well is latched to the commit gestures instead of painted per keystroke: 1, 13, 13: on the way to 13:45 never redden, and a time the field cannot parse reddens the moment the user leaves the field or presses ENTER (HasBadInput).

Returns:

  • (Boolean)


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

def bad_input_settled? = @settled

#coerce(new_value) ⇒ Time

@param new_value

Parameters:

  • new_value (Object)

Returns:

  • (Time)


426
427
428
429
430
431
432
# File 'lib/tuile/component/time_field.rb', line 426

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

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



343
344
345
346
347
# File 'lib/tuile/component/time_field.rb', line 343

def commit
  time = value
  self.value = time unless time.nil? # …which unsettles, hence the order
  settle(true)
end

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

@param current

@return — frozen.

Parameters:

Returns:

  • (::Array[String])


408
409
410
411
412
413
414
415
416
417
418
# File 'lib/tuile/component/time_field.rb', line 408

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


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

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

#empty_valuevoid

This method returns an undefined value.

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



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

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:



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

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)


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

def error_ink?: () -> bool

#error_messageStyledString?

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

Returns:



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

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:



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

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


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

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



366
367
368
369
# File 'lib/tuile/component/time_field.rb', line 366

def handle_editor_change
  super
  settle(false)
end

#handle_key?(key) ⇒ Boolean

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

@param key

@returntrue for the two page keys, else whatever super returns.

Parameters:

  • key (String)

Returns:

  • (Boolean)


328
329
330
331
332
333
334
335
# File 'lib/tuile/component/time_field.rb', line 328

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_changedvoid

This method returns an undefined value.



372
373
374
375
# File 'lib/tuile/component/time_field.rb', line 372

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


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

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

@returntime if the new primary round-trips it, else nil.

Parameters:

  • time (Time, nil)

Returns:

  • (Time, nil)


399
400
401
402
403
404
# File 'lib/tuile/component/time_field.rb', line 399

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)


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

def notify_on_edit? = false

#nowTime

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

Returns:

  • (Time)


452
453
454
455
# File 'lib/tuile/component/time_field.rb', line 452

def now
  wall = Time.now
  self.class.time_of_day(wall.hour, wall.min, seconds_hidden? ? 0 : wall.sec)
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)


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

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)


386
387
388
389
390
391
# File 'lib/tuile/component/time_field.rb', line 386

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

#seconds_hidden?Boolean

Returns:

  • (Boolean)


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

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)


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

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

#set_to_nowvoid

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


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

def set_to_now
  self.value = now
end

#settle(flag) ⇒ void

This method returns an undefined value.

@param flag

Parameters:

  • flag (Boolean)


459
460
461
462
463
464
465
466
# File 'lib/tuile/component/time_field.rb', line 459

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_by(delta) ⇒ void

This method returns an undefined value.

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

@param delta — seconds, either sign.

Parameters:

  • delta (Integer)


438
439
440
441
442
443
# File 'lib/tuile/component/time_field.rb', line 438

def step_by(delta)
  time = value
  return set_to_now if time.nil?

  self.value = advance(time, delta)
end

#sync_placeholdervoid

This method returns an undefined value.



469
470
471
# File 'lib/tuile/component/time_field.rb', line 469

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

#valueTime?

@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

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

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

Parameters:

  • new_value (Time, DateTime, nil)


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

def value=(new_value)
  editor.text = new_value.nil? ? "" : coerce(new_value).strftime(formats.first)
  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
end