tuile 0.7.0 → 0.8.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 +11 -0
- data/examples/sampler.rb +109 -0
- data/ideas/back-buffer.md +217 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +412 -0
- data/lib/tuile/component/button.rb +2 -5
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/label.rb +8 -8
- data/lib/tuile/component/layout.rb +0 -12
- data/lib/tuile/component/list.rb +10 -11
- data/lib/tuile/component/log_window.rb +20 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +48 -13
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -7
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +29 -25
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/screen.rb +90 -100
- data/lib/tuile/screen_pane.rb +80 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +511 -112
- metadata +4 -2
data/lib/tuile/screen.rb
CHANGED
|
@@ -45,6 +45,10 @@ module Tuile
|
|
|
45
45
|
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
46
46
|
# reach the pane. See {#register_global_shortcut}.
|
|
47
47
|
@global_shortcuts = {}
|
|
48
|
+
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
49
|
+
# the terminal, so only changed cells are emitted (flicker-free on any
|
|
50
|
+
# terminal). Sized to the current viewport; {#layout} resizes it.
|
|
51
|
+
@buffer = Buffer.new(@size)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
# Entry in the global shortcut registry: the block to run, whether it
|
|
@@ -56,6 +60,10 @@ module Tuile
|
|
|
56
60
|
# @return [ScreenPane] the structural root of the component tree.
|
|
57
61
|
attr_reader :pane
|
|
58
62
|
|
|
63
|
+
# @return [Buffer] the back buffer components paint into
|
|
64
|
+
# ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
|
|
65
|
+
attr_reader :buffer
|
|
66
|
+
|
|
59
67
|
# Handler invoked when a {StandardError} escapes an event handler inside
|
|
60
68
|
# the event loop (e.g. a {Component::TextField}'s `on_change` raises).
|
|
61
69
|
#
|
|
@@ -232,7 +240,7 @@ module Tuile
|
|
|
232
240
|
# @api private
|
|
233
241
|
# @return [void]
|
|
234
242
|
def refresh_status_bar
|
|
235
|
-
top_popup = @pane.
|
|
243
|
+
top_popup = @pane.modal_popup
|
|
236
244
|
globals = global_shortcut_hints(popup_open: !top_popup.nil?)
|
|
237
245
|
@pane.status_bar.text = if top_popup.nil?
|
|
238
246
|
["q #{@theme.hint("quit")}", *globals,
|
|
@@ -449,31 +457,16 @@ module Tuile
|
|
|
449
457
|
@@instance&.close
|
|
450
458
|
end
|
|
451
459
|
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
# Outside repaint, writes go straight to stdout. We deliberately
|
|
460
|
-
# don't raise on a "print outside repaint" — that would be a useful
|
|
461
|
-
# guardrail against components painting outside the repaint loop,
|
|
462
|
-
# but it'd force terminal-housekeeping writes (`Screen#clear`,
|
|
463
|
-
# mouse-tracking start/stop, cursor-show on teardown) to bypass
|
|
464
|
-
# this method entirely and write directly to `$stdout`. {FakeScreen}
|
|
465
|
-
# overrides `print` to capture every byte into its `@prints` array,
|
|
466
|
-
# and tests that exercise `run_event_loop` against a real {Screen}
|
|
467
|
-
# would otherwise leak escape sequences to the test runner's stdout.
|
|
468
|
-
# Keeping `print` as the single sink preserves that override seam.
|
|
460
|
+
# Writes terminal-housekeeping escapes straight to stdout: {#clear},
|
|
461
|
+
# mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
|
|
462
|
+
# on teardown. Component painting does *not* go through here anymore — it
|
|
463
|
+
# writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
|
|
464
|
+
# overrides this (and {#emit}) to capture into `@prints` instead of the
|
|
465
|
+
# test runner's stdout.
|
|
469
466
|
# @param args [String] stuff to print.
|
|
470
467
|
# @return [void]
|
|
471
468
|
def print(*args)
|
|
472
|
-
|
|
473
|
-
args.each { |s| @frame_buffer << s.to_s }
|
|
474
|
-
else
|
|
475
|
-
Kernel.print(*args)
|
|
476
|
-
end
|
|
469
|
+
Kernel.print(*args)
|
|
477
470
|
end
|
|
478
471
|
|
|
479
472
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
@@ -489,64 +482,61 @@ module Tuile
|
|
|
489
482
|
# simple and very fast in common cases.
|
|
490
483
|
|
|
491
484
|
did_paint = false
|
|
492
|
-
@
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
# Tiled components may overdraw popups; repaint each open
|
|
523
|
-
# popup's full subtree on top, in stacking order
|
|
524
|
-
# (parent-before-child within each popup).
|
|
525
|
-
tiled + popups.flat_map { |p| collect_subtree(p) }
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
@repainting = repaint.to_set
|
|
529
|
-
@invalidated.clear
|
|
530
|
-
|
|
531
|
-
# Don't call {#clear} before repaint — causes flickering, and only
|
|
532
|
-
# needed when @content doesn't cover the entire screen.
|
|
533
|
-
repaint.each(&:repaint)
|
|
534
|
-
|
|
535
|
-
# Repaint done, mark all components as up-to-date.
|
|
536
|
-
@repainting.clear
|
|
485
|
+
until @invalidated.empty?
|
|
486
|
+
# Defensive filter: a component can become detached between enqueue
|
|
487
|
+
# and drain (popup close, sibling removed mid-event-handling, focus
|
|
488
|
+
# repair). Detached components have no place on the screen and must
|
|
489
|
+
# never paint, even though Component#invalidate already gates them
|
|
490
|
+
# out — this catches the case where attachment changed since.
|
|
491
|
+
@invalidated.delete_if { |c| !c.attached? }
|
|
492
|
+
break if @invalidated.empty?
|
|
493
|
+
|
|
494
|
+
did_paint = true
|
|
495
|
+
popups = @pane.popups
|
|
496
|
+
|
|
497
|
+
# Build the repaint list in z-order, leaning on the tree itself rather
|
|
498
|
+
# than a depth sort. The pane's pre-order traversal already orders the
|
|
499
|
+
# tiled layer (content subtree + status bar) parent-before-child; the
|
|
500
|
+
# popups are the top layer and must paint last. The status bar is a
|
|
501
|
+
# *late* pane child yet sits under the popups, so a single pane.on_tree
|
|
502
|
+
# walk won't do — we collect the tiled layer first, then append popups.
|
|
503
|
+
popup_members = Set.new
|
|
504
|
+
popups.each { |p| p.on_tree { popup_members << _1 } }
|
|
505
|
+
|
|
506
|
+
# Tiled layer: invalidated non-popup components, in tree order.
|
|
507
|
+
repaint = []
|
|
508
|
+
tiled_invalidated = false
|
|
509
|
+
@pane.on_tree do |c|
|
|
510
|
+
next if popup_members.include?(c)
|
|
511
|
+
next unless @invalidated.include?(c)
|
|
512
|
+
|
|
513
|
+
repaint << c
|
|
514
|
+
tiled_invalidated = true
|
|
537
515
|
end
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
516
|
+
|
|
517
|
+
# Popups on top: the whole stack when a tiled repaint may have clobbered
|
|
518
|
+
# cells they share in the buffer, else just the invalidated popup
|
|
519
|
+
# components. Overdraw into the buffer is free (only net-visible cell
|
|
520
|
+
# changes reach the terminal), so reasserting the stack is cheap.
|
|
521
|
+
popups.each do |p|
|
|
522
|
+
p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
|
|
542
523
|
end
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
#
|
|
548
|
-
|
|
524
|
+
|
|
525
|
+
@repainting = repaint.to_set
|
|
526
|
+
@invalidated.clear
|
|
527
|
+
|
|
528
|
+
# Components write into @buffer; overdraw is free and correct here
|
|
529
|
+
# because the buffer only diffs net-visible changes to the terminal.
|
|
530
|
+
repaint.each(&:repaint)
|
|
531
|
+
|
|
532
|
+
# Repaint done, mark all components as up-to-date.
|
|
533
|
+
@repainting.clear
|
|
549
534
|
end
|
|
535
|
+
return unless did_paint
|
|
536
|
+
|
|
537
|
+
# Flush only the changed cells, then reposition the cursor — all inside
|
|
538
|
+
# one synchronized-output batch so the terminal composites it atomically.
|
|
539
|
+
emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
|
|
550
540
|
end
|
|
551
541
|
|
|
552
542
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -588,7 +578,7 @@ module Tuile
|
|
|
588
578
|
# @return [Boolean] true if focus moved.
|
|
589
579
|
def cycle_focus(forward:)
|
|
590
580
|
check_locked
|
|
591
|
-
scope = @pane.
|
|
581
|
+
scope = @pane.modal_popup || @pane.content
|
|
592
582
|
return false if scope.nil?
|
|
593
583
|
|
|
594
584
|
stops = []
|
|
@@ -607,25 +597,22 @@ module Tuile
|
|
|
607
597
|
true
|
|
608
598
|
end
|
|
609
599
|
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
# @return [
|
|
614
|
-
def
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
result
|
|
600
|
+
# The escape sequence positioning the hardware cursor for the current focus
|
|
601
|
+
# state: hidden when nothing owns it, else moved to the focused component's
|
|
602
|
+
# {Component#cursor_position} and shown. Appended to each frame's flush.
|
|
603
|
+
# @return [String]
|
|
604
|
+
def cursor_sequence
|
|
605
|
+
pos = cursor_position
|
|
606
|
+
pos.nil? ? TTY::Cursor.hide : "#{TTY::Cursor.move_to(pos.x, pos.y)}#{TTY::Cursor.show}"
|
|
618
607
|
end
|
|
619
608
|
|
|
620
|
-
#
|
|
609
|
+
# Writes an assembled frame (escape string) to the terminal. The single
|
|
610
|
+
# sink for repaint output; {FakeScreen} overrides it to capture instead.
|
|
611
|
+
# @param str [String]
|
|
621
612
|
# @return [void]
|
|
622
|
-
def
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
print TTY::Cursor.hide
|
|
626
|
-
else
|
|
627
|
-
print TTY::Cursor.move_to(pos.x, pos.y), TTY::Cursor.show
|
|
628
|
-
end
|
|
613
|
+
def emit(str)
|
|
614
|
+
$stdout.write(str)
|
|
615
|
+
$stdout.flush
|
|
629
616
|
end
|
|
630
617
|
|
|
631
618
|
# Recalculates positions of all windows, and repaints the scene.
|
|
@@ -634,6 +621,7 @@ module Tuile
|
|
|
634
621
|
# @return [void]
|
|
635
622
|
def layout
|
|
636
623
|
check_locked
|
|
624
|
+
@buffer.resize(size) unless @buffer.size == size
|
|
637
625
|
needs_full_repaint
|
|
638
626
|
@pane.rect = Rect.new(0, 0, size.width, size.height)
|
|
639
627
|
repaint
|
|
@@ -649,10 +637,12 @@ module Tuile
|
|
|
649
637
|
# doesn't trap them.
|
|
650
638
|
# 2. App-level shortcuts from {#register_global_shortcut}. An entry
|
|
651
639
|
# registered with `over_popups: true` always fires; one with the
|
|
652
|
-
# default `over_popups: false` fires only when no popup is open
|
|
653
|
-
# (otherwise the popup receives the key normally).
|
|
654
|
-
#
|
|
655
|
-
#
|
|
640
|
+
# default `over_popups: false` fires only when no modal popup is open
|
|
641
|
+
# (otherwise the modal popup receives the key normally). A non-modal
|
|
642
|
+
# overlay doesn't suppress global shortcuts.
|
|
643
|
+
# 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
|
|
644
|
+
# in the active scope, then delivers the key to {#focused} and bubbles
|
|
645
|
+
# it up the focus chain.
|
|
656
646
|
# @param key [String]
|
|
657
647
|
# @return [Boolean] true if the key was handled by some window.
|
|
658
648
|
def handle_key(key)
|
|
@@ -665,7 +655,7 @@ module Tuile
|
|
|
665
655
|
true
|
|
666
656
|
else
|
|
667
657
|
shortcut = @global_shortcuts[key]
|
|
668
|
-
if !shortcut.nil? && (shortcut.over_popups || @pane.
|
|
658
|
+
if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
|
|
669
659
|
shortcut.block.call
|
|
670
660
|
true
|
|
671
661
|
else
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -28,8 +28,9 @@ module Tuile
|
|
|
28
28
|
|
|
29
29
|
# @return [Component, nil] the tiled content component.
|
|
30
30
|
attr_reader :content
|
|
31
|
-
# @return [Array<Component>]
|
|
32
|
-
# topmost.
|
|
31
|
+
# @return [Array<Component>] overlay popups in stacking order; last is
|
|
32
|
+
# topmost. Holds both modal popups and non-modal overlays
|
|
33
|
+
# ({Component::Popup#modal?}). The array must not be mutated by callers.
|
|
33
34
|
attr_reader :popups
|
|
34
35
|
# @return [Component::Label] the bottom status bar.
|
|
35
36
|
attr_reader :status_bar
|
|
@@ -57,7 +58,11 @@ module Tuile
|
|
|
57
58
|
layout
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
# Adds a popup
|
|
61
|
+
# Adds a popup and invalidates it for repaint. A modal popup is centered
|
|
62
|
+
# and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
|
|
63
|
+
# left wherever the caller positions it and does *not* take focus, so the
|
|
64
|
+
# component that was focused keeps the cursor and keeps receiving keys —
|
|
65
|
+
# the overlay floats above the content, driven from app code.
|
|
61
66
|
# @param window [Component::Popup]
|
|
62
67
|
# @return [void]
|
|
63
68
|
def add_popup(window)
|
|
@@ -67,8 +72,10 @@ module Tuile
|
|
|
67
72
|
@popup_prior_focus[window] = screen.focused
|
|
68
73
|
@popups << window
|
|
69
74
|
window.parent = self
|
|
70
|
-
window.
|
|
71
|
-
|
|
75
|
+
if window.modal?
|
|
76
|
+
window.center
|
|
77
|
+
screen.focused = window
|
|
78
|
+
end
|
|
72
79
|
screen.invalidate(window)
|
|
73
80
|
end
|
|
74
81
|
|
|
@@ -100,6 +107,13 @@ module Tuile
|
|
|
100
107
|
# @return [Boolean] true if this pane currently hosts the popup.
|
|
101
108
|
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
102
109
|
|
|
110
|
+
# @return [Component::Popup, nil] the topmost *modal* popup, or nil when
|
|
111
|
+
# only non-modal overlays (or no popups) are open. This is the "modal
|
|
112
|
+
# owner": the popup that scopes key dispatch, blocks mouse clicks, owns
|
|
113
|
+
# the status bar, and confines Tab cycling. Non-modal overlays are
|
|
114
|
+
# excluded — they float above the content without capturing input.
|
|
115
|
+
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
116
|
+
|
|
103
117
|
# Re-lays out children whenever the pane's own rect changes.
|
|
104
118
|
# @param new_rect [Rect]
|
|
105
119
|
# @return [void]
|
|
@@ -109,13 +123,14 @@ module Tuile
|
|
|
109
123
|
end
|
|
110
124
|
|
|
111
125
|
# Lays out content (full pane minus the bottom row) and the status bar
|
|
112
|
-
# (bottom row).
|
|
126
|
+
# (bottom row). Modal popups self-recenter via {Component::Popup#center};
|
|
127
|
+
# non-modal overlays keep the position their owner assigned.
|
|
113
128
|
# @return [void]
|
|
114
129
|
def layout
|
|
115
130
|
return if rect.empty?
|
|
116
131
|
|
|
117
132
|
@content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
|
|
118
|
-
@popups.each
|
|
133
|
+
@popups.each { |p| p.center if p.modal? }
|
|
119
134
|
@status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
120
135
|
end
|
|
121
136
|
|
|
@@ -123,32 +138,55 @@ module Tuile
|
|
|
123
138
|
# @return [void]
|
|
124
139
|
def repaint; end
|
|
125
140
|
|
|
126
|
-
#
|
|
127
|
-
# when
|
|
141
|
+
# Dispatches a key in two phases, both scoped to the topmost *modal* popup
|
|
142
|
+
# (when one is open) or else the tiled {#content}. Non-modal overlays are
|
|
143
|
+
# never the scope: focus stays in the content beneath them, and the overlay
|
|
144
|
+
# is driven by app code (which forwards keys to it explicitly), so it
|
|
145
|
+
# doesn't appear in this path at all.
|
|
146
|
+
#
|
|
147
|
+
# 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
|
|
148
|
+
# focuses that component and consumes the key. Suppressed while a
|
|
149
|
+
# cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
|
|
150
|
+
# {Component::TextField} isn't hijacked by a sibling's shortcut.
|
|
151
|
+
# 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
|
|
152
|
+
# ancestor chain to the scope root; the first component to return true
|
|
153
|
+
# wins. Focus that is nil or sits outside the scope receives nothing,
|
|
154
|
+
# which is what keeps an open modal popup modal.
|
|
155
|
+
# @param key [String]
|
|
156
|
+
# @return [Boolean] true if the key was handled.
|
|
128
157
|
def handle_key(key)
|
|
129
|
-
|
|
130
|
-
return
|
|
131
|
-
return @content.handle_key(key) unless @content.nil?
|
|
158
|
+
scope = modal_popup || @content
|
|
159
|
+
return false if scope.nil?
|
|
132
160
|
|
|
133
|
-
|
|
161
|
+
if screen.cursor_position.nil?
|
|
162
|
+
target = scope.find_shortcut_component(key)
|
|
163
|
+
unless target.nil?
|
|
164
|
+
screen.focused = target
|
|
165
|
+
return true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
bubble_key(key, scope)
|
|
134
170
|
end
|
|
135
171
|
|
|
136
172
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
137
|
-
# fall through to content only when no popup is hit *and*
|
|
138
|
-
#
|
|
139
|
-
#
|
|
173
|
+
# fall through to content only when no popup is hit *and* no modal popup is
|
|
174
|
+
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
175
|
+
# even outside its rect — while a non-modal overlay blocks nothing: clicks
|
|
176
|
+
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
177
|
+
# content beneath.
|
|
140
178
|
# @param event [MouseEvent]
|
|
141
179
|
# @return [void]
|
|
142
180
|
def handle_mouse(event)
|
|
143
181
|
clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
|
|
144
|
-
clicked = @content if clicked.nil? &&
|
|
182
|
+
clicked = @content if clicked.nil? && modal_popup.nil?
|
|
145
183
|
clicked&.handle_mouse(event)
|
|
146
184
|
end
|
|
147
185
|
|
|
148
186
|
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
149
187
|
# would refocus to `self` (the pane), which isn't a useful focus target.
|
|
150
188
|
# Instead, route focus to the first interactable widget in the now-topmost
|
|
151
|
-
# popup; falling back to the focus snapshotted when this popup was opened
|
|
189
|
+
# modal popup; falling back to the focus snapshotted when this popup was opened
|
|
152
190
|
# (if still attached and still focusable); then to the first interactable
|
|
153
191
|
# widget in {#content}; then to {#content} itself; then nil.
|
|
154
192
|
#
|
|
@@ -167,7 +205,7 @@ module Tuile
|
|
|
167
205
|
cursor = f
|
|
168
206
|
while cursor
|
|
169
207
|
if cursor == child
|
|
170
|
-
fallback = first_tab_stop_or_root(
|
|
208
|
+
fallback = first_tab_stop_or_root(modal_popup)
|
|
171
209
|
if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
|
|
172
210
|
fallback = @removing_popup_prior
|
|
173
211
|
end
|
|
@@ -181,6 +219,29 @@ module Tuile
|
|
|
181
219
|
|
|
182
220
|
private
|
|
183
221
|
|
|
222
|
+
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
223
|
+
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
224
|
+
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
225
|
+
# open popup modal, since focus is always inside it and content beneath
|
|
226
|
+
# never receives keys.
|
|
227
|
+
# @param key [String]
|
|
228
|
+
# @param scope [Component] the modal scope root (topmost popup or content).
|
|
229
|
+
# @return [Boolean] true if some component on the chain handled the key.
|
|
230
|
+
def bubble_key(key, scope)
|
|
231
|
+
chain = []
|
|
232
|
+
cursor = screen.focused
|
|
233
|
+
until cursor.nil?
|
|
234
|
+
chain << cursor
|
|
235
|
+
break if cursor.equal?(scope)
|
|
236
|
+
|
|
237
|
+
cursor = cursor.parent
|
|
238
|
+
end
|
|
239
|
+
return false unless chain.last.equal?(scope)
|
|
240
|
+
|
|
241
|
+
chain.each { |c| return true if c.handle_key(key) }
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
184
245
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
185
246
|
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
186
247
|
# if `root` is `nil`.
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -105,6 +105,45 @@ module Tuile
|
|
|
105
105
|
# @param overrides [Hash{Symbol => Object}]
|
|
106
106
|
# @return [Style]
|
|
107
107
|
def merge(**overrides) = self.class.new(**to_h.merge(overrides))
|
|
108
|
+
|
|
109
|
+
# Minimal SGR escape that transitions a terminal already showing `self`
|
|
110
|
+
# into `other`: only the attributes that differ are emitted. Returns
|
|
111
|
+
# `""` when the styles are identical (nothing to do), and {Ansi::RESET}
|
|
112
|
+
# (`\e[0m`, one code) when `other` is the default style — shorter than
|
|
113
|
+
# turning each attribute off individually.
|
|
114
|
+
#
|
|
115
|
+
# Shared by {StyledString#to_ansi} (diffing span-to-span from the default
|
|
116
|
+
# style) and {Buffer}'s flush (diffing cell-to-cell against the style the
|
|
117
|
+
# terminal currently holds), so both emit identical minimal sequences.
|
|
118
|
+
# @param other [Style] the style to transition to.
|
|
119
|
+
# @return [String]
|
|
120
|
+
def sgr_to(other)
|
|
121
|
+
return "" if self == other
|
|
122
|
+
return Ansi::RESET if other.default?
|
|
123
|
+
|
|
124
|
+
codes = []
|
|
125
|
+
codes << (other.bold ? 1 : 22) if bold != other.bold
|
|
126
|
+
codes << (other.italic ? 3 : 23) if italic != other.italic
|
|
127
|
+
codes << (other.underline ? 4 : 24) if underline != other.underline
|
|
128
|
+
codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
|
|
129
|
+
codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
|
|
130
|
+
codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
|
|
131
|
+
return "" if codes.empty?
|
|
132
|
+
|
|
133
|
+
"\e[#{codes.join(";")}m"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# @param color [Color, nil]
|
|
139
|
+
# @param target [Symbol] either `:fg` or `:bg`.
|
|
140
|
+
# @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default"
|
|
141
|
+
# reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
|
|
142
|
+
def color_codes(color, target:)
|
|
143
|
+
return [target == :fg ? 39 : 49] if color.nil?
|
|
144
|
+
|
|
145
|
+
color.sgr_codes(target)
|
|
146
|
+
end
|
|
108
147
|
end
|
|
109
148
|
|
|
110
149
|
# A maximal run of text sharing a single {Style}. `text` is plain — it
|
|
@@ -586,7 +625,7 @@ module Tuile
|
|
|
586
625
|
out = +""
|
|
587
626
|
current = Style::DEFAULT
|
|
588
627
|
@spans.each do |span|
|
|
589
|
-
out <<
|
|
628
|
+
out << current.sgr_to(span.style)
|
|
590
629
|
out << span.text
|
|
591
630
|
current = span.style
|
|
592
631
|
end
|
|
@@ -611,35 +650,6 @@ module Tuile
|
|
|
611
650
|
result
|
|
612
651
|
end
|
|
613
652
|
|
|
614
|
-
# @param from [Style]
|
|
615
|
-
# @param to [Style]
|
|
616
|
-
# @return [String]
|
|
617
|
-
def sgr_diff(from, to)
|
|
618
|
-
return "" if from == to
|
|
619
|
-
return Ansi::RESET if to.default?
|
|
620
|
-
|
|
621
|
-
codes = []
|
|
622
|
-
codes << (to.bold ? 1 : 22) if from.bold != to.bold
|
|
623
|
-
codes << (to.italic ? 3 : 23) if from.italic != to.italic
|
|
624
|
-
codes << (to.underline ? 4 : 24) if from.underline != to.underline
|
|
625
|
-
codes << (to.strikethrough ? 9 : 29) if from.strikethrough != to.strikethrough
|
|
626
|
-
codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
|
|
627
|
-
codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
|
|
628
|
-
return "" if codes.empty?
|
|
629
|
-
|
|
630
|
-
"\e[#{codes.join(";")}m"
|
|
631
|
-
end
|
|
632
|
-
|
|
633
|
-
# @param color [Color, nil]
|
|
634
|
-
# @param target [Symbol] `:fg` or `:bg`.
|
|
635
|
-
# @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
|
|
636
|
-
# when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
|
|
637
|
-
def color_codes(color, target:)
|
|
638
|
-
return [target == :fg ? 39 : 49] if color.nil?
|
|
639
|
-
|
|
640
|
-
color.sgr_codes(target)
|
|
641
|
-
end
|
|
642
|
-
|
|
643
653
|
# @param start_or_range [Integer, Range]
|
|
644
654
|
# @param len [Integer, nil]
|
|
645
655
|
# @param total [Integer] receiver's full display width.
|
data/lib/tuile/version.rb
CHANGED