tuile 0.11.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 +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- 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 +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- 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 +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -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
|
@@ -536,7 +536,7 @@ module Tuile
|
|
|
536
536
|
result
|
|
537
537
|
end
|
|
538
538
|
|
|
539
|
-
# Word-wraps to
|
|
539
|
+
# Word-wraps to rows that each fit within `width` display
|
|
540
540
|
# columns, preserving spans and styles across breaks. Greedy word-wrap,
|
|
541
541
|
# hard-break for words wider than `width`, leading whitespace dropped on
|
|
542
542
|
# wrapped continuations, hard `"\n"` breaks preserved as separate output
|
|
@@ -560,16 +560,16 @@ module Tuile
|
|
|
560
560
|
# @param width [Integer, nil] target column width. `nil` or `<= 0` skips
|
|
561
561
|
# wrapping and returns each hard-line as-is, so callers can pass a
|
|
562
562
|
# stale viewport width without crashing.
|
|
563
|
-
# @return [Array<StyledString>] one entry per
|
|
563
|
+
# @return [Array<StyledString>] one entry per output row.
|
|
564
564
|
# An empty receiver returns `[]`.
|
|
565
565
|
def wrap(width)
|
|
566
566
|
return [] if empty?
|
|
567
567
|
|
|
568
|
-
|
|
569
|
-
return
|
|
568
|
+
input_lines = lines
|
|
569
|
+
return input_lines if width.nil? || width <= 0
|
|
570
570
|
|
|
571
571
|
result = []
|
|
572
|
-
|
|
572
|
+
input_lines.each { |line| result.concat(wrap_one(line, width)) }
|
|
573
573
|
result
|
|
574
574
|
end
|
|
575
575
|
|
|
@@ -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}>"
|
|
@@ -717,17 +757,17 @@ module Tuile
|
|
|
717
757
|
self.class.new(out)
|
|
718
758
|
end
|
|
719
759
|
|
|
720
|
-
# @param
|
|
760
|
+
# @param line [StyledString] one line — no embedded `"\n"`.
|
|
721
761
|
# @param width [Integer]
|
|
722
762
|
# @return [Array<StyledString>]
|
|
723
|
-
def wrap_one(
|
|
724
|
-
return [
|
|
763
|
+
def wrap_one(line, width)
|
|
764
|
+
return [line] if line.empty?
|
|
725
765
|
|
|
726
766
|
result = []
|
|
727
767
|
line_glyphs = []
|
|
728
768
|
line_w = 0
|
|
729
769
|
|
|
730
|
-
tokenize_for_wrap(
|
|
770
|
+
tokenize_for_wrap(line).each do |type, glyphs, w|
|
|
731
771
|
if type == :space
|
|
732
772
|
if line_w.zero? && (!result.empty? || w > width)
|
|
733
773
|
# Nothing to emit: a continuation's leading run was consumed by the
|
|
@@ -763,18 +803,18 @@ module Tuile
|
|
|
763
803
|
# a cluster is the unit a terminal draws, so measuring its parts separately
|
|
764
804
|
# would both mis-total an emoji sequence and let a wrap break a letter away
|
|
765
805
|
# from its combining mark.
|
|
766
|
-
# @param
|
|
806
|
+
# @param line [StyledString]
|
|
767
807
|
# @return [Array<Array>] tokens shaped `[type, glyphs, w]` where `type` is
|
|
768
808
|
# `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
|
|
769
809
|
# (grapheme cluster, style, display width), and `w` is the token's total
|
|
770
810
|
# width.
|
|
771
|
-
def tokenize_for_wrap(
|
|
811
|
+
def tokenize_for_wrap(line)
|
|
772
812
|
tokens = []
|
|
773
813
|
current_glyphs = []
|
|
774
814
|
current_w = 0
|
|
775
815
|
current_type = nil
|
|
776
816
|
|
|
777
|
-
each_glyph_with_style(
|
|
817
|
+
each_glyph_with_style(line) do |g, s|
|
|
778
818
|
type = [" ", "\t"].include?(g) ? :space : :word
|
|
779
819
|
gw = Buffer.display_width(g)
|
|
780
820
|
if current_type && current_type != type
|
data/lib/tuile/version.rb
CHANGED
|
@@ -18,20 +18,20 @@ module Tuile
|
|
|
18
18
|
|
|
19
19
|
# @param height [Integer] number of rows in the scrollbar (== viewport
|
|
20
20
|
# height).
|
|
21
|
-
# @param
|
|
22
|
-
# @param
|
|
23
|
-
def initialize(height,
|
|
21
|
+
# @param row_count [Integer] total number of content rows.
|
|
22
|
+
# @param scroll_top_row [Integer] index of the first visible content row.
|
|
23
|
+
def initialize(height, row_count:, scroll_top_row:)
|
|
24
24
|
@height = height
|
|
25
25
|
|
|
26
26
|
return unless height >= 1
|
|
27
27
|
|
|
28
|
-
if
|
|
28
|
+
if row_count <= height
|
|
29
29
|
@handle_height = height
|
|
30
30
|
@handle_start = 0
|
|
31
31
|
@handle_end = height - 1
|
|
32
32
|
else
|
|
33
|
-
@handle_height = [(height * height /
|
|
34
|
-
@handle_start = (height *
|
|
33
|
+
@handle_height = [(height * height / row_count.to_f).ceil, 1].max
|
|
34
|
+
@handle_start = (height * scroll_top_row / row_count.to_f).floor
|
|
35
35
|
@handle_end = @handle_start + @handle_height - 1
|
|
36
36
|
end
|
|
37
37
|
end
|