tuile 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -8,7 +8,7 @@ module Tuile
8
8
  # {#content} and the {#popups} stack. Putting them under a single Component
9
9
  # parent gives focus traversal a real root, makes {Component#attached?} a
10
10
  # one-liner, and lets popup-focus repair fall out of the standard
11
- # {Component#on_child_removed} hook.
11
+ # {Component#handle_child_removed} hook.
12
12
  #
13
13
  # The pane owns no chrome of its own — no status bar, no reserved row.
14
14
  # {#content} gets the full pane rect, and an app that wants a status line
@@ -79,7 +79,7 @@ module Tuile
79
79
  window.center
80
80
  screen.focused = window
81
81
  end
82
- window.on_tree { |c| screen.invalidate(c) }
82
+ window.walk_tree { |c| screen.invalidate(c) }
83
83
  end
84
84
 
85
85
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
@@ -102,7 +102,7 @@ module Tuile
102
102
  @removing_popup_prior = nil
103
103
  end
104
104
 
105
- # Unmounts everything: each child is detached — firing {Component#on_detached}
105
+ # Unmounts everything: each child is detached — firing {Component#handle_detached}
106
106
  # down its subtree — and every slot is emptied. Terminal; the pane isn't
107
107
  # reusable afterwards, and {Screen#close} is its only caller.
108
108
  #
@@ -113,7 +113,7 @@ module Tuile
113
113
  # components, which is the desync the tree API exists to prevent.
114
114
  # @return [void]
115
115
  def detach_all
116
- screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
116
+ screen.focused = nil # …so the focus repair in handle_child_removed has nothing to do
117
117
  children.dup.each { detach_child(_1) }
118
118
  @content = nil
119
119
  @popups.clear
@@ -157,7 +157,7 @@ module Tuile
157
157
  def repaint; end
158
158
 
159
159
  # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
160
- # the first component whose `handle_key` returns true wins.
160
+ # the first component whose `handle_key?` returns true wins.
161
161
  #
162
162
  # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
163
163
  # open, else the tiled {#content}. Focus that is nil or sits outside the
@@ -173,44 +173,48 @@ module Tuile
173
173
  # hijacked).
174
174
  # @param key [String]
175
175
  # @return [Boolean] true if the key was handled.
176
- def handle_key(key)
176
+ def handle_key?(key)
177
177
  scope = modal_popup || @content
178
178
  return false if scope.nil?
179
179
 
180
180
  bubble_key(key, scope)
181
181
  end
182
182
 
183
- # Delivers pasted text along the same focus chain {#handle_key} bubbles
184
- # along, and with the same scoping — first {Component#handle_paste}
185
- # returning true wins.
183
+ # Delivers pasted text to {Screen#focused} — and to nobody else.
184
+ #
185
+ # Scoped exactly like {#handle_key?} (focus that is nil or sits outside the
186
+ # modal scope receives nothing, which is what keeps a popup modal) but
187
+ # **not bubbled**: an ancestor is never offered a paste its descendant
188
+ # declined, and unhandled text is dropped. Why keys bubble and pastes
189
+ # don't: `D_bracketed_paste`.
186
190
  # @param text [String]
187
- # @return [Boolean] true if the text was consumed.
191
+ # @return [void]
188
192
  def handle_paste(text)
189
193
  scope = modal_popup || @content
190
- return false if scope.nil?
194
+ return if scope.nil?
191
195
 
192
196
  chain = focus_chain(scope)
193
- return false if chain.nil?
197
+ return if chain.nil?
194
198
 
195
- chain.each { |c| return true if c.handle_paste(text) }
196
- false
199
+ chain.first.handle_paste(text)
197
200
  end
198
201
 
