tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -20,7 +20,7 @@ module Tuile
20
20
  # dialog.button("Save") { save! }
21
21
  # dialog.button("Discard") { discard! }
22
22
  # dialog.button("Cancel") # no action: pressing it dismisses
23
- # dialog.on_dismiss = -> { stay_put }
23
+ # dialog.on_dismiss { stay_put }
24
24
  # dialog.open
25
25
  #
26
26
  # **Every button closes the dialog.** A button with a block then fires it; a
@@ -80,7 +80,6 @@ module Tuile
80
80
  @popup = nil
81
81
  @chosen = false
82
82
  @message = nil
83
- @on_dismiss = nil
84
83
  # Insertion-ordered; identity-keyed so equal captions stay two buttons.
85
84
  @actions = {}.compare_by_identity
86
85
  @mnemonics = {}
@@ -93,11 +92,18 @@ module Tuile
93
92
  self.content = @box
94
93
  end
95
94
 
96
- # Callback taking no arguments, fired when the dialog is dismissed — ESC,
97
- # `q`, an outside click, or a {#button} declared without a block. Fires
98
- # exactly once per {#open}, and never when an action button was chosen.
99
- # @return [Proc, nil]
100
- attr_accessor :on_dismiss
95
+ # What {#on_dismiss} fires.
96
+ #
97
+ # @!attribute [r] source
98
+ # @return [ConfirmWindow] the dialog that was dismissed.
99
+ DismissEvent = Data.define(:source) { include Tuile::Event }
100
+
101
+ # @!method on_dismiss
102
+ # Fired with a {DismissEvent} when the dialog is dismissed — ESC, `q`, an
103
+ # outside click, or a {#button} declared without a block. Fires exactly
104
+ # once per {#open}, and never when an action button was chosen.
105
+ # @return [Listeners]
106
+ listener :on_dismiss
101
107
 
102
108
  # @return [String, StyledString, Component, nil] whatever {#message=} was
103
109
  # given — set a `String`, read that `String` back. The component
@@ -169,7 +175,7 @@ module Tuile
169
175
  # is the downcased matching key.
170
176
  cue = mnemonic == :auto ? styled.to_s.grapheme_clusters.first : mnemonic
171
177
  btn = Button.new(letter ? underline_mnemonic(styled, cue) : styled)
172
- btn.on_click = -> { activate(btn) }
178
+ btn.on_click { activate(btn) }
173
179
  @actions[btn] = action
174
180
  @mnemonics[letter] = btn unless letter.nil?
175
181
  @button_row.add(btn, Layout::Fixed[btn.caption.display_width + 4])
@@ -191,7 +197,7 @@ module Tuile
191
197
  @popup&.content = nil
192
198
  @chosen = false
193
199
  @popup = MeasuredPopup.new(self)
194
- @popup.on_close = -> { notice_dismissed }
200
+ @popup.on_close { notice_dismissed }
195
201
  @popup.open
196
202
  end
197
203
 
@@ -292,7 +298,7 @@ module Tuile
292
298
  window.message = message
293
299
  window.button(confirm, &action)
294
300
  window.button(cancel)
295
- window.on_dismiss = on_dismiss
301
+ window.on_dismiss << on_dismiss if on_dismiss
296
302
  window.open
297
303
  end
298
304
 
@@ -307,23 +313,19 @@ module Tuile
307
313
  confirm(caption, message, confirm: "Yes", cancel: "No", on_dismiss:, &action)
308
314
  end
309
315
 
310
- # The {Popup} that {#open} wraps the dialog in: its declared size is
311
- # derived from the dialog on every {#reposition}, so a message change and
312
- # a SIGWINCH both re-measure against the current screen.
316
+ # The {Popup} that {#open} wraps the dialog in: its size is measured from
317
+ # the dialog on every layout pass, so a message change and a SIGWINCH both
318
+ # re-measure against the current screen.
313
319
  class MeasuredPopup < Popup
