tuile 0.12.0 → 0.14.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
data/lib/tuile/screen.rb CHANGED
@@ -14,10 +14,15 @@ module Tuile
14
14
  # Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
15
15
  # {#content} (set via {#content=}, filling the whole terminal and laying
16
16
  # out its own children), the modal/overlay {#popups} stack (opened via
17
- # {Component::Popup#open}, drawn on top of the content), and the bottom
18
- # status bar. Popups are *not* sized from their content — each carries its
19
- # own top-down {Component::Popup#size} — and they deliberately overdraw the
20
- # content without clipping.
17
+ # {Component::Popup#open}, drawn on top of the content). Popups are *not*
18
+ # sized from their content — each carries its own top-down
19
+ # {Component::Popup#declared_size} — and they deliberately overdraw the content
20
+ # without clipping.
21
+ #
22
+ # Tuile draws no chrome of its own: there is no status bar and no reserved
23
+ # row, so {#content} gets the whole terminal. An app that wants a status line
24
+ # builds one into its own layout and drives it from {#on_focus_changed=}
25
+ # (`D_status_bar`).
21
26
  #
22
27
  # ## Repaint model
23
28
  #
@@ -66,11 +71,14 @@ module Tuile
66
71
  # during :idle, at both ends of the screen's life. See {#check_locked}.
67
72
  @ui_thread = Thread.current
68
73
  @closed = false
69
- @color_scheme = detect_scheme
74
+ background = detect_background
75
+ @color_scheme = background.scheme
76
+ @background_color = background.color
77
+ @color_depth = detect_color_depth
70
78
  @theme_def = ThemeDef.default
71
79
  @theme = @theme_def.for(@color_scheme)
72
- # Structural root of the component tree: holds tiled content, popup
73
- # stack and status bar.
80
+ # Structural root of the component tree: holds tiled content and the
81
+ # popup stack.
74
82
  @pane = ScreenPane.new
75
83
  @on_error = ->(e) { raise e }
76
84
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
@@ -79,13 +87,13 @@ module Tuile
79
87
  # The back buffer components paint into. {#repaint} flushes its diff to
80
88
  # the terminal, so only changed cells are emitted (flicker-free on any
81
89
  # terminal). Sized to the current viewport; {#layout} resizes it.
82
- @buffer = Buffer.new(@size)
90
+ @buffer = Buffer.new(@size, color_depth: @color_depth)
83
91
  end
84
92
 
85
- # Entry in the global shortcut registry: the block to run, whether it
86
- # pre-empts open popups, and an optional preformatted status-bar hint.
93
+ # Entry in the global shortcut registry: the block to run, and whether it
94
+ # pre-empts open popups.
87
95
  # @api private
88
- Shortcut = Data.define(:block, :over_popups, :hint)
96
+ Shortcut = Data.define(:block, :over_popups)
89
97
  private_constant :Shortcut
90
98
 
91
99
  # Keys {#register_global_shortcut} refuses because every editable widget
@@ -113,6 +121,17 @@ module Tuile
113
121
  # @return [Symbol] `:light` or `:dark`
114
122
  attr_reader :color_scheme
115
123
 
124
+ # How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
125
+ # at construction. {Buffer#flush} degrades every color it emits to this,
126
+ # so an app may compute an RGB tint — say from {#background_color} — and
127
+ # paint with it, whatever the terminal turns out to understand.
128
+ #
129
+ # Deliberately read-only: detection runs once and the answer can't change
130
+ # mid-session. Override a terminal that reports itself wrong through
131
+ # {ColorDepth::OVERRIDE_ENV} instead.
132
+ # @return [Symbol]
133
+ attr_reader :color_depth
134
+
116
135
  # @return [Buffer] the back buffer components paint into
117
136
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
118
137
  attr_reader :buffer
@@ -178,6 +197,26 @@ module Tuile
178
197
  # @return [ThemeDef]
179
198
  attr_reader :theme_def
180
199
 
