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
@@ -12,8 +12,9 @@ module Tuile
12
12
  # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
13
13
  # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
14
14
  # that single-line and multi-line inputs both need: ESC handling,
15
- # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
16
- # `tab_stop?` flag (`focusable?` comes from {HasValue}).
15
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
16
+ # word-delete, and the `tab_stop?` flag (`focusable?` comes from
17
+ # {HasValue}).
17
18
  #
18
19
  # {#caret} counts *characters* into {#text} but may only sit *between*
19
20
  # grapheme clusters — the glyphs a terminal draws. Both write sites snap it
@@ -23,7 +24,7 @@ module Tuile
23
24
  # f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
24
25
  # f.caret = 1 # into the middle of the e-acute …
25
26
  # f.caret # => 2, its end — where the caret already drew
26
- # f.handle_key(Keys::BACKSPACE)
27
+ # f.handle_key?(Keys::BACKSPACE)
27
28
  # f.text # => "x": the whole glyph went, not its accent
28
29
  #
29
30
  # Insertion stays character-native, so `String#insert` merges a typed
@@ -33,18 +34,49 @@ module Tuile
33
34
  # Subclasses implement the layout-specific pieces ({#cursor_position},
34
35
  # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
35
36
  # printable insertion) by overriding the protected
36
- # {#handle_text_input_key} hook — `super` falls through to the common
37
+ # {#handle_text_input_key?} hook — `super` falls through to the common
37
38
  # navigation handling.
38
39
  #
40
+ # == Customizing a field is subclassing it, and there are two seams
41
+ # To change what **keys** do, override {#handle_text_input_key?}; to
42
+ # constrain what the buffer may **hold**, override {#insert_text}, which
43
+ # every insertion runs through — typed, pasted, or the ENTER newline:
44
+ #
45
+ # class HexField < TextField
46
+ # protected
47
+ #
48
+ # # ENTER submits instead of falling through to the parent.
49
+ # def handle_text_input_key?(key)
50
+ # return super unless key == Keys::ENTER
51
+ #
52
+ # submit(text)
53
+ # true
54
+ # end
55
+ #
56
+ # # Hex digits only — and a paste of "12zz" lands nothing, not "12".
57
+ # def insert_text(str)
58
+ # return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
59
+ #
60
+ # super
61
+ # end
62
+ # end
63
+ #
64
+ # Both compose through `super`, which is why they are overrides rather than
65
+ # the callback slot this class carried until 0.15.0: two behaviors could not
66
+ # share one slot, and a filter written on a *key* callback let the same
67
+ # characters in through a paste (`D_input_filters`, book ch7).
68
+ #
39
69
  # The mutation pipeline is a template method: {#text=} and {#caret=}
40
70
  # detect no-ops, mutate state, fire {#on_change}, and invalidate.
41
- # Subclasses inject their own behavior via two protected hooks:
71
+ # Subclasses inject their own behavior via four protected hooks:
42
72
  #
43
- # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
44
- # fit `rect.width - 1`).
45
- # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
46
- # whole clipboard at the caret in one mutation.
47
- # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
73
+ # - {#insert_text} — **the one filter seam**: every insertion runs through
74
+ # it, typed or pasted, so what the buffer may hold is decided here.
75
+ # - {#preprocess_text} — filter for a whole assignment to {#text=},
76
+ # which insertion does *not* pass through.
77
+ # - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
78
+ # clipboard reaches {#insert_text} ({TextField} keeps its first line).
79
+ # - {#handle_text_mutated} / {#handle_caret_mutated} — post-mutation side
48
80
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
49
81
  # keep the caret visible).
50
82
  class AbstractStringField < Component
@@ -56,7 +88,6 @@ module Tuile
56
88
  @caret = 0
57
89
  @on_change = nil
58
90
  @on_value_change = nil
59
- @on_key = nil
60
91
  @on_escape = method(:default_on_escape)
61
92
  end
62
93
 
@@ -89,20 +120,6 @@ module Tuile
89
120
  # @return [Proc, Method, nil] one-arg callable, or nil.
90
121
  attr_accessor :on_change
91
122
 
