tuile 0.15.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  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 +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  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 +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -9,9 +9,9 @@ module Tuile
9
9
  # drag this String-typed `text`/`value` seam onto its face alongside the
10
10
  # real typed one.
11
11
  #
12
- # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
13
- # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
14
- # that single-line and multi-line inputs both need: ESC handling,
12
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index, the
13
+ # {#on_escape} slot — and the keyboard machinery that single-line and
14
+ # multi-line inputs both need: ESC handling,
15
15
  # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
16
16
  # word-delete, and the `tab_stop?` flag (`focusable?` comes from
17
17
  # {HasValue}).
@@ -21,24 +21,24 @@ module Tuile
21
21
  # forward onto the enclosing cluster's end, and every edit steps by a whole
22
22
  # cluster:
23
23
  #
24
- # f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
24
+ # f.value = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
25
25
  # f.caret = 1 # into the middle of the e-acute …
26
26
  # f.caret # => 2, its end — where the caret already drew
27
- # f.handle_key(Keys::BACKSPACE)
27
+ # f.handle_key?(Keys::BACKSPACE)
28
28
  # f.text # => "x": the whole glyph went, not its accent
29
29
  #
30
30
  # Insertion stays character-native, so `String#insert` merges a typed
31
- # combining mark into its base; {#text=}'s snap covers the case where that
31
+ # combining mark into its base; {#set_value}'s snap covers the case where that
32
32
  # re-segments the text around the caret.
33
33
  #
34
34
  # Subclasses implement the layout-specific pieces ({#cursor_position},
35
35
  # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
36
36
  # printable insertion) by overriding the protected
37
- # {#handle_text_input_key} hook — `super` falls through to the common
37
+ # {#handle_text_input_key?} hook — `super` falls through to the common
38
38
  # navigation handling.
39
39
  #
40
40
  # == Customizing a field is subclassing it, and there are two seams
41
- # To change what **keys** do, override {#handle_text_input_key}; to
41
+ # To change what **keys** do, override {#handle_text_input_key?}; to
42
42
  # constrain what the buffer may **hold**, override {#insert_text}, which
43
43
  # every insertion runs through — typed, pasted, or the ENTER newline:
44
44
  #
@@ -46,7 +46,7 @@ module Tuile
46
46
  # protected
47
47
  #
48
48
  # # ENTER submits instead of falling through to the parent.
49
- # def handle_text_input_key(key)
49
+ # def handle_text_input_key?(key)
50
50
  # return super unless key == Keys::ENTER
51
51
  #
52
52
  # submit(text)
@@ -66,17 +66,19 @@ module Tuile
66
66
  # share one slot, and a filter written on a *key* callback let the same
67
67
  # characters in through a paste (`D_input_filters`, book ch7).
68
68
  #
69
- # The mutation pipeline is a template method: {#text=} and {#caret=}
70
- # detect no-ops, mutate state, fire {#on_change}, and invalidate.
69
+ # The mutation pipeline is a template method: {#set_value} and {#caret=}
70
+ # detect no-ops, mutate state, fire {HasValue#on_value_change}, and invalidate.
71
+ # Typing, a paste and the deleting keys write with `from_user: true`, every
72
+ # other write with `false` ({HasValue::ValueChangeEvent#from_user?}).
71
73
  # Subclasses inject their own behavior via four protected hooks:
72
74
  #
73
75
  # - {#insert_text} — **the one filter seam**: every insertion runs through
74
76
  # it, typed or pasted, so what the buffer may hold is decided here.
75
- # - {#preprocess_text} — filter for a whole assignment to {#text=},
77
+ # - {#preprocess_text} — filter for a whole assignment to {#set_value},
76
78
  # which insertion does *not* pass through.
77
79
  # - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
78
80
  # clipboard reaches {#insert_text} ({TextField} keeps its first line).
79
- # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
81
+ # - {#handle_text_mutated} / {#handle_caret_mutated} — post-mutation side
80
82
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
81
83
  # keep the caret visible).
82
84
  class AbstractStringField < Component
@@ -86,24 +88,41 @@ module Tuile
86
88
  super
87
89
  @text = +""
88
90
  @caret = 0
