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
|
@@ -22,10 +22,12 @@ module Tuile
|
|
|
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
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# The buffer only ever holds `0`–`9`, one leading `-` and one `.`: a key
|
|
26
|
+
# that would break that is dropped without moving the caret, and so is a
|
|
27
|
+
# *paste* that would (`"$19.99"` lands nothing, rather than sieving through
|
|
28
|
+
# as a price the user never copied). Up/Down step by one. Range checks
|
|
29
|
+
# (`min`/`max`) and a display scale (`19.9` → `19.90`) belong to a forms
|
|
30
|
+
# layer, not here — nothing rounds or pads what you typed.
|
|
29
31
|
#
|
|
30
32
|
# Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
|
|
31
33
|
# bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
|
|
@@ -48,14 +50,18 @@ module Tuile
|
|
|
48
50
|
# `Float` is refused on both, and display goes through `to_s("F")` — plain
|
|
49
51
|
# notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
|
|
50
52
|
#
|
|
51
|
-
# It *
|
|
52
|
-
#
|
|
53
|
-
#
|
|
53
|
+
# It *wraps* a {TextField} rather than subclassing one, so its face carries
|
|
54
|
+
# only the typed {HasValue} seam and never the widget's `String`-typed
|
|
55
|
+
# `text`; {AbstractWrappingField} supplies the wrapping.
|
|
54
56
|
#
|
|
55
57
|
# UI-thread-confined, like every component (see {Screen}).
|
|
56
|
-
class BigDecimalField <
|
|
57
|
-
include
|
|
58
|
-
|
|
58
|
+
class BigDecimalField < AbstractWrappingField
|
|
59
|
+
include HasBadInput
|
|
60
|
+
|
|
61
|
+
# @return [String] what {#bad_input_message} reports for a buffer that is
|
|
62
|
+
# typeable but not a decimal.
|
|
63
|
+
BAD_INPUT_MESSAGE = "not a decimal number"
|
|
64
|
+
private_constant :BAD_INPUT_MESSAGE
|
|
59
65
|
|
|
60
66
|
# A buffer {#value} parses: an optional sign and digits with an optional
|
|
61
67
|
# fractional part (either side may be empty, but not both). No exponent —
|
|
@@ -64,19 +70,42 @@ module Tuile
|
|
|
64
70
|
NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/
|
|
65
71
|
private_constant :NUMERIC
|
|
66
72
|
|
|
73
|
+
# The face: a {TextField} that admits only the buffers a decimal can be
|
|
74
|
+
# typed through, however the characters arrive.
|
|
75
|
+
class Field < TextField
|
|
76
|
+
# Buffers reachable by typing a decimal: an optional leading `-` and
|
|
77
|
+
# digits with at most one `.`. Looser than {NUMERIC} on purpose —
|
|
78
|
+
# `""`, `"-"` and `"1."` are members, or the values past them could not
|
|
79
|
+
# be typed at all.
|
|
80
|
+
# @return [Regexp]
|
|
81
|
+
TYPEABLE = /\A-?\d*(?:\.\d*)?\z/
|
|
82
|
+
private_constant :TYPEABLE
|
|
83
|
+
|
|
84
|
+
protected
|
|
85
|
+
|
|
86
|
+
# Accepts the insertion only if the whole resulting buffer is still
|
|
87
|
+
# typeable, so a pasted `"$19.99"` is dropped rather than sieved into
|
|
88
|
+
# `"19.99"` — a price the user never copied.
|
|
89
|
+
# @param str [String]
|
|
90
|
+
# @return [Boolean] true if the text changed.
|
|
91
|
+
def insert_text(str)
|
|
92
|
+
return false unless TYPEABLE.match?(@text.dup.insert(@caret, str))
|
|
93
|
+
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
67
98
|
def initialize
|
|
68
|
-
super()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
field.on_key = method(:field_key)
|
|
73
|
-
self.content = field
|
|
99
|
+
super(Field.new)
|
|
100
|
+
# Not the general on_key interceptor: that slot stays free for the app.
|
|
101
|
+
editor.on_key_up = -> { step(1) }
|
|
102
|
+
editor.on_key_down = -> { step(-1) }
|
|
74
103
|
end
|
|
75
104
|
|
|
76
105
|
# @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
|
|
77
106
|
# number (e.g. a lone `"-"`).
|
|
78
107
|
def value
|
|
79
|
-
text =
|
|
108
|
+
text = editor.text
|
|
80
109
|
text.match?(NUMERIC) ? BigDecimal(normalize(text)) : nil
|
|
81
110
|
end
|
|
82
111
|
|
|
@@ -91,34 +120,18 @@ module Tuile
|
|
|
91
120
|
# @raise [TypeError] on a value `BigDecimal()` won't take at all.
|
|
92
121
|
# @return [void]
|
|
93
122
|
def value=(new_value)
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
editor.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
|
|
124
|
+
editor.caret = editor.text.length
|
|
96
125
|
end
|
|
97
126
|
|
|
98
127
|
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
99
128
|
# @return [nil]
|
|
100
129
|
def empty_value = nil
|
|
101
130
|
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
107
|
-
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
108
|
-
def on_enter = content.on_enter
|
|
109
|
-
|
|
110
|
-
# @param callback [Proc, Method, nil]
|
|
111
|
-
# @return [void]
|
|
112
|
-
def on_enter=(callback)
|
|
113
|
-
content.on_enter = callback
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
protected
|
|
117
|
-
|
|
118
|
-
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
119
|
-
# @param field [Component]
|
|
120
|
-
# @return [void]
|
|
121
|
-
def layout(field) = (field.rect = rect)
|
|
131
|
+
# `"-"`, `"."` and `"-."` are typeable and parse to nothing; an *empty*
|
|
132
|
+
# buffer is empty, not bad ({HasBadInput}).
|
|
133
|
+
# @return [String, nil]
|
|
134
|
+
def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
|
|
122
135
|
|
|
123
136
|
private
|
|
124
137
|
|
|
@@ -149,51 +162,11 @@ module Tuile
|
|
|
149
162
|
big
|
|
150
163
|
end
|
|
151
164
|
|
|
152
|
-
# The field's key interceptor, consulted *before* the field acts on the
|
|
153
|
-
# key — which is what lets a rejected character be swallowed without the
|
|
154
|
-
# caret ever moving.
|
|
155
|
-
# @param key [String]
|
|
156
|
-
# @return [Boolean] true to consume the key.
|
|
157
|
-
def field_key(key)
|
|
158
|
-
case key
|
|
159
|
-
when Keys::UP_ARROW then step(1)
|
|
160
|
-
when Keys::DOWN_ARROW then step(-1)
|
|
161
|
-
else return Keys.printable?(key) && !accepts?(key)
|
|
162
|
-
end
|
|
163
|
-
true
|
|
164
|
-
end
|
|
165
|
-
|
|
166
165
|
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
167
166
|
# zero.
|
|
168
167
|
# @param delta [Integer]
|
|
169
168
|
# @return [void]
|
|
170
169
|
def step(delta) = (self.value = (value || BigDecimal(0)) + delta)
|
|
171
|
-
|
|
172
|
-
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
173
|
-
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
174
|
-
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
175
|
-
# @param char [String] a single printable character.
|
|
176
|
-
# @return [Boolean]
|
|
177
|
-
def accepts?(char)
|
|
178
|
-
case char
|
|
179
|
-
when /\A[0-9]\z/ then true
|
|
180
|
-
when "-" then content.caret.zero? && !content.text.start_with?("-")
|
|
181
|
-
when "." then !content.text.include?(".")
|
|
182
|
-
else false
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
187
|
-
# when it differs from the last one fired — so a buffer edit that leaves
|
|
188
|
-
# the value unchanged (`"1.0"`→`"1.00"`) stays silent.
|
|
189
|
-
# @return [void]
|
|
190
|
-
def fire_if_changed
|
|
191
|
-
v = value
|
|
192
|
-
return if v == @last_value
|
|
193
|
-
|
|
194
|
-
@last_value = v
|
|
195
|
-
on_value_change&.call(v)
|
|
196
|
-
end
|
|
197
170
|
end
|
|
198
171
|
end
|
|
199
172
|
end
|
|
@@ -9,7 +9,7 @@ module Tuile
|
|
|
9
9
|
#
|
|
10
10
|
# Buttons are tab stops — Tab and Shift+Tab will land on them as part of
|
|
11
11
|
# the standard focus cycle. Click-to-focus also works via the inherited
|
|
12
|
-
# {Component#
|
|
12
|
+
# {Component#handle_mouse_down?}.
|
|
13
13
|
#
|
|
14
14
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
15
15
|
# show `[ caption ]` — that natural width is `caption.display_width + 4`.
|
|
@@ -38,7 +38,7 @@ module Tuile
|
|
|
38
38
|
|
|
39
39
|
# @param key [String]
|
|
40
40
|
# @return [Boolean]
|
|
41
|
-
def handle_key(key)
|
|
41
|
+
def handle_key?(key)
|
|
42
42
|
case key
|
|
43
43
|
when Keys::ENTER, " "
|
|
44
44
|
@on_click&.call
|
|
@@ -52,7 +52,7 @@ module Tuile
|
|
|
52
52
|
# columns, clipped to {#rect}. Both the focus highlight and the click hit
|
|
53
53
|
# test use it, so a click on the blank tail of an over-wide rect — or on a
|
|
54
54
|
# lower row, when the rect is taller than one — does not fire {#on_click}.
|
|
55
|
-
# It still *focuses*: {
|
|
55
|
+
# It still *focuses*: {Mouse::Router}'s click-to-focus is ungated
|
|
56
56
|
# by geometry. Same rule as {Checkbox#extent}, which documents the two
|
|
57
57
|
# traps behind it.
|
|
58
58
|
# @return [Size]
|
|
@@ -60,13 +60,13 @@ module Tuile
|
|
|
60
60
|
|
|
61
61
|
# Fires {#on_click} on a left click within {#extent}; `super` runs first, so
|
|
62
62
|
# a click anywhere in {#rect} still focuses.
|
|
63
|
-
# @param event [
|
|
64
|
-
# @return [
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
63
|
+
# @param event [Mouse::DownEvent]
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def handle_mouse_down?(event)
|
|
66
|
+
return false unless event.button == :left
|
|
68
67
|
|
|
69
68
|
@on_click&.call
|
|
69
|
+
true
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# @return [void]
|
|
@@ -89,7 +89,7 @@ module Tuile
|
|
|
89
89
|
#
|
|
90
90
|
# Both the focus highlight and the click hit test use it, so a click on the
|
|
91
91
|
# blank tail — or on a lower row, when the rect is taller than one — does
|
|
92
|
-
# not toggle. It still *focuses*: {
|
|
92
|
+
# not toggle. It still *focuses*: {Mouse::Router}'s click-to-focus
|
|
93
93
|
# is ungated by geometry, and the tail is the field's own row.
|
|
94
94
|
#
|
|
95
95
|
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
@@ -102,22 +102,22 @@ module Tuile
|
|
|
102
102
|
# to an ancestor.
|
|
103
103
|
# @param key [String]
|
|
104
104
|
# @return [Boolean]
|
|
105
|
-
def handle_key(key)
|
|
105
|
+
def handle_key?(key)
|
|
106
106
|
return false unless [" ", Keys::ENTER].include?(key)
|
|
107
107
|
|
|
108
108
|
toggle
|
|
109
109
|
true
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
# Toggles on a left
|
|
113
|
-
#
|
|
114
|
-
# @param event [
|
|
115
|
-
# @return [
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
112
|
+
# Toggles on a left press; a press on the dead tail past {#extent} focuses
|
|
113
|
+
# the checkbox without reaching here.
|
|
114
|
+
# @param event [Mouse::DownEvent]
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def handle_mouse_down?(event)
|
|
117
|
+
return false unless event.button == :left
|
|
119
118
|
|
|
120
119
|
toggle
|
|
120
|
+
true
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
# @return [void]
|
|
@@ -24,12 +24,12 @@ module Tuile
|
|
|
24
24
|
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
25
25
|
#
|
|
26
26
|
# Composes rather than subclasses, like {ComboBox}: a {List} of the items is
|
|
27
|
-
# its single
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# `show_cursor_when_inactive`, …)
|
|
32
|
-
# inner list is the tab stop, not the group.
|
|
27
|
+
# its single child, which is where the cursor, scrolling, the scrollbar and
|
|
28
|
+
# per-row mouse hit-testing come from — the group only supplies the
|
|
29
|
+
# {List#renderer} that puts the box in front of the label. {#list} is that
|
|
30
|
+
# list, exposed read-only so an app can tune it (`scrollbar_visibility`,
|
|
31
|
+
# `show_cursor_when_inactive`, …) but never swap it out. Rows beyond
|
|
32
|
+
# {#rect}'s height scroll; the inner list is the tab stop, not the group.
|
|
33
33
|
#
|
|
34
34
|
# == +items+ is chrome; +value+ is authoritative
|
|
35
35
|
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
@@ -54,7 +54,6 @@ module Tuile
|
|
|
54
54
|
#
|
|
55
55
|
# UI-thread-confined, like every component (see {Screen}).
|
|
56
56
|
class CheckboxGroup < Component
|
|
57
|
-
include HasContent
|
|
58
57
|
include HasValue
|
|
59
58
|
|
|
60
59
|
# @return [Set]
|
|
@@ -78,11 +77,36 @@ module Tuile
|
|
|
78
77
|
list.renderer = method(:render_row)
|
|
79
78
|
list.on_item_chosen = ->(_index, item) { toggle(item) }
|
|
80
79
|
list.items = items.to_a
|
|
81
|
-
|
|
80
|
+
@list = list
|
|
81
|
+
add_child(list, at: 0)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The composed {List}: an app may *tune* it — its scrollbar, its cursor,
|
|
85
|
+
# `show_cursor_when_inactive` — but never replace it, since this group's
|
|
86
|
+
# renderer and selection are wired into this one (`design/decisions.md`
|
|
87
|
+
# `D_has_content`). Those knobs are {List} concepts rather than group
|
|
88
|
+
# concepts, which is why they are reached here instead of forwarded
|
|
89
|
+
# (`D_wrapping_field`).
|
|
90
|
+
# @return [List]
|
|
91
|
+
attr_reader :list
|
|
92
|
+
|
|
93
|
+
# @param new_rect [Rect]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def rect=(new_rect)
|
|
96
|
+
super
|
|
97
|
+
list.rect = rect
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def handle_focus
|
|
102
|
+
super
|
|
103
|
+
# The list is what the arrows drive, so it takes the focus this group
|
|
104
|
+
# was given; the group itself claims only Space.
|
|
105
|
+
screen.focused = list if list.focusable?
|
|
82
106
|
end
|
|
83
107
|
|
|
84
108
|
# @return [Array] the presented items.
|
|
85
|
-
def items =
|
|
109
|
+
def items = list.items
|
|
86
110
|
|
|
87
111
|
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
88
112
|
# anything with `#to_s`); `:to_s` by default.
|
|
@@ -93,14 +117,14 @@ module Tuile
|
|
|
93
117
|
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
94
118
|
# @return [void]
|
|
95
119
|
def items=(new_items)
|
|
96
|
-
|
|
120
|
+
list.items = new_items
|
|
97
121
|
end
|
|
98
122
|
|
|
99
123
|
# @param proc [Proc, Method] item -> row label.
|
|
100
124
|
# @return [void]
|
|
101
125
|
def item_label=(proc)
|
|
102
126
|
@item_label = proc
|
|
103
|
-
|
|
127
|
+
list.refresh_rows
|
|
104
128
|
end
|
|
105
129
|
|
|
106
130
|
# @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
|
|
@@ -120,7 +144,7 @@ module Tuile
|
|
|
120
144
|
return if value == selected
|
|
121
145
|
|
|
122
146
|
super(selected)
|
|
123
|
-
|
|
147
|
+
list.refresh_rows
|
|
124
148
|
end
|
|
125
149
|
|
|
126
150
|
# Toggles the cursor row on Space. Nothing else is claimed: the composed
|
|
@@ -129,20 +153,13 @@ module Tuile
|
|
|
129
153
|
# neither of us wants bubbles on to an ancestor.
|
|
130
154
|
# @param key [String]
|
|
131
155
|
# @return [Boolean]
|
|
132
|
-
def handle_key(key)
|
|
156
|
+
def handle_key?(key)
|
|
133
157
|
return false unless key == " "
|
|
134
158
|
|
|
135
|
-
toggle_at(
|
|
159
|
+
toggle_at(list.cursor.position)
|
|
136
160
|
true
|
|
137
161
|
end
|
|
138
162
|
|
|
139
|
-
protected
|
|
140
|
-
|
|
141
|
-
# Places the composed list across the whole rect ({HasContent} hook).
|
|
142
|
-
# @param list [Component]
|
|
143
|
-
# @return [void]
|
|
144
|
-
def layout(list) = (list.rect = rect)
|
|
145
|
-
|
|
146
163
|
private
|
|
147
164
|
|
|
148
165
|
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
@@ -30,10 +30,18 @@ module Tuile
|
|
|
30
30
|
# The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
|
|
31
31
|
# it for the theming knob.
|
|
32
32
|
#
|
|
33
|
+
# == The inner field is private machinery
|
|
34
|
+
# It has no public accessor: its buffer is the *query*, so swapping the field
|
|
35
|
+
# would break the filtering. What is worth reaching is re-exposed here
|
|
36
|
+
# ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
|
|
37
|
+
#
|
|
38
|
+
# field = Testing.get(Component::TextField, in: combo)
|
|
39
|
+
# field.text = "ap" # type a query without a real loop
|
|
40
|
+
#
|
|
33
41
|
# UI-thread-confined, like every component (see {Screen}).
|
|
34
42
|
class ComboBox < Component
|
|
35
|
-
include HasContent
|
|
36
43
|
include HasValue
|
|
44
|
+
include HasPlaceholder
|
|
37
45
|
|
|
38
46
|
# @param items [Array] the candidate items (any type); also settable via
|
|
39
47
|
# {#items=}.
|
|
@@ -46,10 +54,16 @@ module Tuile
|
|
|
46
54
|
@filtered = []
|
|
47
55
|
@suppressing_filter = false
|
|
48
56
|
|
|
49
|
-
field = TextField.new
|
|
50
|
-
field
|
|
51
|
-
field
|
|
52
|
-
|
|
57
|
+
@field = TextField.new
|
|
58
|
+
# One widget, one surface: this field paints no well of its own, so the
|
|
59
|
+
# composed field's own bg_color reaches the cells the field paints.
|
|
60
|
+
@field.bg_color = BG_INHERIT
|
|
61
|
+
@field.on_change = ->(_text) { refill unless @suppressing_filter }
|
|
62
|
+
# ESC is the one key this combo wants that the field consumes itself, so
|
|
63
|
+
# it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
|
|
64
|
+
# open it keeps the field's own meaning: cancel text entry.
|
|
65
|
+
@field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
|
|
66
|
+
add_child(@field, at: 0)
|
|
53
67
|
|
|
54
68
|
@overlay = ListDropdown.new
|
|
55
69
|
# Outside-click dismissal spans the owner chain, so a click on this
|
|
@@ -98,19 +112,39 @@ module Tuile
|
|
|
98
112
|
|
|
99
113
|
# @return [Point, nil] the field's caret position (the combo delegates the
|
|
100
114
|
# hardware cursor to its field).
|
|
101
|
-
def cursor_position =
|
|
115
|
+
def cursor_position = field.cursor_position
|
|
116
|
+
|
|
117
|
+
# The hint the inner field paints while empty ({HasPlaceholder}) — for a
|
|
118
|
+
# combo that means while nothing is selected *and* nothing is typed, so it
|
|
119
|
+
# reads as a prompt for the query: `"type to filter"`.
|
|
120
|
+
# @return [String, nil]
|
|
121
|
+
def placeholder = field.placeholder
|
|
102
122
|
|
|
103
|
-
# @
|
|
123
|
+
# @param text [String, nil]
|
|
124
|
+
# @return [void]
|
|
125
|
+
# @raise [TypeError] unless `text` is a String or nil.
|
|
126
|
+
def placeholder=(text)
|
|
127
|
+
field.placeholder = text
|
|
128
|
+
end
|
|
104
129
|
|
|
105
|
-
#
|
|
106
|
-
# field via {#layout}.
|
|
130
|
+
# Resizes the field and re-anchors the dropdown if it is open.
|
|
107
131
|
# @param new_rect [Rect]
|
|
108
132
|
# @return [void]
|
|
109
133
|
def rect=(new_rect)
|
|
110
134
|
super
|
|
135
|
+
# One row, or none at all when the combo itself was given none — a
|
|
136
|
+
# starved parent must not hand out a rect it doesn't own.
|
|
137
|
+
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
111
138
|
anchor if @overlay.open?
|
|
112
139
|
end
|
|
113
140
|
|
|
141
|
+
# @return [void]
|
|
142
|
+
def handle_focus
|
|
143
|
+
super
|
|
144
|
+
# The field is what edits, so it takes the focus the combo was given.
|
|
145
|
+
screen.focused = field if field.focusable?
|
|
146
|
+
end
|
|
147
|
+
|
|
114
148
|
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
115
149
|
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
116
150
|
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
@@ -127,15 +161,41 @@ module Tuile
|
|
|
127
161
|
revert_query
|
|
128
162
|
end
|
|
129
163
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
164
|
+
# Moves the dropdown's highlight ({ListDropdown::MOVE_KEYS}) and commits on
|
|
165
|
+
# Enter while it is open; opens it on Down or Enter while it is closed.
|
|
166
|
+
#
|
|
167
|
+
# These arrive by **bubbling**: the inner field is what holds focus, and it
|
|
168
|
+
# declines every one of them (it claims no Up/Down and no Enter of its
|
|
169
|
+
# own), so they reach this ancestor untouched while printable keys and the
|
|
170
|
+
# editing keys are consumed below and never get here. ESC is the exception
|
|
171
|
+
# — the field consumes it, so the combo takes it through
|
|
172
|
+
# {AbstractStringField#on_escape} instead.
|
|
173
|
+
# @param key [String]
|
|
174
|
+
# @return [Boolean] true if consumed.
|
|
175
|
+
def handle_key?(key)
|
|
176
|
+
if @overlay.open?
|
|
177
|
+
return true if @overlay.move(key)
|
|
178
|
+
return false unless key == Keys::ENTER
|
|
179
|
+
|
|
180
|
+
@overlay.choose
|
|
181
|
+
else
|
|
182
|
+
return false unless [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
|
|
183
|
+
|
|
184
|
+
open_menu
|
|
138
185
|
end
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Toggles the dropdown on a left press on the ▾ cell — the only cell of
|
|
190
|
+
# this component's own that is not the field's.
|
|
191
|
+
# @param event [Mouse::DownEvent]
|
|
192
|
+
# @return [Boolean]
|
|
193
|
+
def handle_mouse_down?(event)
|
|
194
|
+
return false unless event.button == :left
|
|
195
|
+
|
|
196
|
+
field.focus
|
|
197
|
+
@overlay.open? ? close_menu : open_menu
|
|
198
|
+
true
|
|
139
199
|
end
|
|
140
200
|
|
|
141
201
|
# @return [void]
|
|
@@ -143,10 +203,16 @@ module Tuile
|
|
|
143
203
|
super
|
|
144
204
|
return if rect.empty?
|
|
145
205
|
|
|
146
|
-
|
|
147
|
-
draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
|
|
206
|
+
draw_char(rect.left + rect.width - 1, rect.top, "▾")
|
|
148
207
|
end
|
|
149
208
|
|
|
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
|
+
|
|
150
216
|
# The one row this combo paints — the full width, at the top of {#rect}.
|
|
151
217
|
# A single-slot container hands its content the whole inner rect, so a
|
|
152
218
|
# ComboBox is routinely assigned more height than it uses; the dropdown
|
|
@@ -154,46 +220,23 @@ module Tuile
|
|
|
154
220
|
# @return [Size]
|
|
155
221
|
def extent = Size.new(rect.width, 1)
|
|
156
222
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
#
|
|
160
|
-
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
161
|
-
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
162
|
-
# @param field [Component]
|
|
223
|
+
# Declines the default's blank: `field` covers every column of the face but
|
|
224
|
+
# the last, and this combo paints the `▾` into that one, so blanking would
|
|
225
|
+
# only dirty a cell it is about to repaint (`D_progress_bar`).
|
|
163
226
|
# @return [void]
|
|
164
|
-
def
|
|
165
|
-
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
166
|
-
end
|
|
227
|
+
def clear_inside_extent = nil
|
|
167
228
|
|
|
168
229
|
private
|
|
169
230
|
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
# @
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if @overlay.move(key)
|
|
180
|
-
true
|
|
181
|
-
elsif key == Keys::ENTER
|
|
182
|
-
@overlay.choose
|
|
183
|
-
true
|
|
184
|
-
elsif key == Keys::ESC
|
|
185
|
-
close_menu
|
|
186
|
-
revert_query
|
|
187
|
-
true
|
|
188
|
-
else
|
|
189
|
-
false
|
|
190
|
-
end
|
|
191
|
-
elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
|
|
192
|
-
open_menu
|
|
193
|
-
true
|
|
194
|
-
else
|
|
195
|
-
false
|
|
196
|
-
end
|
|
231
|
+
# @return [TextField] the inner field, holding the query.
|
|
232
|
+
attr_reader :field
|
|
233
|
+
|
|
234
|
+
# Dismisses the dropdown and puts the current value's label back in the
|
|
235
|
+
# field, undoing an uncommitted query.
|
|
236
|
+
# @return [void]
|
|
237
|
+
def dismiss_menu
|
|
238
|
+
close_menu
|
|
239
|
+
revert_query
|
|
197
240
|
end
|
|
198
241
|
|
|
199
242
|
# Recomputes the matches for the current query, opening the dropdown when
|
|
@@ -201,7 +244,7 @@ module Tuile
|
|
|
201
244
|
# when there are none.
|
|
202
245
|
# @return [void]
|
|
203
246
|
def refill
|
|
204
|
-
@filtered = matching(
|
|
247
|
+
@filtered = matching(field.text)
|
|
205
248
|
if @filtered.empty?
|
|
206
249
|
close_menu
|
|
207
250
|
else
|
|
@@ -245,7 +288,7 @@ module Tuile
|
|
|
245
288
|
# Sets the field's text without triggering a refilter — for programmatic
|
|
246
289
|
# value changes and query reverts, which must not spring the dropdown.
|
|
247
290
|
# Every programmatic write to the field goes through here; a direct
|
|
248
|
-
# `
|
|
291
|
+
# `field.text =` reaches the field's `on_change` and pops the dropdown
|
|
249
292
|
# open on a {#value=} the user never asked to browse.
|
|
250
293
|
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
251
294
|
# shorter query replaced by a longer label would otherwise strand it
|
|
@@ -254,8 +297,8 @@ module Tuile
|
|
|
254
297
|
# @return [void]
|
|
255
298
|
def sync_field(text)
|
|
256
299
|
@suppressing_filter = true
|
|
257
|
-
|
|
258
|
-
|
|
300
|
+
field.text = text
|
|
301
|
+
field.caret = field.text.length
|
|
259
302
|
ensure
|
|
260
303
|
@suppressing_filter = false
|
|
261
304
|
end
|
|
@@ -44,7 +44,7 @@ module Tuile
|
|
|
44
44
|
# There is deliberately no content slot: the body is prose ({#message=}
|
|
45
45
|
# takes a component for the rare rich body, but the dialog then cannot
|
|
46
46
|
# measure it). A dialog collecting *input* is not a confirm dialog — build a
|
|
47
|
-
# `Popup.new(content: your_layout)`. See `
|
|
47
|
+
# `Popup.new(content: your_layout)`. See `design/decisions.md` `D_confirm_window`
|
|
48
48
|
# for the API rationale.
|
|
49
49
|
class ConfirmWindow < Window
|
|
50
50
|
# Keys handed to the message body from anywhere in the dialog, so it
|
|
@@ -221,9 +221,11 @@ module Tuile
|
|
|
221
221
|
end
|
|
222
222
|
|
|
223
223
|
# Focus lands on the first button rather than cascading into the message
|
|
224
|
-
# body, which sits before the button row in the tree.
|
|
224
|
+
# body, which sits before the button row in the tree. `super` is reached
|
|
225
|
+
# only when there is no button to take it: {HasContent#handle_focus} *is*
|
|
226
|
+
# the cascade this override exists to skip.
|
|
225
227
|
# @return [void]
|
|
226
|
-
def
|
|
228
|
+
def handle_focus
|
|
227
229
|
first = @actions.keys.first
|
|
228
230
|
if first.nil?
|
|
229
231
|
super
|
|
@@ -239,11 +241,11 @@ module Tuile
|
|
|
239
241
|
# before this runs.
|
|
240
242
|
# @param key [String]
|
|
241
243
|
# @return [Boolean] true if the key was handled.
|
|
242
|
-
def handle_key(key)
|
|
244
|
+
def handle_key?(key)
|
|
243
245
|
case key
|
|
244
246
|
when Keys::LEFT_ARROW then return focus_button_step(-1)
|
|
245
247
|
when Keys::RIGHT_ARROW then return focus_button_step(1)
|
|
246
|
-
when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key(key) || false
|
|
248
|
+
when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key?(key) || false
|
|
247
249
|
end
|
|
248
250
|
|
|
249
251
|
target = @mnemonics[key.downcase]
|