314
320
  # @param window [ConfirmWindow]
315
321
  def initialize(window)
316
- # Before super: Popup#initialize ends in the first #reposition call.
317
322
  @window = window
318
323
  super(content: window)
319
324
  end
320
325
 
321
- # @return [void]
322
- def reposition
323
- # The ivar, not #declared_size= — the writer calls reposition itself.
324
- @declared_size = @window.measured_size(screen.size)
325
- super
326
- end
326
+ # @param screen_size [Size]
327
+ # @return [Size] {ConfirmWindow#measured_size}.
328
+ def declared_size_in(screen_size) = @window.measured_size(screen_size)
327
329
  end
328
330
  private_constant :MeasuredPopup
329
331
 
@@ -339,7 +341,7 @@ module Tuile
339
341
  action = @actions[btn]
340
342
  @chosen = true
341
343
  @popup&.close
342
- action.nil? ? @on_dismiss&.call : action.call
344
+ action.nil? ? fire_dismissed : action.call
343
345
  end
344
346
 
345
347
  # The popup's on_close: fires {#on_dismiss} unless a button was chosen —
@@ -350,9 +352,12 @@ module Tuile
350
352
  return if @chosen
351
353
 
352
354
  @chosen = true
353
- @on_dismiss&.call
355
+ fire_dismissed
354
356
  end
355
357
 
358
+ # @return [void]
359
+ def fire_dismissed = on_dismiss.fire(DismissEvent.new(source: self))
360
+
356
361
  # @param delta [Integer] -1 or 1.
357
362
  # @return [Boolean] false when focus is not on a button (the key bubbles on).
358
363
  def focus_button_step(delta)
@@ -6,7 +6,7 @@ module Tuile
6
6
  # empty). Give it a single-row {#rect}:
7
7
  #
8
8
  # field = Component::DateField.new
9
- # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
9
+ # field.on_value_change { |e| puts e.value.inspect } # Date or nil, per commit
10
10
  # field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
11
11
  # field.placeholder # => "yyyy-mm-dd"
12
12
  # field.clear # empties it; value => nil
@@ -110,8 +110,8 @@ module Tuile
110
110
  editor.max_text_length = MAX_TEXT_LENGTH
111
111
  # Claiming the editor's two arrow slots, not the general interceptor:
112
112
  # that one stays free for the app.
113
- editor.on_key_up = -> { step(1) }
114
- editor.on_key_down = -> { step(-1) }
113
+ editor.on_key_up { step(1) }
114
+ editor.on_key_down { step(-1) }
115
115
  @settled = false
116
116
  @placeholder_override = nil
117
117
  # Both nil: follow the screen's locale until an app overrides them.
@@ -144,13 +144,14 @@ module Tuile
144
144
  # field.value = Time.now # shows today; reads back a Date, time dropped
145
145
  #
146
146
  # @param new_value [Date, nil] `nil` empties the field.
147
+ # @param from_user [Boolean] see {HasValue#set_value}.
147
148
  # @return [void]
148
- def value=(new_value)
149
- editor.text = new_value.nil? ? "" : new_value.strftime(formats.first)
149
+ def set_value(new_value, from_user:)
150
+ editor.set_value(new_value.nil? ? "" : new_value.strftime(formats.first), from_user:)
150
151
  editor.caret = editor.text.length
151
152
  # The edit above announced nothing ({#notify_on_edit?}); a date written
152
153
  # rather than typed has no prefix to be mistaken for a value.
153
- fire_if_changed
154
+ fire_if_changed(from_user:)
154
155
  end
155
156
 
156
157
  # `nil`, not `""`: a date field with no parseable date is empty.
@@ -189,7 +190,7 @@ module Tuile
189
190
  def formats=(list)
190
191
  @formats = list.nil? ? nil : Locale::DateFormats.validate(list)
191
192
  sync_placeholder
192
- fire_if_changed
193
+ fire_if_changed(from_user: false)
193
194
  end
194
195
 
