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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -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 +26 -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 +41 -23
- data/lib/tuile/component/select.rb +23 -16
- 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 +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -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#
|
|
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.
|
|
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#
|
|
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
|
|
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
|
|
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
|
|
184
|
-
#
|
|
185
|
-
#
|
|
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 [
|
|
191
|
+
# @return [void]
|
|
188
192
|
def handle_paste(text)
|
|
189
193
|
scope = modal_popup || @content
|
|
190
|
-
return
|
|
194
|
+
return if scope.nil?
|
|
191
195
|
|
|
192
196
|
chain = focus_chain(scope)
|
|
193
|
-
return
|
|
197
|
+
return if chain.nil?
|
|
194
198
|
|
|
195
|
-
chain.
|
|
196
|
-
false
|
|
199
|
+
chain.first.handle_paste(text)
|
|
197
200
|
end
|
|
198
201
|
|
|
199
|
-
# Mouse
|
|
200
|
-
#
|
|
201
|
-
# open
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
# @
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
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#
|
|
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
|
|
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.
|
|
358
|
+
root.walk_shown_tree { |c| return c if c.tab_stop? }
|
|
350
359
|
root
|
|
351
360
|
end
|
|
352
361
|
end
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
|
414
|
-
#
|
|
415
|
-
# shifts the rest of
|
|
416
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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 ]")
|
|
15
|
-
# screen.theme.
|
|
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#
|
|
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]
|
|
68
|
-
# Foreground of
|
|
69
|
-
#
|
|
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,
|
|
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
|
|
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:,
|
|
86
|
-
|
|
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:,
|
|
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#
|
|
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 {#
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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