tuile 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
@@ -94,20 +94,29 @@ module Tuile
94
94
  end
95
95
 
96
96
  # Repaints the component. The default does the bookkeeping most components
97
- # need: it clears the background, and for a container whose children leave
98
- # gaps in {#rect} it re-invalidates those children so they repaint over the
99
- # cleared area (what makes mixed-width form layouts safe). A container whose
100
- # children fully tile {#rect} is left alone — the children cover everything.
97
+ # need: it clears the background — unless the direct children already tile
98
+ # {#rect}, in which case there is no gap to wipe and blanking cells they are
99
+ # about to repaint would only make them dirty — and then re-invalidates
100
+ # those children so they paint over the cleared area. That is what makes
101
+ # mixed-width form layouts safe.
101
102
  #
102
103
  # Call `super` from your own `repaint` to inherit this. Skip it only if you
103
104
  # paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
104
105
  # row-by-row paint). Never draw outside {#rect}. Only called when attached.
106
+ #
107
+ # **The children are re-invalidated whether or not they tile.** A container
108
+ # that paints nothing of its own can only redraw its area *through* them, so
109
+ # a tiling container that skipped this would be a dead end in the cascade: an
110
+ # ancestor's `clear_background` wipes the whole ancestor rect — siblings and
111
+ # grandchildren included — and re-invalidates only its *direct* children, so
112
+ # the notice has to keep travelling down or the cleared cells are never
113
+ # repainted. Cheap by construction: repainting the same glyphs leaves
114
+ # {Buffer::Cell} unchanged, so nothing extra reaches the wire.
105
115
  # @return [void]
106
116
  def repaint
107
117
  return if rect.empty?
108
- return if children.any? && children_tile_rect?
109
118
 
110
- clear_background
119
+ clear_background unless children.any? && children_tile_rect?
111
120
  children.each { |c| screen.invalidate(c) }
112
121
  end
113
122
 
@@ -122,6 +131,27 @@ module Tuile
122
131
  false
123
132
  end
124
133
 
134
+ # Called when text is pasted while this component is on the focus chain;
135
+ # override to accept it (the default reports every paste unhandled, and
136
+ # unhandled text is dropped). Arrives whole and `\n`-normalized, so
137
+ # `text.lines.size` is the paste's line count and a single mutation can
138
+ # absorb it:
139
+ #
140
+ # def handle_paste(text)
141
+ # self.caption = "[Pasted #{text.lines.size} lines]"
142
+ # true
143
+ # end
144
+ #
145
+ # Reaching here means the terminal said "this came from the clipboard" —
146
+ # {Component::AbstractStringField} inserts it at the caret, which is why a
147
+ # subclass that rebinds ENTER to submit needs no paste handling of its own
148
+ # to stop firing once per pasted line.
149
+ # @param _text [String] the pasted text.
150
+ # @return [Boolean] true if the paste was consumed.
151
+ def handle_paste(_text)
152
+ false
153
+ end
154
+
125
155
  # Handles mouse event. Default implementation focuses this component when
126
156
  # clicked (if {#focusable?}).
127
157
  # @param event [MouseEvent]
@@ -268,17 +298,12 @@ module Tuile
268
298
  # @return [Point, nil] absolute screen coordinates, or nil to hide.
269
299
  def cursor_position = nil
270
300
 
271
- # @return [String] formatted keyboard hint surfaced in the status bar by
272
- # {Screen} when this component is the active tiled window or the
273
- # topmost popup. Empty by default; override to advertise shortcuts.
274
- def keyboard_hint = ""
275
-
276
301
  protected
277
302
 
278
303
  # Adopts `child`: places it in {#children} and wires its parent pointer.
279
304
  #
280
- # add_child(@status_bar) # paints last
281
- # add_child(popup, at: @children.index(@status_bar)) # …just before it
305
+ # add_child(content, at: 0) # the tiled layer, painted beneath …
306
+ # add_child(@footer) # … and chrome appended, painted over it
282
307
  #
283
308
  # @param child [Component] must not already have a parent.
284
309
  # @param at [Integer, nil] index to insert at; appends when nil.
@@ -167,6 +167,26 @@ module Tuile
167
167
  class KeyEvent < Data.define(:key)
