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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. 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(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
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
- # For *what a component shows*, assert on {Screen#buffer} instead — this
33
- # locates and drives, it does not replace that channel. See book ch8 for the
34
- # worked usage and `design/decisions.md` `D_component_lookup` for the design.
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 when the match count is not the one asked for. A {Tuile::Error},
37
- # so an app rescuing that still catches it.
38
- class LookupError < Error; end
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, caption: /^Total/) # Regexp: partial match
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 [LookupError] if `count` is given and the match count differs.
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, caption: nil, count: nil, &predicate)
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, caption, predicate) }
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 LookupError, failure(klass, id, caption, predicate, count, matches, scope, spec)
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 [LookupError] unless exactly one component matches.
143
+ # @raise [AssertionError] unless exactly one component matches.
89
144
  # @return [Component]
90
- def get(klass = Component, in: nil, id: nil, caption: nil, &predicate)
145
+ def get(klass = Component, in: nil, id: nil, &predicate)
91
146
  scope = binding.local_variable_get(:in)
92
- find(klass, in: scope, id:, caption:, count: 1, &predicate).first
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.inspect.sub("#<Tuile::Component::", "#<").sub("#<Tuile::", "#<")
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, caption, predicate)
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 value, which for both `caption:` and
148
- # `count:` may be either an exact value or a pattern — `===` is the
149
- # feature, not an accident: a String caption matches exactly and a Regexp
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, caption, predicate, count, matches, scope, spec)
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" \