199
- # Mouse events check popups in reverse stacking order (topmost first), and
200
- # fall through to content only when no popup is hit *and* no modal popup is
201
- # open. This preserves modal click-blocking — an open modal eats clicks
202
- # even outside its rect — while a non-modal overlay blocks nothing: clicks
203
- # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
204
- # content beneath.
205
- #
206
- # A left click also *dismisses* the open popups it landed outside of that
207
- # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
208
- # second thing happening on a click, but not a second dispatch: the click is
209
- # still delivered exactly once, down one chain, and a dismissed popup is
202
+ # Where {Mouse::Router} starts its walk for a pointer at `point`: the
203
+ # topmost popup containing it, else {#content} — unless a modal popup is
204
+ # open, which eats the event even outside its rect. A non-modal overlay
205
+ # blocks nothing: a point outside it reaches the content beneath.
206
+ # @api private
207
+ # @param point [Point]
208
+ # @return [Component, nil]
209
+ def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?)
210
+
211
+ # Runs the press delivery in the block, then *dismisses* the open popups a
212
+ # left press landed outside of that asked for it
213
+ # ({Component::Overlay#close_on_outside_click?}). A dismissed popup is
210
214
  # closed rather than told.
211
215
  #
212
216
  # "Outside" is measured against the {Component::Overlay#owner} chain, not
213
- # against one rect and not against stacking order: the popup the click hit
217
+ # against one rect and not against stacking order: the popup the press hit
214
218
  # is kept, and so is every popup that one *belongs to*, transitively. That
215
219
  # is what stops a dialog being dismissed by a click on a dropdown its own
216
220
  # field opened, and a menu cascade being dismissed by a click on one of its
@@ -220,10 +224,10 @@ module Tuile
220
224
  #
221
225
  # Two halves of the ordering are load-bearing, and both are specced:
222
226
  #
223
- # - **Snapshot before routing.** A popup the delivered click *opens* must
227
+ # - **Snapshot before the block.** A popup the delivered press *opens* must
224
228
  # not be in the set (it would immediately dismiss itself — every
225
229
  # {Component::Select} would be unopenable by mouse).
226
- # - **Close after routing.** A widget toggling its own overlay from a click
230
+ # - **Close after the block.** A widget toggling its own overlay from a press
227
231
  # on its face closes it during delivery, and {Component::Popup#close} is
228
232
  # idempotent, so the dismissal no-ops. Close *first* and the widget sees
229
233
  # a shut overlay and reopens it — a Select's dropdown could then never be
@@ -231,19 +235,19 @@ module Tuile
231
235
  #
232
236
  # The snapshot is a fresh array for a third reason: a handler may close
233
237
  # further popups, and `@popups` must not be mutated mid-iteration.
234
- # @param event [MouseEvent]
238
+ # @api private
239
+ # @param point [Point]
240
+ # @param left [Boolean] whether the press was the left button; no other
241
+ # button dismisses.
242
+ # @yield the press delivery.
235
243
  # @return [void]
236
- def handle_mouse(event)
237
- hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
238
- dismissable = event.button == :left ? @popups - kept_by(hit) : []
239
-
240
- clicked = hit || (@content if modal_popup.nil?)
241
- clicked&.handle_mouse(event)
242
-
244
+ def dismissing_popups_outside(point, left:)
245
+ dismissable = left ? @popups - kept_by(popup_at(point)) : []
246
+ yield
243
247
  dismissable.each { _1.close if _1.close_on_outside_click? }
244
248
  end
245
249
 
246
- # Focus repair when a child detaches. Default {Component#on_child_removed}
250
+ # Focus repair when a child detaches. Default {Component#handle_child_removed}
247
251
  # would refocus to `self` (the pane), which isn't a useful focus target.
248
252
  # Instead, route focus to the first interactable widget in the now-topmost
249
253
  # modal popup; falling back to the focus snapshotted when this popup was opened
@@ -256,7 +260,7 @@ module Tuile
256
260
  # `q`/ESC still has somewhere to dispatch from.
257
261
  # @param child [Component]
258
262
  # @return [void]
259
- def on_child_removed(child)
263
+ def handle_child_removed(child)
260
264
  return unless attached?
261
265
 
262
266
  f = screen.focused
@@ -298,6 +302,10 @@ module Tuile
298
302
  kept
299
303
  end
300
304
 
305
+ # @param point [Point]
306
+ # @return [Component::Overlay, nil] the topmost popup containing `point`.
307
+ def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) }
308
+
301
309
  # @param component [Component, nil]