195
196
  # When the Gregorian calendar takes over from the Julian one, as a Julian
@@ -222,7 +223,7 @@ module Tuile
222
223
  end
223
224
 
224
225
  @calendar_start = start
225
- fire_if_changed
226
+ fire_if_changed(from_user: false)
226
227
  end
227
228
 
228
229
  # Overrides the hint derived from the primary format.
@@ -248,6 +249,14 @@ module Tuile
248
249
  # @return [String, nil]
249
250
  def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
250
251
 
252
+ # Every prefix of a date is bad input, so the report is latched to the
253
+ # commit gestures instead of shown per keystroke: `2`, `20`, `202` on the
254
+ # way to `2026-09-04` never redden and announce nothing, and a date the
255
+ # field cannot parse reddens the moment the user leaves the field or
256
+ # presses ENTER ({HasBadInput}).
257
+ # @return [Boolean]
258
+ def bad_input_settled? = @settled
259
+
251
260
  protected
252
261
 
253
262
  # Rewrites a buffer that parses in the primary format, leaving one that
@@ -256,7 +265,8 @@ module Tuile
256
265
  # @return [void]
257
266
  def commit
258
267
  date = value
259
- self.value = date unless date.nil? # …which unsettles, hence the order
268
+ # The user's: a commit gesture, and a held notice announces here.
269
+ set_value(date, from_user: true) unless date.nil? # …which unsettles, hence the order
260
270
  settle(true)
261
271
  end
262
272
 
@@ -266,20 +276,14 @@ module Tuile
266
276
  # @return [Boolean]
267
277
  def notify_on_edit? = false
268
278
 
269
- # Every prefix of a date is bad input, so the well is latched to the
270
- # commit gestures instead of painted per keystroke: `2`, `20`, `202` on
271
- # the way to `2026-09-04` never redden, and a date the field cannot parse
272
- # reddens the moment the user leaves the field or presses ENTER
273
- # ({HasBadInput}).
274
- # @return [Boolean]
275
- def bad_input_settled? = @settled
276
-
277
279
  # An edit is the user having another go, so the well goes quiet again
278
280
  # until the next commit gesture.
279
281
  # @return [void]
280
282
  def handle_editor_change
281
- super
283
+ # Unsettled before the sync riding `super`, which would otherwise read
284
+ # the new buffer against the old latch.
282
285
  settle(false)
286
+ super
283
287
  end
284
288
 
285
289
  # Re-derives the hint (which was *pushed* into the editor, so a repaint
@@ -293,7 +297,7 @@ module Tuile
293
297
  sync_placeholder
294
298
  date = value
295
299
  self.value = date unless date.nil?
296
- fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
300
+ fire_if_changed(from_user: false) # for the buffer that just *stopped* parsing: nothing above touched it
297
301
  end
298
302
 
299
303
  private
@@ -307,6 +311,9 @@ module Tuile
307
311
  # Nothing else painted: an ENTER on an untouched buffer writes no cells,
308
312
  # and neither does leaving the field with bad input in it.
309
313
  invalidate
314
+ # The latch moves without the buffer, so this is the one input to the
315
+ # showable report that {HasBadInput}'s edit funnel cannot see.
316
+ sync_bad_input
310
317
  end
311
318
 
312
319
  # @param text [String]
@@ -330,7 +337,7 @@ module Tuile
330
337
  # @return [void]
331
338
  def step(delta)
332
339
  date = value
333
- self.value = date.nil? ? Date.today : date + delta
340
+ set_value(date.nil? ? Date.today : date + delta, from_user: true)
334
341
  end
335
342
 
336
343
  # @return [void]
@@ -9,7 +9,7 @@ module Tuile
9
9
  # ↑ the blank column is {Layout::Box#spacing}, not a component
10
10
  #
11
11
  # f = Component::DateTimeField.new
12
- # f.on_value_change = ->(dt) { puts dt.inspect } # DateTime or nil, per commit
12
+ # f.on_value_change { |e| puts e.value.inspect } # DateTime or nil, per commit
13
13
  # f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
