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.
@@ -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
- frame = build_frame(frame_caption)
206
- frame = screen.theme.active_border(frame) if active?
207
- screen.print frame
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
@@ -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
- # Also called for inactive components. Inactive component should just return
87
- # false.
88
- #
89
- # Default implementation searches for a component with {#key_shortcut} and
90
- # focuses it. The shortcut search is suppressed while the focused component
91
- # owns the hardware cursor (e.g. a {Component::TextField} the user is
92
- # typing into) so that hotkeys don't steal printable keys from the editor.
93
- # @param key [String] a key.
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(key)
96
- return false unless screen.cursor_position.nil?
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 { it.on_tree(&block) }
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
- return if rect.empty?
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
@@ -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} printed so far.
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
- if char.start_with?("\e[?")
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 { it.active = false }
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 { it.active = active.include?(it) }
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.popups.last
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 = it if it.is_a?(Component::Window) && it.active? }
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
- # Prints given strings. While {#repaint} is running, writes are
440
- # accumulated into a frame buffer and flushed to the terminal as a
441
- # single `$stdout.write` at the end of the cycle. This stops the
442
- # emulator from rendering half-finished frames (e.g. a layout's
443
- # clear-background pass before its children have re-painted), which
444
- # was visible as a brief flicker when the auto-clear path triggers.
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
- if @frame_buffer
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
- @frame_buffer = +""
480
- begin
481
- until @invalidated.empty?
482
- # Defensive filter: a component can become detached between enqueue
483
- # and drain (popup close, sibling removed mid-event-handling, focus
484
- # repair). Detached components have no place on the screen and must
485
- # never paint, even though Component#invalidate already gates them
486
- # out — this catches the case where attachment changed since.
487
- @invalidated.delete_if { |c| !c.attached? }
488
- break if @invalidated.empty?
489
-
490
- did_paint = true
491
- popups = @pane.popups
492
-
493
- # Partition invalidated components into tiled vs popup-tree. Sorting
494
- # by depth across the whole tree would interleave them: a tiled
495
- # grandchild (depth 3) sorts after a popup's content (depth 2) and
496
- # overdraws it.
497
- popup_tree = Set.new
498
- popups.each { |p| p.on_tree { popup_tree << it } }
499
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
500
-
501
- # Within the tiled tree, paint parents before children.
502
- tiled.sort_by!(&:depth)
503
-
504
- repaint = if tiled.empty?
505
- # Only popups need repaint — paint just their invalidated
506
- # components in depth order.
507
- popup_invalidated.sort_by(&:depth)
508
- else
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
- position_cursor if did_paint
526
- unless @frame_buffer.empty?
527
- $stdout.write(@frame_buffer)
528
- $stdout.flush
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
- ensure
531
- # Always release the frame buffer, even on exception, so any
532
- # subsequent {#print} call (e.g. teardown emits during crash unwind)
533
- # reaches stdout instead of being swallowed by a stranded buffer.
534
- # The partial frame we hold here is incoherent discard it.
535
- @frame_buffer = nil
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.popups.last || @pane.content
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
- # Collects a component and all its descendants in tree order
598
- # (parent before children).
599
- # @param component [Component]
600
- # @return [Array<Component>]
601
- def collect_subtree(component)
602
- result = []
603
- component.on_tree { result << it }
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
- # Hides or moves the hardware cursor based on the current focus state.
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 position_cursor
610
- pos = cursor_position
611
- if pos.nil?
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
- # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
649
- # tiled content.
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.popups.empty?)
658
+ if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
663
659
  shortcut.block.call
664
660
  true
665
661
  else