168
168
  end
169
169
 
170
+ # Text arrived from the clipboard rather than the keyboard: the terminal
171
+ # bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
172
+ # {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
173
+ # so a component sees one mutation instead of a keystroke per character —
174
+ # and a pasted line break can no longer be mistaken for a typed Enter.
175
+ #
176
+ # {Screen#event_loop} routes it to {Component#handle_paste} down the focus
177
+ # chain. It never enters the key ladder: no Tab traversal, no global
178
+ # shortcut, no {Component#handle_key}.
179
+ #
180
+ # @!attribute [r] text
181
+ # @return [String] the pasted text, `\n`-normalized by
182
+ # {Keys.normalize_paste}.
183
+ class PasteEvent < Data.define(:text)
184
+ # @param text [String]
185
+ def initialize(text:)
186
+ super(text: text.freeze)
187
+ end
188
+ end
189
+
170
190
  # An error event, causes {EventQueue#run_loop} to throw `StandardError` with
171
191
  # {#error} as its origin.
172
192
  #
@@ -321,7 +341,11 @@ module Tuile
321
341
  @key_thread = Thread.new do
322
342
  loop do
323
343
  key = Keys.getkey
324
- event = MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
344
+ event = if key == Keys::PASTE_START
345
+ PasteEvent.new(Keys.read_paste)
346
+ else
347
+ MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
348
+ end
325
349
  post event
326
350
  end
327
351
  rescue StandardError => e
@@ -58,6 +58,20 @@ module Tuile
58
58
  @prints << str
59
59
  end
60
60
 
61
+ # Pastes `text` into the focused component, as a real terminal would with
62
+ # bracketed paste on:
63
+ #
64
+ # area.focus
65
+ # Screen.instance.paste("one\r\ntwo")
66
+ # area.text # => "one\ntwo" — one mutation, no ENTER anywhere
67
+ #
68
+ # Goes through {Keys.normalize_paste} first, so a spec can hand it the
69
+ # CR-flavored line endings terminals actually deliver and still assert
70
+ # against `\n`.
71
+ # @param text [String]
72
+ # @return [Boolean] true if some component consumed it.
73
+ def paste(text) = handle_paste(Keys.normalize_paste(text))
74
+
61
75
  # @param component [Component] the component to check.
62
76
  # @return [Boolean]
63
77
  def invalidated?(component) = @invalidated.include?(component)
data/lib/tuile/keys.rb CHANGED
@@ -114,6 +114,24 @@ module Tuile
114
114
  # @return [String]
115
115
  SHIFT_TAB = "\e[Z"
116
116
 
117
+ # Enables **bracketed paste** (DEC private mode 2004): the terminal wraps
118
+ # pasted text in {PASTE_START} … {PASTE_END} instead of feeding it as
119
+ # keystrokes. Terminals that don't know the mode ignore it, so
120
+ # {Screen#run_event_loop} prints it unconditionally.
121
+ # @return [String]
122
+ BRACKETED_PASTE_ON = "\e[?2004h"
123
+ # Disables bracketed paste. See {BRACKETED_PASTE_ON}.
124
+ # @return [String]
125
+ BRACKETED_PASTE_OFF = "\e[?2004l"
126
+ # Opens a bracketed paste. {.getkey} returns it like any other key; the
127
+ # payload behind it is read by {.read_paste}.
128
+ # @return [String]
129
+ PASTE_START = "\e[200~"
130
+ # Closes a bracketed paste. Never surfaces as a key — {.read_paste}
131
+ # consumes it as the payload terminator.
132
+ # @return [String]
133
+ PASTE_END = "\e[201~"
134
+
117
135
  # True iff `key` is a single printable character — a one-character string
118
136
  # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
119
137
  # character escape sequences ({UP_ARROW}, mouse events, …), control bytes
@@ -168,5 +186,52 @@ module Tuile
168
186
 
169
187
  char
170
188
  end