14
14
  # f.clear # empties both halves
15
15
  #
@@ -27,10 +27,10 @@ module Tuile
27
27
  # f.date_field.calendar_start = Date::ITALY
28
28
  # f.time_field.step = 900 # Up/Down walk a quarter hour
29
29
  #
30
- # Two of their knobs are **claimed** by this field and must not be
31
- # reassigned: each half's {HasValue#on_value_change} (that is how the
32
- # composite hears them) and each half's {Component#bg_color} (see the well
33
- # rule below).
30
+ # What this field owns on them: each half's {Component#bg_color} (the well
31
+ # rule below), and one listener on each half's {HasValue#on_value_change}
32
+ # and {HasBadInput#on_bad_input_change} — that is how the composite hears
33
+ # them, so append your own and never remove those.
34
34
  #
35
35
  # == The value is a `DateTime` at +00:00
36
36
  # Both halves feed it with no adapter, and the offset is a placeholder
@@ -56,8 +56,11 @@ module Tuile
56
56
  # 2026-09-14 (empty) nil "needs both a date and a time" this field
57
57
  #
58
58
  # A half's bad input is the half's to paint, on its own latch, and this
59
- # field paints nothing. Half-filled is nobody else's, so this field reddens
60
- # whole — but **only while it is not active**: it judges you when you leave
59
+ # field paints nothing — but it does *report*: {HasBadInput#bad_input_message}
60
+ # relays the guilty half's message and {HasBadInput#on_bad_input_change}
61
+ # fires on that half's latch, so whoever has cells beside the pair prints the
62
+ # words the half has nowhere to put. Half-filled is nobody else's, so this
63
+ # field reddens whole — but **only while it is not active**: it judges you when you leave
61
64
  # and goes quiet when you come back to fix it. A validator's verdict
62
65
  # ({HasValidation#error_message=}) is by definition not attributable either,
63
66
  # and reddens whole with no latch at all.
@@ -70,10 +73,10 @@ module Tuile
70
73
  # == Implementation details
71
74
  # - **The halves keep their own wells, and this field's ink is *synced* onto
72
75
  # them.** `error_bg_color` sits at the top of the background chain, so a
73
- # child answering {Component#default_bg_color} — every field does — never
76
+ # child answering {ComponentBackground#default_color} — every field does — never
74
77
  # inherits an ancestor's error level, so marking only this field would
75
78
  # leave the halves untouched and reach no cell at all. So the halves are
76
- # marked {Component::BG_INHERIT} exactly while this field inks, and `nil`
79
+ # marked {ComponentBackground::INHERIT} exactly while this field inks, and `nil`
77
80
  # otherwise. A guilty half's *own* error well still beats the mark, which
78
81
  # is what keeps the ink rule free of arithmetic.
79
82
  # - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
@@ -129,7 +132,14 @@ module Tuile
129
132
  # one handed a three-row rect paints a three-row well.
130
133
  add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1])
131
134
  add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1])
132
- [@date_field, @time_field].each { _1.on_value_change = ->(_) { handle_half_change } }
135
+ [@date_field, @time_field].each do |half|
136
+ half.on_value_change { |e| handle_half_change(from_user: e.from_user?) }
137
+ # A half's report moves without its value — garbage and an empty
138
+ # buffer both read `nil` — and this field relays it. The report
139
+ # only: that event carries no origin to announce a value with, and
140
+ # every change of a half's value reaches the slot above anyway.
141
+ half.on_bad_input_change { handle_half_report_change }
142
+ end
133
143
  end
134
144
 
135
145
  # @return [DateField] the left half; tune it, never replace it.
@@ -149,7 +159,13 @@ module Tuile
149
159
  end
150
160
 
151
161
  # Writes the date into one half and the time of day into the other, firing