200
+ # The terminal's own background, as it reported it — for deriving a
201
+ # color *from* the background rather than picking one against it. A pane
202
+ # tinted a few percent off it sits right on any terminal, where a fixed
203
+ # near-neutral only sits right near the one it was tuned on:
204
+ #
205
+ # bg = screen.background_color
206
+ # sidebar.bg_color =
207
+ # bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
208
+ #
209
+ # **Nil is the normal case, not an edge** — a terminal answering only
210
+ # `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
211
+ #
212
+ # Kept current across OS appearance flips, a frame behind: the flip
213
+ # report carries light/dark only, so the screen re-probes and this
214
+ # updates when the reply lands. A changed color then fires
215
+ # {Component#on_theme_changed} across the tree exactly as a theme swap
216
+ # does — a background-derived tint *is* a theme-derived color.
217
+ # @return [Color, nil]
218
+ attr_reader :background_color
219
+
181
220
  # Replaces the theme definition and immediately applies the member
182
221
  # matching the current color scheme (via {#theme=}, so the whole UI
183
222
  # restyles — or nothing repaints if that member equals the current
@@ -193,12 +232,10 @@ module Tuile
193
232
  end
194
233
 
195
234
  # Replaces the theme and restyles the whole UI: fires
196
- # {Component#on_theme_changed} across the attached tree, refreshes the
197
- # status bar, and invalidates every attached component. No-op when
198
- # `new_theme` equals the current theme. This is a *transient* override —
199
- # the next OS appearance flip re-picks from {#theme_def}; assign {#theme_def=}
200
- # for durable theming. Preformatted status-bar hints (see
201
- # {#register_global_shortcut}) have their colors baked in and aren't restyled.
235
+ # {Component#on_theme_changed} across the attached tree and invalidates
236
+ # every attached component. No-op when `new_theme` equals the current theme.
237
+ # This is a *transient* override — the next OS appearance flip re-picks from
238
+ # {#theme_def}; assign {#theme_def=} for durable theming.
202
239
  # @param new_theme [Theme]
203
240
  # @return [void]
204
241
  def theme=(new_theme)
@@ -208,13 +245,15 @@ module Tuile
208
245
  return if @theme == new_theme
209
246
 
210
247
  @theme = new_theme
211
- @pane&.on_tree(&:on_theme_changed)
212
- refresh_status_bar
248
+ # `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
249
+ # subclass may narrow it further (`D_hook_visibility`).
250
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
213
251
  needs_full_repaint
214
252
  end
215
253
 
216
- # @return [Array<Component>] currently active popup components (forwarded
217
- # to {ScreenPane}). The array must not be modified!
254
+ # @return [Array<Component::Overlay>] the open overlay stack — both
255
+ # {Component::Popup} modals and bare {Component::Overlay}s — in stacking
256
+ # order (forwarded to {ScreenPane}). The array must not be modified!
218
257
  def popups = @pane.popups
219
258
 
220
259
  # @return [EventQueue] the event queue.
@@ -287,6 +326,7 @@ module Tuile
287
326
  end
288
327
 
289
328
  check_locked
329
+ previous = @focused
290
330
  if focused.nil?
291
331
  @focused = nil
292
332
  @pane.on_tree { _1.active = false }
@@ -303,50 +343,39 @@ module Tuile
303
343
  @pane.on_tree { _1.active = active.include?(_1) }
304
344
  @focused.on_focus
305
345
  end