189
+
190
+ # Reads the body of a bracketed paste, having just read {PASTE_START}, and
191
+ # returns it {.normalize_paste}d:
192
+ #
193
+ # Keys.getkey # => "\e[200~"
194
+ # Keys.read_paste # => "one\ntwo" (terminator consumed)
195
+ #
196
+ # Reads **raw**, one byte at a time, rather than looping on {.getkey}: a
197
+ # pasted `\e` would send `getkey` gulping five bytes of the *payload* as an
198
+ # escape tail, surfacing them as phantom keypresses. One byte at a time is
199
+ # also what keeps the terminator from being over-read — nothing past
200
+ # {PASTE_END} is consumed, so typing that lands behind a paste survives.
201
+ #
202
+ # Blocks until the terminator arrives; returns what it has at EOF.
203
+ # @return [String] the pasted text, UTF-8, without the brackets.
204
+ def self.read_paste
205
+ buffer = +"".b
206
+ terminator = PASTE_END.b
207
+ until buffer.end_with?(terminator)
208
+ byte = $stdin.read(1)
209
+ break if byte.nil?
210
+
211
+ buffer << byte
212
+ end
213
+ buffer.delete_suffix!(terminator)
214
+ normalize_paste(buffer.force_encoding(Encoding::UTF_8))
215
+ end
216
+
217
+ # Rewrites a raw paste payload into the one convention callers see: `\n`
218
+ # line endings, valid UTF-8.
219
+ #
220
+ # Keys.normalize_paste("a\r\nb\rc") # => "a\nb\nc"
221
+ #
222
+ # Both are terminal-layer artifacts, not text: a terminal with bracketed
223
+ # paste *off* rewrites the clipboard's `\n` to `\r` so a paste looks like
224
+ # typing, and several keep doing it inside the brackets — so the byte a
225
+ # line break arrives as is not something a component should have to know.
226
+ # Invalid bytes are scrubbed to `U+FFFD`, which is what lets
227
+ # {Component#handle_paste} take any clipboard, a binary file included,
228
+ # without the grapheme-cluster walk raising downstream.
229
+ #
230
+ # Control characters other than `\n` are left alone: they are content, and
231
+ # what a *text buffer* may hold is {Component::AbstractStringField}'s call,
232
+ # not this layer's.
233
+ # @param text [String] raw payload.
234
+ # @return [String]
235
+ def self.normalize_paste(text) = text.scrub.gsub(/\r\n?/, "\n")
171
236
  end
172
237
  end
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#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
  #
@@ -69,8 +74,8 @@ module Tuile
69
74
  @color_scheme = detect_scheme
70
75
  @theme_def = ThemeDef.default
71
76
  @theme = @theme_def.for(@color_scheme)
72
- # Structural root of the component tree: holds tiled content, popup
73
- # stack and status bar.
77
+ # Structural root of the component tree: holds tiled content and the
78
+ # popup stack.
74
79
  @pane = ScreenPane.new
75
80
  @on_error = ->(e) { raise e }
76
81
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
@@ -82,10 +87,10 @@ module Tuile
82
87
  @buffer = Buffer.new(@size)
83
88
  end
84
89
 
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.
90
+ # Entry in the global shortcut registry: the block to run, and whether it
91
+ # pre-empts open popups.
87
92
  # @api private
88
- Shortcut = Data.define(:block, :over_popups, :hint)
93
+ Shortcut = Data.define(:block, :over_popups)
89
94
  private_constant :Shortcut
90
95
 
91
96
  # Keys {#register_global_shortcut} refuses because every editable widget
@@ -193,12 +198,10 @@ module Tuile
193
198
  end
194
199
 
195
200
  # 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.
201
+ # {Component#on_theme_changed} across the attached tree and invalidates
202
+ # every attached component. No-op when `new_theme` equals the current theme.
203
+ # This is a *transient* override — the next OS appearance flip re-picks from
204
+ # {#theme_def}; assign {#theme_def=} for durable theming.
202
205
  # @param new_theme [Theme]
203
206
  # @return [void]
204
207
  def theme=(new_theme)
@@ -209,7 +212,6 @@ module Tuile
209
212
 
210
213
  @theme = new_theme
211
214
  @pane&.on_tree(&:on_theme_changed)
212
- refresh_status_bar
213
215
  needs_full_repaint
214
216
  end
215
217
 
@@ -287,6 +289,7 @@ module Tuile
287
289
  end
288
290
 
289
291
  check_locked