89
- @on_change = nil
90
- @on_value_change = nil
91
- @on_escape = method(:default_on_escape)
91
+ @escape_clears_focus = true
92
+ # Unconditional on purpose: a field used as the face of a composed one
93
+ # ({ComboBox}, {IntegerField} …) is *told* to drop its well with
94
+ # {ComponentBackground::INHERIT} — a second well would make the
95
+ # composer's own bg_color inert over the very cells this field paints.
96
+ bg.default_color = ComponentBackground::INPUT_WELL
92
97
  end
93
98
 
94
99
  # @return [String] current text contents.
95
100
  attr_reader :text
96
101
 
97
- # A text component's value *is* its text: {#value}/{#value=} are the
98
- # {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
99
- # drive it alongside typed fields. `text` stays the text-native name.
102
+ # A text component's value *is* its text: {#value} reads the same buffer
103
+ # as {#text}, and {#set_value} is its one writer.
100
104
  # @return [String]
101
105
  def value = text
102
106
 
107
+ # Replaces the text. Runs {#preprocess_text} first, then clamps the caret
108
+ # to the new text and snaps it onto a cluster boundary of it. Fires
109
+ # {HasValue#on_value_change} only on a real change — never on {#caret=}.
110
+ #
111
+ # field.value = "admin"
112
+ # field.caret = field.text.length # the caret is clamped, never moved to the end
113
+ #
103
114
  # @param new_value [String, #to_s]
115
+ # @param from_user [Boolean] see {HasValue#set_value}.
104
116
  # @return [void]
105
- def value=(new_value)
106
- self.text = new_value.to_s
117
+ def set_value(new_value, from_user:)
118
+ new_value = preprocess_text(new_value)
119
+ return if @text == new_value
120
+
121
+ @text = +new_value
122
+ @caret = snap_to_cluster(@caret.clamp(0, @text.length))
123
+ handle_text_mutated
124
+ invalidate
125
+ on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: @text, from_user:))
107
126
  end
108
127
 
109
128
  # `""` (not `nil`): a text field is empty when its buffer is blank.
@@ -114,42 +133,40 @@ module Tuile
114
133
  # and always on a grapheme-cluster boundary (see the class doc).
115
134
  attr_reader :caret
116
135
 
117
- # Optional callback fired whenever {#text} changes. Receives the new text
118
- # as a single argument. Not fired by {#caret=} (text unchanged) and not
119
- # fired when a setter is a no-op.
120
- # @return [Proc, Method, nil] one-arg callable, or nil.
121
- attr_accessor :on_change
122
-
123
- # Callback fired when ESC is pressed. Defaults to a closure that clears
124
- # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
125
- # of bubbling to the parent — and, in particular, instead of reaching the
126
- # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
127
- # to the parent again; set to any other callable to replace the default.
128
- # @return [Proc, Method, nil] no-arg callable, or nil.
129
- attr_accessor :on_escape
136
+ # What {#on_escape} fires.
137
+ #
138
+ # @!attribute [r] source
139
+ # @return [AbstractStringField] the field ESC reached.
140
+ EscapeEvent = Data.define(:source) { include Tuile::Event }
141
+
142
+ # @!method on_escape
143
+ # Fired with an {EscapeEvent} when ESC is pressed, after
144
+ # {#escape_clears_focus} has had its say. Append to react as well as
145
+ # blur; turn that flag off first to react *instead*:
146
+ #
147
+ # field.escape_clears_focus = false
148
+ # field.on_escape << method(:close_search)
149
+ #
150
+ # **Empty means the field declines ESC** — but only while
151
+ # {#escape_clears_focus} is off too, since a field that blurs has
152
+ # handled the key. With both, ESC bubbles to the parent, and on to the
153
+ # screen's ESC-to-quit.
154
+ # @return [Listeners]
155
+ listener :on_escape
156
+
157
+ # Whether ESC clears focus (`true` by default), so text entry visibly
158
+ # cancels instead of quitting the app. It runs before {#on_escape} fires,
159
+ # and it — not a listener an app must remove by identity — is the whole
160
+ # representation of the default, so turning it off is the one way to give
161
+ # ESC another meaning. See `D_escape_opt_out`.
162
+ # @return [Boolean]
163
+ attr_accessor :escape_clears_focus
130
164
 
131
165
  def tab_stop? = true
132
166
 