302
310
  # @return [Component::Overlay, nil] `component` itself when it is an
303
311
  # overlay, else the nearest overlay above it, else nil.
@@ -318,7 +326,7 @@ module Tuile
318
326
  chain = focus_chain(scope)
319
327
  return false if chain.nil?
320
328
 
321
- chain.each { |c| return true if c.handle_key(key) }
329
+ chain.each { |c| return true if c.handle_key?(key) }
322
330
  false
323
331
  end
324
332
 
@@ -340,13 +348,14 @@ module Tuile
340
348
 
341
349
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
342
350
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
343
- # if `root` is `nil`.
351
+ # if `root` is `nil`, or if `root` is itself hidden — there is nothing in
352
+ # there to focus, so the caller falls through to its next candidate.
344
353
  # @param root [Component, nil]
345
354
  # @return [Component, nil]
346
355
  def first_tab_stop_or_root(root)
347
- return nil if root.nil?
356
+ return nil if root.nil? || !root.visible?
348
357
 
349
- root.on_tree { |c| return c if c.tab_stop? }
358
+ root.walk_shown_tree { |c| return c if c.tab_stop? }
350
359
  root
351
360
  end
352
361
  end
@@ -410,11 +410,11 @@ module Tuile
410
410
  #
411
411
  # `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
412
412
  # sequences — the ones vendors actually ship a single glyph for — and *sums*
413
- # the parts of anything else. That is the one setting never wrong in the
414
- # dangerous direction: under-measuring lets a glyph overrun its cell, which
415
- # shifts the rest of the row, desyncs the cursor and escapes the component's
416
- # rect, while over-measuring leaves a blank column. `D_cluster_width` has the
417
- # per-setting reasoning.
413
+ # the parts of anything else, erring only in the direction that cannot escape
414
+ # a component: {Buffer#flush} emits a dirty run contiguously, so a mis-measure
415
+ # of either sign shifts the rest of that run, but only an under-measure shifts
416
+ # it right, into a neighbour whose cells are clean and so never repainted.
417
+ # `D_cluster_width` has the per-setting reasoning.
418
418
  # @return [Symbol]
419
419
  EMOJI_WIDTH = :rgi