292
+ previous = @focused
290
293
  if focused.nil?
291
294
  @focused = nil
292
295
  @pane.on_tree { _1.active = false }
@@ -303,45 +306,34 @@ module Tuile
303
306
  @pane.on_tree { _1.active = active.include?(_1) }
304
307
  @focused.on_focus
305
308
  end
306
- refresh_status_bar
309
+ @on_focus_changed&.call unless @focused.equal?(previous)
307
310
  end
308
311
 
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
343
- end
344
- private :global_shortcut_hints
312
+ # Called after the focused component *changes* — including to and from
313
+ # `nil`, and including the focus repair that runs when a popup closes.
314
+ # Takes no arguments; read {#focused} (and walk its `parent` chain) for the
315
+ # new state.
316
+ #
317
+ # This is the hook an app drives its own status line from. Tuile owns no
318
+ # status bar and reserves no row: build a {Component::Label} into your own
319
+ # layout and fill it here (`D-status-bar`).
320
+ #
321
+ # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
322
+ #
323
+ # **Edge-triggered**, like {Component#on_attached}: re-assigning the
324
+ # component that already has focus fires nothing, so a callback can be as
325
+ # expensive as rebuilding a hint string without a `did it really change?`
326
+ # guard of its own. That matters more than it looks — `ScreenPane#content=`
327
+ # clears focus on every content swap, which on a level-triggered hook would
328
+ # fire a nil→nil notification during assembly.
329
+ #
330
+ # It runs *after* the active-flag cascade and `on_focus`, so the tree is
331
+ # settled. Two things a callback must tolerate: {#focused} being `nil`, and
332
+ # firing during {#close} — teardown clears focus, exactly as it fires
333
+ # {Component#on_detached}. A raising callback propagates out of {#focused=}
334
+ # and leaves focus assigned; keep it trivial, as with the attach hooks.
335
+ # @return [Proc, nil]
336
+ attr_accessor :on_focus_changed
345
337
 
346
338
  # Internal — use {Component::Popup#open} instead. Adds the popup to
347
339
  # {#pane}, centers and focuses it.
@@ -370,9 +362,16 @@ module Tuile
370
362
  # you want if the app benefits more from select-to-copy than from
371
363
  # click-to-focus. Components' `handle_mouse` is simply never invoked
372
364
  # from the loop in that mode (the terminal stops sending the bytes).
365
+ # @param bracketed_paste [Boolean] when true (default), enables DEC private
366
+ # mode 2004 so pasted text arrives whole, as {Component#handle_paste},
367
+ # instead of as one keystroke per character — which is the only way a
368
+ # pasted line break can be told from a typed Enter. When false, a paste
369
+ # streams in as keys again and a component that gives ENTER a meaning
370
+ # fires it once per pasted line. Turn it off only for a terminal that
371
+ # mishandles the mode.
373
372
  # @raise [Tuile::Error] if the screen is already {#close}d.
374
373
  # @return [void]
375
- def run_event_loop(capture_mouse: true)
374
+ def run_event_loop(capture_mouse: true, bracketed_paste: true)
376
375
  raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
377
376
 
378
377
  # The guard above stays outside the begin: teardown for a setup that never
@@ -381,6 +380,7 @@ module Tuile
381
380
  begin
382
381
  $stdin.echo = false
383
382
  print MouseEvent.start_tracking if capture_mouse
383
+ print Keys::BRACKETED_PASTE_ON if bracketed_paste
384
384
  # Follow OS light/dark flips live: terminals supporting mode 2031
385
385
  # push color-scheme reports that the key thread turns into
386
386
  # {EventQueue::ColorSchemeEvent}s.
@@ -390,6 +390,7 @@ module Tuile
390
390
  end
391
391
  ensure
392
392
  print TerminalBackground::NOTIFY_OFF
393
+ print Keys::BRACKETED_PASTE_OFF if bracketed_paste
393
394
  print MouseEvent.stop_tracking if capture_mouse
394
395
  print TTY::Cursor.show
395
396
  $stdin.echo = true
@@ -425,9 +426,7 @@ module Tuile
425
426
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
426
427
  # which every editable widget needs.
427
428
  #