133
- # Sets the text. Runs {#preprocess_text} first (subclasses may filter or
134
- # truncate). Caret is clamped to the new text length, then snapped back
135
- # onto a cluster boundary of the *new* text. Fires {#on_change} only on a
136
- # real change.
137
- # @param new_text [String]
138
- def text=(new_text)
139
- new_text = preprocess_text(new_text)
140
- return if @text == new_text
141
-
142
- @text = +new_text
143
- @caret = snap_to_cluster(@caret.clamp(0, @text.length))
144
- on_text_mutated
145
- invalidate
146
- @on_change&.call(@text)
147
- on_value_change&.call(@text)
148
- end
149
-
150
167
  # Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
151
168
  # boundary, so an index that fell inside a cluster reads back as that
152
- # cluster's end. Fires the {#on_caret_mutated} hook for subclasses (e.g.
169
+ # cluster's end. Fires the {#handle_caret_mutated} hook for subclasses (e.g.
153
170
  # {TextArea} scrolls).
154
171
  # @param new_caret [Integer]
155
172
  def caret=(new_caret)
@@ -157,27 +174,26 @@ module Tuile
157
174
  return if @caret == new_caret
158
175
 
159
176
  @caret = new_caret
160
- on_caret_mutated
177
+ handle_caret_mutated
161
178
  invalidate
162
179
  end
163
180
 
164
- # Handles a key, by delegating to the {#handle_text_input_key} hook a
165
- # subclass overrides. Dispatch ({ScreenPane#handle_key}) only routes keys
181
+ # Handles a key, by delegating to the {#handle_text_input_key?} hook a
182
+ # subclass overrides. Dispatch ({ScreenPane#handle_key?}) only routes keys
166
183
  # here when this input is on the focus chain, so there is no {#active?}
167
184
  # gate.
168
185
  # @param key [String]
169
186
  # @return [Boolean]
170
- def handle_key(key) = handle_text_input_key(key)
187
+ def handle_key?(key) = handle_text_input_key?(key)
171
188
 
172
- # Inserts pasted text at the caret as **one** mutation, so {#on_change}
173
- # fires once for the whole paste rather than once per character.
189
+ # Inserts pasted text at the caret as **one** mutation, so
190
+ # {HasValue#on_value_change} fires once for the whole paste rather than
191
+ # once per character.
174
192
  # {#preprocess_paste} filters it first.
175
193
  # @param text [String]
176
- # @return [Boolean] always true — a field consumes every paste: an empty
177
- # one, and one its {#insert_text} rejects wholesale.
194
+ # @return [void]
178
195
  def handle_paste(text)
179
196
  insert_text(preprocess_paste(text))
180
- true
181
197
  end
182
198
 
183
199
  protected
@@ -212,8 +228,10 @@ module Tuile
212
228
  # (a date — `"2020-13-45"` is well-formed at every character) reports bad
213
229
  # input rather than filtering it (`D_input_filters`, book ch7).
214
230
  #
215
- # {#text=} does *not* pass through here: only user input is filtered, so a
216
- # programmatic {HasValue#value=} may still write what no key types.
231
+ # {#set_value} does *not* pass through here: only user input is filtered,
232
+ # so a programmatic write may still hold what no key types. The same line
233
+ # decides the origin: an insertion is the user's, so it writes with
234
+ # `from_user: true`.
217
235
  # @param str [String]
218
236
  # @return [Boolean] true if the text changed.
219
237
  def insert_text(str)
@@ -221,25 +239,11 @@ module Tuile
221
239
 
222
240
  new_text = @text.dup.insert(@caret, str)
223
241
  @caret += str.length
224
- self.text = new_text
242
+ set_value(new_text, from_user: true)
225
243
  true
226
244
  end
227
245
 
228
- # The field's background well, looked up from the current {Screen#theme}
229
- # at paint time: {Theme#active_bg_color} while this input is on the active
230
- # (focus) chain, {Theme#input_bg_color} otherwise — visibly a field either
231
- # way, distinctly highlighted when focused. An app overrides the pair by
232
- # setting {Component#bg_color}, which wins over this.
233
- #
234
- # Unconditional on purpose. A field used as the face of a composed one
235
- # ({Component::ComboBox}, {Component::IntegerField} …) is *told* to drop
236
- # its well — that widget assigns {Component::BG_INHERIT} at construction,
237
- # since it owns the surface and a second well would make its own
238
- # {Component#bg_color} inert over the very cells this field paints.
239
- # @return [Color]
240
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
241
-
242
- # Input filter for a whole assignment to {#text=}. Nothing overrides it
246
+ # Input filter for a whole assignment to {#set_value}. Nothing overrides it
243
247
  # today; a subclass that does is filtering the *programmatic* setter, not