420
420
 
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Finds a component in the tree, so a spec can drive the UI it built four
5
+ # layers down:
6
+ #
7
+ # Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
8
+ # Testing.get(id: :name).value = "Zaphod"
9
+ # Testing.find(Component::Checkbox, in: pane, count: 3)
10
+ #
11
+ # {.get} demands exactly one match and raises with a {.dump} of the tree it
12
+ # searched; {.find} returns every match and takes an optional `count:`. Both
13
+ # search {Screen}'s whole tree by default — popups included, since they live
14
+ # under the same {ScreenPane} as the content — or the subtree given as `in:`.
15
+ #
16
+ # **Call these qualified**, as above: `find` and `get` collide with names a
17
+ # spec suite is likely to have already (Capybara's `find`), so there is no
18
+ # `Component#get` and mixing this module in is not recommended. Tuile's own
19
+ # specs sit inside `module Tuile` and so need no include.
20
+ #
21
+ # **A hidden component is never found**: these simulate a user, and a spec
22
+ # that drove a hidden {Component::Button} would pass against a form nobody
23
+ # can operate. Both walk {Component#walk_shown_tree}; a failed lookup says how
24
+ # many hidden components *would* have matched, and {.dump} shows them.
25
+ #
26
+ # Testing.find(Component::TextField, count: 0) # the user can't reach it
27
+ # refute field.visible? # it is hidden
28
+ #
29
+ # There is deliberately no `visible:` filter handing one back to drive
30
+ # (`D_visibility`).
31
+ #
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.
35
+ 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
39
+
40
+ class << self
41
+ # Every component in the searched tree matching the spec, in pre-order.
42
+ #
43
+ # find(Component::Button) # every button on screen
44
+ # find(Component::HasBadInput, in: form) # a mixin works too
45
+ # find(Component::Label, caption: /^Total/) # Regexp: partial match
46
+ # find(Component::Popup, count: 1..) # assert at least one
47
+ #
48
+ # @param klass [Module] matched with `is_a?`, so a mixin
49
+ # ({Component::HasValue}) finds every field that includes it.
50
+ # @param in [Component, nil] root of the subtree to search, itself
51
+ # included. Defaults to `Screen.instance.pane` — the whole UI.
52
+ # @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
+ # @param count [Integer, Range, nil] how many matches are expected; any
58
+ # number when nil.
59
+ # @yield [component] optional extra predicate; a component matches only
60
+ # when the block returns truthy.
61
+ # @yieldparam component [Component]
62
+ # @yieldreturn [Boolean]
63
+ # @raise [LookupError] if `count` is given and the match count differs.
64
+ # @return [Array<Component>]
65
+ def find(klass = Component, in: nil, id: nil, caption: nil, count: nil, &predicate)
66
+ # `in` is a Ruby keyword, so the local it binds is unreachable by name.
67
+ scope = binding.local_variable_get(:in) || Screen.instance.pane
68
+ spec = ->(c) { matches_spec?(c, klass, id, caption, predicate) }
69
+ matches = []
70
+ scope.walk_shown_tree { |c| matches << c if spec.call(c) }
71
+ return matches if count.nil? || spec_match?(count, matches.size)
72
+
73
+ raise LookupError, failure(klass, id, caption, predicate, count, matches, scope, spec)
74
+ end
75
+
76
+ # The one component matching the spec — {.find} with `count: 1`, so it
77
+ # raises rather than returning nil, and raises on an ambiguous spec too.
78
+ #
79
+ # get(Component::ComboBox, in: sampler.demo_window)
80
+ #
81
+ # @param klass [Module] see {.find}.
82
+ # @param in [Component, nil] see {.find}.
83
+ # @param id [Symbol, nil] see {.find}.
84
+ # @param caption [String, Regexp, nil] see {.find}.
85
+ # @yield [component] see {.find}.
86
+ # @yieldparam component [Component]
87
+ # @yieldreturn [Boolean]
88
+ # @raise [LookupError] unless exactly one component matches.
89
+ # @return [Component]
90
+ def get(klass = Component, in: nil, id: nil, caption: nil, &predicate)
91
+ scope = binding.local_variable_get(:in)
92
+ find(klass, in: scope, id:, caption:, count: 1, &predicate).first
93
+ end
94
+
95
+ # The searched tree, one component per row, indented by depth and with
96
+ # the `Tuile::` namespaces stripped so a fifty-row dump stays readable
97
+ # (an app's own component classes keep their full name):
98
+ #
99
+ # #<ScreenPane rect=(0,0 160x50)>
100
+ # #<Window rect=(0,0 40x10) caption="Settings">
101
+ # #<Layout::Vertical rect=(1,1 38x8)>
102
+ # → #<Button id=:save rect=(1,1 38x1) caption="Save">
103
+ #
104
+ # @param scope [Component] root of the tree to dump.
105
+ # @param marked [Array<Component>] components to flag with a leading
106
+ # arrow — the matches, when a lookup found the wrong number of them.
107
+ # @param excluded [Array<Component>] components to flag with `⊘` — ones
108
+ # that matched the spec but were skipped for being hidden.
109
+ # @return [String]
110
+ def dump(scope, marked = [], excluded = [])
111
+ base = scope.depth
112
+ rows = []
113
+ # walk_tree, not walk_shown_tree: a reader looks here to find out where
114
+ # their component went, so the ones the search skipped are the point.
115
+ scope.walk_tree do |c|
116
+ mark = if marked.any? { _1.equal?(c) } then "→"
117
+ elsif excluded.any? { _1.equal?(c) } then "⊘"
118
+ else " "
119
+ end
120
+ row = c.inspect.sub("#<Tuile::Component::", "#<").sub("#<Tuile::", "#<")
121
+ rows << "#{mark} #{" " * (c.depth - base)}#{row}"
122
+ end
123
+ rows.join("\n")
124
+ end
125
+
126
+ private
127
+
128
+ # @param component [Component]
129
+ # @param klass [Module] see {.find}.
130
+ # @param id [Symbol, nil] see {.find}.
131
+ # @param caption [String, Regexp, nil] see {.find}.
132
+ # @param predicate [Proc, nil] see {.find}.
133
+ # @return [Boolean] whether the component satisfies every given term.
134
+ # Visibility is the *walk's* business, deliberately not tested here, so
135
+ # {.failure} can re-run this over the components the walk skipped.
136
+ def matches_spec?(component, klass, id, caption, predicate)
137
+ return false unless component.is_a?(klass)
138
+ 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
+
144
+ predicate.nil? || predicate.call(component)
145
+ end
146
+
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.
151
+ # @param spec [Object] the expected value or pattern.
152
+ # @param actual [Object]
153
+ # @return [Boolean]
154
+ def spec_match?(spec, actual) = spec === actual # rubocop:disable Style/CaseEquality
155
+
156
+ # @param klass [Module] the class or mixin that was asked for.
157
+ # @param id [Symbol, nil] the id spec, if any.
158
+ # @param caption [String, Regexp, nil] the caption spec, if any.
159
+ # @param predicate [Proc, nil] the block spec, if any.
160
+ # @param count [Integer, Range] the count that was not met.
161
+ # @param matches [Array<Component>] what the search did find.
162
+ # @param scope [Component] the root that was searched.
163
+ # @param spec [Proc] the same term test the search ran, re-run over the
164
+ # components the walk skipped.
165
+ # @return [String]
166
+ def failure(klass, id, caption, predicate, count, matches, scope, spec)
167
+ wanted = [(klass.name || klass.to_s).sub("Tuile::", "")]
168
+ wanted << "id=#{id.inspect}" unless id.nil?
169
+ wanted << "caption=#{caption.inspect}" unless caption.nil?
170
+ wanted << "matching the block" unless predicate.nil?
171
+ excluded = hidden_matches(scope, spec)
172
+ "expected #{count} #{wanted.join(" ")}, found #{matches.size}#{excluded_note(excluded)}\n" \
173
+ "searched:\n#{dump(scope, matches, excluded)}"
174
+ end
175
+
176
+ # The components that satisfy the spec but were skipped for being hidden
177
+ # — the answer to the "but it *is* there" a failed lookup provokes.
178
+ # @param scope [Component] the root that was searched.
179
+ # @param spec [Proc] the term test.
180
+ # @return [Array<Component>]
181
+ def hidden_matches(scope, spec)
182
+ shown = Set.new
183
+ scope.walk_shown_tree { shown << _1 }
184
+ hidden = []
185
+ scope.walk_tree { |c| hidden << c if !shown.include?(c) && spec.call(c) }
186
+ hidden
187
+ end
188
+
189
+ # @param excluded [Array<Component>] see {.hidden_matches}.
190
+ # @return [String] the clause naming them, or "" when there were none.
191
+ def excluded_note(excluded)
192
+ return "" if excluded.empty?
193
+
194
+ " (#{excluded.size} hidden #{excluded.size == 1 ? "match" : "matches"} excluded)"
195
+ end
196
+ end
197
+ end
198
+ end
data/lib/tuile/theme.rb CHANGED
@@ -7,12 +7,12 @@ module Tuile
7
7
  # restyles everything via one invalidate-everything pass. Book ch6 is the