152
- # {HasValue#on_value_change} once if the value actually changed.
162
+ # {HasValue#on_value_change} once if the value actually changed. The
163
+ # halves are written programmatically whatever `from_user` says — their
164
+ # notices are muted — and this field's own event carries it.
165
+ #
166
+ # An app never needs this with `from_user: true` to reach a half: each
167
+ # half is a {DateField} / {TimeField} with its own {#set_value}, and a
168
+ # user's edit there already arrives here as the user's.
153
169
  #
154
170
  # @param new_value [DateTime, Time, nil] anything carrying both a civil
155
171
  # date and a time of day; `nil` empties both halves.
@@ -157,7 +173,8 @@ module Tuile
157
173
  # @raise [TypeError] on a `Date` (it has no hour, and midnight would be
158
174
  # invented) or anything else missing one of the two — checked before
159
175
  # either half is written, so a rejected value leaves the field as it was.
160
- def value=(new_value)
176
+ # @param from_user [Boolean] see {HasValue#set_value}.
177
+ def set_value(new_value, from_user:)
161
178
  unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) }
162
179
  raise TypeError,
163
180
  "expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}"
@@ -167,7 +184,8 @@ module Tuile
167
184
  date_field.value = new_value
168
185
  time_field.value = new_value
169
186
  end
170
- fire_if_changed
187
+ sync_bad_input
188
+ fire_if_changed(from_user:)
171
189
  end
172
190
 
173
191
  # `nil`, not a pair of nils: a field with no parseable date *and* time is
@@ -182,19 +200,32 @@ module Tuile
182
200
  applying { [date_field, time_field].each(&:clear) }
183
201
  # Announced even though the halves hold their own notice: emptying is
184
202
  # not a half-typed prefix.
185
- fire_if_changed
203
+ sync_bad_input
204
+ fire_if_changed(from_user: false)
186
205
  end
187
206
 
188
207
  # The guilty half's own report, the date's first when both are bad; else
189
208
  # the one fault no half can wear, a half-filled pair.
190
209
  # @return [String, nil]
191
210
  def bad_input_message
192
- attributed = date_field.bad_input_message || time_field.bad_input_message
211
+ attributed = guilty_half&.bad_input_message
193
212
  return attributed unless attributed.nil?
194
213
 
195
214
  date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil
196
215
  end
197
216
 
217
+ # Relayed from the half whose message this field is carrying, so the report
218
+ # reaches a listener at the instant that half reddens rather than a focus
219
+ # edge later; the fault no half can wear settles on leaving this field.
220
+ #
221
+ # No latch ivar, deliberately: every input here is a fact something
222
+ # announces, which is what lets the sync sites be a complete list.
223
+ # @return [Boolean]
224
+ def bad_input_settled?
225
+ half = guilty_half
226
+ half ? half.bad_input_settled? : !active?
227
+ end
228
+
198
229
  # Sets the verdict and syncs the halves' wells onto it.
199
230
  # @param new_message [String, StyledString, nil]
200
231
  # @return [void]
@@ -210,7 +241,10 @@ module Tuile
210
241
  def active=(flag)
211
242
  was = active?
212
243
  super
213
- sync_half_wells unless was == active?
244
+ return if was == active?
245
+
246
+ sync_half_wells
247
+ sync_bad_input
214
248
  end
215
249
 
216
250
  # @return [Size] the full width, one row — so a taller rect gets the
@@ -219,20 +253,19 @@ module Tuile
219
253
 
220
254
  protected
221
255
 
222
- # The ink rule in the class doc, as an expression.
223
- #
224
- # No latch ivar, deliberately: every input here is a fact something
225
- # announces, which is what lets the well sync have a complete call list. A
226
- # half's `bad_input?` moves with every keystroke and announces nothing at
227
- # all by design, so a latch of this field's own could not follow it.
256
+ # `false` while a half is guilty: that half paints the fault where it
257
+ # happened, and a well over the pair would say the other half was wrong
258
+ # too. This field reddens for the half-filled fault and for a verdict, the
259
+ # two nobody else can wear.
228
260
  # @return [Boolean]