244
248
  # user input — that is {#insert_text}.
245
249
  # @param new_text [String]
@@ -256,18 +260,18 @@ module Tuile
256
260
  def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
257
261
 
258
262
  # Hook called after {#text} has been mutated, before invalidation /
259
- # {#on_change}. Default no-op. Subclasses use this to invalidate caches
260
- # ({TextArea}'s wrap cache) and update derived state.
263
+ # {HasValue#on_value_change}. Default no-op. Subclasses use this to
264
+ # invalidate caches ({TextArea}'s wrap cache) and update derived state.
261
265
  # @return [void]
262
- def on_text_mutated; end
266
+ def handle_text_mutated; end
263
267
 
264
268
  # Hook called after {#caret} has been mutated, before invalidation.
265
269
  # Default no-op. Subclasses use this to keep the caret visible
266
270
  # ({TextArea}'s vertical scroll).
267
271
  # @return [void]
268
- def on_caret_mutated; end
272
+ def handle_caret_mutated; end
269
273
 
270
- # Dispatch hook for {#handle_key}. Handles ESC and the editing keys that
274
+ # Dispatch hook for {#handle_key?}. Handles ESC and the editing keys that
271
275
  # have identical semantics in single-line and multi-line inputs:
272
276
  # LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
273
277
  # moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
@@ -277,7 +281,7 @@ module Tuile
277
281
  # `super` to fall back to the common handling.
278
282
  # @param key [String]
279
283
  # @return [Boolean] true if the key was handled.
280
- def handle_text_input_key(key)
284
+ def handle_text_input_key?(key)
281
285
  case key
282
286
  when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret)
283
287
  when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
@@ -285,9 +289,10 @@ module Tuile
285
289
  when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
286
290
  when Keys::CTRL_W then delete_back_to(word_left)
287
291
  when Keys::ESC
288
- return false if @on_escape.nil?
292
+ return false if !@escape_clears_focus && on_escape.empty?
289
293
 
290
- @on_escape.call
294
+ screen.focused = nil if @escape_clears_focus
295
+ on_escape.fire(EscapeEvent.new(source: self))
291
296
  else
292
297
  return false
293
298
  end
@@ -301,7 +306,8 @@ module Tuile
301
306
  def delete_before_caret = delete_back_to(cluster_boundary_before(@caret))
302
307
 
303
308
  # Removes the text between `index` and the caret, leaving the caret at
304
- # `index` — one mutation, so {#on_change} fires once.
309
+ # `index` — one mutation, so {HasValue#on_value_change} fires once, as the
310
+ # user's: every caller is a deleting key.
305
311
  #
306
312
  # `index` is snapped forward onto a grapheme-cluster boundary, so a
307
313
  # caller may compute it by counting characters.
@@ -314,7 +320,7 @@ module Tuile
314
320
  new_text = @text.dup
315
321
  new_text.slice!(start...@caret)
316
322
  @caret = start
317
- self.text = new_text
323
+ set_value(new_text, from_user: true)
318
324
  end
319
325
 
320
326
  # Removes the whole grapheme cluster at the caret.
@@ -324,7 +330,7 @@ module Tuile
324
330
 
325
331
  new_text = @text.dup
326
332
  new_text.slice!(@caret...cluster_boundary_after(@caret))
327
- self.text = new_text
333
+ set_value(new_text, from_user: true)
328
334
  end
329
335
 
330
336
  private
@@ -368,13 +374,6 @@ module Tuile
368
374
  offset
369
375
  end
370
376
 
371
- # Default {#on_escape} action: clear focus. Component deactivates; user
372
- # can re-focus by clicking or tabbing back in.
373
- # @return [void]
374
- def default_on_escape
375
- screen.focused = nil
376
- end
377
-
378
377
  # Caret target for ctrl+left: skip whitespace going left, then a run of
379
378
  # non-whitespace. Lands at the beginning of the current word, or the
380
379
  # beginning of the previous word if already there.
