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
data/lib/tuile/testing.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Tuile
|
|
|
4
4
|
# Finds a component in the tree, so a spec can drive the UI it built four
|
|
5
5
|
# layers down:
|
|
6
6
|
#
|
|
7
|
-
# Testing.get(
|
|
8
|
-
# Testing.get(id: :name).value = "Zaphod"
|
|
7
|
+
# Testing.get(id: :save).handle_key?(Keys::ENTER)
|
|
8
|
+
# Testing.get(Component::TextField, id: :name).value = "Zaphod"
|
|
9
9
|
# Testing.find(Component::Checkbox, in: pane, count: 3)
|
|
10
10
|
#
|
|
11
11
|
# {.get} demands exactly one match and raises with a {.dump} of the tree it
|
|
@@ -13,6 +13,16 @@ module Tuile
|
|
|
13
13
|
# search {Screen}'s whole tree by default — popups included, since they live
|
|
14
14
|
# under the same {ScreenPane} as the content — or the subtree given as `in:`.
|
|
15
15
|
#
|
|
16
|
+
# **The handles are structural — a class, an {Component#id}, a subtree — never
|
|
17
|
+
# what a component *says*.** There is no `caption:` filter: chrome text is UI
|
|
18
|
+
# copy, and a lookup keyed to it makes a rewording break a spec that tests
|
|
19
|
+
# nothing about the wording. Where a spec does want it, the block says so
|
|
20
|
+
# per-class and needs nothing from the framework:
|
|
21
|
+
#
|
|
22
|
+
# Testing.get(Component::Button) { _1.caption.to_s == "Save" }
|
|
23
|
+
#
|
|
24
|
+
# See `D_component_lookup`.
|
|
25
|
+
#
|
|
16
26
|
# **Call these qualified**, as above: `find` and `get` collide with names a
|
|
17
27
|
# spec suite is likely to have already (Capybara's `find`), so there is no
|
|
18
28
|
# `Component#get` and mixing this module in is not recommended. Tuile's own
|
|
@@ -29,20 +39,69 @@ module Tuile
|
|
|
29
39
|
# There is deliberately no `visible:` filter handing one back to drive
|
|
30
40
|
# (`D_visibility`).
|
|
31
41
|
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
42
|
+
# == Setup
|
|
43
|
+
#
|
|
44
|
+
# A spec dispatches no event, so nothing lays out what a mutation left
|
|
45
|
+
# pending: call {Component#flush_layout} before reading a rect — it settles
|
|
46
|
+
# the whole tree, from any component in it. {.place} sizes a component the
|
|
47
|
+
# way its parent would (`rect=` raises outside the parent's `relayout`) and
|
|
48
|
+
# settles on its own:
|
|
49
|
+
#
|
|
50
|
+
# Testing.place(field, Rect.new(0, 0, 20, 1)) # already settled
|
|
51
|
+
# field.visible = false
|
|
52
|
+
# field.flush_layout # before reading any rect
|
|
53
|
+
#
|
|
54
|
+
# `place` never attaches: that is the spec's own step. The pane hands its
|
|
55
|
+
# content the whole screen, so to attach at a size put an `Absolute` between:
|
|
56
|
+
#
|
|
57
|
+
# holder = Component::Layout::Absolute.new
|
|
58
|
+
# Screen.instance.content = holder
|
|
59
|
+
# holder.add(field, Rect.new(0, 0, 20, 1)) # attached, at that rect
|
|
60
|
+
#
|
|
61
|
+
# The terminal is 160×50; pick another size with `Screen.fake(width:, height:)`,
|
|
62
|
+
# or resize mid-example as the terminal would report it:
|
|
63
|
+
#
|
|
64
|
+
# Screen.instance.resize_terminal(80, 24) # FakeScreen#resize_terminal
|
|
65
|
+
#
|
|
66
|
+
# == Gestures
|
|
67
|
+
#
|
|
68
|
+
# Drive what you found as a *user* would: {.click} routes a real press and
|
|
69
|
+
# {.set_value} refuses a field the keyboard cannot reach, where a bare
|
|
70
|
+
# `handle_key?` or `value=` on a handle asserts neither.
|
|
71
|
+
# {Tuile::Testing::Gestures} gives them receiver syntax:
|
|
72
|
+
#
|
|
73
|
+
# using Tuile::Testing::Gestures
|
|
74
|
+
#
|
|
75
|
+
# Testing.get(Component::TextField, id: :name)._value = "Zaphod"
|
|
76
|
+
# Testing.get(Component::Button, id: :save)._click
|
|
77
|
+
#
|
|
78
|
+
# == Painting
|
|
79
|
+
#
|
|
80
|
+
# {.paint} returns what one component paints, in a {Buffer} of its own whose
|
|
81
|
+
# `(0, 0)` is the component's top-left. For what the *user* sees, popups
|
|
82
|
+
# included, call {Screen#repaint} and read {Screen#buffer} at the component's
|
|
83
|
+
# {Component#absolute_rect}.
|
|
84
|
+
#
|
|
85
|
+
# See book ch8 for the worked usage, and `design/decisions.md`
|
|
86
|
+
# `D_component_lookup` / `D_test_gestures` for the design.
|
|
35
87
|
module Testing
|
|
36
|
-
# Raised
|
|
37
|
-
#
|
|
38
|
-
|
|
88
|
+
# Raised by every lookup and every gesture that does not hold: the match
|
|
89
|
+
# count is not the one asked for, or a gesture was handed a component no
|
|
90
|
+
# user could have operated.
|
|
91
|
+
#
|
|
92
|
+
# **Not a {Tuile::Error}**, which is production's. It descends from
|
|
93
|
+
# `Exception` rather than `StandardError` for the reason
|
|
94
|
+
# `Minitest::Assertion` does: a stray `rescue` must not swallow a failed
|
|
95
|
+
# assertion. Tuile defines its own because the gem depends on no test
|
|
96
|
+
# framework.
|
|
97
|
+
class AssertionError < Exception; end # rubocop:disable Lint/InheritException
|
|
39
98
|
|
|
40
99
|
class << self
|
|
41
100
|
# Every component in the searched tree matching the spec, in pre-order.
|
|
42
101
|
#
|
|
43
102
|
# find(Component::Button) # every button on screen
|
|
44
103
|
# find(Component::HasBadInput, in: form) # a mixin works too
|
|
45
|
-
# find(Component::Label
|
|
104
|
+
# find(Component::Label) { _1.text.to_s.start_with?("Total") }
|
|
46
105
|
# find(Component::Popup, count: 1..) # assert at least one
|
|
47
106
|
#
|
|
48
107
|
# @param klass [Module] matched with `is_a?`, so a mixin
|
|
@@ -50,27 +109,24 @@ module Tuile
|
|
|
50
109
|
# @param in [Component, nil] root of the subtree to search, itself
|
|
51
110
|
# included. Defaults to `Screen.instance.pane` — the whole UI.
|
|
52
111
|
# @param id [Symbol, nil] matched against {Component#id}.
|
|
53
|
-
# @param caption [String, Regexp, nil] matched with `===` against
|
|
54
|
-
# {Component::HasCaption#caption}`.to_s`, so a String is exact and a
|
|
55
|
-
# Regexp is a partial match. Never matches a component without a
|
|
56
|
-
# caption.
|
|
57
112
|
# @param count [Integer, Range, nil] how many matches are expected; any
|
|
58
113
|
# number when nil.
|
|
59
114
|
# @yield [component] optional extra predicate; a component matches only
|
|
60
|
-
# when the block returns truthy.
|
|
115
|
+
# when the block returns truthy. This is where a text test goes — see
|
|
116
|
+
# the module doc for why there is no `caption:` term.
|
|
61
117
|
# @yieldparam component [Component]
|
|
62
118
|
# @yieldreturn [Boolean]
|
|
63
|
-
# @raise [
|
|
119
|
+
# @raise [AssertionError] if `count` is given and the match count differs.
|
|
64
120
|
# @return [Array<Component>]
|
|
65
|
-
def find(klass = Component, in: nil, id: nil,
|
|
121
|
+
def find(klass = Component, in: nil, id: nil, count: nil, &predicate)
|
|
66
122
|
# `in` is a Ruby keyword, so the local it binds is unreachable by name.
|
|
67
123
|
scope = binding.local_variable_get(:in) || Screen.instance.pane
|
|
68
|
-
spec = ->(c) { matches_spec?(c, klass, id,
|
|
124
|
+
spec = ->(c) { matches_spec?(c, klass, id, predicate) }
|
|
69
125
|
matches = []
|
|
70
126
|
scope.walk_shown_tree { |c| matches << c if spec.call(c) }
|
|
71
127
|
return matches if count.nil? || spec_match?(count, matches.size)
|
|
72
128
|
|
|
73
|
-
raise
|
|
129
|
+
raise AssertionError, failure(klass, id, predicate, count, matches, scope, spec)
|
|
74
130
|
end
|
|
75
131
|
|
|
76
132
|
# The one component matching the spec — {.find} with `count: 1`, so it
|
|
@@ -81,15 +137,14 @@ module Tuile
|
|
|
81
137
|
# @param klass [Module] see {.find}.
|
|
82
138
|
# @param in [Component, nil] see {.find}.
|
|
83
139
|
# @param id [Symbol, nil] see {.find}.
|
|
84
|
-
# @param caption [String, Regexp, nil] see {.find}.
|
|
85
140
|
# @yield [component] see {.find}.
|
|
86
141
|
# @yieldparam component [Component]
|
|
87
142
|
# @yieldreturn [Boolean]
|
|
88
|
-
# @raise [
|
|
143
|
+
# @raise [AssertionError] unless exactly one component matches.
|
|
89
144
|
# @return [Component]
|
|
90
|
-
def get(klass = Component, in: nil, id: nil,
|
|
145
|
+
def get(klass = Component, in: nil, id: nil, &predicate)
|
|
91
146
|
scope = binding.local_variable_get(:in)
|
|
92
|
-
find(klass, in: scope, id:,
|
|
147
|
+
find(klass, in: scope, id:, count: 1, &predicate).first
|
|
93
148
|
end
|
|
94
149
|
|
|
95
150
|
# The searched tree, one component per row, indented by depth and with
|
|
@@ -108,6 +163,7 @@ module Tuile
|
|
|
108
163
|
# that matched the spec but were skipped for being hidden.
|
|
109
164
|
# @return [String]
|
|
110
165
|
def dump(scope, marked = [], excluded = [])
|
|
166
|
+
settle_layout
|
|
111
167
|
base = scope.depth
|
|
112
168
|
rows = []
|
|
113
169
|
# walk_tree, not walk_shown_tree: a reader looks here to find out where
|
|
@@ -117,37 +173,257 @@ module Tuile
|
|
|
117
173
|
elsif excluded.any? { _1.equal?(c) } then "⊘"
|
|
118
174
|
else " "
|
|
119
175
|
end
|
|
120
|
-
row = c
|
|
176
|
+
row = brief(c)
|
|
121
177
|
rows << "#{mark} #{" " * (c.depth - base)}#{row}"
|
|
122
178
|
end
|
|
123
179
|
rows.join("\n")
|
|
124
180
|
end
|
|
125
181
|
|
|
182
|
+
# Clicks `component` as the terminal would: a press and a release at the
|
|
183
|
+
# top-left cell of its {Component#absolute_extent_rect}, posted through
|
|
184
|
+
# {Screen#handle_mouse}, so it focuses and dismisses popups exactly as a
|
|
185
|
+
# real click does.
|
|
186
|
+
#
|
|
187
|
+
# Testing.click(Testing.get(Component::Button, id: :save))
|
|
188
|
+
#
|
|
189
|
+
# It does **not** raise when the press lands and nobody claims it: a user
|
|
190
|
+
# really can click a {Component::Label} and have nothing happen, so this
|
|
191
|
+
# asserts the click was possible, not that it achieved something.
|
|
192
|
+
# @param component [Component]
|
|
193
|
+
# @param button [Symbol] `:left`, `:middle` or `:right`.
|
|
194
|
+
# @raise [AssertionError] unless a press at that cell reaches `component`
|
|
195
|
+
# — it is unattached, hidden, collapsed to no cells, or covered.
|
|
196
|
+
# @return [void]
|
|
197
|
+
def click(component, button: :left)
|
|
198
|
+
point = gesture_point(component)
|
|
199
|
+
path = component_path_at(point)
|
|
200
|
+
unless path.include?(component)
|
|
201
|
+
reached = path.empty? ? "nothing — a modal popup is open" : brief(path.last)
|
|
202
|
+
raise AssertionError, "#{brief(component)} is not clickable at #{point.x},#{point.y}: " \
|
|
203
|
+
"a press there reaches #{reached}\n" \
|
|
204
|
+
"searched:\n#{dump(Screen.instance.pane, [component])}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Screen.instance.handle_mouse(Mouse::DownEvent.new(button, point.x, point.y))
|
|
208
|
+
Screen.instance.handle_mouse(Mouse::UpEvent.new(point.x, point.y))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Sets a field's value as a user who could reach it would.
|
|
212
|
+
#
|
|
213
|
+
# Testing.set_value(Testing.get(Component::IntegerField, id: :age), 25)
|
|
214
|
+
#
|
|
215
|
+
# **Moves no focus** — no keystroke is involved — and writes through
|
|
216
|
+
# {Component::HasValue#set_value} with `from_user: true`, so it is the
|
|
217
|
+
# value-level shortcut rather than a simulation of typing: the editor's
|
|
218
|
+
# `insert_text` and its input filters never run, yet a listener reading
|
|
219
|
+
# {Component::HasValue::ValueChangeEvent#from_user?} sees the user's edit.
|
|
220
|
+
# The reachability checks below are what keep that claim honest.
|
|
221
|
+
# @param component [Component]
|
|
222
|
+
# @param value [Object] whatever the field's {Component::HasValue#set_value} takes.
|
|
223
|
+
# @raise [AssertionError] unless `component` is a {Component::HasValue}
|
|
224
|
+
# the keyboard can reach: shown with every ancestor shown, and inside
|
|
225
|
+
# {ScreenPane#key_scope}, so a field behind a modal popup refuses.
|
|
226
|
+
# @return [void]
|
|
227
|
+
def set_value(component, value)
|
|
228
|
+
unless component.is_a?(Component::HasValue)
|
|
229
|
+
raise AssertionError, "#{brief(component)} is not a field: set_value needs a Component::HasValue"
|
|
230
|
+
end
|
|
231
|
+
unless component.focusable?
|
|
232
|
+
raise AssertionError, "#{brief(component)} is not focusable, so a user could never edit it"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
scope = Screen.instance.pane.key_scope
|
|
236
|
+
unless reachable?(component, scope)
|
|
237
|
+
where = scope.nil? ? "the pane has no content" : "the key scope is #{brief(scope)}"
|
|
238
|
+
raise AssertionError, "#{brief(component)} is hidden or out of reach: #{where}\n" \
|
|
239
|
+
"searched:\n#{dump(Screen.instance.pane, [component])}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
component.set_value(value, from_user: true)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Moves `component` to `rect` within the parent it already has, through
|
|
246
|
+
# whatever places it there, since {Component#rect=} raises outside the
|
|
247
|
+
# parent's `relayout`. It never changes the tree:
|
|
248
|
+
#
|
|
249
|
+
# Testing.place(label, Rect.new(0, 0, 10, 1)) # a parentless root
|
|
250
|
+
# Testing.place(field, Rect.new(2, 1, 20, 1)) # moves it in its Layout::Absolute
|
|
251
|
+
# Testing.place(popup, Rect.new(5, 5, 30, 10)) # an open overlay: At[rect]
|
|
252
|
+
#
|
|
253
|
+
# A parentless root is sized in a throwaway {Component::Layout::Absolute}
|
|
254
|
+
# and released again, so it stays unattached.
|
|
255
|
+
# @param component [Component]
|
|
256
|
+
# @param rect [Rect] in the parent's coordinates (the screen's, for an
|
|
257
|
+
# overlay).
|
|
258
|
+
# @raise [ArgumentError] if any other container places `component` — move
|
|
259
|
+
# it by its constraint there. That includes the pane's content, which is
|
|
260
|
+
# always the whole screen: put a {Component::Layout::Absolute} in between.
|
|
261
|
+
# @return [Component] `component`, settled.
|
|
262
|
+
def place(component, rect)
|
|
263
|
+
parent = component.parent
|
|
264
|
+
case parent
|
|
265
|
+
when nil
|
|
266
|
+
holder = Component::Layout::Absolute.new
|
|
267
|
+
holder.add(component, rect)
|
|
268
|
+
holder.flush_layout
|
|
269
|
+
holder.remove(component)
|
|
270
|
+
when Component::Layout::Absolute
|
|
271
|
+
parent.constrain(component, rect)
|
|
272
|
+
when ScreenPane
|
|
273
|
+
unless component.is_a?(Component::Overlay)
|
|
274
|
+
raise ArgumentError, "place: the pane gives its content, #{component}, the whole screen; " \
|
|
275
|
+
"put a Layout::Absolute in between, or resize the terminal"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
component.placement = Component::Overlay::At[rect]
|
|
279
|
+
else
|
|
280
|
+
raise ArgumentError, "place: #{parent} places #{component} itself; constrain it there"
|
|
281
|
+
end
|
|
282
|
+
component.tap(&:flush_layout)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Paints `component` and its subtree, attached or not, into a {Buffer} of
|
|
286
|
+
# its own size whose `(0, 0)` is the component's top-left:
|
|
287
|
+
#
|
|
288
|
+
# Testing.place(window, Rect.new(0, 0, 14, 4))
|
|
289
|
+
# buffer = Testing.paint(window)
|
|
290
|
+
# buffer.text # => ["┌Caption─────┐", "│ alpha │", …]
|
|
291
|
+
# buffer.cell(1, 1).style # component coordinates, no absolute_rect
|
|
292
|
+
#
|
|
293
|
+
# What the component paints, not what the user sees: popups over it don't
|
|
294
|
+
# show, and ancestors don't clip it — only their background shows through.
|
|
295
|
+
# {Screen#buffer} is left untouched.
|
|
296
|
+
#
|
|
297
|
+
# == Implementation details
|
|
298
|
+
#
|
|
299
|
+
# A {Component#repaint} paints only its own ink and queues its children,
|
|
300
|
+
# so this walks the subtree in {Screen#repaint}'s order: pre-order, shown
|
|
301
|
+
# components only, nothing under an empty rect. The queued children stay
|
|
302
|
+
# on the screen's queue, harmlessly: a detached one is dropped, an
|
|
303
|
+
# attached one repaints unchanged.
|
|
304
|
+
# @param component [Component]
|
|
305
|
+
# @raise [AssertionError] if `component` is hidden, or has no cell to
|
|
306
|
+
# paint — {.place} it first.
|
|
307
|
+
# @return [Buffer]
|
|
308
|
+
def paint(component)
|
|
309
|
+
component.flush_layout
|
|
310
|
+
raise AssertionError, "#{brief(component)} is hidden, so it paints nothing" unless component.visible?
|
|
311
|
+
if component.rect.empty?
|
|
312
|
+
raise AssertionError, "#{brief(component)} has no cell to paint: place it first, " \
|
|
313
|
+
"or it is deliberately collapsed"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
buffer = Buffer.new(component.rect.size)
|
|
317
|
+
paint_subtree(component, component, buffer)
|
|
318
|
+
buffer
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The shown components under `point`, outermost first — the descent
|
|
322
|
+
# {Mouse::Router} makes when the terminal reports a press there.
|
|
323
|
+
#
|
|
324
|
+
# A deliberate copy of the router's private walk, kept honest by
|
|
325
|
+
# `testing_spec`'s pin against where a press is really delivered. When
|
|
326
|
+
# that pin gets hard to keep green the two have diverged for a reason:
|
|
327
|
+
# move the walk onto {Mouse::Router} and delete this.
|
|
328
|
+
# @param point [Point]
|
|
329
|
+
# @return [Array<Component>]
|
|
330
|
+
def component_path_at(point)
|
|
331
|
+
settle_layout
|
|
332
|
+
path = []
|
|
333
|
+
component = Screen.instance.pane.mouse_root_at(point)
|
|
334
|
+
while component&.visible? && component.rect.contains?(point)
|
|
335
|
+
# Rebase into the component's own coordinates as the router does:
|
|
336
|
+
# a rect is parent-relative (`D_relative_rect`).
|
|
337
|
+
point = Point.new(point.x - component.rect.left, point.y - component.rect.top)
|
|
338
|
+
path << component
|
|
339
|
+
component = component.children.find { _1.visible? && _1.rect.contains?(point) }
|
|
340
|
+
end
|
|
341
|
+
path
|
|
342
|
+
end
|
|
343
|
+
|
|
126
344
|
private
|
|
127
345
|
|
|
346
|
+
# The cell a pointer gesture aims at: the top-left of what the component
|
|
347
|
+
# actually paints.
|
|
348
|
+
#
|
|
349
|
+
# The three refusals are ordered because a layout gives a hidden child no
|
|
350
|
+
# row, so a *hidden* component reaches the geometry check with an empty
|
|
351
|
+
# rect and would be reported as collapsed if that ran first.
|
|
352
|
+
# @param component [Component] the target of a pointer gesture.
|
|
353
|
+
# @raise [AssertionError] if no cell of it could be clicked.
|
|
354
|
+
# @return [Point]
|
|
355
|
+
def gesture_point(component)
|
|
356
|
+
settle_layout
|
|
357
|
+
raise AssertionError, "#{brief(component)} is not attached to the screen" unless component.attached?
|
|
358
|
+
|
|
359
|
+
unless reachable?(component, Screen.instance.pane)
|
|
360
|
+
raise AssertionError, "#{brief(component)} is hidden, or sits under a hidden ancestor"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
rect = component.absolute_extent_rect
|
|
364
|
+
if rect.empty?
|
|
365
|
+
raise AssertionError, "#{brief(component)} has no cell to click: the tree was never laid " \
|
|
366
|
+
"out (repaint the screen first), or it is deliberately collapsed"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
Point.new(rect.left, rect.top)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Brings child rects up to date before anything here reads one — these
|
|
373
|
+
# helpers run straight from spec code, with no event dispatched to settle
|
|
374
|
+
# the layout the way the loop's would. A no-op with nothing pending.
|
|
375
|
+
# @return [void]
|
|
376
|
+
def settle_layout
|
|
377
|
+
Screen.instance.flush_layout if Screen.instance?
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# @param component [Component] the node to paint, with its subtree.
|
|
381
|
+
# @param root [Component] the component {.paint} was asked for.
|
|
382
|
+
# @param buffer [Buffer]
|
|
383
|
+
# @return [void]
|
|
384
|
+
def paint_subtree(component, root, buffer)
|
|
385
|
+
return if !component.visible? || component.rect.empty?
|
|
386
|
+
|
|
387
|
+
component.repaint(Screen.instance.canvas_for(component, backend: buffer, root:))
|
|
388
|
+
component.children.each { paint_subtree(_1, root, buffer) }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Whether `component` is shown, ancestors included, *and* inside `scope`.
|
|
392
|
+
# One walk answers both: `walk_shown_tree` skips a hidden subtree whole.
|
|
393
|
+
# @param component [Component]
|
|
394
|
+
# @param scope [Component, nil] see {ScreenPane#key_scope}.
|
|
395
|
+
# @return [Boolean]
|
|
396
|
+
def reachable?(component, scope)
|
|
397
|
+
return false if scope.nil?
|
|
398
|
+
|
|
399
|
+
scope.walk_shown_tree { |c| return true if c.equal?(component) }
|
|
400
|
+
false
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# @param component [Component]
|
|
404
|
+
# @return [String] its `inspect` with the `Tuile::` namespaces stripped,
|
|
405
|
+
# as {.dump} prints it.
|
|
406
|
+
def brief(component)
|
|
407
|
+
component.inspect.sub("#<Tuile::Component::", "#<").sub("#<Tuile::", "#<")
|
|
408
|
+
end
|
|
409
|
+
|
|
128
410
|
# @param component [Component]
|
|
129
411
|
# @param klass [Module] see {.find}.
|
|
130
412
|
# @param id [Symbol, nil] see {.find}.
|
|
131
|
-
# @param caption [String, Regexp, nil] see {.find}.
|
|
132
413
|
# @param predicate [Proc, nil] see {.find}.
|
|
133
414
|
# @return [Boolean] whether the component satisfies every given term.
|
|
134
415
|
# Visibility is the *walk's* business, deliberately not tested here, so
|
|
135
416
|
# {.failure} can re-run this over the components the walk skipped.
|
|
136
|
-
def matches_spec?(component, klass, id,
|
|
417
|
+
def matches_spec?(component, klass, id, predicate)
|
|
137
418
|
return false unless component.is_a?(klass)
|
|
138
419
|
return false unless id.nil? || component.id == id
|
|
139
|
-
if !caption.nil? &&
|
|
140
|
-
!(component.is_a?(Component::HasCaption) && spec_match?(caption, component.caption.to_s))
|
|
141
|
-
return false
|
|
142
|
-
end
|
|
143
420
|
|
|
144
421
|
predicate.nil? || predicate.call(component)
|
|
145
422
|
end
|
|
146
423
|
|
|
147
|
-
# Whether `actual` satisfies a spec
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
# partially, an Integer count exactly and a Range as a bound.
|
|
424
|
+
# Whether `actual` satisfies a `count:` spec, which may be an exact value
|
|
425
|
+
# or a pattern — `===` is the feature, not an accident: an Integer matches
|
|
426
|
+
# exactly and a Range as a bound.
|
|
151
427
|
# @param spec [Object] the expected value or pattern.
|
|
152
428
|
# @param actual [Object]
|
|
153
429
|
# @return [Boolean]
|
|
@@ -155,7 +431,6 @@ module Tuile
|
|
|
155
431
|
|
|
156
432
|
# @param klass [Module] the class or mixin that was asked for.
|
|
157
433
|
# @param id [Symbol, nil] the id spec, if any.
|
|
158
|
-
# @param caption [String, Regexp, nil] the caption spec, if any.
|
|
159
434
|
# @param predicate [Proc, nil] the block spec, if any.
|
|
160
435
|
# @param count [Integer, Range] the count that was not met.
|
|
161
436
|
# @param matches [Array<Component>] what the search did find.
|
|
@@ -163,10 +438,9 @@ module Tuile
|
|
|
163
438
|
# @param spec [Proc] the same term test the search ran, re-run over the
|
|
164
439
|
# components the walk skipped.
|
|
165
440
|
# @return [String]
|
|
166
|
-
def failure(klass, id,
|
|
441
|
+
def failure(klass, id, predicate, count, matches, scope, spec)
|
|
167
442
|
wanted = [(klass.name || klass.to_s).sub("Tuile::", "")]
|
|
168
443
|
wanted << "id=#{id.inspect}" unless id.nil?
|
|
169
|
-
wanted << "caption=#{caption.inspect}" unless caption.nil?
|
|
170
444
|
wanted << "matching the block" unless predicate.nil?
|
|
171
445
|
excluded = hidden_matches(scope, spec)
|
|
172
446
|
"expected #{count} #{wanted.join(" ")}, found #{matches.size}#{excluded_note(excluded)}\n" \
|