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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- 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
|
-
# {#
|
|
14
|
-
#
|
|
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.
|
|
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; {#
|
|
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: {#
|
|
70
|
-
# detect no-ops, mutate state, fire {#
|
|
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 {#
|
|
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
|
-
# - {#
|
|
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
|
-
@
|
|
90
|
-
|
|
91
|
-
|
|
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}
|
|
98
|
-
#
|
|
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
|
|
106
|
-
|
|
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
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
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 {#
|
|
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
|
-
|
|
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
|
|
173
|
-
# fires once for the whole paste rather than
|
|
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 [
|
|
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
|
-
# {#
|
|
216
|
-
# programmatic
|
|
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
|
-
|
|
242
|
+
set_value(new_text, from_user: true)
|
|
225
243
|
true
|
|
226
244
|
end
|
|
227
245
|
|
|
228
|
-
#
|
|
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
|
-
# {#
|
|
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
|
|
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
|
|
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
|
|
292
|
+
return false if !@escape_clears_focus && on_escape.empty?
|
|
289
293
|
|
|
290
|
-
@
|
|
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 {#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
# editor.
|
|
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.
|
|
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 = (
|
|
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 {#
|
|
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 `
|
|
72
|
-
# guard, and to commit before an app's ENTER handler
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
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
|
-
|
|
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 =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
115
|
-
raise(NotImplementedError, "#{self.class} must implement
|
|
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
|
|
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
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
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
|
-
#
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
commit_and_notify if was && !active?
|
|
179
209
|
end
|
|
180
210
|
|
|
181
211
|
# @return [void]
|
|
182
|
-
def
|
|
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 {#
|
|
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
|
|
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
|
|
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
|
|
280
|
+
on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
|
|
240
281
|
end
|
|
241
282
|
end
|
|
242
283
|
end
|