@@ -12,8 +12,8 @@ module Tuile
12
12
  #
13
13
  # def value = Integer(editor.text, 10) rescue nil
14
14
  #
15
- # def value=(new_value)
16
- # editor.text = new_value.nil? ? "" : new_value.to_s
15
+ # def set_value(new_value, from_user:)
16
+ # editor.set_value(new_value.nil? ? "" : new_value.to_s, from_user:)
17
17
  # editor.caret = editor.text.length
18
18
  # end
19
19
  #
@@ -37,7 +37,7 @@ module Tuile
37
37
  # editor is how a test reaches a state no public setter produces:
38
38
  #
39
39
  # editor = Testing.get(Component::TextField, in: field)
40
- # editor.text = "-" # bad input; field.value still reads nil
40
+ # editor.value = "-" # bad input; field.value still reads nil
41
41
  #
42
42
  # A knob that is *editor-shaped* rather than a concept of this field's own
43
43
  # domain is **not** forwarded, and the subclass sets it on its editor
@@ -55,24 +55,38 @@ module Tuile
55
55
  # by ENTER never moves focus at all. Override it to canonicalize a buffer
56
56
  # the user typed loosely:
57
57
  #
58
- # def commit = (self.value = value unless value.nil?) # rewrite in the canonical form
58
+ # def commit = (set_value(value, from_user: true) unless value.nil?) # rewrite canonically
59
59
  #
60
60
  # ENTER is committed and then **left to keep bubbling**, so a scope's
61
61
  # default button still sees it; only an {#on_enter} of this field's own
62
62
  # consumes it, which is {TextField#on_enter}'s existing contract.
63
63
  #
64
+ # == When the value notice fires
65
+ # Per edit by default. A field whose grammar is not prefix-closed sets
66
+ # {#notify_on_edit?} to `false` and lets the notice settle onto those same
67
+ # two gestures, so a form is never handed a half-typed date that happens to
68
+ # parse ({DateField}, {TimeField}).
69
+ #
70
+ # == Who made the change
71
+ # The editor's own event says whether the user moved the buffer, and this
72
+ # field's notice passes that on: a subclass writes the editor through
73
+ # {AbstractStringField#set_value} with the `from_user:` it was handed, and
74
+ # the edit route relays it. The commit gestures announce as the user's —
75
+ # an approximation for the one commit code can cause, a programmatic
76
+ # `screen.focused =` moving focus off this field.
77
+ #
64
78
  # == Implementation details
65
- # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
79
+ # - **{HasValue#value} and {#set_value} raise until overridden.** The inherited
66
80
  # pair stores into `@value` and never touches the editor, so a subclass
67
81
  # that defined only one would silently half-work.
68
82
  # - **{HasValue#empty_value} is called during construction**, to seed the
69
83
  # change guard, so it must not depend on subclass state that `super` has
70
84
  # not set yet. In practice it is a constant per class.
71
- # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
72
- # guard, and to commit before an app's ENTER handler runs. A slot cannot
73
- # be shared, so a subclass reacting to buffer edits overrides
74
- # {#on_editor_change} (every edit), {#value=} or {#commit} rather than
75
- # reassigning either.
85
+ # - **The editor's `on_value_change` and `on_enter` slots carry this field's own
86
+ # listeners** — for that guard, and to commit before an app's ENTER handler
87
+ # runs. They are lists, so nothing an app adds displaces them; a subclass
88
+ # reacting to buffer edits still overrides {#handle_editor_change} (every
89
+ # edit), {#set_value} or {#commit}, which run in a defined order.
76
90
  # - **Not for a field whose editor is a *filter*.** This base assumes the
77
91
  # buffer is a rendering of the value, so an edit may change the value.
78
92
  # {ComboBox} breaks both halves — its text is a transient query and only a
@@ -92,13 +106,19 @@ module Tuile
92
106
 
93
107
  @editor = editor
94
108
  @last_value = empty_value
95
- @on_enter = nil
109
+ # Held so the transition block can unsubscribe the same object it added:
110
+ # a lambda is only equal to itself.
111
+ @enter_bridge = lambda do
112
+ commit_and_notify
113
+ on_enter.fire(EnterEvent.new(source: self))
114
+ end
96
115
  # One widget, one surface: the editor paints no well of its own, so this
