tuile 0.12.0 → 0.13.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 +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- data/mise.toml +0 -2
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -5,10 +5,15 @@ module Tuile
|
|
|
5
5
|
#
|
|
6
6
|
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
7
7
|
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
8
|
-
# {#content}
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# {#content} and the {#popups} stack. Putting them under a single Component
|
|
9
|
+
# parent gives focus traversal a real root, makes {Component#attached?} a
|
|
10
|
+
# one-liner, and lets popup-focus repair fall out of the standard
|
|
11
|
+
# {Component#on_child_removed} hook.
|
|
12
|
+
#
|
|
13
|
+
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
14
|
+
# {#content} gets the full pane rect, and an app that wants a status line
|
|
15
|
+
# builds one into its own layout and drives it from
|
|
16
|
+
# {Screen#on_focus_changed=} (`D-status-bar`).
|
|
12
17
|
#
|
|
13
18
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
14
19
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -22,10 +27,6 @@ module Tuile
|
|
|
22
27
|
# user was, instead of falling through to {#content} and getting
|
|
23
28
|
# cascaded to the first focusable child.
|
|
24
29
|
@popup_prior_focus = {}
|
|
25
|
-
@status_bar = Component::Label.new
|
|
26
|
-
# Added first and never removed, so it is always the last child — which is
|
|
27
|
-
# the anchor `add_popup` inserts against.
|
|
28
|
-
add_child(@status_bar)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
# @return [Component, nil] the tiled content component.
|
|
@@ -34,8 +35,6 @@ module Tuile
|
|
|
34
35
|
# topmost. Holds both modal popups and non-modal overlays
|
|
35
36
|
# ({Component::Popup#modal?}). The array must not be mutated by callers.
|
|
36
37
|
attr_reader :popups
|
|
37
|
-
# @return [Component::Label] the bottom status bar.
|
|
38
|
-
attr_reader :status_bar
|
|
39
38
|
|
|
40
39
|
def focusable? = false
|
|
41
40
|
|
|
@@ -74,7 +73,7 @@ module Tuile
|
|
|
74
73
|
|
|
75
74
|
@popup_prior_focus[window] = screen.focused
|
|
76
75
|
@popups << window
|
|
77
|
-
add_child(window
|
|
76
|
+
add_child(window) # appended: popups paint over the tiled content
|
|
78
77
|
if window.modal?
|
|
79
78
|
window.center
|
|
80
79
|
screen.focused = window
|
|
@@ -126,9 +125,9 @@ module Tuile
|
|
|
126
125
|
|
|
127
126
|
# @return [Component::Popup, nil] the topmost *modal* popup, or nil when
|
|
128
127
|
# only non-modal overlays (or no popups) are open. This is the "modal
|
|
129
|
-
# owner": the popup that scopes key dispatch, blocks mouse clicks,
|
|
130
|
-
#
|
|
131
|
-
#
|
|
128
|
+
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
129
|
+
# confines Tab cycling. Non-modal overlays are excluded — they float above
|
|
130
|
+
# the content without capturing input.
|
|
132
131
|
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
133
132
|
|
|
134
133
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -139,18 +138,17 @@ module Tuile
|
|
|
139
138
|
layout
|
|
140
139
|
end
|
|
141
140
|
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
141
|
+
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
142
|
+
# itself. Each popup re-resolves its {Component::Popup#size} against the new
|
|
143
|
+
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
144
|
+
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
145
|
+
# keep the top-left their owner assigned).
|
|
147
146
|
# @return [void]
|
|
148
147
|
def layout
|
|
149
148
|
return if rect.empty?
|
|
150
149
|
|
|
151
|
-
@content
|
|
150
|
+
@content&.rect = rect
|
|
152
151
|
@popups.each(&:reposition)
|
|
153
|
-
@status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
154
152
|
end
|
|
155
153
|
|
|
156
154
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
@@ -181,18 +179,67 @@ module Tuile
|
|
|
181
179
|
bubble_key(key, scope)
|
|
182
180
|
end
|
|
183
181
|
|
|
182
|
+
# Delivers pasted text along the same focus chain {#handle_key} bubbles
|
|
183
|
+
# along, and with the same scoping — first {Component#handle_paste}
|
|
184
|
+
# returning true wins.
|
|
185
|
+
# @param text [String]
|
|
186
|
+
# @return [Boolean] true if the text was consumed.
|
|
187
|
+
def handle_paste(text)
|
|
188
|
+
scope = modal_popup || @content
|
|
189
|
+
return false if scope.nil?
|
|
190
|
+
|
|
191
|
+
chain = focus_chain(scope)
|
|
192
|
+
return false if chain.nil?
|
|
193
|
+
|
|
194
|
+
chain.each { |c| return true if c.handle_paste(text) }
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
184
198
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
185
199
|
# fall through to content only when no popup is hit *and* no modal popup is
|
|
186
200
|
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
187
201
|
# even outside its rect — while a non-modal overlay blocks nothing: clicks
|
|
188
202
|
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
189
203
|
# content beneath.
|
|
204
|
+
#
|
|
205
|
+
# A left click also *dismisses* the open popups it landed outside of that
|
|
206
|
+
# asked for it ({Component::Popup#close_on_outside_click?}). That is a
|
|
207
|
+
# second thing happening on a click, but not a second dispatch: the click is
|
|
208
|
+
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
209
|
+
# closed rather than told.
|
|
210
|
+
#
|
|
211
|
+
# "Outside" is measured against the {Component::Popup#owner} chain, not
|
|
212
|
+
# against one rect and not against stacking order: the popup the click hit
|
|
213
|
+
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
214
|
+
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
215
|
+
# field opened, and a menu cascade being dismissed by a click on one of its
|
|
216
|
+
# own deeper panels. Order carries no meaning here — between unrelated
|
|
217
|
+
# overlays it is merely the order they opened in — so ownership is declared
|
|
218
|
+
# rather than inferred from the stack.
|
|
219
|
+
#
|
|
220
|
+
# Two halves of the ordering are load-bearing, and both are specced:
|
|
221
|
+
#
|
|
222
|
+
# - **Snapshot before routing.** A popup the delivered click *opens* must
|
|
223
|
+
# not be in the set (it would immediately dismiss itself — every
|
|
224
|
+
# {Component::Select} would be unopenable by mouse).
|
|
225
|
+
# - **Close after routing.** A widget toggling its own overlay from a click
|
|
226
|
+
# on its face closes it during delivery, and {Component::Popup#close} is
|
|
227
|
+
# idempotent, so the dismissal no-ops. Close *first* and the widget sees
|
|
228
|
+
# a shut overlay and reopens it — a Select's dropdown could then never be
|
|
229
|
+
# dismissed by clicking the Select.
|
|
230
|
+
#
|
|
231
|
+
# The snapshot is a fresh array for a third reason: a handler may close
|
|
232
|
+
# further popups, and `@popups` must not be mutated mid-iteration.
|
|
190
233
|
# @param event [MouseEvent]
|
|
191
234
|
# @return [void]
|
|
192
235
|
def handle_mouse(event)
|
|
193
|
-
|
|
194
|
-
|
|
236
|
+
hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
|
|
237
|
+
dismissable = event.button == :left ? @popups - kept_by(hit) : []
|
|
238
|
+
|
|
239
|
+
clicked = hit || (@content if modal_popup.nil?)
|
|
195
240
|
clicked&.handle_mouse(event)
|
|
241
|
+
|
|
242
|
+
dismissable.each { _1.close if _1.close_on_outside_click? }
|
|
196
243
|
end
|
|
197
244
|
|
|
198
245
|
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
@@ -231,6 +278,32 @@ module Tuile
|
|
|
231
278
|
|
|
232
279
|
private
|
|
233
280
|
|
|
281
|
+
# The popups a click counts as landing *inside*: the one it hit, plus every
|
|
282
|
+
# popup that one belongs to, up the {Component::Popup#owner} chain. An owner
|
|
283
|
+
# is any component, so it is resolved to the popup enclosing it (a popup
|
|
284
|
+
# resolves to itself) — which keeps the relationship a live tree question
|
|
285
|
+
# rather than one frozen when the overlay opened. The `include?` guard makes
|
|
286
|
+
# a mis-wired cycle terminate instead of hanging the UI thread.
|
|
287
|
+
# @param hit [Component::Popup, nil] the popup the click landed in, if any.
|
|
288
|
+
# @return [Array<Component::Popup>]
|
|
289
|
+
def kept_by(hit)
|
|
290
|
+
kept = []
|
|
291
|
+
popup = hit
|
|
292
|
+
while popup && !kept.include?(popup)
|
|
293
|
+
kept << popup
|
|
294
|
+
popup = enclosing_popup(popup.owner)
|
|
295
|
+
end
|
|
296
|
+
kept
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# @param component [Component, nil]
|
|
300
|
+
# @return [Component::Popup, nil] `component` itself when it is a popup,
|
|
301
|
+
# else the nearest popup above it, else nil.
|
|
302
|
+
def enclosing_popup(component)
|
|
303
|
+
component = component.parent until component.nil? || component.is_a?(Component::Popup)
|
|
304
|
+
component
|
|
305
|
+
end
|
|
306
|
+
|
|
234
307
|
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
235
308
|
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
236
309
|
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
@@ -240,6 +313,18 @@ module Tuile
|
|
|
240
313
|
# @param scope [Component] the modal scope root (topmost popup or content).
|
|
241
314
|
# @return [Boolean] true if some component on the chain handled the key.
|
|
242
315
|
def bubble_key(key, scope)
|
|
316
|
+
chain = focus_chain(scope)
|
|
317
|
+
return false if chain.nil?
|
|
318
|
+
|
|
319
|
+
chain.each { |c| return true if c.handle_key(key) }
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# {Screen#focused} and its ancestors up to and including `scope`.
|
|
324
|
+
# @param scope [Component] the modal scope root (topmost popup or content).
|
|
325
|
+
# @return [Array<Component>, nil] the chain, innermost first; nil when
|
|
326
|
+
# focus is nil or sits outside `scope`.
|
|
327
|
+
def focus_chain(scope)
|
|
243
328
|
chain = []
|
|
244
329
|
cursor = screen.focused
|
|
245
330
|
until cursor.nil?
|
|
@@ -248,10 +333,7 @@ module Tuile
|
|
|
248
333
|
|
|
249
334
|
cursor = cursor.parent
|
|
250
335
|
end
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
chain.each { |c| return true if c.handle_key(key) }
|
|
254
|
-
false
|
|
336
|
+
chain.last.equal?(scope) ? chain : nil
|
|
255
337
|
end
|
|
256
338
|
|
|
257
339
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -630,6 +630,46 @@ module Tuile
|
|
|
630
630
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
|
|
631
631
|
end
|
|
632
632
|
|
|
633
|
+
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
634
|
+
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
635
|
+
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
636
|
+
# {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
|
|
637
|
+
# possibly multi-span content — a widget marking one caption out of several
|
|
638
|
+
# as selected, where the caption may already carry its own colors.
|
|
639
|
+
#
|
|
640
|
+
# There is deliberately no `under_bold` (the fill-unset counterpart
|
|
641
|
+
# {#under_bg} provides for backgrounds): a background is inherited down the
|
|
642
|
+
# component tree, so a span with none has a meaningful "unset" state to
|
|
643
|
+
# fill, while `bold` is a plain per-span attribute that is either on or
|
|
644
|
+
# off. Pass `bold: false` to clear it.
|
|
645
|
+
#
|
|
646
|
+
# @param bold [Boolean] whether the spans should be bold.
|
|
647
|
+
# @return [StyledString]
|
|
648
|
+
def with_bold(bold: true)
|
|
649
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bold:)) })
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Returns a new {StyledString} with `underline` applied to every span,
|
|
653
|
+
# preserving each span's text and other style attributes (`fg`, `bg`,
|
|
654
|
+
# `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
|
|
655
|
+
# of a string, which is what a one-character cue needs:
|
|
656
|
+
#
|
|
657
|
+
# cap = StyledString.parse("File")
|
|
658
|
+
# cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
|
|
659
|
+
# # => "File" with the F underlined — a menu mnemonic
|
|
660
|
+
#
|
|
661
|
+
# Note {#slice} counts **columns**, not characters, so a caption with a
|
|
662
|
+
# wide glyph before the cue needs the prefix measured rather than counted.
|
|
663
|
+
#
|
|
664
|
+
# There is deliberately no `under_underline`, for the reason {#with_bold}
|
|
665
|
+
# spells out. Pass `underline: false` to clear it.
|
|
666
|
+
#
|
|
667
|
+
# @param underline [Boolean] whether the spans should be underlined.
|
|
668
|
+
# @return [StyledString]
|
|
669
|
+
def with_underline(underline: true)
|
|
670
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(underline:)) })
|
|
671
|
+
end
|
|
672
|
+
|
|
633
673
|
# @return [String]
|
|
634
674
|
def inspect
|
|
635
675
|
"#<#{self.class.name} #{to_s.inspect}>"
|
data/lib/tuile/version.rb
CHANGED