tuile 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
|
@@ -20,8 +20,8 @@ module Tuile
|
|
|
20
20
|
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
21
21
|
# in view.
|
|
22
22
|
#
|
|
23
|
-
# Meant to be the content of a {Window} — focus indication
|
|
24
|
-
#
|
|
23
|
+
# Meant to be the content of a {Window} — focus indication relies on the
|
|
24
|
+
# surrounding window chrome.
|
|
25
25
|
class TextView < Component
|
|
26
26
|
def initialize
|
|
27
27
|
super
|
|
@@ -354,7 +354,7 @@ module Tuile
|
|
|
354
354
|
# an unfocused view scrolls it (dispatch gates on focus, this doesn't).
|
|
355
355
|
# @param key [String]
|
|
356
356
|
# @return [Boolean]
|
|
357
|
-
def handle_key(key)
|
|
357
|
+
def handle_key?(key)
|
|
358
358
|
case key
|
|
359
359
|
when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
|
|
360
360
|
when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)
|
|
@@ -369,14 +369,18 @@ module Tuile
|
|
|
369
369
|
true
|
|
370
370
|
end
|
|
371
371
|
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
372
|
+
# Scrolls four rows a notch, and declines — so the notch bubbles to an
|
|
373
|
+
# ancestor scroller — once this view is at that end of its text.
|
|
374
|
+
# @param event [Mouse::ScrollEvent]
|
|
375
|
+
# @return [Boolean]
|
|
376
|
+
def handle_mouse_scroll?(event)
|
|
377
|
+
before = scroll_top_row
|
|
378
|
+
case event.direction
|
|
379
|
+
when :down then move_scroll_top_row_by(4)
|
|
380
|
+
when :up then move_scroll_top_row_by(-4)
|
|
381
|
+
else return false
|
|
379
382
|
end
|
|
383
|
+
scroll_top_row != before
|
|
380
384
|
end
|
|
381
385
|
|
|
382
386
|
# Paints the text into {#rect}.
|
|
@@ -401,11 +405,11 @@ module Tuile
|
|
|
401
405
|
|
|
402
406
|
protected
|
|
403
407
|
|
|
404
|
-
# Rewraps the text on width changes
|
|
405
|
-
# {#
|
|
406
|
-
#
|
|
408
|
+
# Rewraps the text on width changes — {#wrap_width} is {#rect}`.width`
|
|
409
|
+
# minus {#scrollbar_columns}, and the latter varies with the width too.
|
|
410
|
+
# A {#scrollbar_visibility=} flip rewraps from its own setter instead.
|
|
407
411
|
# @return [void]
|
|
408
|
-
def
|
|
412
|
+
def handle_width_changed
|
|
409
413
|
super
|
|
410
414
|
rewrap
|
|
411
415
|
end
|
|
@@ -782,12 +786,26 @@ module Tuile
|
|
|
782
786
|
end
|
|
783
787
|
|
|
784
788
|
# @return [Integer] column width available for wrapped text — viewport
|
|
785
|
-
# width minus
|
|
786
|
-
#
|
|
789
|
+
# width minus {#scrollbar_columns}. `0` when {#rect}'s width is
|
|
790
|
+
# non-positive, which yields a degenerate "no wrap" result.
|
|
787
791
|
def wrap_width
|
|
788
792
|
return 0 if rect.width <= 0
|
|
789
793
|
|
|
790
|
-
rect.width -
|
|
794
|
+
rect.width - scrollbar_columns
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Columns the scrollbar claims off the right edge: the bar itself plus one
|
|
798
|
+
# blank column, so a row wrapping at the full width doesn't run into `█`
|
|
799
|
+
# (`…to show the█`). `0` when the bar is hidden.
|
|
800
|
+
#
|
|
801
|
+
# The blank is dropped below width 3, where reserving it would leave no
|
|
802
|
+
# column for text at all — that keeps {#paintable_row}'s "exactly
|
|
803
|
+
# {#rect}`.width` columns" contract true at every width.
|
|
804
|
+
# @return [Integer] `0`, `1` or `2`.
|
|
805
|
+
def scrollbar_columns
|
|
806
|
+
return 0 unless scrollbar_visible?
|
|
807
|
+
|
|
808
|
+
rect.width >= 3 ? 2 : 1
|
|
791
809
|
end
|
|
792
810
|
|
|
793
811
|
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
@@ -848,12 +866,15 @@ module Tuile
|
|
|
848
866
|
# @param scrollbar [VerticalScrollBar, nil]
|
|
849
867
|
# @return [StyledString] paintable row exactly `rect.width` columns wide.
|
|
850
868
|
# Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
851
|
-
# plus a concat of the scrollbar glyph when
|
|
869
|
+
# plus a concat of the blank column and the scrollbar glyph when a bar
|
|
870
|
+
# is present (see {#scrollbar_columns}).
|
|
852
871
|
def paintable_row(index, row_in_viewport, scrollbar)
|
|
853
872
|
row = @rows[index] || @blank_row
|
|
854
873
|
return row unless scrollbar
|
|
855
874
|
|
|
856
|
-
|
|
875
|
+
blanks = " " * (scrollbar_columns - 1)
|
|
876
|
+
bar = StyledString.styled(scrollbar.scrollbar_char(row_in_viewport), fg: screen.theme.scrollbar_color)
|
|
877
|
+
row + StyledString.plain(blanks) + bar
|
|
857
878
|
end
|
|
858
879
|
|
|
859
880
|
# A logical section of a {TextView}'s text — a contiguous run of
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A single-line field whose {#value} is a **time of day** — a wall-clock
|
|
6
|
+
# reading with no date and no zone, carried as a `Time` pinned to
|
|
7
|
+
# {MIDNIGHT}. Give it a single-row {#rect}:
|
|
8
|
+
#
|
|
9
|
+
# field = Component::TimeField.new
|
|
10
|
+
# field.on_value_change = ->(t) { puts t&.strftime("%H:%M") }
|
|
11
|
+
# field.set_to(13, 45) # field shows "13:45"
|
|
12
|
+
# field.placeholder # => "hh:mm"
|
|
13
|
+
# field.value # => 2000-01-01 13:45:00 UTC
|
|
14
|
+
#
|
|
15
|
+
# Up/Down step by {#step} and PageUp/PageDown by an hour (an empty field
|
|
16
|
+
# steps to *now*), wrapping at midnight — a clock has no day to carry into.
|
|
17
|
+
#
|
|
18
|
+
# == The value is an instant, and that is a real cost
|
|
19
|
+
# There is no civil-time class in Ruby, so the value is a `Time` on a fixed
|
|
20
|
+
# epoch date in UTC — which means it is a perfectly good `Time` that is
|
|
21
|
+
# *wrong* anywhere an instant was meant:
|
|
22
|
+
#
|
|
23
|
+
# field.value = Time.now # takes the wall clock it reads
|
|
24
|
+
# field.value == Time.now # => false — different date, different zone
|
|
25
|
+
#
|
|
26
|
+
# An app that forgets to combine it with a date gets the year 2000 in its
|
|
27
|
+
# output, which is visible rather than subtly wrong. Combine it with a date
|
|
28
|
+
# at your own boundary; {MIDNIGHT} names the epoch.
|
|
29
|
+
#
|
|
30
|
+
# == Precision is the step, not a format
|
|
31
|
+
# **Seconds are shown exactly when {#step} is under a minute**, and there is
|
|
32
|
+
# no per-field format setter at all:
|
|
33
|
+
#
|
|
34
|
+
# field.step = 1 # now shows "13:45:00", Up walks a second
|
|
35
|
+
# field.step = 60 # back to "13:45" — the default
|
|
36
|
+
# field.formats # a report of the list in force
|
|
37
|
+
#
|
|
38
|
+
# The *spelling* — separator, digit order, 12- or 24-hour — comes from
|
|
39
|
+
# {Screen#locale} and survives that switch, so a Finnish user sees `13.45`
|
|
40
|
+
# and `13.45.00` rather than a colon either way. An app wanting a spelling
|
|
41
|
+
# the probe did not find sets it for the session, and every field follows:
|
|
42
|
+
#
|
|
43
|
+
# screen.locale = Locale::ISO.with(time_formats: ["%I:%M:%S %p"])
|
|
44
|
+
#
|
|
45
|
+
# == Input the field cannot parse is *reported*, not filtered
|
|
46
|
+
# A time's grammar is not prefix-closed (`"1"` is a prefix of `"13:45"` and
|
|
47
|
+
# is not a time), so nothing is filtered: every character is admitted, typed
|
|
48
|
+
# or pasted, and the residue is reported through {HasBadInput}. A form asks
|
|
49
|
+
# {HasBadInput#bad_input?} *before* {HasValue#empty?}, since a field full of
|
|
50
|
+
# garbage reads `nil`:
|
|
51
|
+
#
|
|
52
|
+
# field.value # => nil
|
|
53
|
+
# field.empty? # => true — empty of *value*
|
|
54
|
+
# field.bad_input? # => true
|
|
55
|
+
#
|
|
56
|
+
# The red **well**, unlike that report, waits for a commit gesture: since
|
|
57
|
+
# every prefix of a time is bad input, `1`, `13`, `13:` stay quiet, leaving
|
|
58
|
+
# the field (or pressing ENTER) reddens what did not parse, and the next
|
|
59
|
+
# edit clears it again.
|
|
60
|
+
#
|
|
61
|
+
# == The value notice waits for the same gesture
|
|
62
|
+
# A prefix of a time can also parse *cleanly*: typing `13:45` passes
|
|
63
|
+
# through `13:4`, a perfectly good four minutes past one. So
|
|
64
|
+
# {HasValue#on_value_change} does not fire per keystroke, but when the user
|
|
65
|
+
# leaves the field or presses ENTER — and a form recalculating from it
|
|
66
|
+
# never sees those intermediate readings.
|
|
67
|
+
#
|
|
68
|
+
# {#value} does *not* wait: it is a live parse of the buffer at every
|
|
69
|
+
# moment, so a save gate reached without leaving the field reads the time
|
|
70
|
+
# on screen. Nor does a change nobody had to type — a {#value=}, a
|
|
71
|
+
# {#set_to}, an arrow-key step, a {#clear} and a reparse under a new
|
|
72
|
+
# {#step} all fire as they happen.
|
|
73
|
+
#
|
|
74
|
+
# == Implementation details
|
|
75
|
+
# - **The buffer is the single source of truth.** {#value} is a parse of it,
|
|
76
|
+
# recomputed on read — so {#step=} and a {Screen#locale=} can change the
|
|
77
|
+
# value with no edit, and a buffer the field cannot parse is left exactly
|
|
78
|
+
# as typed, because the user has to see what they wrote in order to fix it.
|
|
79
|
+
# - **Narrowing {#step=} does not truncate.** `13:45:30` stays in the buffer
|
|
80
|
+
# when the step widens back past a minute, and simply reads as bad input —
|
|
81
|
+
# the field never discards a second a user meant. `13:45:00` does narrow,
|
|
82
|
+
# because dropping a zero discards nothing.
|
|
83
|
+
# - **`24:00` is rejected**, though ISO 8601 permits it as an end-of-day:
|
|
84
|
+
# `Time` cannot hold it, and `Time.utc(…, 24, 0, 0)` is silently the *next
|
|
85
|
+
# day*. So is a 60th second ({Locale::TimeFormats.parse}).
|
|
86
|
+
# - **Canonicalizing fires no {HasValue#on_value_change}** — the spelling
|
|
87
|
+
# changed, not the value. Up/Down canonicalize too, since they go through
|
|
88
|
+
# {#value=}.
|
|
89
|
+
#
|
|
90
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
91
|
+
class TimeField < AbstractWrappingField
|
|
92
|
+
include HasBadInput
|
|
93
|
+
|
|
94
|
+
# The epoch every value sits on, midnight UTC — matching what Rails'
|
|
95
|
+
# `time` column casts to, so an ActiveRecord round-trip is exact.
|
|
96
|
+
#
|
|
97
|
+
# **UTC, not local, and that is not a detail:** a local epoch would put
|
|
98
|
+
# every value on a date whose offset the zone can change, making some wall
|
|
99
|
+
# times unrepresentable or silently shifted — the whole DST class, removed.
|
|
100
|
+
# @return [Time]
|
|
101
|
+
MIDNIGHT = Time.utc(2000, 1, 1).freeze
|
|
102
|
+
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
SECONDS_PER_DAY = 86_400
|
|
105
|
+
private_constant :SECONDS_PER_DAY
|
|
106
|
+
|
|
107
|
+
# @return [Integer] the PageUp/PageDown stride.
|
|
108
|
+
SECONDS_PER_HOUR = 3600
|
|
109
|
+
private_constant :SECONDS_PER_HOUR
|
|
110
|
+
|
|
111
|
+
# The stride below which the field shows seconds. See {#step=}.
|
|
112
|
+
# @return [Integer]
|
|
113
|
+
SECONDS_VISIBLE_BELOW = 60
|
|
114
|
+
private_constant :SECONDS_VISIBLE_BELOW
|
|
115
|
+
|
|
116
|
+
# @return [Integer]
|
|
117
|
+
DEFAULT_STEP = 60
|
|
118
|
+
private_constant :DEFAULT_STEP
|
|
119
|
+
|
|
120
|
+
# @return [String] what {HasBadInput#bad_input_message} reports for a
|
|
121
|
+
# buffer no format in force parses.
|
|
122
|
+
BAD_INPUT_MESSAGE = "not a valid time"
|
|
123
|
+
private_constant :BAD_INPUT_MESSAGE
|
|
124
|
+
|
|
125
|
+
# No time format renders past ~20 columns, so this caps nothing a locale
|
|
126
|
+
# supplies — it stops a pasted novel from sitting in the buffer.
|
|
127
|
+
# @return [Integer]
|
|
128
|
+
MAX_TEXT_LENGTH = 64
|
|
129
|
+
private_constant :MAX_TEXT_LENGTH
|
|
130
|
+
|
|
131
|
+
# Builds a **value**, not a field: a `Time` to compare one a field handed
|
|
132
|
+
# back against, without hand-writing the epoch.
|
|
133
|
+
#
|
|
134
|
+
# Component::TimeField.time_of_day(13, 45) # => 2000-01-01 13:45:00 UTC
|
|
135
|
+
# field.value == Component::TimeField.time_of_day(9, 30)
|
|
136
|
+
#
|
|
137
|
+
# To *set* a field, {#set_to} is shorter and reads as the mutation it is.
|
|
138
|
+
#
|
|
139
|
+
# @param hour [Integer] 0..23.
|
|
140
|
+
# @param minute [Integer] 0..59.
|
|
141
|
+
# @param second [Integer] 0..59.
|
|
142
|
+
# @return [Time] on {MIDNIGHT}'s date, in UTC.
|
|
143
|
+
# @raise [ArgumentError] outside those ranges — sharing the gate
|
|
144
|
+
# {Locale::TimeFormats.parse} uses, since `Time.utc` would otherwise
|
|
145
|
+
# normalize `time_of_day(24, 0)` into the *next day* without a word.
|
|
146
|
+
def self.time_of_day(hour, minute, second = 0)
|
|
147
|
+
unless Locale::TimeFormats.in_range?(hour, minute, second)
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"expected a time of day (0..23, 0..59, 0..59), got #{[hour, minute, second].inspect}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
Time.utc(MIDNIGHT.year, MIDNIGHT.month, MIDNIGHT.day, hour, minute, second)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def initialize
|
|
156
|
+
super(TextField.new)
|
|
157
|
+
editor.max_text_length = MAX_TEXT_LENGTH
|
|
158
|
+
# Claiming the editor's two arrow slots, not the general interceptor:
|
|
159
|
+
# that one stays free for the app.
|
|
160
|
+
editor.on_key_up = -> { step_by(@step) }
|
|
161
|
+
editor.on_key_down = -> { step_by(-@step) }
|
|
162
|
+
@settled = false
|
|
163
|
+
@placeholder_override = nil
|
|
164
|
+
@step = DEFAULT_STEP
|
|
165
|
+
sync_placeholder
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Time, nil] the buffer parsed by the first format that matches
|
|
169
|
+
# it whole, on {MIDNIGHT}'s date; `nil` when the buffer is empty or no
|
|
170
|
+
# format parses it.
|
|
171
|
+
def value
|
|
172
|
+
text = editor.text
|
|
173
|
+
return nil if text.empty?
|
|
174
|
+
|
|
175
|
+
formats.each do |format|
|
|
176
|
+
time = Locale::TimeFormats.parse(text, format, MIDNIGHT)
|
|
177
|
+
return time unless time.nil?
|
|
178
|
+
end
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Writes `new_value` into the buffer in the primary format and parks the
|
|
183
|
+
# caret at its end; fires {HasValue#on_value_change} only if the value
|
|
184
|
+
# actually changed.
|
|
185
|
+
#
|
|
186
|
+
# Lenient about what it takes: the receiver's own `hour` / `min` / `sec`
|
|
187
|
+
# are read and rebuilt on {MIDNIGHT}, so a `Time`, a `DateTime` and a
|
|
188
|
+
# `Sequel::SQLTime` all work and any date, zone or fraction of a second
|
|
189
|
+
# they carried is dropped.
|
|
190
|
+
#
|
|
191
|
+
# @param new_value [Time, DateTime, nil] `nil` empties the field.
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @raise [TypeError] on a `Date` (it has no hour, and midnight would be
|
|
194
|
+
# invented) or a `String` (that is what the buffer is for).
|
|
195
|
+
def value=(new_value)
|
|
196
|
+
editor.text = new_value.nil? ? "" : coerce(new_value).strftime(formats.first)
|
|
197
|
+
editor.caret = editor.text.length
|
|
198
|
+
# The edit above announced nothing ({#notify_on_edit?}); a time written
|
|
199
|
+
# rather than typed has no prefix to be mistaken for a value.
|
|
200
|
+
fire_if_changed
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Sets the value from its parts, so nothing assembles a `Time` on the
|
|
204
|
+
# epoch only to hand it straight back.
|
|
205
|
+
#
|
|
206
|
+
# field.set_to(13, 45) # shows "13:45"
|
|
207
|
+
# field.set_to(9, 30, 15) # the seconds show only while step < 60
|
|
208
|
+
#
|
|
209
|
+
# @param hour [Integer] 0..23.
|
|
210
|
+
# @param minute [Integer] 0..59.
|
|
211
|
+
# @param second [Integer] 0..59.
|
|
212
|
+
# @return [void]
|
|
213
|
+
# @raise [ArgumentError] outside those ranges ({.time_of_day}).
|
|
214
|
+
def set_to(hour, minute, second = 0)
|
|
215
|
+
self.value = self.class.time_of_day(hour, minute, second)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Sets the value to the local wall clock, truncated to this field's
|
|
219
|
+
# precision — the same place Up/Down land an empty field.
|
|
220
|
+
#
|
|
221
|
+
# field.set_to_now # "13:45" at the default step, "13:45:37" under a minute
|
|
222
|
+
#
|
|
223
|
+
# @return [void]
|
|
224
|
+
def set_to_now
|
|
225
|
+
self.value = now
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# `nil`, not `""`: a time field with no parseable time is empty.
|
|
229
|
+
# @return [nil]
|
|
230
|
+
def empty_value = nil
|
|
231
|
+
|
|
232
|
+
# @return [Integer] how many seconds Up/Down move the value, and — under
|
|
233
|
+
# a minute — the reason seconds are shown at all. See {#step=}.
|
|
234
|
+
attr_reader :step
|
|
235
|
+
|
|
236
|
+
# Sets the arrow-key stride, **and with it the precision**: under a
|
|
237
|
+
# minute the field shows seconds, at a minute or more it does not.
|
|
238
|
+
#
|
|
239
|
+
# field.step = 1 # "13:45:00"; Up walks a second
|
|
240
|
+
# field.step = 900 # "13:45"; Up walks a quarter hour
|
|
241
|
+
#
|
|
242
|
+
# The stride need not divide an hour: stepping adds and wraps rather than
|
|
243
|
+
# snapping to a grid, so `step = 90` from `13:45` walks to `13:46:30`.
|
|
244
|
+
#
|
|
245
|
+
# A buffer that still parses under the new precision is rewritten, and so
|
|
246
|
+
# is one the new precision can write *exactly* — narrowing over
|
|
247
|
+
# `13:45:00` shows `13:45`. One that would lose something (`13:45:30`) is
|
|
248
|
+
# left as typed and reads as bad input, so this never silently discards
|
|
249
|
+
# seconds a user meant.
|
|
250
|
+
#
|
|
251
|
+
# Why precision rides the stride rather than a knob of its own, and what
|
|
252
|
+
# that costs — seconds with a minute stride is unsayable — is
|
|
253
|
+
# `design/decisions.md` `D_time_field`.
|
|
254
|
+
#
|
|
255
|
+
# @param seconds [Integer] 1 up to (not including) a full day.
|
|
256
|
+
# @return [void]
|
|
257
|
+
# @raise [TypeError] unless `seconds` is an Integer — a fraction of a
|
|
258
|
+
# second is not a stride this field can show.
|
|
259
|
+
# @raise [ArgumentError] outside `1...86400`.
|
|
260
|
+
def step=(seconds)
|
|
261
|
+
raise TypeError, "step must be an Integer number of seconds, got #{seconds.inspect}" \
|
|
262
|
+
unless seconds.is_a?(Integer)
|
|
263
|
+
unless (1...SECONDS_PER_DAY).cover?(seconds)
|
|
264
|
+
raise ArgumentError, "step must be 1...#{SECONDS_PER_DAY} seconds, got #{seconds.inspect}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
return if @step == seconds
|
|
268
|
+
|
|
269
|
+
carried = value # read under the outgoing formats, while it still parses
|
|
270
|
+
@step = seconds
|
|
271
|
+
reformat(carried)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The formats in force, primary first — the locale's spelling
|
|
275
|
+
# ({Locale#time_formats}) reduced to this field's precision, with the
|
|
276
|
+
# seconds-bearing forms *in front* when {#step} shows seconds so that
|
|
277
|
+
# typing `13:45` still parses and widens to `13:45:00`.
|
|
278
|
+
#
|
|
279
|
+
# field.formats # => ["%H:%M"] at the default step
|
|
280
|
+
# field.step = 1
|
|
281
|
+
# field.formats # => ["%H:%M:%S", "%H:%M"]
|
|
282
|
+
#
|
|
283
|
+
# **A report, not a request — there is deliberately no writer.** The
|
|
284
|
+
# spelling is a session convention ({Screen#locale=}) and the precision is
|
|
285
|
+
# {#step}; a per-field override would be a third authority over one fact.
|
|
286
|
+
# @return [Array<String>] frozen.
|
|
287
|
+
def formats
|
|
288
|
+
current = locale
|
|
289
|
+
# Keyed on both inputs rather than snapshotted, since this is read on
|
|
290
|
+
# every repaint (through HasBadInput#error_ink?) and derives its answer
|
|
291
|
+
# with a StringScanner per entry.
|
|
292
|
+
if !@formats_locale.equal?(current) || @formats_step != @step
|
|
293
|
+
@formats_locale = current
|
|
294
|
+
@formats_step = @step
|
|
295
|
+
@formats = derive_formats(current)
|
|
296
|
+
end
|
|
297
|
+
@formats
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Overrides the hint derived from the primary format.
|
|
301
|
+
#
|
|
302
|
+
# field.placeholder = "when it happened" # a hint of your own
|
|
303
|
+
# field.placeholder = "" # no hint at all
|
|
304
|
+
# field.placeholder = nil # back to the derived one
|
|
305
|
+
#
|
|
306
|
+
# @param text [String, nil] `nil` restores the derived hint, `""`
|
|
307
|
+
# suppresses it.
|
|
308
|
+
# @return [void]
|
|
309
|
+
# @raise [TypeError] unless `text` is a String or nil.
|
|
310
|
+
def placeholder=(text)
|
|
311
|
+
# The editor validates the type, so a bad one raises before it is stored.
|
|
312
|
+
editor.placeholder = text || derived_placeholder
|
|
313
|
+
@placeholder_override = text
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Nothing a format parses is bad input, and an *empty* buffer is empty
|
|
317
|
+
# rather than bad ({HasBadInput}) — so this reports the residue of a
|
|
318
|
+
# grammar that cannot be filtered as it is typed: every prefix of a time,
|
|
319
|
+
# and everything that is simply not one.
|
|
320
|
+
# @return [String, nil]
|
|
321
|
+
def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
|
|
322
|
+
|
|
323
|
+
# Claims PageUp/PageDown for the hour step; they reach this field only
|
|
324
|
+
# because the editor declines them.
|
|
325
|
+
# @param key [String]
|
|
326
|
+
# @return [Boolean] `true` for the two page keys, else whatever `super`
|
|
327
|
+
# returns.
|
|
328
|
+
def handle_key?(key)
|
|
329
|
+
case key
|
|
330
|
+
when Keys::PAGE_UP then step_by(SECONDS_PER_HOUR)
|
|
331
|
+
when Keys::PAGE_DOWN then step_by(-SECONDS_PER_HOUR)
|
|
332
|
+
else return super
|
|
333
|
+
end
|
|
334
|
+
true
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
protected
|
|
338
|
+
|
|
339
|
+
# Rewrites a buffer that parses in the primary format, leaving one that
|
|
340
|
+
# does not exactly as the user typed it — and settles the field either
|
|
341
|
+
# way, so input it could not parse starts painting the well.
|
|
342
|
+
# @return [void]
|
|
343
|
+
def commit
|
|
344
|
+
time = value
|
|
345
|
+
self.value = time unless time.nil? # …which unsettles, hence the order
|
|
346
|
+
settle(true)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# `false`: a prefix of a time can parse cleanly (`13:4` for `13:45`), so
|
|
350
|
+
# the notice settles onto the commit gestures, exactly as the ink does.
|
|
351
|
+
# The class docs carry the case.
|
|
352
|
+
# @return [Boolean]
|
|
353
|
+
def notify_on_edit? = false
|
|
354
|
+
|
|
355
|
+
# Every prefix of a time is bad input, so the well is latched to the
|
|
356
|
+
# commit gestures instead of painted per keystroke: `1`, `13`, `13:` on
|
|
357
|
+
# the way to `13:45` never redden, and a time the field cannot parse
|
|
358
|
+
# reddens the moment the user leaves the field or presses ENTER
|
|
359
|
+
# ({HasBadInput}).
|
|
360
|
+
# @return [Boolean]
|
|
361
|
+
def bad_input_settled? = @settled
|
|
362
|
+
|
|
363
|
+
# An edit is the user having another go, so the well goes quiet again
|
|
364
|
+
# until the next commit gesture.
|
|
365
|
+
# @return [void]
|
|
366
|
+
def handle_editor_change
|
|
367
|
+
super
|
|
368
|
+
settle(false)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# @return [void]
|
|
372
|
+
def handle_locale_changed
|
|
373
|
+
super
|
|
374
|
+
reformat
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
# Re-derives the hint (which was *pushed* into the editor, so a repaint
|
|
380
|
+
# alone would keep the old one) and rewrites a buffer that still parses —
|
|
381
|
+
# the one path a {#step=} and a {Screen#locale=} share, since both mean
|
|
382
|
+
# "the format list changed under a buffer".
|
|
383
|
+
# @param carried [Time, nil] what the buffer meant under the *outgoing*
|
|
384
|
+
# formats, where the caller could still read it; only {#step=} can.
|
|
385
|
+
# @return [void]
|
|
386
|
+
def reformat(carried = nil)
|
|
387
|
+
sync_placeholder
|
|
388
|
+
time = value || losslessly(carried)
|
|
389
|
+
self.value = time unless time.nil?
|
|
390
|
+
fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# A narrowing {#step=} must not discard seconds the user typed — but
|
|
394
|
+
# dropping a *zero* second discards nothing, and reddening `13:45:00` for
|
|
395
|
+
# switching to minute display would be a bug rather than a report. So a
|
|
396
|
+
# value the new primary can still write exactly is carried across.
|
|
397
|
+
# @param time [Time, nil]
|
|
398
|
+
# @return [Time, nil] `time` if the new primary round-trips it, else nil.
|
|
399
|
+
def losslessly(time)
|
|
400
|
+
return nil if time.nil?
|
|
401
|
+
|
|
402
|
+
primary = formats.first
|
|
403
|
+
Locale::TimeFormats.parse(time.strftime(primary), primary, MIDNIGHT) == time ? time : nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# @param current [Locale]
|
|
407
|
+
# @return [Array<String>] frozen.
|
|
408
|
+
def derive_formats(current)
|
|
409
|
+
spellings = current.time_formats
|
|
410
|
+
stripped = spellings.map { Locale::TimeFormats.strip_seconds(_1) }.uniq
|
|
411
|
+
return stripped.freeze if seconds_hidden?
|
|
412
|
+
|
|
413
|
+
full = spellings.select { Locale::TimeFormats.seconds?(_1) }
|
|
414
|
+
# A locale whose own spelling stops at minutes has no seconds form to
|
|
415
|
+
# widen into, and splicing a separator would be inventing one.
|
|
416
|
+
full = [Locale::ISO.time_formats.first] if full.empty?
|
|
417
|
+
(full + stripped).uniq.freeze
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# @return [Boolean]
|
|
421
|
+
def seconds_hidden? = @step >= SECONDS_VISIBLE_BELOW
|
|
422
|
+
|
|
423
|
+
# @param new_value [Object]
|
|
424
|
+
# @return [Time]
|
|
425
|
+
# @raise [TypeError]
|
|
426
|
+
def coerce(new_value)
|
|
427
|
+
unless %i[hour min sec].all? { new_value.respond_to?(_1) }
|
|
428
|
+
raise TypeError, "expected a time of day answering hour/min/sec, got #{new_value.inspect}"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
self.class.time_of_day(new_value.hour, new_value.min, new_value.sec)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Steps {#value} by `delta` seconds; an empty or unparseable field steps
|
|
435
|
+
# to *now* ({#set_to_now}) instead, and `delta` is ignored.
|
|
436
|
+
# @param delta [Integer] seconds, either sign.
|
|
437
|
+
# @return [void]
|
|
438
|
+
def step_by(delta)
|
|
439
|
+
time = value
|
|
440
|
+
return set_to_now if time.nil?
|
|
441
|
+
|
|
442
|
+
self.value = advance(time, delta)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# @param time [Time]
|
|
446
|
+
# @param delta [Integer] seconds, either sign.
|
|
447
|
+
# @return [Time] wrapped into the day: 23:59 + a minute is 00:00.
|
|
448
|
+
def advance(time, delta) = MIDNIGHT + (((time - MIDNIGHT).to_i + delta) % SECONDS_PER_DAY)
|
|
449
|
+
|
|
450
|
+
# @return [Time] the local wall clock, truncated to this field's
|
|
451
|
+
# precision — landing *on* now rather than a stride away from it.
|
|
452
|
+
def now
|
|
453
|
+
wall = Time.now
|
|
454
|
+
self.class.time_of_day(wall.hour, wall.min, seconds_hidden? ? 0 : wall.sec)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# @param flag [Boolean]
|
|
458
|
+
# @return [void]
|
|
459
|
+
def settle(flag)
|
|
460
|
+
return if @settled == flag
|
|
461
|
+
|
|
462
|
+
@settled = flag
|
|
463
|
+
# Nothing else painted: an ENTER on an untouched buffer writes no cells,
|
|
464
|
+
# and neither does leaving the field with bad input in it.
|
|
465
|
+
invalidate
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# @return [void]
|
|
469
|
+
def sync_placeholder
|
|
470
|
+
editor.placeholder = @placeholder_override || derived_placeholder
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# @return [String, nil] the hint for the primary format, or `nil` when it
|
|
474
|
+
# holds a directive the humanizer cannot translate exactly — never a
|
|
475
|
+
# half-translated one.
|
|
476
|
+
def derived_placeholder = Locale::TimeFormats.humanize(formats.first)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
@@ -91,21 +91,24 @@ module Tuile
|
|
|
91
91
|
layout(content)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# Fully repaints the window:
|
|
94
|
+
# Fully repaints the window: the border ring here, the interior through
|
|
95
|
+
# the content and footer it re-invalidates.
|
|
95
96
|
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
97
|
+
# Deliberately *not* `super`: the default would blank the whole rect
|
|
98
|
+
# first, because the content slot is inset by the border and so never
|
|
99
|
+
# tiles — and every one of those blanked border cells is one this method
|
|
100
|
+
# is about to repaint identically, which marks it dirty and makes
|
|
101
|
+
# {Buffer#flush} re-emit it. That cost 925 bytes on every unchanged
|
|
102
|
+
# repaint of an 80×25 window, paid on each focus change
|
|
103
|
+
# (`D_component_contract`). The ring is this window's own paint and the
|
|
104
|
+
# interior is the content's, so the only cell nobody covers is an
|
|
105
|
+
# interior with no content in it — cleared here, exactly.
|
|
104
106
|
# @return [void]
|
|
105
107
|
def repaint
|
|
106
108
|
return if rect.empty?
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
clear_background(content_rect) if content.nil? && !content_rect.empty?
|
|
111
|
+
invalidate_children
|
|
109
112
|
repaint_border
|
|
110
113
|
end
|
|
111
114
|
|
|
@@ -113,8 +116,15 @@ module Tuile
|
|
|
113
116
|
|
|
114
117
|
# @param content [Component]
|
|
115
118
|
# @return [void]
|
|
116
|
-
def layout(content)
|
|
117
|
-
|
|
119
|
+
def layout(content) = content.rect = content_rect
|
|
120
|
+
|
|
121
|
+
# The interior the content fills: inside the border on three sides, and on
|
|
122
|
+
# the fourth only while there is a right border — {#scrollbar=} drops it so
|
|
123
|
+
# the content's own bar takes that column.
|
|
124
|
+
# @return [Rect] may be {Rect#empty? empty}, for a window too small to have
|
|
125
|
+
# an inside.
|
|
126
|
+
def content_rect
|
|
127
|
+
Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
118
128
|
end
|
|
119
129
|
|
|
120
130
|
# Paints the window border via {Component#draw_text}/{Component#draw_char},
|
|
@@ -138,7 +148,10 @@ module Tuile
|
|
|
138
148
|
draw_text(left, top, top_border(inner_w, fg).slice(0, w))
|
|
139
149
|
(1..(h - 2)).each do |dy|
|
|
140
150
|
draw_char(left, top + dy, "│", bar)
|
|
141
|
-
|
|
151
|
+
# Skipped once {#scrollbar=} has given that column to the content: the
|
|
152
|
+
# bar would paint over the border anyway, and painting it first only
|
|
153
|
+
# dirties the column into every frame's diff (`D_component_contract`).
|
|
154
|
+
draw_char(left + w - 1, top + dy, "│", bar) if @border_right.positive?
|
|
142
155
|
end
|
|
143
156
|
draw_text(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
|
|
144
157
|
end
|