8
8
  # concept in full (why accents-only, dark/light, live OS flips).
9
9
  #
10
- # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
11
- # {#hint} — wrap a plain string in the token's SGR color (on the channel
12
- # appropriate for the token's role) and reset:
10
+ # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg} — wrap
11
+ # a plain string in the token's SGR color (on the channel appropriate for the
12
+ # token's role) and reset:
13
13
  #
14
- # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
15
- # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
14
+ # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
15
+ # screen.theme.active_border("┌────┐") # => "\e[32m┌────┐\e[0m"
16
16
  #
17
17
  # Content passes through verbatim (so it may carry other escapes). For
18
18
  # span-aware styling — a token applied to a {StyledString} without flattening
@@ -44,7 +44,7 @@ module Tuile
44
44
  #
45
45
  # For a color slot resolved *live* at paint — currently
46
46
  # {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
47
- # the token in {Component#on_theme_changed}; it tracks theme swaps on its
47
+ # the token in {Component#handle_theme_changed}; it tracks theme swaps on its
48
48
  # own. Baked content colors ({Component::Label} text and friends) can't:
49
49
  # they live in a frozen {StyledString} and still need the hook.
50
50
  #
@@ -64,9 +64,32 @@ module Tuile
64
64
  # {Component::TextArea} when *not* active — visibly a field, but