97
- # field's bg_color reaches the cells the editor paints.
98
- editor.bg_color = BG_INHERIT
99
- editor.on_change = lambda do |_text|
100
- on_editor_change
101
- fire_if_changed
116
+ # field's well covers it and its bg_color reaches the cells the editor paints.
117
+ editor.bg_color = ComponentBackground::INHERIT
118
+ bg.default_color = ComponentBackground::INPUT_WELL
119
+ editor.on_value_change do |e|
120
+ handle_editor_change
121
+ fire_if_changed(from_user: e.from_user?) if notify_on_edit?
102
122
  end
103
123
  add_child(editor, at: 0)
104
124
  end
@@ -107,19 +127,27 @@ module Tuile
107
127
  # @raise [NotImplementedError] unless the subclass overrides it.
108
128
  def value = raise(NotImplementedError, "#{self.class} must implement value")
109
129
 
110
- # Writes `new_value` into the editor's buffer.
130
+ # Writes `new_value` into the editor's buffer, passing `from_user` on to
131
+ # {AbstractStringField#set_value}.
111
132
  # @param new_value [Object]
133
+ # @param from_user [Boolean] see {HasValue#set_value}.
112
134
  # @return [void]
113
135
  # @raise [NotImplementedError] unless the subclass overrides it.
114
- def value=(new_value)
115
- raise(NotImplementedError, "#{self.class} must implement value=")
136
+ def set_value(new_value, from_user:)
137
+ raise(NotImplementedError, "#{self.class} must implement set_value")
116
138
  end
117
139
 
118
140
  # Empties the *input*, not just the value — a field holding bad input
119
141
  # already reads {HasValue#empty_value}, so clearing through {#value=} could
120
142
  # leave the glyphs on screen ({HasBadInput}).
121
143
  # @return [void]
122
- def clear = editor.clear
144
+ def clear
145
+ editor.clear
146
+ # Announced here rather than through the editor's change, so a field
147
+ # holding its notice ({#notify_on_edit?}) still reports an emptying as
148
+ # it happens: emptying is not a half-typed prefix.
149
+ fire_if_changed(from_user: false)
150
+ end
123
151
 
124
152
  # @return [String, nil] the hint the editor paints while empty
125
153
  # ({HasPlaceholder}).
@@ -132,34 +160,36 @@ module Tuile
132
160
  editor.placeholder = text
133
161
  end
134
162
 
135
- # @return [Proc, Method, nil] fired when ENTER is pressed, *after*
136
- # {#commit}; see {TextField#on_enter}.
137
- attr_reader :on_enter
163
+ # What {#on_enter} fires.
164
+ #
165
+ # @!attribute [r] source
166
+ # @return [AbstractWrappingField] the field ENTER reached.
167
+ EnterEvent = Data.define(:source) { include Tuile::Event }
138
168
 
139
- # @param callback [Proc, Method, nil]
140
- # @return [void]
141
- def on_enter=(callback)
142
- @on_enter = callback
143
- # Wrapped rather than forwarded, so an app's ENTER handler reads a
144
- # committed buffer. A nil callback leaves the editor's own slot nil,
145
- # which is what keeps ENTER *bubbling* — see {#handle_key}.
146
- editor.on_enter = callback && lambda do
147
- commit
148
- callback.call
149
- end
169
+ # @!method on_enter
170
+ # Fired with an {EnterEvent} when ENTER is pressed, *after* {#commit};
171
+ # see {TextField#on_enter}.
172
+ #
173
+ # **Empty means the field declines ENTER**, which keeps it bubbling to
174
+ # the scope's default button — see {#handle_key?}. The editor's own slot
175
+ # is claimed only while this one is non-empty, by a bridge that commits
176
+ # first, so a listener here always reads a committed buffer.
177
+ # @return [Listeners]
178
+ listener :on_enter do |claimed|
179
+ claimed ? editor.on_enter << @enter_bridge : editor.on_enter.remove(@enter_bridge)
150
180
  end
151
181
 
152
182
  # Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.
153
183
  #
154
- # The editor declines ENTER whenever {#on_enter} is nil, so the key
184
+ # The editor declines ENTER whenever {#on_enter} is empty, so the key
155
185
  # reaches this field instead — and it must be committed on the way past,
156
186
  # or the form default button it is bubbling towards acts on an
