Class: Tuile::Component::TimeField
- Inherits:
-
AbstractWrappingField
- Object
- Component
- AbstractWrappingField
- Tuile::Component::TimeField
- 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:30stays 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:00does narrow, because dropping a zero discards nothing. 24:00is rejected, though ISO 8601 permits it as an end-of-day:Timecannot hold it, andTime.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'
timecolumn 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.
Time.utc(2000, 1, 1).freeze
- SECONDS_PER_DAY =
86_400- SECONDS_PER_HOUR =
Returns the PageUp/PageDown stride.
3600- SECONDS_VISIBLE_BELOW =
The stride below which the field shows seconds. See #step=.
60- DEFAULT_STEP =
60- BAD_INPUT_MESSAGE =
Returns what HasBadInput#bad_input_message reports for a buffer no format in force parses.
"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.
64
Instance Attribute Summary collapse
-
#step ⇒ Integer
@return — how many seconds Up/Down move the value, and — under a minute — the reason seconds are shown at all.
Attributes included from HasValidation
Attributes inherited from AbstractWrappingField
Attributes included from HasValue
Class Method Summary collapse
-
.time_of_day(hour, minute, second = 0) ⇒ Time
Builds a value, not a field: a
Timeto compare one a field handed back against, without hand-writing the epoch.
Instance Method Summary collapse
-
#advance(time, delta) ⇒ Time
@param
time. -
#bad_input? ⇒ Boolean
@return — true iff the field is holding input its value cannot represent.
-
#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.
-
#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 to13:45never redden, and a time the field cannot parse reddens the moment the user leaves the field or presses ENTER (HasBadInput). -
#coerce(new_value) ⇒ Time
@param
new_value. -
#commit ⇒ void
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.
-
#derive_formats(current) ⇒ ::Array[String]
@param
current. -
#derived_placeholder ⇒ String?
@return — the hint for the primary format, or
nilwhen it holds a directive the humanizer cannot translate exactly — never a half-translated one. -
#empty_value ⇒ void
nil, not"": a time field with no parseable 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= ⇒ void
Sets the verdict and repaints the field in Theme#error_color;
nilclears it. -
#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:45still parses and widens to13:45:00. -
#handle_editor_change ⇒ void
An edit is the user having another go, so the well goes quiet again until the next commit gesture.
-
#handle_key?(key) ⇒ Boolean
Claims PageUp/PageDown for the hour step; they reach this field only because the editor declines them.
- #handle_locale_changed ⇒ void
-
#initialize ⇒ TimeField
constructor
A new instance of TimeField.
-
#inspect_details ⇒ ::Array[String]
Adds
error_message=…to #inspect, omitted while valid — so a Testing.get failure dump says which field is already flagged. -
#losslessly(time) ⇒ Time?
A narrowing #step= must not discard seconds the user typed — but dropping a zero second discards nothing, and reddening
13:45:00for switching to minute display would be a bug rather than a report. -
#notify_on_edit? ⇒ Boolean
false: a prefix of a time can parse cleanly (13:4for13:45), so the notice settles onto the commit gestures, exactly as the ink does. -
#now ⇒ Time
@return — the local wall clock, truncated to this field's precision — landing on now rather than a stride away from it.
-
#placeholder=(text) ⇒ void
Overrides the hint derived from the primary format.
-
#reformat(carried = nil) ⇒ void
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".
- #seconds_hidden? ⇒ Boolean
-
#set_to(hour, minute, second = 0) ⇒ void
Sets the value from its parts, so nothing assembles a
Timeon the epoch only to hand it straight back. -
#set_to_now ⇒ void
Sets the value to the local wall clock, truncated to this field's precision — the same place Up/Down land an empty field.
-
#settle(flag) ⇒ void
@param
flag. -
#step_by(delta) ⇒ void
Steps #value by
deltaseconds; an empty or unparseable field steps to now (#set_to_now) instead, anddeltais ignored. - #sync_placeholder ⇒ void
-
#value ⇒ Time?
@return — the buffer parsed by the first format that matches it whole, on MIDNIGHT's date;
nilwhen the buffer is empty or no format parses it. -
#value=(new_value) ⇒ void
Writes
new_valueinto the buffer in the primary format and parks the caret at its end; fires HasValue#on_value_change only if the value actually changed.
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
Methods included from HasValue
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=.
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.
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.
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.
8501 |
# File 'sig/tuile.rbs', line 8501
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.
321 |
# File 'lib/tuile/component/time_field.rb', line 321 def = 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).
361 |
# File 'lib/tuile/component/time_field.rb', line 361 def bad_input_settled? = @settled |
#coerce(new_value) ⇒ Time
@param new_value
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 |
#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.
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.
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_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.
476 |
# File 'lib/tuile/component/time_field.rb', line 476 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.
230 |
# File 'lib/tuile/component/time_field.rb', line 230 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).
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.
8506 |
# File 'sig/tuile.rbs', line 8506
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
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
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.
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_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.
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
@return — true for the two page keys, else whatever super
returns.
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_changed ⇒ void
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.
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
@return — time if the new primary round-trips it, else 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.
353 |
# File 'lib/tuile/component/time_field.rb', line 353 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.
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 text — nil restores the derived hint, "" suppresses it.
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.
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
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.
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_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
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
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.
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_placeholder ⇒ void
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 |
#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.
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_value — nil empties the field.
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 |