65
65
  # distinctly subtler than {#active_bg_color}.
66
66
  # @return [Color]
67
- # @!attribute [r] hint_color
68
- # Foreground of keyboard-shortcut captions in status-bar hints (the
69
- # "quit" in "q quit") — see {#hint}.
67
+ # @!attribute [r] placeholder_color
68
+ # Foreground of the hint a field paints into its own empty well
69
+ # ({Component::HasPlaceholder}) — the one token tuned to be *barely*
70
+ # visible, since a placeholder the user misses costs nothing.
71
+ # @return [Color]
72
+ # @!attribute [r] error_color
73
+ # Foreground for the *message* beside an invalid field — the text a
74
+ # container paints from {Component::HasValidation#error_message}. The
75
+ # field's own face uses {#error_bg_color} instead.
76
+ # @return [Color]
77
+ # @!attribute [r] error_bg_color
78
+ # Resting well of a field that is invalid — {#input_bg_color}'s red
79
+ # counterpart, and the reason the pair exists rather than one flat error
80
+ # color: a field's well is what shows its boundary, so an invalid field
81
+ # needs a well *and* still needs to show focus.
82
+ # @return [Color]
83
+ # @!attribute [r] error_active_bg_color
84
+ # Well of an invalid field that also has focus — {#active_bg_color}'s red
85
+ # counterpart. Must stay distinguishable from {#error_bg_color} after
86
+ # {Color#quantize}, or a focused invalid {Component::Select} (which paints
87
+ # no caret) shows no focus at all.
88
+ # @return [Color]
89
+ # @!attribute [r] scrollbar_color
90
+ # Foreground of the {VerticalScrollBar} a {Component::List} or
91
+ # {Component::TextView} paints down its right edge — handle and track
92
+ # alike, which the glyphs' own ink densities tell apart.
70
93
  # @return [Color]
71
94
  # @!attribute [r] custom
72
95
  # App-specific color tokens; empty in the built-in themes. Frozen —
@@ -74,16 +97,24 @@ module Tuile
74
97
  # lookups (it fail-fasts on typos); read this directly to enumerate
75
98
  # the tokens.
76
99
  # @return [Hash{Symbol => Color}]
77
- class Theme < Data.define(:active_bg_color, :active_border_color, :input_bg_color, :hint_color, :custom)
100
+ class Theme < Data.define(:active_bg_color, :active_border_color, :input_bg_color,
101
+ :placeholder_color, :error_color, :error_bg_color, :error_active_bg_color,
102
+ :scrollbar_color, :custom)
78
103
  # @param active_bg_color [Color]
79
104
  # @param active_border_color [Color]
80
105
  # @param input_bg_color [Color]
81
- # @param hint_color [Color]
106
+ # @param placeholder_color [Color]
107
+ # @param error_color [Color]
108
+ # @param error_bg_color [Color]
109
+ # @param error_active_bg_color [Color]
110
+ # @param scrollbar_color [Color]
82
111
  # @param custom [Hash{Symbol => Color}] app-specific tokens, see {#custom}.
83
112
  # @raise [TypeError] when a token is not a {Color}, or `custom` is not a
84
113
  # `Hash{Symbol => Color}`.
85
- def initialize(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: {})
86
- { active_bg_color:, active_border_color:, input_bg_color:, hint_color: }.each do |name, value|
114
+ def initialize(active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
115
+ error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color:, custom: {})
116
+ { active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
117
+ error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color: }.each do |name, value|
87
118
  raise TypeError, "#{name} must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
