tuile 0.6.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 +16 -0
- data/README.md +1 -1
- data/examples/sampler.rb +112 -3
- 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 +1 -13
- data/lib/tuile/component/list.rb +45 -23
- data/lib/tuile/component/log_window.rb +21 -5
- data/lib/tuile/component/picker_window.rb +8 -6
- data/lib/tuile/component/popup.rb +69 -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 +30 -10
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +30 -26
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/keys.rb +2 -6
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +109 -113
- data/lib/tuile/screen_pane.rb +81 -20
- data/lib/tuile/styled_string.rb +164 -59
- data/lib/tuile/version.rb +1 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +639 -133
- metadata +10 -4
|
@@ -83,14 +83,6 @@ module Tuile
|
|
|
83
83
|
@footer.nil? ? super : super + [@footer]
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
# @param key [String]
|
|
87
|
-
# @return [Boolean]
|
|
88
|
-
def handle_key(key)
|
|
89
|
-
return @footer.handle_key(key) if @footer&.active?
|
|
90
|
-
|
|
91
|
-
super
|
|
92
|
-
end
|
|
93
|
-
|
|
94
86
|
# @param event [MouseEvent]
|
|
95
87
|
# @return [void]
|
|
96
88
|
def handle_mouse(event)
|
|
@@ -197,14 +189,31 @@ module Tuile
|
|
|
197
189
|
content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
198
190
|
end
|
|
199
191
|
|
|
200
|
-
# Paints the window border.
|
|
192
|
+
# Paints the window border into the {Screen#buffer}. Title is clipped to
|
|
193
|
+
# the inner width so the box never overflows {#rect}; when the window is
|
|
194
|
+
# active the whole border is drawn in {Theme#active_border_color}.
|
|
201
195
|
# @return [void]
|
|
202
196
|
def repaint_border
|
|
203
197
|
return if rect.empty?
|
|
204
198
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
199
|
+
w = rect.width
|
|
200
|
+
h = rect.height
|
|
201
|
+
top = rect.top
|
|
202
|
+
left = rect.left
|
|
203
|
+
inner_w = [w - 2, 0].max
|
|
204
|
+
title = frame_caption.to_s
|
|
205
|
+
title = title[0, inner_w] if title.length > inner_w
|
|
206
|
+
dashes = "─" * (inner_w - title.length)
|
|
207
|
+
|
|
208
|
+
fg = active? ? screen.theme.active_border_color : nil
|
|
209
|
+
bar = StyledString::Style.new(fg: fg)
|
|
210
|
+
buf = screen.buffer
|
|
211
|
+
buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
|
|
212
|
+
(1..(h - 2)).each do |dy|
|
|
213
|
+
buf.set_char(left, top + dy, "│", bar)
|
|
214
|
+
buf.set_char(left + w - 1, top + dy, "│", bar)
|
|
215
|
+
end
|
|
216
|
+
buf.set_line(left, top + h - 1, StyledString.styled("└#{"─" * inner_w}┘", fg: fg)) if h >= 2
|
|
208
217
|
end
|
|
209
218
|
|
|
210
219
|
# The caption text as it appears in the rendered border, including the
|
|
@@ -215,32 +224,6 @@ module Tuile
|
|
|
215
224
|
key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
|
|
216
225
|
end
|
|
217
226
|
|
|
218
|
-
# Builds the border as a single string with embedded cursor-positioning
|
|
219
|
-
# escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
|
|
220
|
-
# is clipped to fit the inner width so the box never overflows {#rect}.
|
|
221
|
-
# @param caption [String]
|
|
222
|
-
# @return [String]
|
|
223
|
-
def build_frame(caption)
|
|
224
|
-
w = @rect.width
|
|
225
|
-
h = @rect.height
|
|
226
|
-
top = @rect.top
|
|
227
|
-
left = @rect.left
|
|
228
|
-
inner_w = [w - 2, 0].max
|
|
229
|
-
|
|
230
|
-
title = caption.to_s
|
|
231
|
-
title = title[0, inner_w] if title.length > inner_w
|
|
232
|
-
dashes = "─" * (inner_w - title.length)
|
|
233
|
-
|
|
234
|
-
out = +""
|
|
235
|
-
out << TTY::Cursor.move_to(left, top) << "┌#{title}#{dashes}┐"
|
|
236
|
-
(1..(h - 2)).each do |dy|
|
|
237
|
-
out << TTY::Cursor.move_to(left, top + dy) << "│"
|
|
238
|
-
out << TTY::Cursor.move_to(left + w - 1, top + dy) << "│"
|
|
239
|
-
end
|
|
240
|
-
out << TTY::Cursor.move_to(left, top + h - 1) << "└#{"─" * inner_w}┘" if h >= 2
|
|
241
|
-
out
|
|
242
|
-
end
|
|
243
|
-
|
|
244
227
|
private
|
|
245
228
|
|
|
246
229
|
# Recomputes the window's natural size: content's natural size (or the
|
data/lib/tuile/component.rb
CHANGED
|
@@ -81,27 +81,22 @@ module Tuile
|
|
|
81
81
|
children.each { |c| screen.invalidate(c) }
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Called when a character is pressed on the keyboard.
|
|
84
|
+
# Called when a character is pressed on the keyboard. The default does
|
|
85
|
+
# nothing and reports the key as unhandled; input components
|
|
86
|
+
# ({Component::TextField}, {Component::List}, {Component::Button}, …)
|
|
87
|
+
# override it to act on keys they care about.
|
|
85
88
|
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# @param
|
|
89
|
+
# Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
|
|
90
|
+
# anywhere in the active scope is captured first (suppressed while a
|
|
91
|
+
# cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
|
|
92
|
+
# and bubbles up its ancestor chain until some component handles it. A
|
|
93
|
+
# component therefore only ever receives keys when it is on the focus chain
|
|
94
|
+
# — or when app code hands it a key directly — so it acts on the key alone
|
|
95
|
+
# and must never gate on its own {#active?} state.
|
|
96
|
+
# @param _key [String] a key.
|
|
94
97
|
# @return [Boolean] true if the key was handled, false if not.
|
|
95
|
-
def handle_key(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
c = find_shortcut_component(key)
|
|
99
|
-
if !c.nil?
|
|
100
|
-
screen.focused = c
|
|
101
|
-
true
|
|
102
|
-
else
|
|
103
|
-
false
|
|
104
|
-
end
|
|
98
|
+
def handle_key(_key)
|
|
99
|
+
false
|
|
105
100
|
end
|
|
106
101
|
|
|
107
102
|
# A global keyboard shortcut. When pressed, will focus this component.
|
|
@@ -190,7 +185,7 @@ module Tuile
|
|
|
190
185
|
# @return [void]
|
|
191
186
|
def on_tree(&block)
|
|
192
187
|
block.call(self)
|
|
193
|
-
children.each {
|
|
188
|
+
children.each { _1.on_tree(&block) }
|
|
194
189
|
end
|
|
195
190
|
|
|
196
191
|
# Called when the component receives focus.
|
|
@@ -293,6 +288,20 @@ module Tuile
|
|
|
293
288
|
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
294
289
|
def keyboard_hint = ""
|
|
295
290
|
|
|
291
|
+
# Advice to a wrapping {Component::Popup} on the minimum height this
|
|
292
|
+
# component prefers to occupy when shown in a popup. `nil` (the default)
|
|
293
|
+
# means no preference — the popup uses its own {Component::Popup#min_height}.
|
|
294
|
+
# Override in a content component that should not collapse to a couple of
|
|
295
|
+
# rows when sparse (e.g. {Component::LogWindow}).
|
|
296
|
+
# @return [Integer, nil]
|
|
297
|
+
def popup_min_height = nil
|
|
298
|
+
|
|
299
|
+
# Advice to a wrapping {Component::Popup} on the maximum height this
|
|
300
|
+
# component may grow to when shown in a popup. `nil` (the default) means
|
|
301
|
+
# no preference — the popup uses its own {Component::Popup#max_height}.
|
|
302
|
+
# @return [Integer, nil]
|
|
303
|
+
def popup_max_height = nil
|
|
304
|
+
|
|
296
305
|
protected
|
|
297
306
|
|
|
298
307
|
# @param parent [Component, nil]
|
|
@@ -354,12 +363,7 @@ module Tuile
|
|
|
354
363
|
# component's rect.
|
|
355
364
|
# @return [void]
|
|
356
365
|
def clear_background
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
spaces = " " * rect.width
|
|
360
|
-
(rect.top..(rect.top + rect.height - 1)).each do |row|
|
|
361
|
-
screen.print TTY::Cursor.move_to(rect.left, row), spaces
|
|
362
|
-
end
|
|
366
|
+
screen.buffer.fill(rect)
|
|
363
367
|
end
|
|
364
368
|
end
|
|
365
369
|
end
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -25,10 +25,14 @@ module Tuile
|
|
|
25
25
|
super
|
|
26
26
|
@event_queue = FakeEventQueue.new
|
|
27
27
|
@size = Size.new(160, 50)
|
|
28
|
+
@buffer.resize(@size) # super sized it to the test runner's TTY
|
|
28
29
|
@prints = []
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
# @return [Array<String>] whatever {#print}
|
|
32
|
+
# @return [Array<String>] whatever {#print} / {#emit} produced so far.
|
|
33
|
+
# Component painting lands in {#buffer}, not here — assert on
|
|
34
|
+
# {Buffer#row_text} / {Buffer#row_ansi} / {Buffer#cell} for content, and
|
|
35
|
+
# on `prints` for cursor and housekeeping escapes.
|
|
32
36
|
attr_reader :prints
|
|
33
37
|
|
|
34
38
|
# @return [void]
|
|
@@ -46,6 +50,15 @@ module Tuile
|
|
|
46
50
|
@prints += args
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
# Captures the assembled repaint frame instead of writing to the test
|
|
54
|
+
# runner's TTY. Lands in {#prints} so cursor/sync escapes can be asserted;
|
|
55
|
+
# painted content is read from {#buffer}.
|
|
56
|
+
# @param str [String]
|
|
57
|
+
# @return [void]
|
|
58
|
+
def emit(str)
|
|
59
|
+
@prints << str
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
# @param component [Component] the component to check.
|
|
50
63
|
# @return [Boolean]
|
|
51
64
|
def invalidated?(component) = @invalidated.include?(component)
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -156,9 +156,7 @@ module Tuile
|
|
|
156
156
|
# sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
|
|
157
157
|
# with a blocking read so the parser downstream sees a complete event
|
|
158
158
|
# instead of leaking tail bytes as keypresses.
|
|
159
|
-
if char.start_with?("\e[M") && char.bytesize < 6
|
|
160
|
-
char += $stdin.read(6 - char.bytesize)
|
|
161
|
-
end
|
|
159
|
+
char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
|
|
162
160
|
|
|
163
161
|
# Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
|
|
164
162
|
# can outgrow the 5-byte gulp above — the mode-2031 color-scheme
|
|
@@ -166,9 +164,7 @@ module Tuile
|
|
|
166
164
|
# bytes after the `\e`. Drain to the final byte with blocking 1-byte
|
|
167
165
|
# reads so the tail doesn't surface as phantom keypresses. Keyboard
|
|
168
166
|
# sequences never start with `\e[?`, so this can't eat a regular key.
|
|
169
|
-
|
|
170
|
-
char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
|
|
171
|
-
end
|
|
167
|
+
char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
|
|
172
168
|
|
|
173
169
|
char
|
|
174
170
|
end
|
data/lib/tuile/rect.rb
CHANGED
|
@@ -49,6 +49,18 @@ module Tuile
|
|
|
49
49
|
point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# @param other [Rect] another rectangle.
|
|
53
|
+
# @return [Boolean] true if `other` lies entirely within this rectangle.
|
|
54
|
+
# Uses the same half-open edges as {#contains?} (right/bottom exclusive).
|
|
55
|
+
# An {#empty? empty} `other` covers no cells, so it is trivially contained.
|
|
56
|
+
def contains_rect?(other)
|
|
57
|
+
return true if other.empty?
|
|
58
|
+
|
|
59
|
+
other.left >= left && other.top >= top &&
|
|
60
|
+
other.left + other.width <= left + width &&
|
|
61
|
+
other.top + other.height <= top + height
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
# @return [Size]
|
|
53
65
|
def size = Size.new(width, height)
|
|
54
66
|
|
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
|
#
|
|
@@ -206,7 +214,7 @@ module Tuile
|
|
|
206
214
|
check_locked
|
|
207
215
|
if focused.nil?
|
|
208
216
|
@focused = nil
|
|
209
|
-
@pane.on_tree {
|
|
217
|
+
@pane.on_tree { _1.active = false }
|
|
210
218
|
else
|
|
211
219
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
212
220
|
|
|
@@ -217,7 +225,7 @@ module Tuile
|
|
|
217
225
|
active << cursor
|
|
218
226
|
cursor = cursor.parent
|
|
219
227
|
end
|
|
220
|
-
@pane.on_tree {
|
|
228
|
+
@pane.on_tree { _1.active = active.include?(_1) }
|
|
221
229
|
@focused.on_focus
|
|
222
230
|
end
|
|
223
231
|
refresh_status_bar
|
|
@@ -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,
|
|
@@ -370,9 +378,7 @@ module Tuile
|
|
|
370
378
|
raise ArgumentError,
|
|
371
379
|
"#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
|
|
372
380
|
end
|
|
373
|
-
unless hint.nil? || hint.is_a?(String)
|
|
374
|
-
raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
|
|
375
|
-
end
|
|
381
|
+
raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
|
|
376
382
|
|
|
377
383
|
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
|
|
378
384
|
refresh_status_bar
|
|
@@ -391,7 +397,7 @@ module Tuile
|
|
|
391
397
|
def active_window
|
|
392
398
|
check_locked
|
|
393
399
|
result = nil
|
|
394
|
-
@pane.content&.on_tree { result =
|
|
400
|
+
@pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
|
|
395
401
|
result
|
|
396
402
|
end
|
|
397
403
|
|
|
@@ -404,10 +410,25 @@ module Tuile
|
|
|
404
410
|
# @return [void]
|
|
405
411
|
def remove_popup(window)
|
|
406
412
|
check_locked
|
|
413
|
+
return unless @pane.has_popup?(window)
|
|
414
|
+
|
|
407
415
|
@pane.remove_popup(window)
|
|
408
416
|
needs_full_repaint
|
|
409
417
|
end
|
|
410
418
|
|
|
419
|
+
# Invalidates the entire attached tree, forcing every component to repaint
|
|
420
|
+
# on the next cycle. Needed whenever something overdraws the scene without
|
|
421
|
+
# clipping and then exposes what was underneath — a closing popup
|
|
422
|
+
# ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
|
|
423
|
+
# longer covers the cells it previously painted ({Component::Popup#rect=}).
|
|
424
|
+
# The popup-only fast path in {#repaint} can't clear those vacated cells on
|
|
425
|
+
# its own, so we accept the cost of a full repaint.
|
|
426
|
+
# @api private
|
|
427
|
+
# @return [void]
|
|
428
|
+
def needs_full_repaint
|
|
429
|
+
@pane&.on_tree { invalidate _1 }
|
|
430
|
+
end
|
|
431
|
+
|
|
411
432
|
# Internal — use {Component::Popup#open?} instead.
|
|
412
433
|
# @api private
|
|
413
434
|
# @param window [Component::Popup]
|
|
@@ -436,31 +457,16 @@ module Tuile
|
|
|
436
457
|
@@instance&.close
|
|
437
458
|
end
|
|
438
459
|
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
# Outside repaint, writes go straight to stdout. We deliberately
|
|
447
|
-
# don't raise on a "print outside repaint" — that would be a useful
|
|
448
|
-
# guardrail against components painting outside the repaint loop,
|
|
449
|
-
# but it'd force terminal-housekeeping writes (`Screen#clear`,
|
|
450
|
-
# mouse-tracking start/stop, cursor-show on teardown) to bypass
|
|
451
|
-
# this method entirely and write directly to `$stdout`. {FakeScreen}
|
|
452
|
-
# overrides `print` to capture every byte into its `@prints` array,
|
|
453
|
-
# and tests that exercise `run_event_loop` against a real {Screen}
|
|
454
|
-
# would otherwise leak escape sequences to the test runner's stdout.
|
|
455
|
-
# 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.
|
|
456
466
|
# @param args [String] stuff to print.
|
|
457
467
|
# @return [void]
|
|
458
468
|
def print(*args)
|
|
459
|
-
|
|
460
|
-
args.each { |s| @frame_buffer << s.to_s }
|
|
461
|
-
else
|
|
462
|
-
Kernel.print(*args)
|
|
463
|
-
end
|
|
469
|
+
Kernel.print(*args)
|
|
464
470
|
end
|
|
465
471
|
|
|
466
472
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
@@ -476,64 +482,61 @@ module Tuile
|
|
|
476
482
|
# simple and very fast in common cases.
|
|
477
483
|
|
|
478
484
|
did_paint = false
|
|
479
|
-
@
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
# Tiled components may overdraw popups; repaint each open
|
|
510
|
-
# popup's full subtree on top, in stacking order
|
|
511
|
-
# (parent-before-child within each popup).
|
|
512
|
-
tiled + popups.flat_map { |p| collect_subtree(p) }
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
@repainting = repaint.to_set
|
|
516
|
-
@invalidated.clear
|
|
517
|
-
|
|
518
|
-
# Don't call {#clear} before repaint — causes flickering, and only
|
|
519
|
-
# needed when @content doesn't cover the entire screen.
|
|
520
|
-
repaint.each(&:repaint)
|
|
521
|
-
|
|
522
|
-
# Repaint done, mark all components as up-to-date.
|
|
523
|
-
@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
|
|
524
515
|
end
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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) }
|
|
529
523
|
end
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
#
|
|
535
|
-
|
|
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
|
|
536
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}")
|
|
537
540
|
end
|
|
538
541
|
|
|
539
542
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -575,7 +578,7 @@ module Tuile
|
|
|
575
578
|
# @return [Boolean] true if focus moved.
|
|
576
579
|
def cycle_focus(forward:)
|
|
577
580
|
check_locked
|
|
578
|
-
scope = @pane.
|
|
581
|
+
scope = @pane.modal_popup || @pane.content
|
|
579
582
|
return false if scope.nil?
|
|
580
583
|
|
|
581
584
|
stops = []
|
|
@@ -594,25 +597,22 @@ module Tuile
|
|
|
594
597
|
true
|
|
595
598
|
end
|
|
596
599
|
|
|
597
|
-
#
|
|
598
|
-
#
|
|
599
|
-
#
|
|
600
|
-
# @return [
|
|
601
|
-
def
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
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}"
|
|
605
607
|
end
|
|
606
608
|
|
|
607
|
-
#
|
|
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]
|
|
608
612
|
# @return [void]
|
|
609
|
-
def
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
print TTY::Cursor.hide
|
|
613
|
-
else
|
|
614
|
-
print TTY::Cursor.move_to(pos.x, pos.y), TTY::Cursor.show
|
|
615
|
-
end
|
|
613
|
+
def emit(str)
|
|
614
|
+
$stdout.write(str)
|
|
615
|
+
$stdout.flush
|
|
616
616
|
end
|
|
617
617
|
|
|
618
618
|
# Recalculates positions of all windows, and repaints the scene.
|
|
@@ -621,18 +621,12 @@ module Tuile
|
|
|
621
621
|
# @return [void]
|
|
622
622
|
def layout
|
|
623
623
|
check_locked
|
|
624
|
+
@buffer.resize(size) unless @buffer.size == size
|
|
624
625
|
needs_full_repaint
|
|
625
626
|
@pane.rect = Rect.new(0, 0, size.width, size.height)
|
|
626
627
|
repaint
|
|
627
628
|
end
|
|
628
629
|
|
|
629
|
-
# Called after a popup is closed. Since a popup can cover any window,
|
|
630
|
-
# top-level component or other popups, we need to redraw everything.
|
|
631
|
-
# @return [void]
|
|
632
|
-
def needs_full_repaint
|
|
633
|
-
@pane&.on_tree { invalidate it }
|
|
634
|
-
end
|
|
635
|
-
|
|
636
630
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
637
631
|
# window.
|
|
638
632
|
#
|
|
@@ -643,10 +637,12 @@ module Tuile
|
|
|
643
637
|
# doesn't trap them.
|
|
644
638
|
# 2. App-level shortcuts from {#register_global_shortcut}. An entry
|
|
645
639
|
# registered with `over_popups: true` always fires; one with the
|
|
646
|
-
# default `over_popups: false` fires only when no popup is open
|
|
647
|
-
# (otherwise the popup receives the key normally).
|
|
648
|
-
#
|
|
649
|
-
#
|
|
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.
|
|
650
646
|
# @param key [String]
|
|
651
647
|
# @return [Boolean] true if the key was handled by some window.
|
|
652
648
|
def handle_key(key)
|
|
@@ -659,7 +655,7 @@ module Tuile
|
|
|
659
655
|
true
|
|
660
656
|
else
|
|
661
657
|
shortcut = @global_shortcuts[key]
|
|
662
|
-
if !shortcut.nil? && (shortcut.over_popups || @pane.
|
|
658
|
+
if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
|
|
663
659
|
shortcut.block.call
|
|
664
660
|
true
|
|
665
661
|
else
|