tuile 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Tuile
|
|
|
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
|
|
15
15
|
# builds one into its own layout and drives it from
|
|
16
|
-
# {Screen#on_focus_changed
|
|
16
|
+
# {Screen#on_focus_changed} (`D_status_bar`).
|
|
17
17
|
#
|
|
18
18
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
19
19
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -27,6 +27,10 @@ module Tuile
|
|
|
27
27
|
# user was, instead of falling through to {#content} and getting
|
|
28
28
|
# cascaded to the first focusable child.
|
|
29
29
|
@popup_prior_focus = {}
|
|
30
|
+
# Where each open popup wants to be, and the anchor rect its last
|
|
31
|
+
# placement used (`:lost` once the anchor went away) — see #relayout.
|
|
32
|
+
@placements = {}.compare_by_identity
|
|
33
|
+
@placed_anchors = {}.compare_by_identity
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# @return [Component, nil] the tiled content component.
|
|
@@ -52,14 +56,13 @@ module Tuile
|
|
|
52
56
|
remove_child(@content) unless @content.nil?
|
|
53
57
|
@content = content
|
|
54
58
|
add_child(content, at: 0) # the tiled layer paints beneath everything else
|
|
55
|
-
layout
|
|
56
59
|
end
|
|
57
60
|
|
|
58
|
-
# Adds an overlay and invalidates it for repaint
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
61
|
+
# Adds an overlay at `placement` and invalidates it for repaint; the next
|
|
62
|
+
# settle gives it its rect. A {Component::Popup} grabs focus; a bare
|
|
63
|
+
# {Component::Overlay} does *not*, so the component that was focused keeps
|
|
64
|
+
# the cursor and keeps receiving keys — the overlay floats above the
|
|
65
|
+
# content, driven from app code.
|
|
63
66
|
#
|
|
64
67
|
# The *whole subtree* is invalidated, not just the overlay wrapper (which
|
|
65
68
|
# paints nothing on its own): a reopened popup may land on cells that the
|
|
@@ -67,21 +70,48 @@ module Tuile
|
|
|
67
70
|
# last time its content components won't re-invalidate themselves — so
|
|
68
71
|
# without this the overlay's contents would stay blank on reopen.
|
|
69
72
|
# @param window [Component::Overlay] any overlay, modal or not.
|
|
73
|
+
# @param placement [Component::Overlay::Placement, nil] where it wants to
|
|
74
|
+
# be; `nil` takes its {Component::Overlay#default_placement}.
|
|
75
|
+
# @raise [TypeError] if `placement` does not include
|
|
76
|
+
# {Component::Overlay::Placement}.
|
|
70
77
|
# @return [void]
|
|
71
|
-
def add_popup(window)
|
|
78
|
+
def add_popup(window, placement = nil)
|
|
72
79
|
raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
|
|
73
80
|
raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
|
|
74
81
|
|
|
82
|
+
placement ||= window.default_placement
|
|
83
|
+
check_placement(placement)
|
|
75
84
|
@popup_prior_focus[window] = screen.focused
|
|
85
|
+
@placements[window] = placement
|
|
76
86
|
@popups << window
|
|
77
87
|
add_child(window) # appended: popups paint over the tiled content
|
|
78
|
-
if window.modal?
|
|
79
|
-
window.center
|
|
80
|
-
screen.focused = window
|
|
81
|
-
end
|
|
88
|
+
screen.focused = window if window.modal?
|
|
82
89
|
window.walk_tree { |c| screen.invalidate(c) }
|
|
83
90
|
end
|
|
84
91
|
|
|
92
|
+
# @param popup [Component::Overlay] an open popup.
|
|
93
|
+
# @return [Component::Overlay::Placement, nil] where it wants to be; `nil`
|
|
94
|
+
# if it isn't open here.
|
|
95
|
+
def placement(popup) = @placements[popup]
|
|
96
|
+
|
|
97
|
+
# Moves an open popup; it takes the new rect on the next settle.
|
|
98
|
+
# {Component::Overlay#placement=} is the usual way in.
|
|
99
|
+
# @param popup [Component::Overlay] an open popup.
|
|
100
|
+
# @param placement [Component::Overlay::Placement] where it wants to be.
|
|
101
|
+
# @raise [ArgumentError] if `popup` isn't open on this pane.
|
|
102
|
+
# @raise [TypeError] if `placement` does not include
|
|
103
|
+
# {Component::Overlay::Placement}.
|
|
104
|
+
# @return [void]
|
|
105
|
+
def constrain(popup, placement)
|
|
106
|
+
raise ArgumentError, "#{popup} is not an open popup on this pane" unless has_popup?(popup)
|
|
107
|
+
|
|
108
|
+
check_placement(placement)
|
|
109
|
+
return if @placements[popup] == placement
|
|
110
|
+
|
|
111
|
+
@placements[popup] = placement
|
|
112
|
+
invalidate_layout
|
|
113
|
+
end
|
|
114
|
+
|
|
85
115
|
# Removes a popup. If the popup held focus, focus shifts to the now-topmost
|
|
86
116
|
# remaining popup, falling back to the focus snapshotted when the popup
|
|
87
117
|
# was opened (if still attached), then to {#content}, then to nil.
|
|
@@ -91,6 +121,8 @@ module Tuile
|
|
|
91
121
|
raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
|
|
92
122
|
|
|
93
123
|
prior = @popup_prior_focus.delete(window)
|
|
124
|
+
@placements.delete(window)
|
|
125
|
+
@placed_anchors.delete(window)
|
|
94
126
|
@removing_popup_prior = prior
|
|
95
127
|
remove_child(window)
|
|
96
128
|
# Runs after the detach, so a prior pointing *inside* the removed popup is
|
|
@@ -118,6 +150,8 @@ module Tuile
|
|
|
118
150
|
@content = nil
|
|
119
151
|
@popups.clear
|
|
120
152
|
@popup_prior_focus.clear
|
|
153
|
+
@placements.clear
|
|
154
|
+
@placed_anchors.clear
|
|
121
155
|
end
|
|
122
156
|
|
|
123
157
|
# @param window [Component]
|
|
@@ -131,30 +165,40 @@ module Tuile
|
|
|
131
165
|
# content without capturing input.
|
|
132
166
|
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
133
167
|
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
# The root of the current **key scope**: the topmost modal popup when one
|
|
169
|
+
# is open, else the tiled {#content}. Keys bubble up to it and no further,
|
|
170
|
+
# a paste reaches only a focus chain inside it, and Tab cycles only the
|
|
171
|
+
# stops beneath it — so a component outside it is one the keyboard cannot
|
|
172
|
+
# reach at all. The mouse resolves against a *point* instead
|
|
173
|
+
# ({#mouse_root_at}).
|
|
174
|
+
# @return [Component, nil] nil when the pane holds neither.
|
|
175
|
+
def key_scope = modal_popup || @content
|
|
141
176
|
|
|
142
177
|
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
143
|
-
# itself
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
178
|
+
# itself — and each popup the rect its placement asks for, in stacking
|
|
179
|
+
# order, so a submenu is placed after the panel it hangs from.
|
|
180
|
+
#
|
|
181
|
+
# Re-running it moves nothing that stood still: a placement is a rule, so
|
|
182
|
+
# a second popup opening re-derives the first one's rect unchanged.
|
|
183
|
+
#
|
|
184
|
+
# An empty pane collapses every popup rather than placing it — an `At` rect
|
|
185
|
+
# would otherwise stand in a pane with no cells — and has no `return if
|
|
186
|
+
# rect.empty?` guard, which would strand the content at its last rect
|
|
187
|
+
# (`D_empty_ancestor`).
|
|
147
188
|
# @return [void]
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
189
|
+
def relayout
|
|
190
|
+
@content&.rect = local_rect
|
|
191
|
+
if rect.empty?
|
|
192
|
+
@popups.each { _1.rect = Rect.new(0, 0, 0, 0) }
|
|
193
|
+
else
|
|
194
|
+
@popups.each { place(_1) }
|
|
195
|
+
end
|
|
153
196
|
end
|
|
154
197
|
|
|
155
198
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
199
|
+
# @param _canvas [Canvas] see {Component#repaint}.
|
|
156
200
|
# @return [void]
|
|
157
|
-
def repaint; end
|
|
201
|
+
def repaint(_canvas); end
|
|
158
202
|
|
|
159
203
|
# Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
|
|
160
204
|
# the first component whose `handle_key?` returns true wins.
|
|
@@ -174,7 +218,7 @@ module Tuile
|
|
|
174
218
|
# @param key [String]
|
|
175
219
|
# @return [Boolean] true if the key was handled.
|
|
176
220
|
def handle_key?(key)
|
|
177
|
-
scope =
|
|
221
|
+
scope = key_scope
|
|
178
222
|
return false if scope.nil?
|
|
179
223
|
|
|
180
224
|
bubble_key(key, scope)
|
|
@@ -190,7 +234,7 @@ module Tuile
|
|
|
190
234
|
# @param text [String]
|
|
191
235
|
# @return [void]
|
|
192
236
|
def handle_paste(text)
|
|
193
|
-
scope =
|
|
237
|
+
scope = key_scope
|
|
194
238
|
return if scope.nil?
|
|
195
239
|
|
|
196
240
|
chain = focus_chain(scope)
|
|
@@ -283,6 +327,75 @@ module Tuile
|
|
|
283
327
|
|
|
284
328
|
private
|
|
285
329
|
|
|
330
|
+
# Rejects a non-placement where the app named it, rather than mid-pass: the
|
|
331
|
+
# layout is deferred, so the `NoMethodError` from a missing `rect_for` would
|
|
332
|
+
# otherwise surface a turn later, under this file's backtrace.
|
|
333
|
+
# @param placement [Object]
|
|
334
|
+
# @raise [TypeError] unless it includes {Component::Overlay::Placement}.
|
|
335
|
+
# @return [void]
|
|
336
|
+
def check_placement(placement)
|
|
337
|
+
return if placement.is_a?(Component::Overlay::Placement)
|
|
338
|
+
|
|
339
|
+
raise TypeError, "#{placement.class} must include Tuile::Component::Overlay::Placement"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# A placement's {Component::Overlay::Placement#anchor} in screen
|
|
343
|
+
# coordinates: a `Rect` passes through, a component resolves while it is on
|
|
344
|
+
# screen, and `nil` means either unanchored or gone — {#place} tells those
|
|
345
|
+
# apart by whether an anchor was declared at all.
|
|
346
|
+
# @param anchor [Component, Rect, nil]
|
|
347
|
+
# @return [Rect, nil]
|
|
348
|
+
def resolve_anchor(anchor)
|
|
349
|
+
return anchor if anchor.nil? || anchor.is_a?(Rect)
|
|
350
|
+
|
|
351
|
+
ComponentUtil.effectively_visible?(anchor) ? anchor.absolute_extent_rect : nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @param popup [Component::Overlay]
|
|
355
|
+
# @return [void]
|
|
356
|
+
def place(popup)
|
|
357
|
+
placement = @placements.fetch(popup)
|
|
358
|
+
anchor = placement.anchor
|
|
359
|
+
anchor_rect = resolve_anchor(anchor)
|
|
360
|
+
unless anchor.nil?
|
|
361
|
+
return lose_anchor(popup) if anchor_rect.nil?
|
|
362
|
+
|
|
363
|
+
@placed_anchors[popup] = anchor_rect
|
|
364
|
+
end
|
|
365
|
+
popup.rect = placement.rect_for(popup, rect.size, anchor_rect)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Leaves a popup whose anchor was detached or hidden at its last rect. It
|
|
369
|
+
# warns, once, because closing the popup is its owner's job and an owner
|
|
370
|
+
# that forgot leaves it floating over nothing.
|
|
371
|
+
# @param popup [Component::Overlay]
|
|
372
|
+
# @return [void]
|
|
373
|
+
def lose_anchor(popup)
|
|
374
|
+
return if @placed_anchors[popup] == :lost
|
|
375
|
+
|
|
376
|
+
@placed_anchors[popup] = :lost
|
|
377
|
+
Tuile.logger.warn("#{popup} lost its anchor; left where it was — close it when the anchor goes")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Re-marks the pane when an anchored popup's anchor now resolves elsewhere
|
|
381
|
+
# than where its last placement read it. {Screen#flush_layout} asks once
|
|
382
|
+
# its drain is empty, because this pane's pass runs *before* the content an
|
|
383
|
+
# anchor sits in, so a settle that moved the content handed the pass a
|
|
384
|
+
# stale anchor. Anchors in `lib/` converge in one more round — the content
|
|
385
|
+
# never moves for a popup, and a cascade anchors to a `Rect` — but a popup
|
|
386
|
+
# anchored inside another takes one round per level.
|
|
387
|
+
# @return [Boolean] whether it marked, so the screen drains again.
|
|
388
|
+
def remark_moved_anchors
|
|
389
|
+
return false if rect.empty? # #relayout collapses rather than places then, so nothing records
|
|
390
|
+
|
|
391
|
+
moved = @popups.any? do |popup|
|
|
392
|
+
anchor = @placements[popup].anchor
|
|
393
|
+
!anchor.nil? && (resolve_anchor(anchor) || :lost) != @placed_anchors[popup]
|
|
394
|
+
end
|
|
395
|
+
invalidate_layout if moved
|
|
396
|
+
moved
|
|
397
|
+
end
|
|
398
|
+
|
|
286
399
|
# The overlays a click counts as landing *inside*: the one it hit, plus
|
|
287
400
|
# every overlay that one belongs to, up the {Component::Overlay#owner}
|
|
288
401
|
# chain. An owner is any component, so it is resolved to the overlay
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The stale-rect diagnostic: a {Component#rect} read taken while an ancestor
|
|
5
|
+
# still owes a {Component#relayout} says so, instead of handing back the
|
|
6
|
+
# previous pass's rectangle in silence. **On wherever a {FakeScreen} is the
|
|
7
|
+
# installed screen** — a spec suite needs no setup to get it — and off in an
|
|
8
|
+
# app, which {Tuile.strict_layout} overrides either way:
|
|
9
|
+
#
|
|
10
|
+
# holder.constrain(pane, Rect.new(0, 0, 100, 26))
|
|
11
|
+
# pane.left.rect # => Tuile::Error: read the rect of #<Tuile::Component::Label
|
|
12
|
+
# # rect=(0,0 80x50)> while #<TwoPane rect=(0,0 100x26)> owes a
|
|
13
|
+
# # relayout … at spec/two_pane_spec.rb:42
|
|
14
|
+
#
|
|
15
|
+
# `:warn` logs the same line to {Tuile.logger} and hands the rectangle over,
|
|
16
|
+
# for watching a running app; `:raise` is the default and what a spec wants,
|
|
17
|
+
# since the backtrace names the read and a suite that never set a logger
|
|
18
|
+
# would see nothing at all. For the read that is pre-settle *on purpose*,
|
|
19
|
+
# {Tuile.without_strict_layout}.
|
|
20
|
+
#
|
|
21
|
+
# Reading `size`, `width`, `height`, `local_rect`, `absolute_rect` or
|
|
22
|
+
# `to_screen` reports too — they all go through the one reader.
|
|
23
|
+
#
|
|
24
|
+
# **Only reads the app makes are reported** ({PLUMBING}): a read `lib/` makes
|
|
25
|
+
# on the app's behalf mid-handler is not the app's to fix.
|
|
26
|
+
#
|
|
27
|
+
# == Implementation details
|
|
28
|
+
#
|
|
29
|
+
# {Tuile.strict_layout=} and {FakeScreen} prepend this module into
|
|
30
|
+
# {Component}, so `rect` stays the bare `attr_reader` — the hottest read in
|
|
31
|
+
# the toolkit — in every process that never asks. Nothing unprepends; turning
|
|
32
|
+
# the mode off leaves the check inert. See `D_strict_layout`.
|
|
33
|
+
module StrictLayout
|
|
34
|
+
# Where the gem's own frames live, so the site the message names is the
|
|
35
|
+
# app's — `to_screen` and the `size` / `width` / `height` trio all read
|
|
36
|
+
# `rect` from inside `component.rb`.
|
|
37
|
+
# @return [String]
|
|
38
|
+
LIB_DIR = File.expand_path("..", __dir__)
|
|
39
|
+
|
|
40
|
+
# The thread-local marking a report in progress: {Component#inspect} prints
|
|
41
|
+
# the rect, so building the message re-enters `rect` on the very component
|
|
42
|
+
# that is being complained about.
|
|
43
|
+
# @return [Symbol]
|
|
44
|
+
REPORTING = :tuile_strict_layout_reporting
|
|
45
|
+
|
|
46
|
+
# The readers that only forward to `rect`. A frame of one of these between
|
|
47
|
+
# the read and the app's own code carries no decision of the framework's, so
|
|
48
|
+
# the read still counts as the app's.
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
PLUMBING = %w[rect size width height local_rect local_extent_rect absolute_rect absolute_extent_rect
|
|
51
|
+
to_screen to_local].freeze
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# Makes {Component#rect} consult {Tuile.strict_layout} — idempotent, and
|
|
55
|
+
# permanent for the process.
|
|
56
|
+
# @return [void]
|
|
57
|
+
def install = Component.prepend(self)
|
|
58
|
+
|
|
59
|
+
# Reports `component`'s rect read as stale, the way `mode` asks for.
|
|
60
|
+
#
|
|
61
|
+
# @param component [Component] the component whose rect was read.
|
|
62
|
+
# @param ancestor [Component] the ancestor owing the relayout.
|
|
63
|
+
# @param mode [Symbol] `:raise` or `:warn`.
|
|
64
|
+
# @raise [Error] in `:raise` mode, unless the read was the framework's own.
|
|
65
|
+
# @return [void]
|
|
66
|
+
def report(component, ancestor, mode)
|
|
67
|
+
return unless app_read?
|
|
68
|
+
|
|
69
|
+
message = message_for(component, ancestor)
|
|
70
|
+
raise Error, message if mode == :raise
|
|
71
|
+
|
|
72
|
+
Tuile.logger.warn(message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @param component [Component]
|
|
78
|
+
# @param ancestor [Component]
|
|
79
|
+
# @return [String]
|
|
80
|
+
def message_for(component, ancestor)
|
|
81
|
+
Thread.current[REPORTING] = true
|
|
82
|
+
"Tuile: read the rect of #{component.inspect} while #{ancestor.inspect} owes a relayout — " \
|
|
83
|
+
"that rectangle is the previous pass's. Call flush_layout before reading it (a spec), or " \
|
|
84
|
+
"read it after the next event (an app)#{site}"
|
|
85
|
+
ensure
|
|
86
|
+
Thread.current[REPORTING] = false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the app asked the question rather than the framework on its
|
|
90
|
+
# behalf: the frames between the read and the first one outside the gem
|
|
91
|
+
# are {PLUMBING} and nothing else.
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def app_read?
|
|
94
|
+
frames.each do |frame|
|
|
95
|
+
return true unless frame.path.start_with?(LIB_DIR)
|
|
96
|
+
return false unless PLUMBING.include?(frame.base_label)
|
|
97
|
+
end
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [String] ` at <path>:<line>` for the frame that made the read,
|
|
102
|
+
# or `""` when there is none outside the gem.
|
|
103
|
+
def site
|
|
104
|
+
frame = frames.find { !_1.path.start_with?(LIB_DIR) }
|
|
105
|
+
frame.nil? ? "" : " at #{frame.path}:#{frame.lineno}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The stack above this file, asked from wherever in it — dropping our own
|
|
109
|
+
# frames by path rather than by a `caller_locations` offset, which would
|
|
110
|
+
# be two different numbers and would drift on any refactor here.
|
|
111
|
+
# @return [Array<Object>] `Thread::Backtrace::Location`s.
|
|
112
|
+
def frames = caller_locations.drop_while { _1.path == __FILE__ }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# {Component#rect}, reporting first when it is about to answer the previous
|
|
116
|
+
# pass's rectangle.
|
|
117
|
+
# @return [Rect]
|
|
118
|
+
def rect
|
|
119
|
+
mode = Tuile.strict_layout
|
|
120
|
+
if mode && !Thread.current[REPORTING]
|
|
121
|
+
ancestor = stale_layout_ancestor
|
|
122
|
+
StrictLayout.report(self, ancestor, mode) unless ancestor.nil?
|
|
123
|
+
end
|
|
124
|
+
super
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -403,6 +403,37 @@ module Tuile
|
|
|
403
403
|
raise TypeError, "cannot parse #{input.class}"
|
|
404
404
|
end
|
|
405
405
|
end
|
|
406
|
+
|
|
407
|
+
# Checks a glyph knob's value at assignment: exactly one grapheme
|
|
408
|
+
# cluster, one column wide. A component painting one glyph per cell
|
|
409
|
+
# spills a wider one onto its neighbour — silently, with nothing in the
|
|
410
|
+
# frame to point at — so every glyph knob asks here rather than at paint.
|
|
411
|
+
#
|
|
412
|
+
# def track_char=(char)
|
|
413
|
+
# @track_char = StyledString.validate_glyph(char, :track_char)
|
|
414
|
+
# end
|
|
415
|
+
#
|
|
416
|
+
# East-Asian-Ambiguous glyphs pass: Tuile measures them one column by
|
|
417
|
+
# construction (`D_ambiguous_width`), so no check can catch a terminal
|
|
418
|
+
# drawing them two.
|
|
419
|
+
# @param char [String]
|
|
420
|
+
# @param name [Symbol, String] the knob, for the message.
|
|
421
|
+
# @return [String] `char`, frozen.
|
|
422
|
+
# @raise [TypeError] when `char` is not a String.
|
|
423
|
+
# @raise [ArgumentError] when `char` is not exactly one grapheme cluster
|
|
424
|
+
# one column wide.
|
|
425
|
+
def validate_glyph(char, name)
|
|
426
|
+
raise TypeError, "#{name} must be a String, got #{char.inspect}" unless char.is_a?(String)
|
|
427
|
+
|
|
428
|
+
unless char.each_grapheme_cluster.take(2).size == 1
|
|
429
|
+
raise ArgumentError, "#{name} must be exactly one grapheme cluster, got #{char.inspect}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
width = plain(char).display_width
|
|
433
|
+
raise ArgumentError, "#{name} must be one column wide, got #{char.inspect} (#{width})" unless width == 1
|
|
434
|
+
|
|
435
|
+
-char
|
|
436
|
+
end
|
|
406
437
|
end
|
|
407
438
|
|
|
408
439
|
# The framework's single emoji-width policy, passed to every
|
|
@@ -500,27 +531,88 @@ module Tuile
|
|
|
500
531
|
slice_spans(start, len)
|
|
501
532
|
end
|
|
502
533
|
|
|
503
|
-
# Truncates to a target column width,
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
#
|
|
508
|
-
# is
|
|
534
|
+
# Truncates to a target column width, marking the cut end with an ellipsis.
|
|
535
|
+
# The ellipsis counts toward the target — the returned {StyledString}'s
|
|
536
|
+
# `display_width` never exceeds `display_width`, and comes out a column
|
|
537
|
+
# short when the cut lands mid-cluster, since a wide glyph straddling the
|
|
538
|
+
# boundary is dropped rather than halved. When `self` already fits, `self`
|
|
539
|
+
# is returned. When `display_width` is smaller than the ellipsis's own
|
|
540
|
+
# width, the ellipsis is sliced down to fit and no original content is
|
|
541
|
+
# included.
|
|
542
|
+
#
|
|
543
|
+
# `at: :start` keeps the *tail* instead, for text whose end identifies it
|
|
544
|
+
# and whose head is context — a path (`…/shared/markdown/`), a log line's
|
|
545
|
+
# message after its prefix.
|
|
509
546
|
#
|
|
510
547
|
# @param display_width [Integer] target column width.
|
|
511
|
-
# @param ellipsis [String, StyledString]
|
|
548
|
+
# @param ellipsis [String, StyledString] added when truncation
|
|
512
549
|
# occurs. Defaults to the Unicode horizontal-ellipsis `…` (one
|
|
513
550
|
# column). A `String` is parsed via {.parse}, so ANSI in it is
|
|
514
551
|
# preserved.
|
|
552
|
+
# @param at [Symbol] which end is cut: `:end` (the default) keeps the head
|
|
553
|
+
# and appends, `:start` keeps the tail and prepends.
|
|
554
|
+
# @raise [ArgumentError] unless `at` is `:start` or `:end`.
|
|
515
555
|
# @return [StyledString]
|
|
516
|
-
def ellipsize(display_width, ellipsis = "…")
|
|
556
|
+
def ellipsize(display_width, ellipsis = "…", at: :end)
|
|
557
|
+
raise ArgumentError, "expected :start or :end, got #{at.inspect}" unless %i[start end].include?(at)
|
|
517
558
|
return self.class.new if display_width <= 0
|
|
518
559
|
return self if self.display_width <= display_width
|
|
519
560
|
|
|
520
561
|
ellipsis = self.class.parse(ellipsis)
|
|
521
562
|
return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width
|
|
522
563
|
|
|
523
|
-
|
|
564
|
+
keep = display_width - ellipsis.display_width
|
|
565
|
+
return ellipsis + slice(self.display_width - keep, keep) if at == :start
|
|
566
|
+
|
|
567
|
+
slice(0, keep) + ellipsis
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Pads on the right out to `width` display columns — {String#ljust}
|
|
571
|
+
# counted in columns, so a CJK glyph or an emoji measures what it paints.
|
|
572
|
+
#
|
|
573
|
+
# StyledString.plain("日本").ljust(6).to_s # => "日本 "
|
|
574
|
+
# row.ellipsize(w).ljust(w) # exactly w columns
|
|
575
|
+
#
|
|
576
|
+
# Never truncates: `self` comes back when already at least `width` wide,
|
|
577
|
+
# a negative gap included. The fill is unstyled, so it shows whatever
|
|
578
|
+
# background is painted under it.
|
|
579
|
+
# @param width [Integer] target display width.
|
|
580
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
581
|
+
# @return [StyledString]
|
|
582
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
583
|
+
def ljust(width, pad = " ")
|
|
584
|
+
gap = gap_to(width, pad)
|
|
585
|
+
gap.zero? ? self : self + self.class.plain(pad * gap)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Pads on the left out to `width` display columns; see {#ljust}.
|
|
589
|
+
#
|
|
590
|
+
# StyledString.plain("42").rjust(5).to_s # => " 42"
|
|
591
|
+
#
|
|
592
|
+
# @param width [Integer] target display width.
|
|
593
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
594
|
+
# @return [StyledString]
|
|
595
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
596
|
+
def rjust(width, pad = " ")
|
|
597
|
+
gap = gap_to(width, pad)
|
|
598
|
+
gap.zero? ? self : self.class.plain(pad * gap) + self
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Pads both sides out to `width` display columns; see {#ljust}. An odd gap
|
|
602
|
+
# puts the extra column on the right, as {String#center} does.
|
|
603
|
+
#
|
|
604
|
+
# StyledString.plain("ab").center(5, "·").to_s # => "·ab··"
|
|
605
|
+
#
|
|
606
|
+
# @param width [Integer] target display width.
|
|
607
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
608
|
+
# @return [StyledString]
|
|
609
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
610
|
+
def center(width, pad = " ")
|
|
611
|
+
gap = gap_to(width, pad)
|
|
612
|
+
return self if gap.zero?
|
|
613
|
+
|
|
614
|
+
left = gap / 2
|
|
615
|
+
self.class.plain(pad * left) + self + self.class.plain(pad * (gap - left))
|
|
524
616
|
end
|
|
525
617
|
|
|
526
618
|
# Splits on `"\n"`, preserving spans on each side. A trailing newline
|
|
@@ -646,6 +738,34 @@ module Tuile
|
|
|
646
738
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
|
|
647
739
|
end
|
|
648
740
|
|
|
741
|
+
# Returns a copy with `fg` set **only on spans that have none**; a span with
|
|
742
|
+
# an explicit fg is left untouched. The foreground counterpart of
|
|
743
|
+
# {#under_bg} — it gives a caption a default tone while keeping the parts
|
|
744
|
+
# the app colored itself:
|
|
745
|
+
#
|
|
746
|
+
# caption = StyledString.plain("Queue ") + StyledString.styled("3", fg: theme[:notice])
|
|
747
|
+
# caption.under_fg(theme[:hint]) # "Queue " in :hint, "3" stays :notice
|
|
748
|
+
#
|
|
749
|
+
# An `inverse` span is skipped even when its `fg` member is nil, as
|
|
750
|
+
# {#under_bg} skips it: SGR 7 swaps the pair, so a filled fg would become
|
|
751
|
+
# the span's *background*.
|
|
752
|
+
#
|
|
753
|
+
# @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground color,
|
|
754
|
+
# coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
755
|
+
# @return [StyledString]
|
|
756
|
+
def under_fg(fg)
|
|
757
|
+
return self if fg.nil?
|
|
758
|
+
|
|
759
|
+
fg = Color.coerce(fg)
|
|
760
|
+
self.class.new(@spans.map do |span|
|
|
761
|
+
if span.style.fg.nil? && !span.style.inverse
|
|
762
|
+
Span.new(text: span.text, style: span.style.merge(fg: fg))
|
|
763
|
+
else
|
|
764
|
+
span
|
|
765
|
+
end
|
|
766
|
+
end)
|
|
767
|
+
end
|
|
768
|
+
|
|
649
769
|
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
650
770
|
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
651
771
|
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
@@ -740,6 +860,16 @@ module Tuile
|
|
|
740
860
|
result
|
|
741
861
|
end
|
|
742
862
|
|
|
863
|
+
# @param width [Integer]
|
|
864
|
+
# @param pad [String]
|
|
865
|
+
# @return [Integer] columns {#ljust} and friends must fill, never negative.
|
|
866
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
867
|
+
def gap_to(width, pad)
|
|
868
|
+
# The default skips validation: the fill runs once per painted row.
|
|
869
|
+
self.class.validate_glyph(pad, :pad) unless pad == " "
|
|
870
|
+
(width - display_width).clamp(0, nil)
|
|
871
|
+
end
|
|
872
|
+
|
|
743
873
|
# @param start_or_range [Integer, Range]
|
|
744
874
|
# @param len [Integer, nil]
|
|
745
875
|
# @param total [Integer] receiver's full display width.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
module Testing
|
|
5
|
+
# Receiver syntax for {Tuile::Testing}'s gestures, so a located component
|
|
6
|
+
# is driven in one line instead of passed back as an argument:
|
|
7
|
+
#
|
|
8
|
+
# using Tuile::Testing::Gestures
|
|
9
|
+
#
|
|
10
|
+
# Testing.get(Component::TextField, id: :name)._value = "Zaphod"
|
|
11
|
+
# Testing.get(Component::Button) { _1.caption.to_s == "Save" }._click
|
|
12
|
+
#
|
|
13
|
+
# A **refinement**, so it exists only where `using` says so — per file or
|
|
14
|
+
# per `describe` block, never leaking to a sibling — and nothing lands on
|
|
15
|
+
# {Component} itself. That is `D_component_lookup`'s standing answer for
|
|
16
|
+
# wanting receiver syntax.
|
|
17
|
+
#
|
|
18
|
+
# **The leading underscore is the mark**, borrowed from Karibu-Testing: it
|
|
19
|
+
# says *testing API, not the component's own*, which matters most for
|
|
20
|
+
# `_value=` sitting one character from a real `value=`. The unrefined
|
|
21
|
+
# spellings keep plain names, since `Testing.` already marks them.
|
|
22
|
+
module Gestures
|
|
23
|
+
refine Component do
|
|
24
|
+
# @return [void] see {Testing.click}.
|
|
25
|
+
def _click = Testing.click(self)
|
|
26
|
+
|
|
27
|
+
# @param value [Object] see {Testing.set_value}.
|
|
28
|
+
# @return [void]
|
|
29
|
+
def _value=(value)
|
|
30
|
+
Testing.set_value(self, value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|