88
119
  end
89
120
  raise TypeError, "custom must be a Hash, got #{custom.inspect}" unless custom.is_a?(Hash)
@@ -92,7 +123,8 @@ module Tuile
92
123
  raise TypeError, "custom key must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
93
124
  raise TypeError, "custom[#{key.inspect}] must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
94
125
  end
95
- super(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: custom.dup.freeze)
126
+ super(active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
127
+ error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color:, custom: custom.dup.freeze)
96
128
  end
97
129
 
98
130
  # Looks up an app-specific token from {#custom}.
@@ -123,7 +155,7 @@ module Tuile
123
155
  # A live reference to a theme token, resolved against the current theme at
124
156
  # paint time rather than baked to a concrete {Color}. Assign one where a
125
157
  # slot is resolved late — currently {Component#bg_color=} — and it follows
126
- # light/dark flips with no {Component#on_theme_changed} hook:
158
+ # light/dark flips with no {Component#handle_theme_changed} hook:
127
159
  #
128
160
  # panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
129
161
  # dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
@@ -155,7 +187,14 @@ module Tuile
155
187
  end
156
188
 
157
189
  # Renders `text` in the foreground color of the app-specific `token`
158
- # — the generic counterpart of {#hint} for {#custom} tokens.
190
+ # — the generic counterpart of {#active_border} for {#custom} tokens, and
191
+ # the route for an app's own status-line chrome:
192
+ #
193
+ # "q #{screen.theme.fg(:hint, "quit")}" # => "q \e[38;5;245mquit\e[0m"
194
+ #
195
+ # The color is baked into the returned String, so text built this way does
196
+ # *not* restyle on a {Screen#theme=} — rebuild it from
197
+ # {Component#handle_theme_changed} instead.
159
198
  # @param token [Symbol]
160
199
  # @param text [String]
161
200
  # @return [String] ANSI-rendered text, ending with an SGR reset.
@@ -187,40 +226,79 @@ module Tuile
187
226
  # @return [String] ANSI-rendered text, ending with an SGR reset.
188
227
  def input_bg(text) = wrap(text, input_bg_color, :bg)
189
228
 
190
- # Renders `text` in the {#hint_color} foreground, for status-bar hints,
191
- # e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
192
- # returned String, so strings built this way do *not* restyle when the
193
- # theme changes — rebuild them instead (the framework's own call sites
194
- # rebuild on every status-bar refresh).
195
- # @param text [String]
196
- # @return [String] ANSI-rendered text, ending with an SGR reset.
197
- def hint(text) = wrap(text, hint_color, :fg)
198
-
199
229
  # The colors Tuile used before themes existed, tuned for dark terminal
200
230
  # backgrounds. GREY37 (palette 59) is what Rainbow emits for
201
- # `:darkslategray`, LIGHT_SKY_BLUE3 (109) for `:cadetblue`; GREY27
231
+ # `:darkslategray`; GREY27
202
232
  # (238, ~#444444) sits in the grayscale ramp, bright enough to stand
203
233
  # out against non-pure-black dark terminal themes (Gruvbox/Solarized/
204
234
  # OneDark base backgrounds sit in the #1d–#2d range) yet distinctly
205
- # darker than the active highlight at 59 (~#5f5f5f).
235
+ # darker than the active highlight at 59 (~#5f5f5f). The scrollbar reuses
236
+ # GREY37, giving the handle the weight of the selection well and leaving
237
+ # the sparser track glyph near-invisible.
238
+ #
239
+ # `error_color` is INDIAN_RED1 (203, ~#ff5f5f) rather than a pure RED1
240
+ # (196): the message sits beside a field on the terminal's own background,
241
+ # and the softer red keeps its contrast there while pure red vibrates.
242
+ #
243
+ # The error wells are palette 88 (~#870000) and LIGHT_PINK4 (95, ~#875f5f)
244
+ # — split on lightness the way GREY27/GREY37 are, so the focused one is the
245
+ # *lighter* well and the pair reads as a well rather than an alarm block.
246
+ # Both survive `palette256` as themselves and stay distinct from each other
247
+ # there, which is what keeps focus visible on an invalid field. The focused
248
+ # well stays out of the bright mid-reds around #af5f5f: that is where
249
+ # terminals put the cursor, and a caret sitting in an invalid field blurs
250
+ # into a well of its own color.
251
+ #
252
+ # `placeholder_color` is GREY66 (248, ~#a8a8a8): dimmer than the terminal's
253
+ # own foreground, so an empty field's hint reads as absent-value rather than
254
+ # as typed text. It is the *dimmest* grey that still quantizes to `:white` on
255
+ # a 16-color terminal — everything below 248 lands on `:bright_black`
256
+ # alongside both wells, where the hint is not subtle but gone
257
+ # (`design/decisions.md` `D_placeholder`).
206
258
  # @return [Theme]