229
- def bad_input_settled? = !attributable? && !active?
261
+ def wears_bad_input_ink? = guilty_half.nil?
230
262
 
231
263
  private
232
264
 
233
- # @return [Boolean] whether a half is holding input its own value cannot
234
- # represent, and so wears the error itself.
235
- def attributable? = date_field.bad_input? || time_field.bad_input?
265
+ # @return [AbstractWrappingField, nil] the half holding input its own
266
+ # value cannot represent, the date's first — it owns the message this
267
+ # field relays, and the latch relayed with it.
268
+ def guilty_half = [date_field, time_field].find(&:bad_input?)
236
269
 
237
270
  # One idempotent sync over one condition, this field the sole writer of
238
271
  # its halves' {Component#bg_color} — the shape a hook-owned resource takes.
@@ -243,13 +276,23 @@ module Tuile
243
276
  # @return [void]
244
277
  def sync_half_wells
245
278
  ink = error_ink?
246
- [date_field, time_field].each { _1.bg_color = ink ? BG_INHERIT : nil }
279
+ [date_field, time_field].each { _1.bg_color = ink ? ComponentBackground::INHERIT : nil }
280
+ end
281
+
282
+ # @param from_user [Boolean] what the half's write declared.
283
+ # @return [void]
284
+ def handle_half_change(from_user:)
285
+ sync_half_wells
286
+ return if @applying
287
+
288
+ sync_bad_input
289
+ fire_if_changed(from_user:)
247
290
  end
248
291
 
249
292
  # @return [void]
250
- def handle_half_change
293
+ def handle_half_report_change
251
294
  sync_half_wells
252
- fire_if_changed unless @applying
295
+ sync_bad_input unless @applying
253
296
  end
254
297
 
255
298
  # Runs `block` with the halves' notices suppressed, so a value written
@@ -262,13 +305,14 @@ module Tuile
262
305
  @applying = false
263
306
  end
264
307
 
308
+ # @param from_user [Boolean]
265
309
  # @return [void]
266
- def fire_if_changed
310
+ def fire_if_changed(from_user:)
267
311
  v = value
268
312
  return if v == @last_value
269
313
 
270
314
  @last_value = v
271
- on_value_change&.call(v)
315
+ on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
272
316
  end
273
317
  end
