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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A one-row field pairing a {DateField} and a {TimeField} behind a single
|
|
6
|
+
# `DateTime`. Give it a single-row {#rect}, 16 columns or wider:
|
|
7
|
+
#
|
|
8
|
+
# [2026-09-14] [13:45]
|
|
9
|
+
# ↑ the blank column is {Layout::Box#spacing}, not a component
|
|
10
|
+
#
|
|
11
|
+
# f = Component::DateTimeField.new
|
|
12
|
+
# f.on_value_change = ->(dt) { puts dt.inspect } # DateTime or nil, per commit
|
|
13
|
+
# f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
|
|
14
|
+
# f.clear # empties both halves
|
|
15
|
+
#
|
|
16
|
+
# Neither half is labelled: each paints the hint derived from its own format
|
|
17
|
+
# (`yyyy-mm-dd`, `hh:mm`), which names it while it is empty — the moment
|
|
18
|
+
# naming matters. The *caption* ("Starts at") belongs to the layout around
|
|
19
|
+
# the field, as it does for every field (`design/decisions.md`
|
|
20
|
+
# `D_caption_ownership`).
|
|
21
|
+
#
|
|
22
|
+
# == Tune the halves; don't replace them
|
|
23
|
+
# They are exposed read-only, so everything they configure is reached
|
|
24
|
+
# directly rather than forwarded through a second set of names:
|
|
25
|
+
#
|
|
26
|
+
# f.date_field.formats = "%d.%m.%Y" # ambiguous if it were `f.formats=`
|
|
27
|
+
# f.date_field.calendar_start = Date::ITALY
|
|
28
|
+
# f.time_field.step = 900 # Up/Down walk a quarter hour
|
|
29
|
+
#
|
|
30
|
+
# Two of their knobs are **claimed** by this field and must not be
|
|
31
|
+
# reassigned: each half's {HasValue#on_value_change} (that is how the
|
|
32
|
+
# composite hears them) and each half's {Component#bg_color} (see the well
|
|
33
|
+
# rule below).
|
|
34
|
+
#
|
|
35
|
+
# == The value is a `DateTime` at +00:00
|
|
36
|
+
# Both halves feed it with no adapter, and the offset is a placeholder
|
|
37
|
+
# rather than a zone — {TimeField}'s epoch cost, taken the same way: a value
|
|
38
|
+
# that is visibly wrong where an instant was meant beats one that is subtly
|
|
39
|
+
# wrong. Combine it with a zone at your own boundary (`f.value&.to_time`).
|
|
40
|
+
#
|
|
41
|
+
# Lenient in, strict out, so an input carrying more than the halves can hold
|
|
42
|
+
# does not round-trip:
|
|
43
|
+
#
|
|
44
|
+
# f.value = Time.now # takes today's date and the wall clock
|
|
45
|
+
# f.value == DateTime.now # => false — the zone went, and the seconds with it
|
|
46
|
+
#
|
|
47
|
+
# == Three states, and only one of them is this field's own fault
|
|
48
|
+
# {HasValue#value} is non-nil **iff both halves parse**, so a half going bad
|
|
49
|
+
# nils the whole value ({HasBadInput}: a field holds bad input *or* a value,
|
|
50
|
+
# never both). Who reddens follows from whether the fault is attributable:
|
|
51
|
+
#
|
|
52
|
+
# date half time half value bad_input? red
|
|
53
|
+
# 2026-09-14 13:45 DateTime no nobody
|
|
54
|
+
# (empty) (empty) nil no — empty is not bad input nobody
|
|
55
|
+
# 2026-99-99 13:45 nil "not a valid date" the date half
|
|
56
|
+
# 2026-09-14 (empty) nil "needs both a date and a time" this field
|
|
57
|
+
#
|
|
58
|
+
# A half's bad input is the half's to paint, on its own latch, and this
|
|
59
|
+
# field paints nothing. Half-filled is nobody else's, so this field reddens
|
|
60
|
+
# whole — but **only while it is not active**: it judges you when you leave
|
|
61
|
+
# and goes quiet when you come back to fix it. A validator's verdict
|
|
62
|
+
# ({HasValidation#error_message=}) is by definition not attributable either,
|
|
63
|
+
# and reddens whole with no latch at all.
|
|
64
|
+
#
|
|
65
|
+
# The one cost: **ENTER does not redden this field**, where it reddens a
|
|
66
|
+
# half. A save gate on ENTER over a date with no time still reads
|
|
67
|
+
# {HasBadInput#bad_input?} true and gets the message; only the ink waits for
|
|
68
|
+
# the blur.
|
|
69
|
+
#
|
|
70
|
+
# == Implementation details
|
|
71
|
+
# - **The halves keep their own wells, and this field's ink is *synced* onto
|
|
72
|
+
# them.** `error_bg_color` sits at the top of the background chain, so a
|
|
73
|
+
# child answering {Component#default_bg_color} — every field does — never
|
|
74
|
+
# inherits an ancestor's error level, so marking only this field would
|
|
75
|
+
# leave the halves untouched and reach no cell at all. So the halves are
|
|
76
|
+
# marked {Component::BG_INHERIT} exactly while this field inks, and `nil`
|
|
77
|
+
# otherwise. A guilty half's *own* error well still beats the mark, which
|
|
78
|
+
# is what keeps the ink rule free of arithmetic.
|
|
79
|
+
# - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
|
|
80
|
+
# blanks it in the ambient background, so the two wells read as two fields
|
|
81
|
+
# rather than one long one and each half keeps its own focus highlight.
|
|
82
|
+
# - **A half announces from its own `value=` and its Up/Down step** — the
|
|
83
|
+
# other half of {AbstractWrappingField#notify_on_edit?}'s contract — so
|
|
84
|
+
# writing a value into both halves would announce a half-assembled
|
|
85
|
+
# `DateTime`. Suppressed while applying, and announced once from this
|
|
86
|
+
# field's own diff.
|
|
87
|
+
# - **Nothing else is wired.** Focus forwards through {Layout#handle_focus},
|
|
88
|
+
# the mouse routes down through {Mouse::Router}, each half commits
|
|
89
|
+
# on its own blur (Tab between them canonicalizes the date and leaves this
|
|
90
|
+
# field active), and ENTER commits inside the half and keeps bubbling to
|
|
91
|
+
# the scope's default button.
|
|
92
|
+
#
|
|
93
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
94
|
+
class DateTimeField < Layout::Horizontal
|
|
95
|
+
include HasValue
|
|
96
|
+
include HasBadInput
|
|
97
|
+
|
|
98
|
+
# @return [String] what {HasBadInput#bad_input_message} reports when one
|
|
99
|
+
# half holds a value and the other is empty.
|
|
100
|
+
HALF_FILLED_MESSAGE = "needs both a date and a time"
|
|
101
|
+
private_constant :HALF_FILLED_MESSAGE
|
|
102
|
+
|
|
103
|
+
# What {#value=} needs off whatever it is handed — the two halves' own
|
|
104
|
+
# leniencies, checked together so a rejected value writes neither.
|
|
105
|
+
# @return [Array<Symbol>]
|
|
106
|
+
CIVIL_PARTS = %i[strftime hour min sec].freeze
|
|
107
|
+
private_constant :CIVIL_PARTS
|
|
108
|
+
|
|
109
|
+
# The content ratio, which decides this field's minimum width rather than
|
|
110
|
+
# merely its looks: `2026-09-14` is 10 columns and `13:45` is 5, so at 16
|
|
111
|
+
# the 2:1 split lands exactly 10 / 5. A constant rather than a measurement,
|
|
112
|
+
# so a locale spelling dates longer simply reaches its own minimum later
|
|
113
|
+
# (`design/decisions.md` `D_date_time_field`).
|
|
114
|
+
# @return [Integer]
|
|
115
|
+
DATE_WEIGHT = 2
|
|
116
|
+
private_constant :DATE_WEIGHT
|
|
117
|
+
|
|
118
|
+
# @return [Integer]
|
|
119
|
+
TIME_WEIGHT = 1
|
|
120
|
+
private_constant :TIME_WEIGHT
|
|
121
|
+
|
|
122
|
+
def initialize
|
|
123
|
+
super(spacing: 1)
|
|
124
|
+
@date_field = DateField.new
|
|
125
|
+
@time_field = TimeField.new
|
|
126
|
+
@last_value = empty_value
|
|
127
|
+
@applying = false
|
|
128
|
+
# cross: Fixed[1] is load-bearing — neither half declares an extent, so
|
|
129
|
+
# one handed a three-row rect paints a three-row well.
|
|
130
|
+
add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1])
|
|
131
|
+
add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1])
|
|
132
|
+
[@date_field, @time_field].each { _1.on_value_change = ->(_) { handle_half_change } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [DateField] the left half; tune it, never replace it.
|
|
136
|
+
attr_reader :date_field
|
|
137
|
+
|
|
138
|
+
# @return [TimeField] the right half; tune it, never replace it.
|
|
139
|
+
attr_reader :time_field
|
|
140
|
+
|
|
141
|
+
# @return [DateTime, nil] the two halves assembled, on the calendar
|
|
142
|
+
# {DateField#calendar_start} parsed the date in; `nil` unless both parse.
|
|
143
|
+
def value
|
|
144
|
+
date = date_field.value
|
|
145
|
+
time = time_field.value
|
|
146
|
+
return nil if date.nil? || time.nil?
|
|
147
|
+
|
|
148
|
+
DateTime.new(date.year, date.month, date.day, time.hour, time.min, time.sec, 0, date.start)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Writes the date into one half and the time of day into the other, firing
|
|
152
|
+
# {HasValue#on_value_change} once if the value actually changed.
|
|
153
|
+
#
|
|
154
|
+
# @param new_value [DateTime, Time, nil] anything carrying both a civil
|
|
155
|
+
# date and a time of day; `nil` empties both halves.
|
|
156
|
+
# @return [void]
|
|
157
|
+
# @raise [TypeError] on a `Date` (it has no hour, and midnight would be
|
|
158
|
+
# invented) or anything else missing one of the two — checked before
|
|
159
|
+
# either half is written, so a rejected value leaves the field as it was.
|
|
160
|
+
def value=(new_value)
|
|
161
|
+
unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) }
|
|
162
|
+
raise TypeError,
|
|
163
|
+
"expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
applying do
|
|
167
|
+
date_field.value = new_value
|
|
168
|
+
time_field.value = new_value
|
|
169
|
+
end
|
|
170
|
+
fire_if_changed
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `nil`, not a pair of nils: a field with no parseable date *and* time is
|
|
174
|
+
# empty.
|
|
175
|
+
# @return [nil]
|
|
176
|
+
def empty_value = nil
|
|
177
|
+
|
|
178
|
+
# Empties the *input* of both halves, not just the value — either may be
|
|
179
|
+
# holding glyphs no parse could use ({HasBadInput}).
|
|
180
|
+
# @return [void]
|
|
181
|
+
def clear
|
|
182
|
+
applying { [date_field, time_field].each(&:clear) }
|
|
183
|
+
# Announced even though the halves hold their own notice: emptying is
|
|
184
|
+
# not a half-typed prefix.
|
|
185
|
+
fire_if_changed
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The guilty half's own report, the date's first when both are bad; else
|
|
189
|
+
# the one fault no half can wear, a half-filled pair.
|
|
190
|
+
# @return [String, nil]
|
|
191
|
+
def bad_input_message
|
|
192
|
+
attributed = date_field.bad_input_message || time_field.bad_input_message
|
|
193
|
+
return attributed unless attributed.nil?
|
|
194
|
+
|
|
195
|
+
date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Sets the verdict and syncs the halves' wells onto it.
|
|
199
|
+
# @param new_message [String, StyledString, nil]
|
|
200
|
+
# @return [void]
|
|
201
|
+
def error_message=(new_message)
|
|
202
|
+
super
|
|
203
|
+
sync_half_wells
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Syncs the halves' wells on both focus edges — this field inks its
|
|
207
|
+
# half-filled fault only once you have left it.
|
|
208
|
+
# @param flag [Boolean]
|
|
209
|
+
# @return [void]
|
|
210
|
+
def active=(flag)
|
|
211
|
+
was = active?
|
|
212
|
+
super
|
|
213
|
+
sync_half_wells unless was == active?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Size] the full width, one row — so a taller rect gets the
|
|
217
|
+
# ambient background rather than this field's well ({Component#extent}).
|
|
218
|
+
def extent = Size.new(rect.width, 1)
|
|
219
|
+
|
|
220
|
+
protected
|
|
221
|
+
|
|
222
|
+
# The ink rule in the class doc, as an expression.
|
|
223
|
+
#
|
|
224
|
+
# No latch ivar, deliberately: every input here is a fact something
|
|
225
|
+
# announces, which is what lets the well sync have a complete call list. A
|
|
226
|
+
# half's `bad_input?` moves with every keystroke and announces nothing at
|
|
227
|
+
# all by design, so a latch of this field's own could not follow it.
|
|
228
|
+
# @return [Boolean]
|
|
229
|
+
def bad_input_settled? = !attributable? && !active?
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# @return [Boolean] whether a half is holding input its own value cannot
|
|
234
|
+
# represent, and so wears the error itself.
|
|
235
|
+
def attributable? = date_field.bad_input? || time_field.bad_input?
|
|
236
|
+
|
|
237
|
+
# One idempotent sync over one condition, this field the sole writer of
|
|
238
|
+
# its halves' {Component#bg_color} — the shape a hook-owned resource takes.
|
|
239
|
+
# Called from the three places {HasValidation#error_ink?} can change: a
|
|
240
|
+
# verdict, a focus edge, and a half's announcement. Leave one out and this
|
|
241
|
+
# field stops inking while its halves stay marked, i.e. both halves flat
|
|
242
|
+
# with their wells gone.
|
|
243
|
+
# @return [void]
|
|
244
|
+
def sync_half_wells
|
|
245
|
+
ink = error_ink?
|
|
246
|
+
[date_field, time_field].each { _1.bg_color = ink ? BG_INHERIT : nil }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @return [void]
|
|
250
|
+
def handle_half_change
|
|
251
|
+
sync_half_wells
|
|
252
|
+
fire_if_changed unless @applying
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Runs `block` with the halves' notices suppressed, so a value written
|
|
256
|
+
# into both is announced once rather than half-assembled.
|
|
257
|
+
# @return [void]
|
|
258
|
+
def applying
|
|
259
|
+
@applying = true
|
|
260
|
+
yield
|
|
261
|
+
ensure
|
|
262
|
+
@applying = false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @return [void]
|
|
266
|
+
def fire_if_changed
|
|
267
|
+
v = value
|
|
268
|
+
return if v == @last_value
|
|
269
|
+
|
|
270
|
+
@last_value = v
|
|
271
|
+
on_value_change&.call(v)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -25,7 +25,7 @@ module Tuile
|
|
|
25
25
|
#
|
|
26
26
|
# == Implementation details
|
|
27
27
|
# An includer overrides {#bad_input_message} and nothing else. Two rules
|
|
28
|
-
# bind that override, and `
|
|
28
|
+
# bind that override, and `design/decisions.md` `D_bad_input` has the why:
|
|
29
29
|
#
|
|
30
30
|
# - **Empty input is not bad input.** Return `nil` for an empty buffer even
|
|
31
31
|
# though it parses to nothing, or every blank *optional* field blocks a
|
|
@@ -79,7 +79,7 @@ module Tuile
|
|
|
79
79
|
# def bad_input_settled? = @settled # set on commit, cleared on an edit
|
|
80
80
|
#
|
|
81
81
|
# It gates the **ink only**: {#bad_input?} is a pull, and a save gate
|
|
82
|
-
# asking at a click must get the answer settled or not (`
|
|
82
|
+
# asking at a click must get the answer settled or not (`design/decisions.md`
|
|
83
83
|
# `D_bad_input`).
|
|
84
84
|
# @return [Boolean]
|
|
85
85
|
def bad_input_settled? = true
|
|
@@ -62,7 +62,7 @@ module Tuile
|
|
|
62
62
|
|
|
63
63
|
old = self.content
|
|
64
64
|
# Detached without notifying, and notified at the very end: the focus
|
|
65
|
-
# repair in
|
|
65
|
+
# repair in handle_child_removed cascades into whatever occupies the slot
|
|
66
66
|
# *now*, so it has to see the new content (window_spec pins it).
|
|
67
67
|
detach_child(old) unless old.nil?
|
|
68
68
|
@content = content
|
|
@@ -71,7 +71,7 @@ module Tuile
|
|
|
71
71
|
content.invalidate
|
|
72
72
|
layout(content)
|
|
73
73
|
end
|
|
74
|
-
|
|
74
|
+
handle_child_removed(old) unless old.nil?
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# @param rect [Rect]
|
|
@@ -82,7 +82,7 @@ module Tuile
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
# @return [void]
|
|
85
|
-
def
|
|
85
|
+
def handle_focus
|
|
86
86
|
super
|
|
87
87
|
# Let the content component receive focus, so that it can immediately
|
|
88
88
|
# start responding to key presses. Hidden content is left alone, so
|
|
@@ -21,7 +21,7 @@ module Tuile
|
|
|
21
21
|
# Include it in a field whose *input shape* is unguessable from an empty
|
|
22
22
|
# well. Not in {Select}, the near miss: a blank face plus `▾` already reads
|
|
23
23
|
# as "nothing picked", so an absent enum *value* needs no hint the way an
|
|
24
|
-
# unguessable input *format* does (`
|
|
24
|
+
# unguessable input *format* does (`design/decisions.md` `D_select`).
|
|
25
25
|
#
|
|
26
26
|
# == Implementation details
|
|
27
27
|
# The ink is {Theme#placeholder_color}, calibrated to be *barely* visible —
|
|
@@ -30,7 +30,7 @@ module Tuile
|
|
|
30
30
|
# nothing — where red *text* is invisible on the empty field that is the
|
|
31
31
|
# required-field case, and invisible again on content carrying colors of its
|
|
32
32
|
# own. It takes two tokens rather than one because a focused invalid field
|
|
33
|
-
# still has to look focused (`
|
|
33
|
+
# still has to look focused (`design/decisions.md` `D_has_validation`).
|
|
34
34
|
#
|
|
35
35
|
# The well reaches the whole widget with nothing forwarding it: a composed
|
|
36
36
|
# field's inner face is marked {Component::BG_INHERIT} and a group's {List}
|
|
@@ -53,7 +53,7 @@ module Tuile
|
|
|
53
53
|
#
|
|
54
54
|
# Unlike `bad_input?`, this fact is *discrete* — asserted at a click or a
|
|
55
55
|
# binder pass, not recomputed per keystroke — which is why it carries a
|
|
56
|
-
# change notice where `bad_input?` deliberately doesn't (`
|
|
56
|
+
# change notice where `bad_input?` deliberately doesn't (`design/decisions.md`
|
|
57
57
|
# `D_bad_input`, `D_has_validation`).
|
|
58
58
|
module HasValidation
|
|
59
59
|
# @return [Proc, Method, nil] one-arg callable fired with the new message
|
|
@@ -68,7 +68,7 @@ module Tuile
|
|
|
68
68
|
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
69
69
|
# a read-only display field could override back to `false`. Only
|
|
70
70
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
71
|
-
# composing wrappers, so it stays per-class (`
|
|
71
|
+
# composing wrappers, so it stays per-class (`design/decisions.md`
|
|
72
72
|
# `D_integer_field`).
|
|
73
73
|
# @return [Boolean]
|
|
74
74
|
def focusable? = true
|
|
@@ -190,7 +190,10 @@ module Tuile
|
|
|
190
190
|
# {Component#visible=} buys over `remove` plus `add(…, at:)`.
|
|
191
191
|
# @param _child [Component]
|
|
192
192
|
# @return [void]
|
|
193
|
-
def
|
|
193
|
+
def handle_child_visibility_changed(_child)
|
|
194
|
+
super
|
|
195
|
+
relayout
|
|
196
|
+
end
|
|
194
197
|
|
|
195
198
|
private
|
|
196
199
|
|
|
@@ -173,7 +173,7 @@ module Tuile
|
|
|
173
173
|
# the popup. Layouts don't paint any visible chrome of their own
|
|
174
174
|
# (the auto-cleared background is just blank space), so this has no
|
|
175
175
|
# mouse-routing consequences — clicks on a gap area land back on the
|
|
176
|
-
# Layout itself and the
|
|
176
|
+
# Layout itself and the handle_focus cascade forwards to a tab stop.
|
|
177
177
|
def focusable? = true
|
|
178
178
|
|
|
179
179
|
# Adds a child component to this layout.
|
|
@@ -198,7 +198,7 @@ module Tuile
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
# @return [void]
|
|
201
|
-
def
|
|
201
|
+
def handle_focus
|
|
202
202
|
super
|
|
203
203
|
# Forward focus to the first interactive widget in the subtree so the
|
|
204
204
|
# user can start typing / cursoring immediately. Prefer a {#tab_stop?}
|
|
@@ -210,7 +210,7 @@ module Tuile
|
|
|
210
210
|
# Both halves skip hidden subtrees — this is the cascade that would
|
|
211
211
|
# otherwise walk straight back into the pane just hidden.
|
|
212
212
|
first_tab_stop = nil
|
|
213
|
-
|
|
213
|
+
walk_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
|
|
214
214
|
if first_tab_stop
|
|
215
215
|
screen.focused = first_tab_stop
|
|
216
216
|
else
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
#
|
|
7
7
|
# list = Component::List.new
|
|
8
8
|
# list.items = people
|
|
9
|
-
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.
|
|
9
|
+
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
|
|
10
10
|
# list.cursor = List::Cursor.new # a bare list has none
|
|
11
11
|
# list.on_item_chosen = ->(index, person) { open(person) }
|
|
12
12
|
#
|
|
@@ -249,7 +249,7 @@ module Tuile
|
|
|
249
249
|
|
|
250
250
|
# @param key [String] a key.
|
|
251
251
|
# @return [Boolean] true if the key was handled.
|
|
252
|
-
def handle_key(key)
|
|
252
|
+
def handle_key?(key)
|
|
253
253
|
if key == Keys::PAGE_UP
|
|
254
254
|
move_scroll_top_row_by(-viewport_rows)
|
|
255
255
|
true
|
|
@@ -259,7 +259,7 @@ module Tuile
|
|
|
259
259
|
elsif key == Keys::ENTER && cursor_on_item?
|
|
260
260
|
fire_item_chosen
|
|
261
261
|
true
|
|
262
|
-
elsif @cursor.handle_key(key, @items.size, viewport_rows)
|
|
262
|
+
elsif @cursor.handle_key?(key, @items.size, viewport_rows)
|
|
263
263
|
move_viewport_to_cursor
|
|
264
264
|
notify_cursor_changed
|
|
265
265
|
invalidate
|
|
@@ -316,25 +316,35 @@ module Tuile
|
|
|
316
316
|
true
|
|
317
317
|
end
|
|
318
318
|
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
elsif event.button == :scroll_up
|
|
326
|
-
move_scroll_top_row_by(-4)
|
|
327
|
-
else
|
|
328
|
-
return unless rect.contains?(event.point)
|
|
319
|
+
# Moves the cursor to the pressed row and fires {#on_item_chosen}; what
|
|
320
|
+
# each {Cursor} does with a press is its own.
|
|
321
|
+
# @param event [Mouse::DownEvent]
|
|
322
|
+
# @return [Boolean]
|
|
323
|
+
def handle_mouse_down?(event)
|
|
324
|
+
return false unless event.button == :left
|
|
329
325
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
end
|
|
336
|
-
fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
326
|
+
item_index = event.y - rect.top + scroll_top_row
|
|
327
|
+
if @cursor.handle_mouse_down?(item_index, event, @items.size)
|
|
328
|
+
move_viewport_to_cursor
|
|
329
|
+
notify_cursor_changed
|
|
330
|
+
invalidate
|
|
337
331
|
end
|
|
332
|
+
fire_item_chosen if item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Scrolls four rows a notch, and declines — so the notch bubbles to an
|
|
337
|
+
# ancestor scroller — once this list is at that end of its items.
|
|
338
|
+
# @param event [Mouse::ScrollEvent]
|
|
339
|
+
# @return [Boolean]
|
|
340
|
+
def handle_mouse_scroll?(event)
|
|
341
|
+
before = scroll_top_row
|
|
342
|
+
case event.direction
|
|
343
|
+
when :down then move_scroll_top_row_by(4)
|
|
344
|
+
when :up then move_scroll_top_row_by(-4)
|
|
345
|
+
else return false
|
|
346
|
+
end
|
|
347
|
+
scroll_top_row != before
|
|
338
348
|
end
|
|
339
349
|
|
|
340
350
|
# Paints the visible items into {#rect}, rendering the ones not already
|
|
@@ -379,15 +389,15 @@ module Tuile
|
|
|
379
389
|
# @param _item_count [Integer]
|
|
380
390
|
# @param _viewport_rows [Integer]
|
|
381
391
|
# @return [Boolean]
|
|
382
|
-
def handle_key(_key, _item_count, _viewport_rows)
|
|
392
|
+
def handle_key?(_key, _item_count, _viewport_rows)
|
|
383
393
|
false
|
|
384
394
|
end
|
|
385
395
|
|
|
386
396
|
# @param _item_index [Integer]
|
|
387
|
-
# @param _event [
|
|
397
|
+
# @param _event [Mouse::DownEvent]
|
|
388
398
|
# @param _item_count [Integer]
|
|
389
399
|
# @return [Boolean]
|
|
390
|
-
def
|
|
400
|
+
def handle_mouse_down?(_item_index, _event, _item_count)
|
|
391
401
|
false
|
|
392
402
|
end
|
|
393
403
|
|
|
@@ -422,7 +432,7 @@ module Tuile
|
|
|
422
432
|
# @param item_count [Integer] number of items in the list.
|
|
423
433
|
# @param viewport_rows [Integer] number of visible rows.
|
|
424
434
|
# @return [Boolean] true if the cursor moved.
|
|
425
|
-
def handle_key(key, item_count, viewport_rows)
|
|
435
|
+
def handle_key?(key, item_count, viewport_rows)
|
|
426
436
|
case key
|
|
427
437
|
when *Keys::DOWN_ARROWS
|
|
428
438
|
go_down_by(1, item_count)
|
|
@@ -441,11 +451,11 @@ module Tuile
|
|
|
441
451
|
end
|
|
442
452
|
end
|
|
443
453
|
|
|
444
|
-
# @param item_index [Integer] the item
|
|
445
|
-
# @param event [
|
|
454
|
+
# @param item_index [Integer] the item pressed on.
|
|
455
|
+
# @param event [Mouse::DownEvent] the event.
|
|
446
456
|
# @param item_count [Integer] number of items in the list.
|
|
447
|
-
# @return [Boolean] true if the
|
|
448
|
-
def
|
|
457
|
+
# @return [Boolean] true if the cursor moved.
|
|
458
|
+
def handle_mouse_down?(item_index, event, item_count)
|
|
449
459
|
if event.button == :left
|
|
450
460
|
go(item_index.clamp(nil, item_count - 1))
|
|
451
461
|
else
|
|
@@ -507,10 +517,10 @@ module Tuile
|
|
|
507
517
|
end
|
|
508
518
|
|
|
509
519
|
# @param item_index [Integer]
|
|
510
|
-
# @param event [
|
|
520
|
+
# @param event [Mouse::DownEvent]
|
|
511
521
|
# @param _item_count [Integer]
|
|
512
522
|
# @return [Boolean]
|
|
513
|
-
def
|
|
523
|
+
def handle_mouse_down?(item_index, event, _item_count)
|
|
514
524
|
if event.button == :left
|
|
515
525
|
prev_pos = @positions.reverse_each.find { _1 <= item_index }
|
|
516
526
|
return go_to_first if prev_pos.nil?
|
|
@@ -571,7 +581,7 @@ module Tuile
|
|
|
571
581
|
# was skipped because there was no viewport — re-run it now that there
|
|
572
582
|
# is one, so the list snaps to the bottom on first paint.
|
|
573
583
|
# @return [void]
|
|
574
|
-
def
|
|
584
|
+
def handle_width_changed
|
|
575
585
|
super
|
|
576
586
|
drop_row_cache
|
|
577
587
|
update_scroll_top_row_if_auto_scroll
|
|
@@ -738,7 +748,7 @@ module Tuile
|
|
|
738
748
|
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
739
749
|
# viewport the "items minus viewport" formula yields `@items.size`,
|
|
740
750
|
# which would leave `scroll_top_row` past the last item once a real rect
|
|
741
|
-
# arrives. {#
|
|
751
|
+
# arrives. {#handle_width_changed} re-runs this hook when the rect grows so
|
|
742
752
|
# the snap-to-bottom intent is preserved.
|
|
743
753
|
#
|
|
744
754
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
@@ -195,7 +195,7 @@ module Tuile
|
|
|
195
195
|
# @param width [Integer] the panel's width in columns, clamped to the
|
|
196
196
|
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
197
197
|
# width and would be meaningless here, so the caller measures (see
|
|
198
|
-
# `
|
|
198
|
+
# `design/decisions.md` `D_select` on why the width policy stays with the
|
|
199
199
|
# driver).
|
|
200
200
|
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
201
201
|
# @return [void]
|
|
@@ -244,7 +244,7 @@ module Tuile
|
|
|
244
244
|
def move(key)
|
|
245
245
|
return false unless open? && MOVE_KEYS.include?(key)
|
|
246
246
|
|
|
247
|
-
@list.handle_key(key)
|
|
247
|
+
@list.handle_key?(key)
|
|
248
248
|
true
|
|
249
249
|
end
|
|
250
250
|
|
|
@@ -253,7 +253,7 @@ module Tuile
|
|
|
253
253
|
# own Enter branch.
|
|
254
254
|
# @return [Boolean] true iff a row was chosen (false when the cursor is
|
|
255
255
|
# off-content).
|
|
256
|
-
def choose = @list.handle_key(Keys::ENTER)
|
|
256
|
+
def choose = @list.handle_key?(Keys::ENTER)
|
|
257
257
|
end
|
|
258
258
|
end
|
|
259
259
|
end
|
|
@@ -8,18 +8,18 @@ module Tuile
|
|
|
8
8
|
# machinery of {MenuBar}; an app never names it.
|
|
9
9
|
#
|
|
10
10
|
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
11
|
-
# return true if cascade.handle_key(key) # MenuBar#handle_key
|
|
11
|
+
# return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
|
|
12
12
|
# cascade.close # focus lost, or rect changed
|
|
13
13
|
#
|
|
14
14
|
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
15
15
|
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
16
|
-
# arrives via {MenuBar#handle_key}, which offers it here first. That is
|
|
16
|
+
# arrives via {MenuBar#handle_key?}, which offers it here first. That is
|
|
17
17
|
# {Component::Select}'s architecture extended to N levels, and it is why
|
|
18
18
|
# nothing in the key-dispatch ladder changes.
|
|
19
19
|
#
|
|
20
20
|
# Widths are measured here, per level — the panel is as wide as the level's
|
|
21
21
|
# widest label — because {ListDropdown} deliberately measures nothing
|
|
22
|
-
# itself (`
|
|
22
|
+
# itself (`design/decisions.md` `D_select`).
|
|
23
23
|
#
|
|
24
24
|
# == Implementation details
|
|
25
25
|
# While open it consumes **everything** except the two keys that mean
|
|
@@ -73,7 +73,7 @@ module Tuile
|
|
|
73
73
|
# @return [Boolean] `true` when consumed — almost always, while open.
|
|
74
74
|
# `false` when closed, and for the two sideways keys {MenuBar} answers
|
|
75
75
|
# (see the class docs).
|
|
76
|
-
def handle_key(key)
|
|
76
|
+
def handle_key?(key)
|
|
77
77
|
return false unless open?
|
|
78
78
|
return true if deepest.move(key)
|
|
79
79
|
|
|
@@ -105,7 +105,7 @@ module Tuile
|
|
|
105
105
|
# @param key [String] a single printable, already downcased.
|
|
106
106
|
# @return [Boolean] whether an item on the deepest level claimed it. A
|
|
107
107
|
# miss is never offered to a shallower level.
|
|
108
|
-
def handle_mnemonic(key)
|
|
108
|
+
def handle_mnemonic?(key)
|
|
109
109
|
return false unless open?
|
|
110
110
|
|
|
111
111
|
level = depth - 1
|