tuile 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- 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 +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- 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 +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- 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 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- 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 +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- 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 +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- 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 +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -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,7 +55,7 @@ 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
|
|
@@ -67,18 +67,26 @@ module Tuile
|
|
|
67
67
|
# two gestures, so a form is never handed a half-typed date that happens to
|
|
68
68
|
# parse ({DateField}, {TimeField}).
|
|
69
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
|
+
#
|
|
70
78
|
# == Implementation details
|
|
71
|
-
# - **{HasValue#value} and {#
|
|
79
|
+
# - **{HasValue#value} and {#set_value} raise until overridden.** The inherited
|
|
72
80
|
# pair stores into `@value` and never touches the editor, so a subclass
|
|
73
81
|
# that defined only one would silently half-work.
|
|
74
82
|
# - **{HasValue#empty_value} is called during construction**, to seed the
|
|
75
83
|
# change guard, so it must not depend on subclass state that `super` has
|
|
76
84
|
# not set yet. In practice it is a constant per class.
|
|
77
|
-
# - **The editor's `
|
|
78
|
-
# guard, and to commit before an app's ENTER handler
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
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.
|
|
82
90
|
# - **Not for a field whose editor is a *filter*.** This base assumes the
|
|
83
91
|
# buffer is a rendering of the value, so an edit may change the value.
|
|
84
92
|
# {ComboBox} breaks both halves — its text is a transient query and only a
|
|
@@ -98,13 +106,19 @@ module Tuile
|
|
|
98
106
|
|
|
99
107
|
@editor = editor
|
|
100
108
|
@last_value = empty_value
|
|
101
|
-
|
|
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
|
|
102
115
|
# 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 =
|
|
105
|
-
|
|
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|
|
|
106
120
|
handle_editor_change
|
|
107
|
-
fire_if_changed if notify_on_edit?
|
|
121
|
+
fire_if_changed(from_user: e.from_user?) if notify_on_edit?
|
|
108
122
|
end
|
|
109
123
|
add_child(editor, at: 0)
|
|
110
124
|
end
|
|
@@ -113,12 +127,14 @@ module Tuile
|
|
|
113
127
|
# @raise [NotImplementedError] unless the subclass overrides it.
|
|
114
128
|
def value = raise(NotImplementedError, "#{self.class} must implement value")
|
|
115
129
|
|
|
116
|
-
# 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}.
|
|
117
132
|
# @param new_value [Object]
|
|
133
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
118
134
|
# @return [void]
|
|
119
135
|
# @raise [NotImplementedError] unless the subclass overrides it.
|
|
120
|
-
def
|
|
121
|
-
raise(NotImplementedError, "#{self.class} must implement
|
|
136
|
+
def set_value(new_value, from_user:)
|
|
137
|
+
raise(NotImplementedError, "#{self.class} must implement set_value")
|
|
122
138
|
end
|
|
123
139
|
|
|
124
140
|
# Empties the *input*, not just the value — a field holding bad input
|
|
@@ -130,7 +146,7 @@ module Tuile
|
|
|
130
146
|
# Announced here rather than through the editor's change, so a field
|
|
131
147
|
# holding its notice ({#notify_on_edit?}) still reports an emptying as
|
|
132
148
|
# it happens: emptying is not a half-typed prefix.
|
|
133
|
-
fire_if_changed
|
|
149
|
+
fire_if_changed(from_user: false)
|
|
134
150
|
end
|
|
135
151
|
|
|
136
152
|
# @return [String, nil] the hint the editor paints while empty
|
|
@@ -144,26 +160,28 @@ module Tuile
|
|
|
144
160
|
editor.placeholder = text
|
|
145
161
|
end
|
|
146
162
|
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
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 }
|
|
150
168
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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)
|
|
162
180
|
end
|
|
163
181
|
|
|
164
182
|
# Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.
|
|
165
183
|
#
|
|
166
|
-
# The editor declines ENTER whenever {#on_enter} is
|
|
184
|
+
# The editor declines ENTER whenever {#on_enter} is empty, so the key
|
|
167
185
|
# reaches this field instead — and it must be committed on the way past,
|
|
168
186
|
# or the form default button it is bubbling towards acts on an
|
|
169
187
|
# uncommitted buffer.
|
|
@@ -198,13 +216,6 @@ module Tuile
|
|
|
198
216
|
screen.focused = editor if editor.focusable?
|
|
199
217
|
end
|
|
200
218
|
|
|
201
|
-
# @param new_rect [Rect]
|
|
202
|
-
# @return [void]
|
|
203
|
-
def rect=(new_rect)
|
|
204
|
-
super
|
|
205
|
-
layout(editor)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
219
|
protected
|
|
209
220
|
|
|
210
221
|
# @return [AbstractStringField] the wrapped editor.
|
|
@@ -225,14 +236,14 @@ module Tuile
|
|
|
225
236
|
#
|
|
226
237
|
# Only the *push* settles: {HasValue#value} stays a live parse of the
|
|
227
238
|
# buffer either way. And overriding this is half the job — {#commit} is
|
|
228
|
-
# covered here, but the field must fire from its own `
|
|
239
|
+
# covered here, but the field must fire from its own `set_value` too, or a
|
|
229
240
|
# programmatic write and an Up/Down step go unannounced until the next
|
|
230
241
|
# commit.
|
|
231
242
|
# @return [Boolean]
|
|
232
243
|
def notify_on_edit? = true
|
|
233
244
|
|
|
234
245
|
# Called whenever the editor's buffer changes, however the characters
|
|
235
|
-
# 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
|
|
236
247
|
# is named for the *editor*, not for the user, because those last two are
|
|
237
248
|
# not input. No-op by default; override it to drop state that describes
|
|
238
249
|
# the *previous* buffer, as a field latching whether its input has settled
|
|
@@ -242,15 +253,8 @@ module Tuile
|
|
|
242
253
|
|
|
243
254
|
# Places the editor across the whole rect; override to reserve cells for a
|
|
244
255
|
# face of your own.
|
|
245
|
-
# @param editor [Component]
|
|
246
256
|
# @return [void]
|
|
247
|
-
def
|
|
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
|
|
257
|
+
def relayout = (editor.rect = local_rect)
|
|
254
258
|
|
|
255
259
|
private
|
|
256
260
|
|
|
@@ -260,19 +264,20 @@ module Tuile
|
|
|
260
264
|
# @return [void]
|
|
261
265
|
def commit_and_notify
|
|
262
266
|
commit
|
|
263
|
-
fire_if_changed
|
|
267
|
+
fire_if_changed(from_user: true)
|
|
264
268
|
end
|
|
265
269
|
|
|
266
270
|
# Re-emits {HasValue#on_value_change}, but only when {#value} differs from
|
|
267
271
|
# the last one fired — so a buffer edit that leaves the value alone
|
|
268
272
|
# (`"7"`→`"07"`) stays silent.
|
|
273
|
+
# @param from_user [Boolean] what the write that moved the buffer declared.
|
|
269
274
|
# @return [void]
|
|
270
|
-
def fire_if_changed
|
|
275
|
+
def fire_if_changed(from_user:)
|
|
271
276
|
v = value
|
|
272
277
|
return if v == @last_value
|
|
273
278
|
|
|
274
279
|
@last_value = v
|
|
275
|
-
on_value_change
|
|
280
|
+
on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
|
|
276
281
|
end
|
|
277
282
|
end
|
|
278
283
|
end
|
|
@@ -18,7 +18,7 @@ module Tuile
|
|
|
18
18
|
# would round. Give it a single-row {#rect}:
|
|
19
19
|
#
|
|
20
20
|
# price = Component::BigDecimalField.new
|
|
21
|
-
# price.on_value_change
|
|
21
|
+
# price.on_value_change { |e| total.value = e.value } # BigDecimal or nil
|
|
22
22
|
# price.value = BigDecimal("19.99") # field shows "19.99"
|
|
23
23
|
# price.value = 19.99 # ArgumentError: a Float can't be exact
|
|
24
24
|
#
|
|
@@ -98,8 +98,8 @@ module Tuile
|
|
|
98
98
|
def initialize
|
|
99
99
|
super(Field.new)
|
|
100
100
|
# Not the general on_key interceptor: that slot stays free for the app.
|
|
101
|
-
editor.on_key_up
|
|
102
|
-
editor.on_key_down
|
|
101
|
+
editor.on_key_up { step(1) }
|
|
102
|
+
editor.on_key_down { step(-1) }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
|
|
@@ -118,9 +118,10 @@ module Tuile
|
|
|
118
118
|
# decimal you wrote, which is the whole reason to use this field), a
|
|
119
119
|
# non-numeric `String`, a NaN or an infinity.
|
|
120
120
|
# @raise [TypeError] on a value `BigDecimal()` won't take at all.
|
|
121
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
121
122
|
# @return [void]
|
|
122
|
-
def
|
|
123
|
-
editor.
|
|
123
|
+
def set_value(new_value, from_user:)
|
|
124
|
+
editor.set_value(new_value.nil? ? "" : coerce(new_value).to_s("F"), from_user:)
|
|
124
125
|
editor.caret = editor.text.length
|
|
125
126
|
end
|
|
126
127
|
|
|
@@ -166,7 +167,7 @@ module Tuile
|
|
|
166
167
|
# zero.
|
|
167
168
|
# @param delta [Integer]
|
|
168
169
|
# @return [void]
|
|
169
|
-
def step(delta) = (
|
|
170
|
+
def step(delta) = set_value((value || BigDecimal(0)) + delta, from_user: true)
|
|
170
171
|
end
|
|
171
172
|
end
|
|
172
173
|
end
|
|
@@ -20,17 +20,24 @@ module Tuile
|
|
|
20
20
|
|
|
21
21
|
# @param caption [String, StyledString, nil] the button's label, coerced
|
|
22
22
|
# the same way {HasCaption#caption=} coerces it.
|
|
23
|
-
# @yield optional `on_click`
|
|
24
|
-
def initialize(caption = nil, &
|
|
23
|
+
# @yield optional `on_click` listener; same as registering one on {#on_click}.
|
|
24
|
+
def initialize(caption = nil, &listener)
|
|
25
25
|
super()
|
|
26
26
|
self.caption = caption
|
|
27
|
-
|
|
27
|
+
on_click << listener if listener
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
30
|
+
# What {#on_click} fires.
|
|
31
|
+
#
|
|
32
|
+
# @!attribute [r] source
|
|
33
|
+
# @return [Button] the button that was activated.
|
|
34
|
+
ClickEvent = Data.define(:source) { include Tuile::Event }
|
|
35
|
+
|
|
36
|
+
# @!method on_click
|
|
37
|
+
# Fired with a {ClickEvent} when the button is activated — Enter, Space
|
|
38
|
+
# or a left click within {#extent}.
|
|
39
|
+
# @return [Listeners]
|
|
40
|
+
listener :on_click
|
|
34
41
|
|
|
35
42
|
def focusable? = true
|
|
36
43
|
|
|
@@ -41,7 +48,7 @@ module Tuile
|
|
|
41
48
|
def handle_key?(key)
|
|
42
49
|
case key
|
|
43
50
|
when Keys::ENTER, " "
|
|
44
|
-
|
|
51
|
+
on_click.fire(ClickEvent.new(source: self))
|
|
45
52
|
true
|
|
46
53
|
else
|
|
47
54
|
false
|
|
@@ -65,18 +72,19 @@ module Tuile
|
|
|
65
72
|
def handle_mouse_down?(event)
|
|
66
73
|
return false unless event.button == :left
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
on_click.fire(ClickEvent.new(source: self))
|
|
69
76
|
true
|
|
70
77
|
end
|
|
71
78
|
|
|
79
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
72
80
|
# @return [void]
|
|
73
|
-
def repaint
|
|
81
|
+
def repaint(canvas)
|
|
74
82
|
super
|
|
75
83
|
return if rect.empty?
|
|
76
84
|
|
|
77
85
|
label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
|
|
78
86
|
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
79
|
-
|
|
87
|
+
canvas.set_text(0, 0, label)
|
|
80
88
|
end
|
|
81
89
|
end
|
|
82
90
|
end
|
|
@@ -8,7 +8,7 @@ module Tuile
|
|
|
8
8
|
# [ ] Enable syslog forwarding
|
|
9
9
|
#
|
|
10
10
|
# cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
11
|
-
# cb.on_value_change
|
|
11
|
+
# cb.on_value_change { |e| config.syslog = e.value }
|
|
12
12
|
# cb.toggle # unchecks it, firing the listener with false
|
|
13
13
|
# cb.checked? # => false
|
|
14
14
|
#
|
|
@@ -60,9 +60,10 @@ module Tuile
|
|
|
60
60
|
# whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
|
|
61
61
|
# the no-op it looks like rather than a spurious change event.
|
|
62
62
|
# @param new_value [Object] anything; truthiness decides.
|
|
63
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
63
64
|
# @return [void]
|
|
64
|
-
def
|
|
65
|
-
super(new_value ? true : false)
|
|
65
|
+
def set_value(new_value, from_user:)
|
|
66
|
+
super(new_value ? true : false, from_user:)
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# @return [Boolean] {#value} under its domain word — `license.checked?`
|
|
@@ -70,15 +71,15 @@ module Tuile
|
|
|
70
71
|
def checked? = value
|
|
71
72
|
|
|
72
73
|
# {#value=} under its domain word. A delegator rather than an `alias`, so it
|
|
73
|
-
# keeps routing through the one write path
|
|
74
|
-
# {#value=} (an `alias` would freeze this onto the body defined here).
|
|
74
|
+
# keeps routing through the one write path, {#set_value}.
|
|
75
75
|
# @param new_value [Object] anything; truthiness decides.
|
|
76
76
|
# @return [void]
|
|
77
77
|
def checked=(new_value)
|
|
78
78
|
self.value = new_value
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# Flips {#value}
|
|
81
|
+
# Flips {#value} — programmatically; Space, Enter and a click flip it as
|
|
82
|
+
# the user's ({HasValue::ValueChangeEvent#from_user?}).
|
|
82
83
|
# @return [void]
|
|
83
84
|
def toggle = (self.value = !value)
|
|
84
85
|
|
|
@@ -105,7 +106,7 @@ module Tuile
|
|
|
105
106
|
def handle_key?(key)
|
|
106
107
|
return false unless [" ", Keys::ENTER].include?(key)
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
set_value(!value, from_user: true)
|
|
109
110
|
true
|
|
110
111
|
end
|
|
111
112
|
|
|
@@ -116,18 +117,19 @@ module Tuile
|
|
|
116
117
|
def handle_mouse_down?(event)
|
|
117
118
|
return false unless event.button == :left
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
set_value(!value, from_user: true)
|
|
120
121
|
true
|
|
121
122
|
end
|
|
122
123
|
|
|
124
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
123
125
|
# @return [void]
|
|
124
|
-
def repaint
|
|
126
|
+
def repaint(canvas)
|
|
125
127
|
super
|
|
126
128
|
return if rect.empty?
|
|
127
129
|
|
|
128
130
|
label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
|
|
129
131
|
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
130
|
-
|
|
132
|
+
canvas.set_text(0, 0, label)
|
|
131
133
|
end
|
|
132
134
|
end
|
|
133
135
|
end
|
|
@@ -12,7 +12,7 @@ module Tuile
|
|
|
12
12
|
#
|
|
13
13
|
# cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
|
|
14
14
|
# cg.value = %w[Errors Info] # any Enumerable, stored as a Set
|
|
15
|
-
# cg.on_value_change
|
|
15
|
+
# cg.on_value_change { |e| filter(e.value) } # once per toggle
|
|
16
16
|
# cg.value # => #<Set: {"Errors", "Info"}>
|
|
17
17
|
# cg.item_label = ->(level) { level.name } # default :to_s
|
|
18
18
|
#
|
|
@@ -69,13 +69,12 @@ module Tuile
|
|
|
69
69
|
super()
|
|
70
70
|
@item_label = :to_s.to_proc
|
|
71
71
|
@value = coerce(value)
|
|
72
|
-
@on_value_change = nil
|
|
73
72
|
|
|
74
73
|
list = List.new
|
|
75
74
|
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
76
75
|
list.cursor = List::Cursor.new
|
|
77
76
|
list.renderer = method(:render_row)
|
|
78
|
-
list.on_item_chosen
|
|
77
|
+
list.on_item_chosen { |e| toggle(e.item) }
|
|
79
78
|
list.items = items.to_a
|
|
80
79
|
@list = list
|
|
81
80
|
add_child(list, at: 0)
|
|
@@ -90,12 +89,8 @@ module Tuile
|
|
|
90
89
|
# @return [List]
|
|
91
90
|
attr_reader :list
|
|
92
91
|
|
|
93
|
-
# @param new_rect [Rect]
|
|
94
92
|
# @return [void]
|
|
95
|
-
def rect=
|
|
96
|
-
super
|
|
97
|
-
list.rect = rect
|
|
98
|
-
end
|
|
93
|
+
def relayout = list.rect = local_rect
|
|
99
94
|
|
|
100
95
|
# @return [void]
|
|
101
96
|
def handle_focus
|
|
@@ -135,15 +130,16 @@ module Tuile
|
|
|
135
130
|
# changed. Stores a frozen `Set` *copy*, so a set the caller goes on
|
|
136
131
|
# mutating can't reach in.
|
|
137
132
|
# @param new_value [Enumerable, nil] `nil` selects nothing.
|
|
133
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
138
134
|
# @raise [TypeError] unless `new_value` is an `Enumerable` or `nil`.
|
|
139
135
|
# @return [void]
|
|
140
|
-
def
|
|
136
|
+
def set_value(new_value, from_user:)
|
|
141
137
|
selected = coerce(new_value)
|
|
142
|
-
# HasValue#
|
|
138
|
+
# HasValue#set_value no-ops on an unchanged value; this guard is what also
|
|
143
139
|
# skips the row rebuild.
|
|
144
140
|
return if value == selected
|
|
145
141
|
|
|
146
|
-
super(selected)
|
|
142
|
+
super(selected, from_user:)
|
|
147
143
|
list.refresh_rows
|
|
148
144
|
end
|
|
149
145
|
|
|
@@ -177,14 +173,16 @@ module Tuile
|
|
|
177
173
|
# @param item [Object]
|
|
178
174
|
# @return [void]
|
|
179
175
|
def toggle(item)
|
|
180
|
-
|
|
176
|
+
set_value(value.include?(item) ? value - [item] : value + [item], from_user: true)
|
|
181
177
|
end
|
|
182
178
|
|
|
183
179
|
# @param item [Object]
|
|
180
|
+
# @param _text_width [Integer] unused: a box plus a label is as wide as it
|
|
181
|
+
# is, and {List} ellipsizes what will not fit.
|
|
184
182
|
# @return [StyledString] the item's row: its label behind a checkmark box.
|
|
185
183
|
# The {List} calls this at paint time, so the boxes track {#value}
|
|
186
184
|
# without re-rendering anything but the visible rows.
|
|
187
|
-
def render_row(item)
|
|
185
|
+
def render_row(item, _text_width)
|
|
188
186
|
StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
|
|
189
187
|
end
|
|
190
188
|
|
|
@@ -10,7 +10,7 @@ module Tuile
|
|
|
10
10
|
# combo = Component::ComboBox.new
|
|
11
11
|
# combo.items = User.all # Array of any type
|
|
12
12
|
# combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
|
|
13
|
-
# combo.on_value_change
|
|
13
|
+
# combo.on_value_change { |e| open(e.value) } # fires on commit, with the item
|
|
14
14
|
# combo.value = some_user # selects it; field shows its label
|
|
15
15
|
#
|
|
16
16
|
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
@@ -36,7 +36,7 @@ module Tuile
|
|
|
36
36
|
# ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
|
|
37
37
|
#
|
|
38
38
|
# field = Testing.get(Component::TextField, in: combo)
|
|
39
|
-
# field.
|
|
39
|
+
# field.value = "ap" # type a query without a real loop
|
|
40
40
|
#
|
|
41
41
|
# UI-thread-confined, like every component (see {Screen}).
|
|
42
42
|
class ComboBox < Component
|
|
@@ -48,29 +48,32 @@ module Tuile
|
|
|
48
48
|
def initialize(items: [])
|
|
49
49
|
super()
|
|
50
50
|
@value = nil
|
|
51
|
-
@on_value_change = nil
|
|
52
51
|
@items = items.to_a
|
|
53
52
|
@item_label = :to_s.to_proc
|
|
54
53
|
@filtered = []
|
|
55
|
-
@suppressing_filter = false
|
|
56
54
|
|
|
57
55
|
@field = TextField.new
|
|
58
|
-
# One widget, one surface:
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
@field.
|
|
56
|
+
# One widget, one surface: the inner field paints no well of its own, so
|
|
57
|
+
# this combo's well covers it and the `▾` alike, and the combo's own
|
|
58
|
+
# bg_color reaches the cells the field paints.
|
|
59
|
+
@field.bg_color = ComponentBackground::INHERIT
|
|
60
|
+
bg.default_color = ComponentBackground::INPUT_WELL
|
|
61
|
+
# Only the user's typing filters: a programmatic write to the field
|
|
62
|
+
# ({#sync_field}) must not spring the dropdown open.
|
|
63
|
+
@field.on_value_change { |e| refill if e.from_user? }
|
|
62
64
|
# ESC is the one key this combo wants that the field consumes itself, so
|
|
63
65
|
# it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
|
|
64
66
|
# open it keeps the field's own meaning: cancel text entry.
|
|
65
|
-
@field.
|
|
66
|
-
|
|
67
|
+
@field.escape_clears_focus = false
|
|
68
|
+
@field.on_escape { @overlay.open? ? dismiss_menu : screen.focused = nil }
|
|
67
69
|
|
|
68
70
|
@overlay = ListDropdown.new
|
|
69
71
|
# Outside-click dismissal spans the owner chain, so a click on this
|
|
70
72
|
# combo's dropdown must not dismiss a dialog the combo sits in.
|
|
71
73
|
@overlay.owner = self
|
|
72
|
-
@overlay.renderer = ->(item) { @item_label.call(item) }
|
|
73
|
-
@overlay.on_item_chosen
|
|
74
|
+
@overlay.renderer = ->(item, _text_width) { @item_label.call(item) }
|
|
75
|
+
@overlay.list.on_item_chosen { |e| commit(e.item) }
|
|
76
|
+
add_child(@field, at: 0)
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
# @return [Array] the candidate items.
|
|
@@ -102,8 +105,9 @@ module Tuile
|
|
|
102
105
|
# *without* opening the dropdown, then fires {#on_value_change}. `nil`
|
|
103
106
|
# clears the selection (blank field). The value need not be in {#items}.
|
|
104
107
|
# @param new_value [Object]
|
|
108
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
105
109
|
# @return [void]
|
|
106
|
-
def
|
|
110
|
+
def set_value(new_value, from_user:)
|
|
107
111
|
return if value == new_value
|
|
108
112
|
|
|
109
113
|
sync_field(display_for(new_value))
|
|
@@ -127,15 +131,12 @@ module Tuile
|
|
|
127
131
|
field.placeholder = text
|
|
128
132
|
end
|
|
129
133
|
|
|
130
|
-
# Resizes the field
|
|
131
|
-
# @param new_rect [Rect]
|
|
134
|
+
# Resizes the field; the open dropdown follows the combo by itself.
|
|
132
135
|
# @return [void]
|
|
133
|
-
def
|
|
134
|
-
super
|
|
136
|
+
def relayout
|
|
135
137
|
# One row, or none at all when the combo itself was given none — a
|
|
136
138
|
# starved parent must not hand out a rect it doesn't own.
|
|
137
|
-
field.rect = Rect.new(
|
|
138
|
-
anchor if @overlay.open?
|
|
139
|
+
field.rect = Rect.new(0, 0, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
139
140
|
end
|
|
140
141
|
|
|
141
142
|
# @return [void]
|
|
@@ -198,21 +199,15 @@ module Tuile
|
|
|
198
199
|
true
|
|
199
200
|
end
|
|
200
201
|
|
|
202
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
201
203
|
# @return [void]
|
|
202
|
-
def repaint
|
|
204
|
+
def repaint(canvas)
|
|
203
205
|
super
|
|
204
206
|
return if rect.empty?
|
|
205
207
|
|
|
206
|
-
|
|
208
|
+
canvas.set_char(rect.width - 1, 0, "▾")
|
|
207
209
|
end
|
|
208
210
|
|
|
209
|
-
# The field well the whole face sits on — the inner {Component::TextField}
|
|
210
|
-
# is marked {Component::BG_INHERIT} so this one covers both it and the `▾`
|
|
211
|
-
# (exactly one well per widget), which is what makes {Component#bg_color}
|
|
212
|
-
# on the ComboBox reach the cells the field paints.
|
|
213
|
-
# @return [Color]
|
|
214
|
-
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
215
|
-
|
|
216
211
|
# The one row this combo paints — the full width, at the top of {#rect}.
|
|
217
212
|
# A single-slot container hands its content the whole inner rect, so a
|
|
218
213
|
# ComboBox is routinely assigned more height than it uses; the dropdown
|
|
@@ -223,8 +218,9 @@ module Tuile
|
|
|
223
218
|
# Declines the default's blank: `field` covers every column of the face but
|
|
224
219
|
# the last, and this combo paints the `▾` into that one, so blanking would
|
|
225
220
|
# only dirty a cell it is about to repaint (`D_progress_bar`).
|
|
221
|
+
# @param _canvas [Canvas] the surface to paint onto.
|
|
226
222
|
# @return [void]
|
|
227
|
-
def clear_inside_extent = nil
|
|
223
|
+
def clear_inside_extent(_canvas) = nil
|
|
228
224
|
|
|
229
225
|
private
|
|
230
226
|
|
|
@@ -250,7 +246,6 @@ module Tuile
|
|
|
250
246
|
else
|
|
251
247
|
@overlay.items = @filtered
|
|
252
248
|
@overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
|
|
253
|
-
@overlay.open unless @overlay.open?
|
|
254
249
|
anchor
|
|
255
250
|
end
|
|
256
251
|
end
|
|
@@ -273,7 +268,7 @@ module Tuile
|
|
|
273
268
|
# @return [void]
|
|
274
269
|
def commit(item)
|
|
275
270
|
close_menu
|
|
276
|
-
|
|
271
|
+
set_value(item, from_user: true)
|
|
277
272
|
end
|
|
278
273
|
|
|
279
274
|
# @return [void]
|
|
@@ -287,20 +282,15 @@ module Tuile
|
|
|
287
282
|
|
|
288
283
|
# Sets the field's text without triggering a refilter — for programmatic
|
|
289
284
|
# value changes and query reverts, which must not spring the dropdown.
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
# open on a {#value=} the user never asked to browse.
|
|
293
|
-
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
285
|
+
# The write is `from_user: false`, which is all the field's listener
|
|
286
|
+
# checks. Parks the caret at the end: `value=` only *clamps* the caret, so a
|
|
294
287
|
# shorter query replaced by a longer label would otherwise strand it
|
|
295
288
|
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
296
289
|
# @param text [String]
|
|
297
290
|
# @return [void]
|
|
298
291
|
def sync_field(text)
|
|
299
|
-
|
|
300
|
-
field.text = text
|
|
292
|
+
field.value = text
|
|
301
293
|
field.caret = field.text.length
|
|
302
|
-
ensure
|
|
303
|
-
@suppressing_filter = false
|
|
304
294
|
end
|
|
305
295
|
|
|
306
296
|
# @param item [Object]
|
|
@@ -312,7 +302,7 @@ module Tuile
|
|
|
312
302
|
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
313
303
|
# the trade a measuring driver ({Select}) makes the other way.
|
|
314
304
|
# @return [void]
|
|
315
|
-
def anchor = @overlay.anchor_to(
|
|
305
|
+
def anchor = @overlay.anchor_to(self)
|
|
316
306
|
end
|
|
317
307
|
end
|
|
318
308
|
end
|