274
318
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Fills every cell of its {#rect} with one glyph. Give it a one-column rect
6
+ # and `│` for a vertical rule between two borderless panes, a one-row rect
7
+ # and `─` for a horizontal one — the glyph decides the direction, the parent
8
+ # decides the length:
9
+ #
10
+ # rule = Component::Fill.new("│", color: Theme.ref(:pane_frame))
11
+ # bottom = Component::Layout::Horizontal.new
12
+ # bottom.add(system_pane, Layout::Percent[40])
13
+ # bottom.add(rule, Layout::Fixed[1])
14
+ # bottom.add(log_pane, Layout::Expand[1])
15
+ #
16
+ # Display-only — not focusable, no keys, no mouse. The background behind
17
+ # the glyph is its own {Component#bg_color}, else inherited, as for any
18
+ # component.
19
+ class Fill < Component
20
+ # @param char [String] initial {#char=}.
21
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
22
+ # initial {#color=}.
23
+ # @raise [ArgumentError, TypeError] see {#char=} and {#color=}.
24
+ def initialize(char, color: nil)
25
+ super()
26
+ @char = StyledString.validate_glyph(char, :char)
27
+ @color = nil
28
+ self.color = color
29
+ end
30
+
31
+ # @return [String] the glyph painted into every cell; one grapheme
32
+ # cluster, one column wide.
33
+ attr_reader :char
34
+
35
+ # @return [Color, Theme::Ref, nil] the value as set, so a {Theme::Ref}
36
+ # comes back unresolved; `nil` (the default) is the terminal's default
37
+ # foreground.
38
+ attr_reader :color
39
+
40
+ # @param char [String]
41
+ # @return [void]
42
+ # @raise [TypeError] when `char` is not a String.
43
+ # @raise [ArgumentError] when `char` is not exactly one grapheme cluster
44
+ # one column wide.
45
+ def char=(char)
46
+ char = StyledString.validate_glyph(char, :char)
47
+ return if @char == char
48
+
49
+ @char = char
50
+ invalidate
51
+ end
52
+
53
+ # Sets the glyph's color, live-resolved at paint time when given a
54
+ # {Theme::Ref} (so it follows a {Screen#theme=} with no
55
+ # {Component#handle_theme_changed} hook).
56
+ #
57
+ # rule.color = :bright_black
58
+ # rule.color = Theme.ref(:pane_frame) # an app #custom token
59
+ #
60
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
61
+ # coerced via {Color.coerce} unless it is a {Theme::Ref}; `nil` is the
62
+ # terminal default.
63
+ # @return [void]
64
+ # @raise [KeyError] when a {Theme::Ref} names a token the current theme
65
+ # lacks.
66
+ def color=(color)
67
+ color = Color.coerce(color) unless color.is_a?(Theme::Ref)
68
+ return if @color == color
69
+
70
+ color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
71
+
72
+ @color = color
73
+ invalidate
74
+ end
75
+
76
+ # Paints {#char} into every cell. Not `super`: the default's clear would
77
+ # dirty every cell just before it is painted over.
78
+ # @param canvas [Canvas] see {Component#repaint}.
79
+ # @return [void]
80
+ def repaint(canvas)
81
+ return if rect.empty?
82
+
83
+ row = StyledString.styled(@char * rect.width, fg: resolved_color)
84
+ rect.height.times { canvas.set_text(0, _1, row) }
85
+ end
86
+
87
+ private
88
+
89
+ # @return [Color, nil]
90
+ def resolved_color = @color.is_a?(Theme::Ref) ? @color.resolve(screen.theme) : @color
91
+ end
92
+ end
93
+ end
@@ -6,7 +6,7 @@ module Tuile
6
6
  # the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
7
7
  #
8
8
  # field = Component::FloatField.new
9
- # field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
9
+ # field.on_value_change { |e| puts e.value.inspect } # Float or nil, per change
10
10
  # field.value = 19.99 # field shows "19.99"
11
11
  # field.clear # empties it; value => nil
12
12
  #
@@ -79,8 +79,8 @@ module Tuile
79
79
  def initialize
80
80
  super(Field.new)
81
81
  # Not the general on_key interceptor: that slot stays free for the app.
82
- editor.on_key_up = -> { step(1.0) }
83
- editor.on_key_down = -> { step(-1.0) }
82
+ editor.on_key_up { step(1.0) }
83
+ editor.on_key_down { step(-1.0) }
84
84
  end
85
85
 
86
86
  # @return [Float, nil] the parsed buffer; `nil` when empty or not a
@@ -96,9 +96,10 @@ module Tuile
96
96
  # is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
97
97
  # @raise [ArgumentError] on a non-numeric `String`, a NaN or an infinity.
98
98
  # @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
99
+ # @param from_user [Boolean] see {HasValue#set_value}.
99
100
  # @return [void]
100
- def value=(new_value)
101
- editor.text = new_value.nil? ? "" : coerce(new_value).to_s
101
+ def set_value(new_value, from_user:)
102
+ editor.set_value(new_value.nil? ? "" : coerce(new_value).to_s, from_user:)
102
103
  editor.caret = editor.text.length
103
104
  end
104
105
 
@@ -130,7 +131,7 @@ module Tuile
130
131
  # `0.0`.
131
132
  # @param delta [Float]
132
133
  # @return [void]
133
- def step(delta) = (self.value = (value || 0.0) + delta)
134
+ def step(delta) = set_value((value || 0.0) + delta, from_user: true)
134
135
  end
135
136
  end
136
137
  end