428
- # screen.register_global_shortcut(Keys::CTRL_L,
429
- # over_popups: true,
430
- # hint: "^L #{screen.theme.hint("log")}") do
429
+ # screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
431
430
  # log_popup.open
432
431
  # end
433
432
  #
@@ -435,11 +434,9 @@ module Tuile
435
434
  # @param over_popups [Boolean] when true, fires even while a modal popup is
436
435
  # open (pre-empting the popup); when false (default), suppressed while any
437
436
  # 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
437
  # @yield invoked with no arguments when `key` is pressed.
441
438
  # @return [void]
442
- def register_global_shortcut(key, over_popups: false, hint: nil, &block)
439
+ def register_global_shortcut(key, over_popups: false, &block)
443
440
  raise ArgumentError, "block required" if block.nil?
444
441
  raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
445
442
  raise ArgumentError, "key cannot be empty" if key.empty?
@@ -461,10 +458,7 @@ module Tuile
461
458
  "button, handle ENTER in the form's own handle_key instead — a focused " \
462
459
  "TextArea/TextField gets first refusal there."
463
460
  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
461
+ @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
468
462
  end
469
463
 
470
464
  # Removes a shortcut previously installed by {#register_global_shortcut}.
@@ -473,15 +467,6 @@ module Tuile
473
467
  # @return [void]
474
468
  def unregister_global_shortcut(key)
475
469
  @global_shortcuts.delete(key)
476
- refresh_status_bar
477
- end
478
-
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
470
  end
486
471
 
487
472
  # Internal — use {Component::Popup#close} instead. Removes the popup
@@ -572,6 +557,26 @@ module Tuile
572
557
  Kernel.print(*args)
573
558
  end
574
559
 
560
+ # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
561
+ # went nowhere, e.g. a letter matching no menu mnemonic while a menu is
562
+ # open.
563
+ #
564
+ # return true if activate_mnemonic(key)
565
+ #
566
+ # screen.beep # no match: the key is swallowed, say so
567
+ # true
568
+ #
569
+ # Writes **immediately** rather than riding the next frame: a beep is not
570
+ # part of a frame, and the keystrokes worth beeping at are precisely the
571
+ # ones that invalidate nothing, so {#repaint} may never emit at all. Whether
572
+ # the user hears anything is the terminal's setting to make, so there is no
573
+ # Tuile-level enable/disable knob.
574
+ # @return [void]
575
+ def beep
576
+ check_locked
577
+ print(Ansi::BEL)
578
+ end
579
+
575
580
  # Repaints the screen; tries to be as effective as possible, by only
576
581
  # considering invalidated components and flushing just the changed cells
577
582
  # of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
@@ -602,10 +607,9 @@ module Tuile
602
607
 
603
608
  # Build the repaint list in z-order, leaning on the tree itself rather
604
609
  # 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.
610
+ # tiled layer (the content subtree) parent-before-child; the popups are
611
+ # the top layer and must paint last, so we collect the tiled layer first
612
+ # and append popups rather than taking a single pane.on_tree walk.
609
613
  popup_members = Set.new
610
614
  popups.each { |p| p.on_tree { popup_members << _1 } }
611
615
 
@@ -770,6 +774,17 @@ module Tuile
770
774
  # @return [void]
771
775
  def handle_mouse(event) = @pane.handle_mouse(event)
772
776
 
777
+ # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
778
+ #
779
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it
780
+ # skips Tab traversal and the global-shortcut registry entirely and goes
781
+ # straight to delivery. Unhandled text is dropped — there is no fallback
782
+ # that replays it as keys, which would put back the very ambiguity mode
783
+ # 2004 exists to remove.
784
+ # @param text [String]
785
+ # @return [Boolean] true if some component consumed it.
786
+ def handle_paste(text) = @pane.handle_paste(text)
787
+
773
788
  # @return [void]
774
789
  def event_loop
775
790
  @event_queue.run_loop do |event|
@@ -778,6 +793,8 @@ module Tuile
778
793
  key = event.key
779
794
  handled = handle_key(key)
780
795
  @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
796
+ when EventQueue::PasteEvent
797
+ handle_paste(event.text)
781
798
  when MouseEvent
782
799
  handle_mouse(event)
783
800
  when EventQueue::TTYSizeEvent