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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. 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 and keyboard-hint
24
- # surfacing rely on the surrounding window chrome.
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
- # @param event [MouseEvent]
373
- # @return [void]
374
- def handle_mouse(event)
375
- super
376
- case event.button
377
- when :scroll_down then move_scroll_top_row_by(4)
378
- when :scroll_up then move_scroll_top_row_by(-4)
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. Wrap width depends on
405
- # {#rect}`.width` and the scrollbar gutter, both of which trigger
406
- # this hook.
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 on_width_changed
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 the scrollbar gutter (when visible). `0` when {#rect}'s
786
- # width is non-positive, which yields a degenerate "no wrap" result.
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 - (scrollbar_visible? ? 1 : 0)
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 one is present.
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
- row + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
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: both frame and contents.
94
+ # Fully repaints the window: the border ring here, the interior through
95
+ # the content and footer it re-invalidates.
95
96
  #
96
- # Window deliberately paints over its entire rect (border around the
97
- # edge, content/footer over the interior), so we don't need the
98
- # {Component#repaint} default's auto-clear — but we do still want its
99
- # "re-invalidate children" effect, since the border overpaints
100
- # whatever the content/footer drew on the perimeter. Calling super
101
- # handles both: the auto-clear is harmless (we re-paint over it), and
102
- # the invalidation queues content + footer for repaint in the same
103
- # cycle.
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
- super
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
- content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
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
- draw_char(left + w - 1, top + dy, "│", bar)
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