207
259
  DARK = new(active_bg_color: Color::GREY37,
208
260
  active_border_color: Color::GREEN,
209
261
  input_bg_color: Color::GREY27,
210
- hint_color: Color::LIGHT_SKY_BLUE3)
262
+ placeholder_color: Color::GREY66,
263
+ error_color: Color::INDIAN_RED1,
264
+ error_bg_color: Color.palette(88),
265
+ error_active_bg_color: Color::LIGHT_PINK4,
266
+ scrollbar_color: Color::GREY37)
211
267
 
212
268
  # Counterparts legible on light terminal backgrounds: grayscale-ramp
213
269
  # highlights just below white (GREY82 = 252 ~#d0d0d0, GREY85 = 253
214
270
  # ~#dadada — dark enough to read as a "well" against white, one step
215
- # lighter than the active highlight) and a dark teal (TURQUOISE4 = 30,
216
- # ~#008787) keeping the hint hue. `active_border_color` stays the
271
+ # lighter than the active highlight). `active_border_color` stays the
217
272
  # named green — named ANSI colors are remapped by the terminal's own
218
- # palette, so the theme picks a light-appropriate green for us.
273
+ # palette, so the theme picks a light-appropriate green for us. GREY62
274
+ # (247, ~#9e9e9e) is the scrollbar: a *foreground* against pale, so it
275
+ # goes a step darker than the highlights rather than matching them. RED3
276
+ # (124, ~#af0000) is the error ink, dark for the same reason — the light
277
+ # red {DARK} uses would wash out on white.
278
+ #
279
+ # The error wells are MISTY_ROSE1 (224, ~#ffd7d7) and LIGHT_PINK1 (217,
280
+ # ~#ffafaf) — near-white tints that read as a well by *hue* rather than by
281
+ # weight, and darken on focus as the grey pair does. 224 is the palest red
282
+ # the 256-color palette holds: anything subtler quantizes onto the grey ramp
283
+ # (`Color.hex("#ffeaea")` → 255) and the signal is gone entirely on a
284
+ # 256-color terminal. It sits a shade *above* GREY85 rather than below it,
285
+ # so an invalid field reads level with a valid one rather than more
286
+ # recessed — the price of staying close to a white background.
287
+ #
288
+ # `placeholder_color` mirrors {DARK}'s rule from the other side: GREY62 (247,
289
+ # ~#9e9e9e) is the *palest* grey that still quantizes to `:bright_black` on a
290
+ # 16-color terminal, where both light wells are `:white`. It doubles as the
291
+ # scrollbar ink, which wants the same thing — a foreground that recedes
292
+ # against pale without vanishing into it.
219
293
  # @return [Theme]
220
294
  LIGHT = new(active_bg_color: Color::GREY82,
221
295
  active_border_color: Color::GREEN,
222
296
  input_bg_color: Color::GREY85,
223
- hint_color: Color::TURQUOISE4)
297
+ placeholder_color: Color::GREY62,
298
+ error_color: Color::RED3,
299
+ error_bg_color: Color::MISTY_ROSE1,
300
+ error_active_bg_color: Color::LIGHT_PINK1,
301
+ scrollbar_color: Color::GREY62)
224
302
 
225
303
  private
226
304
 
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.14.0"
5
+ VERSION = "0.16.0"
6
6
  end