157
187
  # uncommitted buffer.
158
188
  # @param key [String]
159
189
  # @return [Boolean] whatever `super` returns — committing never consumes
160
190
  # the key.
161
- def handle_key(key)
162
- commit if key == Keys::ENTER
191
+ def handle_key?(key)
192
+ commit_and_notify if key == Keys::ENTER
163
193
  super
164
194
  end
165
195
 
@@ -175,24 +205,17 @@ module Tuile
175
205
  def active=(flag)
176
206
  was = active?
177
207
  super
178
- commit if was && !active?
208
+ commit_and_notify if was && !active?
179
209
  end
180
210
 
181
211
  # @return [void]
182
- def on_focus
212
+ def handle_focus
183
213
  super
184
214
  # The editor is what actually edits, so it takes the focus this field was
185
215
  # given — the field itself has no keys of its own.
186
216
  screen.focused = editor if editor.focusable?
187
217
  end
188
218
 
189
- # @param new_rect [Rect]
190
- # @return [void]
191
- def rect=(new_rect)
192
- super
193
- layout(editor)
194
- end
195
-
196
219
  protected
197
220
 
198
221
  # @return [AbstractStringField] the wrapped editor.
@@ -204,39 +227,57 @@ module Tuile
204
227
  # @return [void]
205
228
  def commit = nil
206
229
 
230
+ # Whether an edit of the buffer fires {HasValue#on_value_change} as it
231
+ # happens. `true` here, which is right wherever every buffer state is a
232
+ # value the user might mean: an {IntegerField} passing through `4` on the
233
+ # way to `42` really does hold 4 for that keystroke. A field whose
234
+ # grammar is **not prefix-closed** answers `false` and lets the notice
235
+ # settle onto the commit gestures instead ({DateField}, `D_date_field`).
236
+ #
237
+ # Only the *push* settles: {HasValue#value} stays a live parse of the
238
+ # buffer either way. And overriding this is half the job — {#commit} is
239
+ # covered here, but the field must fire from its own `set_value` too, or a
240
+ # programmatic write and an Up/Down step go unannounced until the next
241
+ # commit.
242
+ # @return [Boolean]
243
+ def notify_on_edit? = true
244
+
207
245
  # Called whenever the editor's buffer changes, however the characters
208
- # arrived — a typed key, a paste, or a {#value=} of this field's own. It
246
+ # arrived — a typed key, a paste, or a {#set_value} of this field's own. It
209
247
  # is named for the *editor*, not for the user, because those last two are
210
248
  # not input. No-op by default; override it to drop state that describes
211
249
  # the *previous* buffer, as a field latching whether its input has settled
212
250
  # must ({HasBadInput}).
213
251
  # @return [void]
214
- def on_editor_change = nil
252
+ def handle_editor_change; end
215
253
 
216
254
  # Places the editor across the whole rect; override to reserve cells for a
217
255
  # face of your own.
218
- # @param editor [Component]
219
256
  # @return [void]
220
- def layout(editor) = (editor.rect = rect)
221
-
222
- # The field well the face sits on — the editor is marked
223
- # {Component::BG_INHERIT}, so this one covers it (exactly one well per
224
- # widget) and {Component#bg_color} set here reaches the cells it paints.
225
- # @return [Color]
226
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
257
+ def relayout = (editor.rect = local_rect)
227
258
 
228
259
  private
229
260
 
261
+ # Every commit gesture runs through here, so a field holding its notice
262
+ # ({#notify_on_edit?}) announces from one place rather than three; the
263
+ # diff guard makes the call free for a field that fired on the way in.
264
+ # @return [void]
265
+ def commit_and_notify
266
+ commit
267
+ fire_if_changed(from_user: true)
268
+ end
269
+
230
270
  # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
231
271
  # the last one fired — so a buffer edit that leaves the value alone
232
272
  # (`"7"`→`"07"`) stays silent.
273
+ # @param from_user [Boolean] what the write that moved the buffer declared.
233
274
  # @return [void]
234
- def fire_if_changed
275
+ def fire_if_changed(from_user:)
235
276
  v = value
236
277
  return if v == @last_value
237
278
 
238
279
  @last_value = v
239
- on_value_change&.call(v)
280
+ on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
240
281
  end
241
282
  end
242
283
  end