tuile 0.15.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 +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -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 +9 -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 +6 -5
- data/lib/tuile/component/select.rb +11 -12
- 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 +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- 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
|
@@ -61,6 +61,12 @@ module Tuile
|
|
|
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
|
+
#
|
|
64
70
|
# == Implementation details
|
|
65
71
|
# - **{HasValue#value} and {#value=} raise until overridden.** The inherited
|
|
66
72
|
# pair stores into `@value` and never touches the editor, so a subclass
|
|
@@ -71,7 +77,7 @@ module Tuile
|
|
|
71
77
|
# - **The editor's `on_change` and `on_enter` slots are claimed** — for that
|
|
72
78
|
# guard, and to commit before an app's ENTER handler runs. A slot cannot
|
|
73
79
|
# be shared, so a subclass reacting to buffer edits overrides
|
|
74
|
-
# {#
|
|
80
|
+
# {#handle_editor_change} (every edit), {#value=} or {#commit} rather than
|
|
75
81
|
# reassigning either.
|
|
76
82
|
# - **Not for a field whose editor is a *filter*.** This base assumes the
|
|
77
83
|
# buffer is a rendering of the value, so an edit may change the value.
|
|
@@ -97,8 +103,8 @@ module Tuile
|
|
|
97
103
|
# field's bg_color reaches the cells the editor paints.
|
|
98
104
|
editor.bg_color = BG_INHERIT
|
|
99
105
|
editor.on_change = lambda do |_text|
|
|
100
|
-
|
|
101
|
-
fire_if_changed
|
|
106
|
+
handle_editor_change
|
|
107
|
+
fire_if_changed if notify_on_edit?
|
|
102
108
|
end
|
|
103
109
|
add_child(editor, at: 0)
|
|
104
110
|
end
|
|
@@ -119,7 +125,13 @@ module Tuile
|
|
|
119
125
|
# already reads {HasValue#empty_value}, so clearing through {#value=} could
|
|
120
126
|
# leave the glyphs on screen ({HasBadInput}).
|
|
121
127
|
# @return [void]
|
|
122
|
-
def clear
|
|
128
|
+
def clear
|
|
129
|
+
editor.clear
|
|
130
|
+
# Announced here rather than through the editor's change, so a field
|
|
131
|
+
# holding its notice ({#notify_on_edit?}) still reports an emptying as
|
|
132
|
+
# it happens: emptying is not a half-typed prefix.
|
|
133
|
+
fire_if_changed
|
|
134
|
+
end
|
|
123
135
|
|
|
124
136
|
# @return [String, nil] the hint the editor paints while empty
|
|
125
137
|
# ({HasPlaceholder}).
|
|
@@ -142,9 +154,9 @@ module Tuile
|
|
|
142
154
|
@on_enter = callback
|
|
143
155
|
# Wrapped rather than forwarded, so an app's ENTER handler reads a
|
|
144
156
|
# committed buffer. A nil callback leaves the editor's own slot nil,
|
|
145
|
-
# which is what keeps ENTER *bubbling* — see {#handle_key}.
|
|
157
|
+
# which is what keeps ENTER *bubbling* — see {#handle_key?}.
|
|
146
158
|
editor.on_enter = callback && lambda do
|
|
147
|
-
|
|
159
|
+
commit_and_notify
|
|
148
160
|
callback.call
|
|
149
161
|
end
|
|
150
162
|
end
|
|
@@ -158,8 +170,8 @@ module Tuile
|
|
|
158
170
|
# @param key [String]
|
|
159
171
|
# @return [Boolean] whatever `super` returns — committing never consumes
|
|
160
172
|
# the key.
|
|
161
|
-
def handle_key(key)
|
|
162
|
-
|
|
173
|
+
def handle_key?(key)
|
|
174
|
+
commit_and_notify if key == Keys::ENTER
|
|
163
175
|
super
|
|
164
176
|
end
|
|
165
177
|
|
|
@@ -175,11 +187,11 @@ module Tuile
|
|
|
175
187
|
def active=(flag)
|
|
176
188
|
was = active?
|
|
177
189
|
super
|
|
178
|
-
|
|
190
|
+
commit_and_notify if was && !active?
|
|
179
191
|
end
|
|
180
192
|
|
|
181
193
|
# @return [void]
|
|
182
|
-
def
|
|
194
|
+
def handle_focus
|
|
183
195
|
super
|
|
184
196
|
# The editor is what actually edits, so it takes the focus this field was
|
|
185
197
|
# given — the field itself has no keys of its own.
|
|
@@ -204,6 +216,21 @@ module Tuile
|
|
|
204
216
|
# @return [void]
|
|
205
217
|
def commit = nil
|
|
206
218
|
|
|
219
|
+
# Whether an edit of the buffer fires {HasValue#on_value_change} as it
|
|
220
|
+
# happens. `true` here, which is right wherever every buffer state is a
|
|
221
|
+
# value the user might mean: an {IntegerField} passing through `4` on the
|
|
222
|
+
# way to `42` really does hold 4 for that keystroke. A field whose
|
|
223
|
+
# grammar is **not prefix-closed** answers `false` and lets the notice
|
|
224
|
+
# settle onto the commit gestures instead ({DateField}, `D_date_field`).
|
|
225
|
+
#
|
|
226
|
+
# Only the *push* settles: {HasValue#value} stays a live parse of the
|
|
227
|
+
# buffer either way. And overriding this is half the job — {#commit} is
|
|
228
|
+
# covered here, but the field must fire from its own `value=` too, or a
|
|
229
|
+
# programmatic write and an Up/Down step go unannounced until the next
|
|
230
|
+
# commit.
|
|
231
|
+
# @return [Boolean]
|
|
232
|
+
def notify_on_edit? = true
|
|
233
|
+
|
|
207
234
|
# Called whenever the editor's buffer changes, however the characters
|
|
208
235
|
# arrived — a typed key, a paste, or a {#value=} of this field's own. It
|
|
209
236
|
# is named for the *editor*, not for the user, because those last two are
|
|
@@ -211,7 +238,7 @@ module Tuile
|
|
|
211
238
|
# the *previous* buffer, as a field latching whether its input has settled
|
|
212
239
|
# must ({HasBadInput}).
|
|
213
240
|
# @return [void]
|
|
214
|
-
def
|
|
241
|
+
def handle_editor_change; end
|
|
215
242
|
|
|
216
243
|
# Places the editor across the whole rect; override to reserve cells for a
|
|
217
244
|
# face of your own.
|
|
@@ -227,6 +254,15 @@ module Tuile
|
|
|
227
254
|
|
|
228
255
|
private
|
|
229
256
|
|
|
257
|
+
# Every commit gesture runs through here, so a field holding its notice
|
|
258
|
+
# ({#notify_on_edit?}) announces from one place rather than three; the
|
|
259
|
+
# diff guard makes the call free for a field that fired on the way in.
|
|
260
|
+
# @return [void]
|
|
261
|
+
def commit_and_notify
|
|
262
|
+
commit
|
|
263
|
+
fire_if_changed
|
|
264
|
+
end
|
|
265
|
+
|
|
230
266
|
# Re-emits {HasValue#on_value_change}, but only when {#value} differs from
|
|
231
267
|
# the last one fired — so a buffer edit that leaves the value alone
|
|
232
268
|
# (`"7"`→`"07"`) stays silent.
|
|
@@ -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]
|
|
@@ -83,9 +83,10 @@ module Tuile
|
|
|
83
83
|
|
|
84
84
|
# The composed {List}: an app may *tune* it — its scrollbar, its cursor,
|
|
85
85
|
# `show_cursor_when_inactive` — but never replace it, since this group's
|
|
86
|
-
# renderer and selection are wired into this one.
|
|
87
|
-
#
|
|
88
|
-
# instead of forwarded
|
|
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`).
|
|
89
90
|
# @return [List]
|
|
90
91
|
attr_reader :list
|
|
91
92
|
|
|
@@ -97,7 +98,7 @@ module Tuile
|
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
# @return [void]
|
|
100
|
-
def
|
|
101
|
+
def handle_focus
|
|
101
102
|
super
|
|
102
103
|
# The list is what the arrows drive, so it takes the focus this group
|
|
103
104
|
# was given; the group itself claims only Space.
|
|
@@ -152,7 +153,7 @@ module Tuile
|
|
|
152
153
|
# neither of us wants bubbles on to an ancestor.
|
|
153
154
|
# @param key [String]
|
|
154
155
|
# @return [Boolean]
|
|
155
|
-
def handle_key(key)
|
|
156
|
+
def handle_key?(key)
|
|
156
157
|
return false unless key == " "
|
|
157
158
|
|
|
158
159
|
toggle_at(list.cursor.position)
|
|
@@ -30,9 +30,16 @@ 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
|
|
37
44
|
include HasPlaceholder
|
|
38
45
|
|
|
@@ -47,16 +54,16 @@ module Tuile
|
|
|
47
54
|
@filtered = []
|
|
48
55
|
@suppressing_filter = false
|
|
49
56
|
|
|
50
|
-
field = TextField.new
|
|
57
|
+
@field = TextField.new
|
|
51
58
|
# One widget, one surface: this field paints no well of its own, so the
|
|
52
59
|
# composed field's own bg_color reaches the cells the field paints.
|
|
53
|
-
field.bg_color = BG_INHERIT
|
|
54
|
-
field.on_change = ->(_text) { refill unless @suppressing_filter }
|
|
60
|
+
@field.bg_color = BG_INHERIT
|
|
61
|
+
@field.on_change = ->(_text) { refill unless @suppressing_filter }
|
|
55
62
|
# ESC is the one key this combo wants that the field consumes itself, so
|
|
56
|
-
# it cannot arrive by bubbling the way {#handle_key}'s do. With no menu
|
|
63
|
+
# it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
|
|
57
64
|
# open it keeps the field's own meaning: cancel text entry.
|
|
58
|
-
field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
|
|
59
|
-
|
|
65
|
+
@field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
|
|
66
|
+
add_child(@field, at: 0)
|
|
60
67
|
|
|
61
68
|
@overlay = ListDropdown.new
|
|
62
69
|
# Outside-click dismissal spans the owner chain, so a click on this
|
|
@@ -105,30 +112,39 @@ module Tuile
|
|
|
105
112
|
|
|
106
113
|
# @return [Point, nil] the field's caret position (the combo delegates the
|
|
107
114
|
# hardware cursor to its field).
|
|
108
|
-
def cursor_position =
|
|
115
|
+
def cursor_position = field.cursor_position
|
|
109
116
|
|
|
110
117
|
# The hint the inner field paints while empty ({HasPlaceholder}) — for a
|
|
111
118
|
# combo that means while nothing is selected *and* nothing is typed, so it
|
|
112
119
|
# reads as a prompt for the query: `"type to filter"`.
|
|
113
120
|
# @return [String, nil]
|
|
114
|
-
def placeholder =
|
|
121
|
+
def placeholder = field.placeholder
|
|
115
122
|
|
|
116
123
|
# @param text [String, nil]
|
|
117
124
|
# @return [void]
|
|
118
125
|
# @raise [TypeError] unless `text` is a String or nil.
|
|
119
126
|
def placeholder=(text)
|
|
120
|
-
|
|
127
|
+
field.placeholder = text
|
|
121
128
|
end
|
|
122
129
|
|
|
123
|
-
#
|
|
124
|
-
# field via {#layout}.
|
|
130
|
+
# Resizes the field and re-anchors the dropdown if it is open.
|
|
125
131
|
# @param new_rect [Rect]
|
|
126
132
|
# @return [void]
|
|
127
133
|
def rect=(new_rect)
|
|
128
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)
|
|
129
138
|
anchor if @overlay.open?
|
|
130
139
|
end
|
|
131
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
|
+
|
|
132
148
|
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
133
149
|
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
134
150
|
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
@@ -156,7 +172,7 @@ module Tuile
|
|
|
156
172
|
# {AbstractStringField#on_escape} instead.
|
|
157
173
|
# @param key [String]
|
|
158
174
|
# @return [Boolean] true if consumed.
|
|
159
|
-
def handle_key(key)
|
|
175
|
+
def handle_key?(key)
|
|
160
176
|
if @overlay.open?
|
|
161
177
|
return true if @overlay.move(key)
|
|
162
178
|
return false unless key == Keys::ENTER
|
|
@@ -170,15 +186,16 @@ module Tuile
|
|
|
170
186
|
true
|
|
171
187
|
end
|
|
172
188
|
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
182
199
|
end
|
|
183
200
|
|
|
184
201
|
# @return [void]
|
|
@@ -203,19 +220,17 @@ module Tuile
|
|
|
203
220
|
# @return [Size]
|
|
204
221
|
def extent = Size.new(rect.width, 1)
|
|
205
222
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
210
|
-
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
211
|
-
# @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`).
|
|
212
226
|
# @return [void]
|
|
213
|
-
def
|
|
214
|
-
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
215
|
-
end
|
|
227
|
+
def clear_inside_extent = nil
|
|
216
228
|
|
|
217
229
|
private
|
|
218
230
|
|
|
231
|
+
# @return [TextField] the inner field, holding the query.
|
|
232
|
+
attr_reader :field
|
|
233
|
+
|
|
219
234
|
# Dismisses the dropdown and puts the current value's label back in the
|
|
220
235
|
# field, undoing an uncommitted query.
|
|
221
236
|
# @return [void]
|
|
@@ -229,7 +244,7 @@ module Tuile
|
|
|
229
244
|
# when there are none.
|
|
230
245
|
# @return [void]
|
|
231
246
|
def refill
|
|
232
|
-
@filtered = matching(
|
|
247
|
+
@filtered = matching(field.text)
|
|
233
248
|
if @filtered.empty?
|
|
234
249
|
close_menu
|
|
235
250
|
else
|
|
@@ -273,7 +288,7 @@ module Tuile
|
|
|
273
288
|
# Sets the field's text without triggering a refilter — for programmatic
|
|
274
289
|
# value changes and query reverts, which must not spring the dropdown.
|
|
275
290
|
# Every programmatic write to the field goes through here; a direct
|
|
276
|
-
# `
|
|
291
|
+
# `field.text =` reaches the field's `on_change` and pops the dropdown
|
|
277
292
|
# open on a {#value=} the user never asked to browse.
|
|
278
293
|
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
279
294
|
# shorter query replaced by a longer label would otherwise strand it
|
|
@@ -282,8 +297,8 @@ module Tuile
|
|
|
282
297
|
# @return [void]
|
|
283
298
|
def sync_field(text)
|
|
284
299
|
@suppressing_filter = true
|
|
285
|
-
|
|
286
|
-
|
|
300
|
+
field.text = text
|
|
301
|
+
field.caret = field.text.length
|
|
287
302
|
ensure
|
|
288
303
|
@suppressing_filter = false
|
|
289
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]
|
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
# empty). Give it a single-row {#rect}:
|
|
7
7
|
#
|
|
8
8
|
# field = Component::DateField.new
|
|
9
|
-
# field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per
|
|
9
|
+
# field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
|
|
10
10
|
# field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
|
|
11
11
|
# field.placeholder # => "yyyy-mm-dd"
|
|
12
12
|
# field.clear # empties it; value => nil
|
|
@@ -63,6 +63,19 @@ module Tuile
|
|
|
63
63
|
# `20`, `202` stay quiet, leaving the field (or pressing ENTER) reddens what
|
|
64
64
|
# did not parse, and the next edit clears it again.
|
|
65
65
|
#
|
|
66
|
+
# == The value notice waits for the same gesture
|
|
67
|
+
# A prefix of a date can also parse *cleanly*: typing `1.1.2024` into a
|
|
68
|
+
# `%d.%m.%Y` field passes through `1.1.2`, a perfectly good 1st of January
|
|
69
|
+
# in the year 2. So {HasValue#on_value_change} does not fire per keystroke,
|
|
70
|
+
# but when the user leaves the field or presses ENTER — and a form
|
|
71
|
+
# recalculating from it never sees that year 2.
|
|
72
|
+
#
|
|
73
|
+
# {#value} does *not* wait: it is a live parse of the buffer at every
|
|
74
|
+
# moment, so a save gate reached without leaving the field reads the date
|
|
75
|
+
# on screen. Nor does a change nobody had to type — a {#value=}, an Up/Down
|
|
76
|
+
# step, a {#clear} and a reparse under new {#formats} all fire as they
|
|
77
|
+
# happen.
|
|
78
|
+
#
|
|
66
79
|
# == Implementation details
|
|
67
80
|
# - **The buffer is the single source of truth.** {#value} is a parse of it,
|
|
68
81
|
# recomputed on read — so {#formats=} and {#calendar_start=} can change the
|
|
@@ -135,6 +148,9 @@ module Tuile
|
|
|
135
148
|
def value=(new_value)
|
|
136
149
|
editor.text = new_value.nil? ? "" : new_value.strftime(formats.first)
|
|
137
150
|
editor.caret = editor.text.length
|
|
151
|
+
# The edit above announced nothing ({#notify_on_edit?}); a date written
|
|
152
|
+
# rather than typed has no prefix to be mistaken for a value.
|
|
153
|
+
fire_if_changed
|
|
138
154
|
end
|
|
139
155
|
|
|
140
156
|
# `nil`, not `""`: a date field with no parseable date is empty.
|
|
@@ -244,6 +260,12 @@ module Tuile
|
|
|
244
260
|
settle(true)
|
|
245
261
|
end
|
|
246
262
|
|
|
263
|
+
# `false`: a prefix of a date can parse cleanly (`1.1.2` for `1.1.2024`),
|
|
264
|
+
# so the notice settles onto the commit gestures, exactly as the ink
|
|
265
|
+
# does. The class docs carry the case.
|
|
266
|
+
# @return [Boolean]
|
|
267
|
+
def notify_on_edit? = false
|
|
268
|
+
|
|
247
269
|
# Every prefix of a date is bad input, so the well is latched to the
|
|
248
270
|
# commit gestures instead of painted per keystroke: `2`, `20`, `202` on
|
|
249
271
|
# the way to `2026-09-04` never redden, and a date the field cannot parse
|
|
@@ -255,12 +277,15 @@ module Tuile
|
|
|
255
277
|
# An edit is the user having another go, so the well goes quiet again
|
|
256
278
|
# until the next commit gesture.
|
|
257
279
|
# @return [void]
|
|
258
|
-
def
|
|
280
|
+
def handle_editor_change
|
|
281
|
+
super
|
|
282
|
+
settle(false)
|
|
283
|
+
end
|
|
259
284
|
|
|
260
285
|
# Re-derives the hint (which was *pushed* into the editor, so a repaint
|
|
261
286
|
# alone would keep the old one) and rewrites a buffer that still parses.
|
|
262
287
|
# @return [void]
|
|
263
|
-
def
|
|
288
|
+
def handle_locale_changed
|
|
264
289
|
super
|
|
265
290
|
# Both overridden: this field follows no session convention.
|
|
266
291
|
return if @formats && @calendar_start
|