tuile 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
|
@@ -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,
|
|
16
|
-
# `tab_stop?` flag (`focusable?` comes from
|
|
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
|
|
71
|
+
# Subclasses inject their own behavior via four protected hooks:
|
|
42
72
|
#
|
|
43
|
-
# - {#
|
|
44
|
-
#
|
|
45
|
-
# - {#
|
|
46
|
-
#
|
|
47
|
-
# - {#
|
|
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
|
-
|
|
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 {#
|
|
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
|
-
|
|
160
|
+
handle_caret_mutated
|
|
144
161
|
invalidate
|
|
145
162
|
end
|
|
146
163
|
|
|
147
|
-
# Handles a key
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
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 [
|
|
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.
|
|
184
|
-
#
|
|
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
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
|
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
|
|
266
|
+
def handle_caret_mutated; end
|
|
232
267
|
|
|
233
|
-
# Dispatch hook for {#handle_key}. Handles ESC and the
|
|
234
|
-
#
|
|
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
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
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
|
-
|
|
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
|