92
- # Optional interceptor consulted before the input's own key handling.
93
- # Receives the pressed key; return a truthy value to consume it (the
94
- # input then ignores that key), falsy to let normal editing proceed.
95
- #
96
- # The keyboard analog of {#on_change}: it lets app code layer behavior
97
- # onto an input without subclassing. The motivating case is an
98
- # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
99
- # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
100
- # them to the overlay's list, but lets ordinary characters fall through
101
- # so typing keeps editing the field (and {#on_change} keeps refilling the
102
- # list).
103
- # @return [Proc, Method, nil] one-arg callable, or nil.
104
- attr_accessor :on_key
105
-
106
123
  # Callback fired when ESC is pressed. Defaults to a closure that clears
107
124
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
108
125
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -124,7 +141,7 @@ module Tuile
124
141
 
125
142
  @text = +new_text
126
143
  @caret = snap_to_cluster(@caret.clamp(0, @text.length))
127
- on_text_mutated
144
+ handle_text_mutated
128
145
  invalidate
129
146
  @on_change&.call(@text)
130
147
  on_value_change&.call(@text)
@@ -132,7 +149,7 @@ module Tuile
132
149
 
133
150
  # Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
134
151
  # boundary, so an index that fell inside a cluster reads back as that
135
- # cluster's end. Fires the {#on_caret_mutated} hook for subclasses (e.g.
152
+ # cluster's end. Fires the {#handle_caret_mutated} hook for subclasses (e.g.
136
153
  # {TextArea} scrolls).
137
154
  # @param new_caret [Integer]
138
155
  def caret=(new_caret)
@@ -140,32 +157,25 @@ module Tuile
140
157
  return if @caret == new_caret
141
158
 
142
159
  @caret = new_caret
143
- on_caret_mutated
160
+ handle_caret_mutated
144
161
  invalidate
145
162
  end
146
163
 
147
- # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
148
- # a truthy return consumes the key — otherwise it delegates to
149
- # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
150
- # keys here when this input is on the focus chain, so there is no
151
- # {#active?} gate.
164
+ # Handles a key, by delegating to the {#handle_text_input_key?} hook a
165
+ # subclass overrides. Dispatch ({ScreenPane#handle_key?}) only routes keys
166
+ # here when this input is on the focus chain, so there is no {#active?}
167
+ # gate.
152
168
  # @param key [String]
153
169
  # @return [Boolean]
154
- def handle_key(key)
155
- return true if @on_key&.call(key)
156
-
157
- handle_text_input_key(key)
158
- end
170
+ def handle_key?(key) = handle_text_input_key?(key)
159
171
 
160
172
  # Inserts pasted text at the caret as **one** mutation, so {#on_change}
161
173
  # fires once for the whole paste rather than once per character.
162
174
  # {#preprocess_paste} filters it first.
163
175
  # @param text [String]
164
- # @return [Boolean] always true — a field consumes every paste, an empty
165
- # one included.
176
+ # @return [void]
166
177
  def handle_paste(text)
167
178
  insert_text(preprocess_paste(text))
168
- true
169
179
  end
170
180
 
171
181
  protected
@@ -180,8 +190,28 @@ module Tuile
180
190
  # @return [String]
181
191
  def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "")
182
192
 
183
- # Inserts `str` at the caret, leaving the caret behind it. The bulk
184
- # counterpart of a subclass's per-key insert.
193
+ # Inserts `str` at the caret, leaving the caret behind it.
194
+ #
195
+ # **Every insertion lands here** — a typed character, the ENTER newline
196
+ # and a whole pasted clipboard alike — so a field constrains its contents
197
+ # by overriding this, and one override covers typing and pasting both:
198
+ #
199
+ # def insert_text(str) # hex digits only, in a TextField subclass
200
+ # return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
201
+ #
202
+ # super
203
+ # end
204
+ #
205
+ # Test the whole resulting buffer, as above, and not the fragment being
206
+ # inserted: sieving per character turns a pasted `"1,5"` into the
207
+ # plausible, wrong `"15"`, where an all-or-nothing test drops it — which
208
+ # is also what typing the comma does. Filtering at all works only for a
209
+ # grammar every valid value can be *typed through*; one where it can't
210
+ # (a date — `"2020-13-45"` is well-formed at every character) reports bad
211
+ # input rather than filtering it (`D_input_filters`, book ch7).
212
+ #
213
+ # {#text=} does *not* pass through here: only user input is filtered, so a
214
+ # programmatic {HasValue#value=} may still write what no key types.
185
215
  # @param str [String]