306
- refresh_status_bar
307
- end
308
-
309
- # Rebuild the status-bar text from the current focus and global-shortcut
310
- # registry. Called from {#focused=} and whenever the global registry
311
- # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
312
- # for the tiled case Screen tacks on the global "q quit" instead.
313
- # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
314
- # for the over_popups filter rule.
315
- # @api private
316
- # @return [void]
317
- def refresh_status_bar
318
- top_popup = @pane.modal_popup
319
- globals = global_shortcut_hints(popup_open: !top_popup.nil?)
320
- @pane.status_bar.text = if top_popup.nil?
321
- ["q #{@theme.hint("quit")}", *globals,
322
- active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
323
- else
324
- [*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
325
- end
326
- end
327
- private :refresh_status_bar
328
-
329
- # Status-bar hints from currently-registered global shortcuts.
330
- # When a popup is open, only `over_popups: true` shortcuts contribute —
331
- # the rest don't fire in that context, so showing them would be a lie.
332
- # Insertion order is preserved (Hash iteration order).
333
- # @api private
334
- # @param popup_open [Boolean]
335
- # @return [Array<String>]
336
- def global_shortcut_hints(popup_open:)
337
- @global_shortcuts.each_value.filter_map do |s|
338
- next if s.hint.nil? || s.hint.empty?
339
- next if popup_open && !s.over_popups
340
-
341
- s.hint
342
- end
346
+ @on_focus_changed&.call unless @focused.equal?(previous)
343
347
  end
344
- private :global_shortcut_hints
345
348
 
346
- # Internal — use {Component::Popup#open} instead. Adds the popup to
347
- # {#pane}, centers and focuses it.
349
+ # Called after the focused component *changes* — including to and from
350
+ # `nil`, and including the focus repair that runs when a popup closes.
351
+ # Takes no arguments; read {#focused} (and walk its `parent` chain) for the
352
+ # new state.
353
+ #
354
+ # This is the hook an app drives its own status line from. Tuile owns no
355
+ # status bar and reserves no row: build a {Component::Label} into your own
356
+ # layout and fill it here (`D_status_bar`).
357
+ #
358
+ # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
359
+ #
360
+ # **Edge-triggered**, like {Component#on_attached}: re-assigning the
361
+ # component that already has focus fires nothing, so a callback can be as
362
+ # expensive as rebuilding a hint string without a `did it really change?`
363
+ # guard of its own. That matters more than it looks — `ScreenPane#content=`
364
+ # clears focus on every content swap, which on a level-triggered hook would
365
+ # fire a nil→nil notification during assembly.
366
+ #
367
+ # It runs *after* the active-flag cascade and `on_focus`, so the tree is
368
+ # settled. Two things a callback must tolerate: {#focused} being `nil`, and
369
+ # firing during {#close} — teardown clears focus, exactly as it fires
370
+ # {Component#on_detached}. A raising callback propagates out of {#focused=}
371
+ # and leaves focus assigned; keep it trivial, as with the attach hooks.
372
+ # @return [Proc, nil]
373
+ attr_accessor :on_focus_changed
374
+
375
+ # Internal — use {Component::Overlay#open} instead. Adds the overlay to
376
+ # {#pane}; a {Component::Popup} is additionally centered and focused.
348
377
  # @api private
349
- # @param window [Component::Popup]
378
+ # @param window [Component::Overlay] any overlay, modal or not.
350
379
  # @return [void]
351
380
  def add_popup(window)
352
381
  check_locked
@@ -370,9 +399,16 @@ module Tuile
370
399
  # you want if the app benefits more from select-to-copy than from
371
400
  # click-to-focus. Components' `handle_mouse` is simply never invoked
372
401
  # from the loop in that mode (the terminal stops sending the bytes).
402
+ # @param bracketed_paste [Boolean] when true (default), enables DEC private
403
+ # mode 2004 so pasted text arrives whole, as {Component#handle_paste},
404
+ # instead of as one keystroke per character — which is the only way a
405
+ # pasted line break can be told from a typed Enter. When false, a paste
406
+ # streams in as keys again and a component that gives ENTER a meaning
407
+ # fires it once per pasted line. Turn it off only for a terminal that
408
+ # mishandles the mode.
373
409
  # @raise [Tuile::Error] if the screen is already {#close}d.
374
410
  # @return [void]
375
- def run_event_loop(capture_mouse: true)
411
+ def run_event_loop(capture_mouse: true, bracketed_paste: true)
376
412
  raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
377
413
 
378
414
  # The guard above stays outside the begin: teardown for a setup that never
@@ -381,6 +417,7 @@ module Tuile
381
417
  begin
382
418
  $stdin.echo = false
383
419
  print MouseEvent.start_tracking if capture_mouse
420
+ print Keys::BRACKETED_PASTE_ON if bracketed_paste
384
421
  # Follow OS light/dark flips live: terminals supporting mode 2031
385
422
  # push color-scheme reports that the key thread turns into
386
423
  # {EventQueue::ColorSchemeEvent}s.
@@ -390,6 +427,7 @@ module Tuile
390
427
  end
391
428
  ensure
392
429
  print TerminalBackground::NOTIFY_OFF
430
+ print Keys::BRACKETED_PASTE_OFF if bracketed_paste
393
431
  print MouseEvent.stop_tracking if capture_mouse
394
432
  print TTY::Cursor.show
395
433
  $stdin.echo = true
@@ -425,9 +463,7 @@ module Tuile
425
463
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
426
464
  # which every editable widget needs.
427
465
  #
428
- # screen.register_global_shortcut(Keys::CTRL_L,
429
- # over_popups: true,
430
- # hint: "^L #{screen.theme.hint("log")}") do
466
+ # screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
431
467
  # log_popup.open
432
468
  # end
433
469
  #
@@ -435,11 +471,9 @@ module Tuile
435
471
  # @param over_popups [Boolean] when true, fires even while a modal popup is
436
472
  # open (pre-empting the popup); when false (default), suppressed while any
437
473
  # popup is open so the popup gets the key.
438
- # @param hint [String, nil] preformatted status-bar hint; nil (default) is
439
- # silent. Colors are baked in — re-register after a {#theme=} to recolor.
440
474
  # @yield invoked with no arguments when `key` is pressed.
441
475
  # @return [void]
442
- def register_global_shortcut(key, over_popups: false, hint: nil, &block)
476
+ def register_global_shortcut(key, over_popups: false, &block)
443
477
  raise ArgumentError, "block required" if block.nil?
444
478
  raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
445
479
  raise ArgumentError, "key cannot be empty" if key.empty?
@@ -461,10 +495,7 @@ module Tuile
461
495
  "button, handle ENTER in the form's own handle_key instead — a focused " \
462
496
  "TextArea/TextField gets first refusal there."
463
497
  end
464
- raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
465
-
466
- @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
467
- refresh_status_bar
498
+ @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
468
499
  end
469
500
 
470
501
  # Removes a shortcut previously installed by {#register_global_shortcut}.
@@ -473,23 +504,14 @@ module Tuile
473
504
  # @return [void]
474
505
  def unregister_global_shortcut(key)
475
506
  @global_shortcuts.delete(key)
476
- refresh_status_bar
477
507
  end
478
508
 
479
- # @return [Component, nil] current active tiled component.
480
- def active_window
481
- check_locked
482
- result = nil
483
- @pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
484
- result
485
- end
486
-
487
- # Internal — use {Component::Popup#close} instead. Removes the popup
509
+ # Internal — use {Component::Overlay#close} instead. Removes the overlay
488
510
  # from {#pane}, repairs focus, and repaints the scene.
489
511
  #
490
- # Does nothing if the window is not open on this screen.
512
+ # Does nothing if the overlay is not open on this screen.
491
513
  # @api private
492
- # @param window [Component::Popup]
514
+ # @param window [Component::Overlay] any overlay, modal or not.
493
515
  # @return [void]
494
516
  def remove_popup(window)
495
517
  check_locked
@@ -512,10 +534,10 @@ module Tuile
512
534
  @pane&.on_tree { invalidate _1 }
513
535
  end
514
536
 
515
- # Internal — use {Component::Popup#open?} instead.
537
+ # Internal — use {Component::Overlay#open?} instead.
516
538
  # @api private
517
- # @param window [Component::Popup]
518
- # @return [Boolean] true if this popup is currently mounted.
539
+ # @param window [Component::Overlay] any overlay, modal or not.
540
+ # @return [Boolean] true if this overlay is currently mounted.
519
541
  def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
520
542
  check_locked
521
543
  @pane.has_popup?(window)
@@ -561,15 +583,40 @@ module Tuile
561
583
  end
562
584
 
563
585
  # Writes terminal-housekeeping escapes straight to stdout: {#clear},
564
- # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
565
- # on teardown. Component painting does *not* go through here anymore — it
566
- # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
567
- # overrides this (and {#emit}) to capture into `@prints` instead of the
568
- # test runner's stdout.
586
+ # mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
587
+ # background re-probe, cursor-show on teardown. Component painting does
588
+ # *not* go through here anymore — it writes into {#buffer}, which
589
+ # {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
590
+ # {#emit}) to capture into `@prints` instead of the test runner's stdout.
591
+ #
592
+ # Flushed, like {#emit}: none of these escapes ends in a newline, and a
593
+ # buffered stdout would hold a *query* — one whose reply the app is
594
+ # waiting on — until the next frame went out.
569
595
  # @param args [String] stuff to print.
570
596
  # @return [void]
571
597
  def print(*args)
572
598
  Kernel.print(*args)
599
+ $stdout.flush
600
+ end
601
+
602
+ # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
603
+ # went nowhere, e.g. a letter matching no menu mnemonic while a menu is
604
+ # open.
605
+ #
606
+ # return true if activate_mnemonic(key)
607
+ #
608
+ # screen.beep # no match: the key is swallowed, say so
609
+ # true
610
+ #
611
+ # Writes **immediately** rather than riding the next frame: a beep is not
612
+ # part of a frame, and the keystrokes worth beeping at are precisely the
613
+ # ones that invalidate nothing, so {#repaint} may never emit at all. Whether
614
+ # the user hears anything is the terminal's setting to make, so there is no
615
+ # Tuile-level enable/disable knob.
616
+ # @return [void]
617
+ def beep
618
+ check_locked
619
+ print(Ansi::BEL)
573
620
  end
574
621
 
575
622
  # Repaints the screen; tries to be as effective as possible, by only
@@ -602,10 +649,9 @@ module Tuile
602
649
 
603
650
  # Build the repaint list in z-order, leaning on the tree itself rather
604
651
  # than a depth sort. The pane's pre-order traversal already orders the
605
- # tiled layer (content subtree + status bar) parent-before-child; the
606
- # popups are the top layer and must paint last. The status bar is a
607
- # *late* pane child yet sits under the popups, so a single pane.on_tree
608
- # walk won't do — we collect the tiled layer first, then append popups.
652
+ # tiled layer (the content subtree) parent-before-child; the popups are
653
+ # the top layer and must paint last, so we collect the tiled layer first
654
+ # and append popups rather than taking a single pane.on_tree walk.
609
655
  popup_members = Set.new
610
656
  popups.each { |p| p.on_tree { popup_members << _1 } }
611
657
 
@@ -620,12 +666,22 @@ module Tuile
620
666
  tiled_invalidated = true
621
667
  end
622
668
 
623
- # Popups on top: the whole stack when a tiled repaint may have clobbered
624
- # cells they share in the buffer, else just the invalidated popup
625
- # components. Overdraw into the buffer is free (only net-visible cell
626
- # changes reach the terminal), so reasserting the stack is cheap.
669
+ # Popups on top: a layer repaints whole whenever anything *beneath* it —
670
+ # the tiled tree, or a lower popup — repaints, else just its invalidated
671
+ # members. Layer-by-layer rather than one tiled_invalidated bool because
672
+ # the drain loops: a lower popup's repaint cascade re-invalidates
673
+ # children into the *next* iteration (its gap-clearing Layout wipes and
674
+ # re-queues a button, say), and that iteration has no tiled repaint —
675
+ # only the full re-assert of every layer above keeps the stacking order
676
+ # true across iterations (screen_spec pins it with two overlapping
677
+ # popups). Overdraw into the buffer is free (only net-visible cell
678
+ # changes reach the terminal), so reasserting layers is cheap.
679
+ below_repainted = tiled_invalidated
627
680
  popups.each do |p|
628
- p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
681
+ layer_invalidated = false
682
+ p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
683
+ p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
684
+ below_repainted ||= layer_invalidated
629
685
  end
630
686
 
631
687
  @repainting = repaint.to_set
@@ -650,25 +706,53 @@ module Tuile
650
706
 
651
707
  private
652
708
 
653
- # Startup color scheme: `:light` when {TerminalBackground.detect}
654
- # reports a light terminal background, `:dark` otherwise (including
655
- # when detection is inconclusive). Runs in the constructor — the
656
- # OSC 11 reply arrives on stdin, which is only safe to read before
657
- # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
658
- # to pin `:dark`, keeping specs deterministic and off the test
659
- # runner's TTY.
660
- # @return [Symbol] `:dark` or `:light`.
661
- def detect_scheme
662
- TerminalBackground.detect == :light ? :light : :dark
709
+ # The startup background probe, seeding {#theme} and
710
+ # {#background_color}. Inconclusive detection means `:dark` with no
711
+ # color. Runs in the constructor — the OSC 11 reply arrives on stdin,
712
+ # which is only safe to read before {EventQueue#start_key_thread} owns
713
+ # it. {FakeScreen} overrides this to pin the result, keeping specs
714
+ # deterministic and off the test runner's TTY.
715
+ # @return [TerminalBackground::Result]
716
+ def detect_background
717
+ TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
663
718
  end
664
719
 
720
+ # The startup color-depth probe, seeding {#color_depth}. Reads the
721
+ # environment only, so unlike {#detect_background} it has no timing
722
+ # constraint. {FakeScreen} overrides it to pin the result, keeping specs
723
+ # off whatever `COLORTERM` the test runner happens to carry.
724
+ # @return [Symbol]
725
+ def detect_color_depth = ColorDepth.detect
726
+
665
727
  # An OS appearance flip arrived (mode-2031 report): remember the
666
- # scheme and apply the matching member of {#theme_def}.
728
+ # scheme, apply the matching member of {#theme_def}, and re-probe for
729
+ # the new background RGB, which the report does not carry.
730
+ #
731
+ # The query goes out from *this* thread — the event-loop thread, which
732
+ # also owns {#emit} — so its bytes can never land inside a frame's
733
+ # synchronized-output batch. The reply comes back through the key
734
+ # thread as an {EventQueue::BackgroundColorEvent}.
667
735
  # @param scheme [Symbol] `:dark` or `:light`.
668
736
  # @return [void]
669
737
  def on_color_scheme(scheme)
670
738
  @color_scheme = scheme
671
739
  self.theme = @theme_def.for(@color_scheme)
740
+ print TerminalBackground::QUERY
741
+ end
742
+
743
+ # The re-probe answered: adopt the color and restyle, since an app's
744
+ # background-derived tints are now a scheme behind. Deliberately keeps
745
+ # the previous color until the reply lands rather than blanking it on
746
+ # the flip — a terminal that reports mode-2031 flips but not OSC 11
747
+ # would otherwise lose the color it gave us at startup, permanently.
748
+ # @param color [Color]
749
+ # @return [void]
750
+ def on_background_color(color)
751
+ return if @background_color == color
752
+
753
+ @background_color = color
754
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
755
+ needs_full_repaint
672
756
  end
673
757
 
674
758
  # Walks the current modal scope in pre-order, collects tab stops, and
@@ -770,6 +854,17 @@ module Tuile
770
854
  # @return [void]
771
855
  def handle_mouse(event) = @pane.handle_mouse(event)
772
856
 
857
+ # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
858
+ #
859
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it
860
+ # skips Tab traversal and the global-shortcut registry entirely and goes
861
+ # straight to delivery. Unhandled text is dropped — there is no fallback
862
+ # that replays it as keys, which would put back the very ambiguity mode
863
+ # 2004 exists to remove.
864
+ # @param text [String]
865
+ # @return [Boolean] true if some component consumed it.
866
+ def handle_paste(text) = @pane.handle_paste(text)
867
+
773
868
  # @return [void]
774
869
  def event_loop
775
870
  @event_queue.run_loop do |event|
@@ -778,6 +873,8 @@ module Tuile
778
873
  key = event.key
779
874
  handled = handle_key(key)
780
875
  @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
876
+ when EventQueue::PasteEvent
877
+ handle_paste(event.text)
781
878
  when MouseEvent
782
879
  handle_mouse(event)
783
880
  when EventQueue::TTYSizeEvent
@@ -785,6 +882,8 @@ module Tuile
785
882
  layout
786
883
  when EventQueue::ColorSchemeEvent
787
884
  on_color_scheme(event.scheme)
885
+ when EventQueue::BackgroundColorEvent
886
+ on_background_color(event.color)
788
887
  when EventQueue::EmptyQueueEvent
789
888
  repaint
790
889
  when Proc