186
216
  # @return [Boolean] true if the text changed.
187
217
  def insert_text(str)
@@ -193,20 +223,25 @@ module Tuile
193
223
  true
194
224
  end
195
225
 
196
- # Renders `text` on the field's background well, looked up from the
197
- # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
198
- # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
199
- # visibly a field either way, distinctly highlighted when active.
200
- # @param text [String]
201
- # @return [StyledString] text on the field's background well.
202
- def background(text)
203
- StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
204
- end
205
-
206
- # Input filter for {#text=}. Subclasses override to truncate or reject
207
- # invalid input. Default coerces to String.
226
+ # The field's background well, looked up from the current {Screen#theme}
227
+ # at paint time: {Theme#active_bg_color} while this input is on the active
228
+ # (focus) chain, {Theme#input_bg_color} otherwise — visibly a field either
229
+ # way, distinctly highlighted when focused. An app overrides the pair by
230
+ # setting {Component#bg_color}, which wins over this.
231
+ #
232
+ # Unconditional on purpose. A field used as the face of a composed one
233
+ # ({Component::ComboBox}, {Component::IntegerField} …) is *told* to drop
234
+ # its well — that widget assigns {Component::BG_INHERIT} at construction,
235
+ # since it owns the surface and a second well would make its own
236
+ # {Component#bg_color} inert over the very cells this field paints.
237
+ # @return [Color]
238
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
239
+
240
+ # Input filter for a whole assignment to {#text=}. Nothing overrides it
241
+ # today; a subclass that does is filtering the *programmatic* setter, not
242
+ # user input — that is {#insert_text}.
208
243
  # @param new_text [String]
209
- # @return [String] possibly transformed text.
244
+ # @return [String] possibly transformed text; the default coerces to String.
210
245
  def preprocess_text(new_text) = new_text.to_s
211
246
 
212
247
  # The one measurement primitive both inputs share: a caret index counts
@@ -222,29 +257,31 @@ module Tuile
222
257
  # {#on_change}. Default no-op. Subclasses use this to invalidate caches
223
258
  # ({TextArea}'s wrap cache) and update derived state.
224
259
  # @return [void]
225
- def on_text_mutated; end
260
+ def handle_text_mutated; end
226
261
 
227
262
  # Hook called after {#caret} has been mutated, before invalidation.
228
263
  # Default no-op. Subclasses use this to keep the caret visible
229
264
  # ({TextArea}'s vertical scroll).
230
265
  # @return [void]
231
- def on_caret_mutated; end
266
+ def handle_caret_mutated; end
232
267
 
233
- # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
234
- # that have identical semantics in single-line and multi-line inputs:
268
+ # Dispatch hook for {#handle_key?}. Handles ESC and the editing keys that
269
+ # have identical semantics in single-line and multi-line inputs:
235
270
  # LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
236
- # moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
237
- # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
238
- # DELETE, printable insertion) and call `super` to fall back to the
239
- # common navigation handling.
271
+ # moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
272
+ # exactly what CTRL+LEFT would have skipped over (readline's
273
+ # `unix-word-rubout`). Subclasses override to add their own keys (HOME/END,
274
+ # UP/DOWN, ENTER, CTRL+U, BACKSPACE/DELETE, printable insertion) and call
275
+ # `super` to fall back to the common handling.
240
276
  # @param key [String]
241
277
  # @return [Boolean] true if the key was handled.
242
- def handle_text_input_key(key)
278
+ def handle_text_input_key?(key)
243
279
  case key
244
280
  when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret)
245
281
  when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
246
282
  when Keys::CTRL_LEFT_ARROW then self.caret = word_left
247
283
  when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
284
+ when Keys::CTRL_W then delete_back_to(word_left)
248
285
  when Keys::ESC
249
286
  return false if @on_escape.nil?
250
287
 
@@ -259,10 +296,19 @@ module Tuile
259
296
  # glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
260
297
  # Hangul syllable each go whole).
261
298
  # @return [void]
262
- def delete_before_caret
263
- return if @caret.zero?
299
+ def delete_before_caret = delete_back_to(cluster_boundary_before(@caret))
300
+
301
+ # Removes the text between `index` and the caret, leaving the caret at
302
+ # `index` — one mutation, so {#on_change} fires once.
303
+ #
304
+ # `index` is snapped forward onto a grapheme-cluster boundary, so a
305
+ # caller may compute it by counting characters.
306
+ # @param index [Integer] a {#text} index; clamped to `0..caret`.
307
+ # @return [void]
308
+ def delete_back_to(index)
309
+ start = snap_to_cluster(index.clamp(0, @caret))
310
+ return if start == @caret
264
311
 
265
- start = cluster_boundary_before(@caret)
266
312
  new_text = @text.dup
267
313
  new_text.slice!(start...@caret)
268
314
  @caret = start
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Abstract base for a field that **wraps one editor completely**: it carries
6
+ # a typed {HasValue#value} but paints nothing itself, handing the whole UI to
7
+ # a single {AbstractStringField} it owns and hides. Subclass it by passing
8
+ # the editor to `super` and defining the conversion both ways:
9
+ #
10
+ # class IntegerField < Component::AbstractWrappingField
11
+ # def initialize = super(TextField.new)
12
+ #
13
+ # def value = Integer(editor.text, 10) rescue nil
14
+ #
15
+ # def value=(new_value)
16
+ # editor.text = new_value.nil? ? "" : new_value.to_s
17
+ # editor.caret = editor.text.length
18
+ # end
19
+ #
20
+ # def empty_value = nil
21
+ # end
22
+ #
23
+ # Everything else arrives already wired: the editor is added as the single
24
+ # child and positioned across {Component#rect}, focus forwards into it, it
25
+ # sits on this field's one background well, and {#placeholder} /
26
+ # {#on_enter} / {#cursor_position} / {#clear} are re-exposed here so an app
27
+ # never addresses it. Give the field a single-row rect.
28
+ #
29
+ # == The editor is private machinery
30
+ # There is no public accessor — {#editor} is protected, for subclasses — and
31
+ # that is the point: swapping it would break the conversion. **An app never
32
+ # addresses the editor**; what it needs is either already delegated here or
33
+ # earns a forwarder here. It is still in `children`, because the tree is
34
+ # reported honestly, but that is not an invitation.
35
+ #
36
+ # A **spec** is the exception, and it has a sanctioned path — driving the
37
+ # editor is how a test reaches a state no public setter produces:
38
+ #
39
+ # editor = Testing.get(Component::TextField, in: field)
40
+ # editor.text = "-" # bad input; field.value still reads nil
41
+ #
42
+ # A knob that is *editor-shaped* rather than a concept of this field's own
43
+ # domain is **not** forwarded, and the subclass sets it on its editor
44
+ # instead:
45
+ #
46
+ # def initialize
47
+ # super(TextField.new)
48
+ # editor.max_text_length = 20 # an internal cap, not part of my surface
49
+ # end
50
+ #
51
+ # == Committing: leaving the widget, and ENTER
52
+ # {#commit} fires on both commit gestures. Leaving the focus chain is one —
53
+ # the *field*'s, not its editor's, which is left on every hop within a
54
+ # widget. ENTER is the other, because a form whose default button is reached
55
+ # by ENTER never moves focus at all. Override it to canonicalize a buffer
56
+ # the user typed loosely:
57
+ #
58
+ # def commit = (self.value = value unless value.nil?) # rewrite in the canonical form
59
+ #
60
+ # ENTER is committed and then **left to keep bubbling**, so a scope's
61
+ # default button still sees it; only an {#on_enter} of this field's own
62
+ # consumes it, which is {TextField#on_enter}'s existing contract.
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
+ # == Implementation details
71
+ # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
72
+ # pair stores into `@value` and never touches the editor, so a subclass
73
+ # that defined only one would silently half-work.
74
+ # - **{HasValue#empty_value} is called during construction**, to seed the
75
+ # change guard, so it must not depend on subclass state that `super` has
76
+ # not set yet. In practice it is a constant per class.
77
+ # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
78
+ # guard, and to commit before an app's ENTER handler runs. A slot cannot
79
+ # be shared, so a subclass reacting to buffer edits overrides
80
+ # {#handle_editor_change} (every edit), {#value=} or {#commit} rather than
81
+ # reassigning either.
82
+ # - **Not for a field whose editor is a *filter*.** This base assumes the
83
+ # buffer is a rendering of the value, so an edit may change the value.
84
+ # {ComboBox} breaks both halves — its text is a transient query and only a
85
+ # commit moves its value — which is the same line {HasBadInput} draws.
86
+ #
87
+ # UI-thread-confined, like every component (see {Screen}).
88
+ class AbstractWrappingField < Component
89
+ include HasValue
90
+ include HasPlaceholder
91
+
92
+ # @param editor [AbstractStringField] the editor to wrap; becomes this
93
+ # field's single child and is never swapped.
94
+ # @raise [TypeError] unless `editor` is an {AbstractStringField}.
95
+ def initialize(editor)
96
+ super()
97
+ raise TypeError, "expected AbstractStringField, got #{editor.inspect}" unless editor.is_a?(AbstractStringField)
98
+
99
+ @editor = editor
100
+ @last_value = empty_value
101
+ @on_enter = nil
102
+ # One widget, one surface: the editor paints no well of its own, so this
103
+ # field's bg_color reaches the cells the editor paints.
104
+ editor.bg_color = BG_INHERIT
105
+ editor.on_change = lambda do |_text|
106
+ handle_editor_change
107
+ fire_if_changed if notify_on_edit?
108
+ end
109
+ add_child(editor, at: 0)
110
+ end
111
+
112
+ # @return [Object] the typed value, parsed from the editor's buffer.
113
+ # @raise [NotImplementedError] unless the subclass overrides it.
114
+ def value = raise(NotImplementedError, "#{self.class} must implement value")
115
+
116
+ # Writes `new_value` into the editor's buffer.
117
+ # @param new_value [Object]
118
+ # @return [void]
119
+ # @raise [NotImplementedError] unless the subclass overrides it.
120
+ def value=(new_value)
121
+ raise(NotImplementedError, "#{self.class} must implement value=")
122
+ end
123
+
124
+ # Empties the *input*, not just the value — a field holding bad input
125
+ # already reads {HasValue#empty_value}, so clearing through {#value=} could
126
+ # leave the glyphs on screen ({HasBadInput}).
127
+ # @return [void]
128
+ def clear
129
+ editor.clear
130
+ # Announced here rather than through the editor's change, so a field
131
+ # holding its notice ({#notify_on_edit?}) still reports an emptying as
132
+ # it happens: emptying is not a half-typed prefix.
133
+ fire_if_changed
134
+ end
135
+
136
+ # @return [String, nil] the hint the editor paints while empty
137
+ # ({HasPlaceholder}).
138
+ def placeholder = editor.placeholder
139
+
140
+ # @param text [String, nil]
141
+ # @return [void]
142
+ # @raise [TypeError] unless `text` is a String or nil.
143
+ def placeholder=(text)
144
+ editor.placeholder = text
145
+ end
146
+
147
+ # @return [Proc, Method, nil] fired when ENTER is pressed, *after*
148
+ # {#commit}; see {TextField#on_enter}.
149
+ attr_reader :on_enter
150
+
151
+ # @param callback [Proc, Method, nil]
152
+ # @return [void]
153
+ def on_enter=(callback)
154
+ @on_enter = callback
155
+ # Wrapped rather than forwarded, so an app's ENTER handler reads a
156
+ # committed buffer. A nil callback leaves the editor's own slot nil,
157
+ # which is what keeps ENTER *bubbling* — see {#handle_key?}.
158
+ editor.on_enter = callback && lambda do
159
+ commit_and_notify
160
+ callback.call
161
+ end
162
+ end
163
+
164
+ # Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.
165
+ #
166
+ # The editor declines ENTER whenever {#on_enter} is nil, so the key
167
+ # reaches this field instead — and it must be committed on the way past,
168
+ # or the form default button it is bubbling towards acts on an
169
+ # uncommitted buffer.
170
+ # @param key [String]
171
+ # @return [Boolean] whatever `super` returns — committing never consumes
172
+ # the key.
173
+ def handle_key?(key)
174
+ commit_and_notify if key == Keys::ENTER
175
+ super
176
+ end
177
+
178
+ # @return [Point, nil] the editor's caret — the hardware cursor is
179
+ # delegated to it.
180
+ def cursor_position = editor.cursor_position
181
+
182
+ # Runs {#commit} on the falling edge, i.e. when this field leaves the focus
183
+ # chain. Moving focus *within* a widget keeps it active, so a future
184
+ # multi-editor field inherits the same semantics unchanged.
185
+ # @param flag [Boolean]
186
+ # @return [void]
187
+ def active=(flag)
188
+ was = active?
189
+ super
190
+ commit_and_notify if was && !active?
191
+ end
192
+
193
+ # @return [void]
194
+ def handle_focus
195
+ super
196
+ # The editor is what actually edits, so it takes the focus this field was
197
+ # given — the field itself has no keys of its own.
198
+ screen.focused = editor if editor.focusable?
199
+ end
200
+
201
+ # @param new_rect [Rect]
202
+ # @return [void]
203
+ def rect=(new_rect)
204
+ super
205
+ layout(editor)
206
+ end
207
+
208
+ protected
209
+
210
+ # @return [AbstractStringField] the wrapped editor.
211
+ attr_reader :editor
212
+
213
+ # Called on a commit gesture — the field leaving the focus chain, or
214
+ # ENTER; no-op by default. This is the commit point a canonicalizing
215
+ # field rewrites its buffer from.
216
+ # @return [void]
217
+ def commit = nil
218
+
219
+ # Whether an edit of the buffer fires {HasValue#on_value_change} as it
220
+ # happens. `true` here, which is right wherever every buffer state is a
221
+ # value the user might mean: an {IntegerField} passing through `4` on the
222
+ # way to `42` really does hold 4 for that keystroke. A field whose
223
+ # grammar is **not prefix-closed** answers `false` and lets the notice
224
+ # settle onto the commit gestures instead ({DateField}, `D_date_field`).
225
+ #
226
+ # Only the *push* settles: {HasValue#value} stays a live parse of the
227
+ # buffer either way. And overriding this is half the job — {#commit} is
228
+ # covered here, but the field must fire from its own `value=` too, or a
229
+ # programmatic write and an Up/Down step go unannounced until the next
230
+ # commit.
231
+ # @return [Boolean]
232
+ def notify_on_edit? = true
233
+
234
+ # Called whenever the editor's buffer changes, however the characters
235
+ # arrived — a typed key, a paste, or a {#value=} of this field's own. It
236
+ # is named for the *editor*, not for the user, because those last two are
237
+ # not input. No-op by default; override it to drop state that describes
238
+ # the *previous* buffer, as a field latching whether its input has settled
239
+ # must ({HasBadInput}).
240
+ # @return [void]
241
+ def handle_editor_change; end
242
+
243
+ # Places the editor across the whole rect; override to reserve cells for a
244
+ # face of your own.
245
+ # @param editor [Component]
246
+ # @return [void]
247
+ def layout(editor) = (editor.rect = rect)
248
+
249
+ # The field well the face sits on — the editor is marked
250
+ # {Component::BG_INHERIT}, so this one covers it (exactly one well per
251
+ # widget) and {Component#bg_color} set here reaches the cells it paints.
252
+ # @return [Color]
253
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
254
+
255
+ private
256
+
257
+ # Every commit gesture runs through here, so a field holding its notice
258
+ # ({#notify_on_edit?}) announces from one place rather than three; the
259
+ # diff guard makes the call free for a field that fired on the way in.
260
+ # @return [void]
261
+ def commit_and_notify
262
+ commit
263
+ fire_if_changed
264
+ end
265
+
266
+ # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
267
+ # the last one fired — so a buffer edit that leaves the value alone
268
+ # (`"7"`→`"07"`) stays silent.
269
+ # @return [void]
270
+ def fire_if_changed
271
+ v = value
272
+ return if v == @last_value
273
+
274
+ @last_value = v
275
+ on_value_change&.call(v)
276
+ end
277
+ end
278
+ end
279
+ end