tuile 0.9.0 → 0.10.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
data/lib/tuile/screen.rb CHANGED
@@ -1,25 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tuile
4
- # The TTY screen. There is exactly one screen per app.
4
+ # The process-singleton runtime: one {Screen} per app, reached through
5
+ # {Screen.instance}. It owns everything the UI needs to exist — the
6
+ # {#event_queue}, the UI lock, the invalidation set, the terminal IO, the
7
+ # back {#buffer}, the {#theme}/{#theme_def}, the {#focused} component, the
8
+ # global-shortcut registry, and the single {ScreenPane} under which *all*
9
+ # UI lives. Construct one with {Screen.new} (or {Screen.fake} in tests),
10
+ # tear it down with {Screen.close}.
5
11
  #
6
- # A screen runs the event loop; call {#run_event_loop} to do that.
12
+ # ## The component tree
7
13
  #
8
- # A screen holds the screen lock; any UI modifications must be called from
9
- # the event queue.
14
+ # Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
15
+ # {#content} (set via {#content=}, filling the whole terminal and laying
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.
10
21
  #
11
- # All UI lives under a single {ScreenPane} owned by the screen. Set tiled
12
- # content via {#content=}; the pane fills the entire terminal and is
13
- # responsible for laying out its children.
22
+ # ## Repaint model
14
23
  #
15
- # Modal popups are supported too, via {Component::Popup#open}. They
16
- # auto-size to their wrapped content and are drawn centered over the
17
- # tiled content.
24
+ # Components never draw to the terminal directly. They call
25
+ # {Component#invalidate} to mark themselves dirty, and when they do paint
26
+ # they write styled cells into {#buffer}. Once the event loop drains its
27
+ # queue, {#repaint} walks the invalidated set in z-order, has each
28
+ # component paint into the buffer, then flushes the buffer's *minimal diff*
29
+ # (only cells that changed) to the terminal in one synchronized-output
30
+ # batch — which is what keeps repaint flicker-free and coalesces many
31
+ # invalidations into a single frame per tick. See the book (ch. 2) for the
32
+ # why.
18
33
  #
19
- # The drawing procedure is very simple: when a window needs repaint, it
20
- # invalidates itself, but won't draw immediately. After the keyboard press
21
- # event processing is done in the event loop, {#repaint} is called which
22
- # then repaints all invalidated windows. This prevents repeated paintings.
34
+ # ## Thread-safety
35
+ #
36
+ # **UI-thread-confined**, where "the UI thread" changes hands once: it is the
37
+ # loop's thread while {#run_event_loop} is in progress, and the thread that
38
+ # *created* the screen whenever no loop is running ({#state} `:idle`). So an
39
+ # app builds its tree on its own thread, hands ownership to the loop, and
40
+ # gets it back for teardown — the loop needn't run on the creating thread.
41
+ # *All* UI mutations — {#content=}, {#focused=}, {#theme=},
42
+ # {Component#invalidate}, `rect=`, … — obey it via {#check_locked}.
43
+ #
44
+ # A worker marshals back with `screen.event_queue.submit { … }`, which runs
45
+ # the block only while a loop is draining the queue — outside `:running` it
46
+ # silently never fires. Terminal resize, key/mouse input and OS color-scheme
47
+ # flips arrive as events on that same queue.
48
+ #
49
+ # The singleton slot survives subclassing (`FakeScreen < Screen`), so
50
+ # {FakeScreen} — which captures output in memory — is what
51
+ # {Screen.instance} returns under test.
23
52
  class Screen
24
53
  # Class variable (not class instance var) so the singleton survives
25
54
  # subclassing — `FakeScreen < Screen` and `Screen.instance` see the same slot.
@@ -33,8 +62,10 @@ module Tuile
33
62
  # Components being repainted right now. A component may invalidate its
34
63
  # children during its repaint phase; this prevents double-draw.
35
64
  @repainting = Set.new
36
- # Until the event loop is run, we pretend we're in the UI thread.
37
- @pretend_ui_lock = true
65
+ # The thread that owns the UI whenever no event loop is running — i.e.
66
+ # during :idle, at both ends of the screen's life. See {#check_locked}.
67
+ @ui_thread = Thread.current
68
+ @closed = false
38
69
  @color_scheme = detect_scheme
39
70
  @theme_def = ThemeDef.default
40
71
  @theme = @theme_def.for(@color_scheme)
@@ -57,6 +88,25 @@ module Tuile
57
88
  Shortcut = Data.define(:block, :over_popups, :hint)
58
89
  private_constant :Shortcut
59
90
 
91
+ # Keys {#register_global_shortcut} refuses because every editable widget
92
+ # needs them: the registry sits *above* the component tree, so binding one
93
+ # app-wide would silently break text entry everywhere — a
94
+ # {Component::TextArea}'s newline, a caret move, a deletion. `ENTER` is the
95
+ # trap worth naming: it is unprintable, so nothing else stops it, and
96
+ # "bind Enter to submit" is the obvious wrong way to build a default
97
+ # button. The right way is a `handle_key` on the form itself, where a
98
+ # focused field still gets first refusal — see {ScreenPane#handle_key}.
99
+ #
100
+ # Deliberately *not* reserved: `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN`. They
101
+ # move within a widget rather than mutate its value, and binding them
102
+ # app-wide (scroll the log pane) is a real use case.
103
+ # @return [Array<String>]
104
+ EDITING_KEYS = [
105
+ Keys::ENTER, Keys::DELETE, *Keys::BACKSPACES,
106
+ Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::LEFT_ARROW, Keys::RIGHT_ARROW,
107
+ Keys::CTRL_LEFT_ARROW, Keys::CTRL_RIGHT_ARROW
108
+ ].freeze
109
+
60
110
  # @return [ScreenPane] the structural root of the component tree.
61
111
  attr_reader :pane
62
112
 
@@ -100,6 +150,9 @@ module Tuile
100
150
  # @param content [Component]
101
151
  # @return [void]
102
152
  def content=(content)
153
+ # Not left to ScreenPane#content='s own checks: after #close there's no
154
+ # pane to forward to, and NoMethodError-for-nil is a poor error.
155
+ check_locked
103
156
  @pane.content = content
104
157
  layout
105
158
  end
@@ -140,19 +193,12 @@ module Tuile
140
193
  end
141
194
 
142
195
  # Replaces the theme and restyles the whole UI: fires
143
- # {Component#on_theme_changed} across the attached tree (so the app can
144
- # rebuild styled content whose colors were derived from the old theme),
145
- # refreshes the status bar and invalidates every attached component so
146
- # the next repaint uses the new colors. No-op when `new_theme` equals
147
- # the current theme.
148
- #
149
- # This is a transient override: the next OS appearance flip re-picks
150
- # from {#theme_def} and replaces it. To theme an app durably, assign
151
- # {#theme_def=} instead.
152
- #
153
- # Note status-bar hints supplied by the host as preformatted strings
154
- # (see {#register_global_shortcut}) have their colors baked in and are
155
- # not restyled by this.
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.
156
202
  # @param new_theme [Theme]
157
203
  # @return [void]
158
204
  def theme=(new_theme)
@@ -174,15 +220,41 @@ module Tuile
174
220
  # @return [EventQueue] the event queue.
175
221
  attr_reader :event_queue
176
222
 
177
- # Checks that the UI lock is held and the current code runs in the "UI
178
- # thread".
223
+ # `:idle` covers *both* ends of the screen's life — before the first
224
+ # {#run_event_loop} and after it returns — and a screen may cycle
225
+ # `:idle` → `:running` → `:idle` repeatedly. `:closed` is terminal.
226
+ # @return [Symbol] `:idle` (no event loop running), `:running` (a
227
+ # {#run_event_loop} is in progress) or `:closed` (after {#close}).
228
+ def state
229
+ return :closed if @closed
230
+
231
+ @event_queue.running? ? :running : :idle
232
+ end
233
+
234
+ # Raises unless the calling thread currently owns the UI (see the
235
+ # class-level threading contract).
236
+ #
237
+ # screen.check_locked # from a worker: raises; wrap the work in
238
+ # # screen.event_queue.submit { ... } instead
239
+ #
240
+ # @raise [Tuile::Error] if {#state} is `:closed`, or the calling thread
241
+ # isn't the current owner.
179
242
  # @return [void]
180
243
  def check_locked
181
- return if @pretend_ui_lock || @event_queue.locked?
182
-
183
- raise Tuile::Error,
184
- "UI lock not held: UI mutations must run on the event-loop thread; " \
185
- "marshal via screen.event_queue.submit { ... }"
244
+ raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
245
+ return if @event_queue.running? ? @event_queue.on_loop_thread? : Thread.current.equal?(@ui_thread)
246
+
247
+ # `submit` is the wrong remedy with no loop running — nothing would drain
248
+ # the queue, so the block silently never fires.
249
+ message = if @event_queue.running?
250
+ "UI lock not held: UI mutations must run on the event-loop thread; " \
251
+ "marshal via screen.event_queue.submit { ... }"
252
+ else
253
+ "UI not owned by #{Thread.current}: no event loop is running, so UI mutations must " \
254
+ "come from #{@ui_thread}, the thread that created this screen " \
255
+ "(or start the event loop first)"
256
+ end
257
+ raise Tuile::Error, message
186
258
  end
187
259
 
188
260
  # Clears the TTY screen.
@@ -283,8 +355,13 @@ module Tuile
283
355
  # current screen contents.
284
356
  end
285
357
 
286
- # Runs event loop – waits for keys and sends them to active window. The
287
- # function exits when the 'ESC' or 'q' key is pressed.
358
+ # Runs the event loop on the calling thread, taking over stdin (raw mode,
359
+ # echo off): keys and mouse events are dispatched via {#handle_key} /
360
+ # {#handle_mouse}, and the loop repaints once per drained tick. Returns
361
+ # when `q` or ESC is pressed unhandled. Restores terminal state on exit.
362
+ #
363
+ # For the duration this thread owns the UI ({#state} is `:running`);
364
+ # ownership reverts to the creating thread once it returns.
288
365
  #
289
366
  # @param capture_mouse [Boolean] when true (default), enables xterm mouse
290
367
  # tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
@@ -293,23 +370,30 @@ module Tuile
293
370
  # you want if the app benefits more from select-to-copy than from
294
371
  # click-to-focus. Components' `handle_mouse` is simply never invoked
295
372
  # from the loop in that mode (the terminal stops sending the bytes).
373
+ # @raise [Tuile::Error] if the screen is already {#close}d.
296
374
  # @return [void]
297
375
  def run_event_loop(capture_mouse: true)
298
- @pretend_ui_lock = false
299
- $stdin.echo = false
300
- print MouseEvent.start_tracking if capture_mouse
301
- # Follow OS light/dark flips live: terminals supporting mode 2031
302
- # push color-scheme reports that the key thread turns into
303
- # {EventQueue::ColorSchemeEvent}s.
304
- print TerminalBackground::NOTIFY_ON
305
- $stdin.raw do
306
- event_loop
376
+ raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
377
+
378
+ # The guard above stays outside the begin: teardown for a setup that never
379
+ # happened restores echo on a non-TTY stdin, and the ENOTTY masks the
380
+ # real error.
381
+ begin
382
+ $stdin.echo = false
383
+ print MouseEvent.start_tracking if capture_mouse
384
+ # Follow OS light/dark flips live: terminals supporting mode 2031
385
+ # push color-scheme reports that the key thread turns into
386
+ # {EventQueue::ColorSchemeEvent}s.
387
+ print TerminalBackground::NOTIFY_ON
388
+ $stdin.raw do
389
+ event_loop
390
+ end
391
+ ensure
392
+ print TerminalBackground::NOTIFY_OFF
393
+ print MouseEvent.stop_tracking if capture_mouse
394
+ print TTY::Cursor.show
395
+ $stdin.echo = true
307
396
  end
308
- ensure
309
- print TerminalBackground::NOTIFY_OFF
310
- print MouseEvent.stop_tracking if capture_mouse
311
- print TTY::Cursor.show
312
- $stdin.echo = true
313
397
  end
314
398
 
315
399
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
@@ -323,31 +407,23 @@ module Tuile
323
407
  # @return [Boolean] true if focus moved.
324
408
  def focus_previous = cycle_focus(forward: false)
325
409
 
326
- # Registers an app-level keyboard shortcut. When `key` arrives, the block
327
- # is invoked on the event-loop thread (so it may freely mutate UI) before
328
- # the key reaches any component. Re-registering the same key replaces the
329
- # previous binding; use {#unregister_global_shortcut} to remove one.
330
- #
331
- # Only unprintable keys are accepted — control characters (Ctrl+letter,
332
- # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
333
- # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
334
- # into a {Component::TextField} and should be expressed as
335
- # {Component#key_shortcut} instead, which the dispatcher suppresses while
336
- # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
337
- # rejected because {#handle_key} intercepts them for focus navigation
338
- # before the global registry is consulted, so a binding on them would
339
- # silently never fire.
410
+ # Registers an app-level keyboard shortcut: when `key` arrives, the block
411
+ # runs on the event-loop thread (free to mutate UI) before the key reaches
412
+ # any component. Re-registering a key replaces its binding.
340
413
  #
341
- # Pass `hint:` to surface the shortcut in the status bar. It's a
342
- # preformatted string the caller fully owns (so colors and the key label
343
- # style stay consistent with whatever the host app uses elsewhere). The
344
- # framework splices it in like any other status hint: in the tiled case,
345
- # right after `q quit` and before the active window's own hint; while a
346
- # popup is open, only hints from `over_popups: true` shortcuts are
347
- # shown, and they're prepended before the popup's `q Close`.
414
+ # This registry is the *only* keyboard mechanism above the component tree,
415
+ # and nothing suppresses it — so it accepts only keys no widget can need.
416
+ # Three groups raise at registration rather than misbehaving at runtime:
348
417
  #
349
- # Example — open a log popup with Ctrl+L from anywhere, even while a
350
- # popup is already on screen:
418
+ # - **Printable keys** — they'd hijack typing into a
419
+ # {Component::TextField}. A scope-wide one-key binding belongs on the
420
+ # scope root's own `handle_key`, where a focused field consumes it first
421
+ # (see {ScreenPane#handle_key}).
422
+ # - **TAB / SHIFT_TAB** — {#handle_key} intercepts them for focus
423
+ # navigation before the registry is consulted, so a binding would never
424
+ # fire.
425
+ # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
426
+ # which every editable widget needs.
351
427
  #
352
428
  # screen.register_global_shortcut(Keys::CTRL_L,
353
429
  # over_popups: true,
@@ -355,16 +431,12 @@ module Tuile
355
431
  # log_popup.open
356
432
  # end
357
433
  #
358
- # @param key [String] unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC},
359
- # {Keys::PAGE_UP}).
360
- # @param over_popups [Boolean] when true, fires even while a modal popup
361
- # is open (pre-empting the popup's own key handling). When false
362
- # (default), the shortcut is suppressed while any popup is open and
363
- # the popup gets the key instead.
364
- # @param hint [String, nil] preformatted status-bar hint (e.g.
365
- # `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut
366
- # is silent in the status bar. The colors are baked into the string,
367
- # so a later {#theme=} does not restyle it — re-register if needed.
434
+ # @param key [String] unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
435
+ # @param over_popups [Boolean] when true, fires even while a modal popup is
436
+ # open (pre-empting the popup); when false (default), suppressed while any
437
+ # 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.
368
440
  # @yield invoked with no arguments when `key` is pressed.
369
441
  # @return [void]
370
442
  def register_global_shortcut(key, over_popups: false, hint: nil, &block)
@@ -374,13 +446,21 @@ module Tuile
374
446
  if Keys.printable?(key)
375
447
  raise ArgumentError,
376
448
  "global shortcut key must be unprintable; got #{key.inspect}. " \
377
- "Use Component#key_shortcut for printable keys (it's suppressed " \
378
- "while a text widget owns the cursor, so it won't hijack typing)."
449
+ "For a one-key binding, override handle_key on the scope root " \
450
+ "(your content layout, or the popup) — a focused text field then " \
451
+ "consumes the key first, so typing isn't hijacked."
379
452
  end
380
453
  if [Keys::TAB, Keys::SHIFT_TAB].include?(key)
381
454
  raise ArgumentError,
382
455
  "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
383
456
  end
457
+ if EDITING_KEYS.include?(key)
458
+ raise ArgumentError,
459
+ "#{key.inspect} is reserved: every editable widget needs it, and this registry " \
460
+ "sits above the component tree with nothing to suppress it. For a default " \
461
+ "button, handle ENTER in the form's own handle_key instead — a focused " \
462
+ "TextArea/TextField gets first refusal there."
463
+ end
384
464
  raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
385
465
 
386
466
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
@@ -448,11 +528,31 @@ module Tuile
448
528
  # @return [FakeScreen]
449
529
  def self.fake = FakeScreen.new
450
530
 
531
+ # Tears the screen down and vacates the singleton slot, moving {#state} to
532
+ # the terminal `:closed`. Unmounts the tree first, so every component gets
533
+ # its {Component#on_detached}. Idempotent.
534
+ # @raise [Tuile::Error] if an event loop is still running — stop it with
535
+ # `event_queue.stop` and let {#run_event_loop} return first, since closing
536
+ # under a live loop drops the pane it is still painting — or if the caller
537
+ # doesn't own the UI.
451
538
  # @return [void]
452
539
  def close
453
- clear
454
- @pane = nil
455
- @@instance = nil # rubocop:disable Style/ClassVars
540
+ return if @closed
541
+
542
+ raise Tuile::Error, "Screen is running: stop the event loop before closing" if state == :running
543
+
544
+ check_locked
545
+ begin
546
+ @pane.detach_all
547
+ ensure
548
+ # A raising on_detached propagates — it's a bug to fix, not something the
549
+ # framework guards — but teardown still has to finish, or one such bug
550
+ # leaves a half-closed screen behind and every later example fails with it.
551
+ clear
552
+ @pane = nil
553
+ @closed = true
554
+ @@instance = nil # rubocop:disable Style/ClassVars
555
+ end
456
556
  end
457
557
 
458
558
  # @return [void]
@@ -473,7 +573,10 @@ module Tuile
473
573
  end
474
574
 
475
575
  # Repaints the screen; tries to be as effective as possible, by only
476
- # considering invalidated windows.
576
+ # considering invalidated components and flushing just the changed cells
577
+ # of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
578
+ # components should {Component#invalidate} and let the loop coalesce rather
579
+ # than call this directly.
477
580
  # @return [void]
478
581
  def repaint
479
582
  check_locked
@@ -528,11 +631,7 @@ module Tuile
528
631
  @repainting = repaint.to_set
529
632
  @invalidated.clear
530
633
 
531
- # Components write into @buffer; overdraw is free and correct here
532
- # because the buffer only diffs net-visible changes to the terminal.
533
634
  repaint.each(&:repaint)
534
-
535
- # Repaint done, mark all components as up-to-date.
536
635
  @repainting.clear
537
636
  end
538
637
  return unless did_paint
@@ -618,9 +717,10 @@ module Tuile
618
717
  $stdout.flush
619
718
  end
620
719
 
621
- # Recalculates positions of all windows, and repaints the scene.
622
- # Automatically called whenever terminal size changes. Call when the app
623
- # starts. {#size} provides correct size of the terminal.
720
+ # Resizes {#buffer} and {#pane} to the current {#size}, invalidates the
721
+ # whole tree and repaints. Run whenever the terminal size changes (the
722
+ # {EventQueue::TTYSizeEvent} path) and once at startup via the first
723
+ # {#content=}.
624
724
  # @return [void]
625
725
  def layout
626
726
  check_locked
@@ -635,17 +735,15 @@ module Tuile
635
735
  #
636
736
  # Dispatch order:
637
737
  # 1. Tab / Shift+Tab — reserved focus navigation, intercepted before
638
- # anything else so a focused {Component::TextField} (which would
639
- # otherwise swallow printable keys via cursor-owner suppression)
640
- # doesn't trap them.
738
+ # anything else so a focused {Component::TextField} (which swallows
739
+ # printable keys) can't trap them.
641
740
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
642
741
  # registered with `over_popups: true` always fires; one with the
643
742
  # default `over_popups: false` fires only when no modal popup is open
644
743
  # (otherwise the modal popup receives the key normally). A non-modal
645
744
  # overlay doesn't suppress global shortcuts.
646
- # 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
647
- # in the active scope, then delivers the key to {#focused} and bubbles
648
- # it up the focus chain.
745
+ # 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
746
+ # focus chain to the scope root.
649
747
  # @param key [String]
650
748
  # @return [Boolean] true if the key was handled by some window.
651
749
  def handle_key(key)
@@ -23,7 +23,9 @@ module Tuile
23
23
  # cascaded to the first focusable child.
24
24
  @popup_prior_focus = {}
25
25
  @status_bar = Component::Label.new
26
- @status_bar.parent = self
26
+ # Added first and never removed, so it is always the last child — which is
27
+ # the anchor `add_popup` inserts against.
28
+ add_child(@status_bar)
27
29
  end
28
30
 
29
31
  # @return [Component, nil] the tiled content component.
@@ -37,10 +39,6 @@ module Tuile
37
39
 
38
40
  def focusable? = false
39
41
 
40
- # Children for tree traversal: content first, popups in stacking order,
41
- # status bar last.
42
- def children = [*[@content].compact, *@popups, @status_bar]
43
-
44
42
  # Replaces the tiled content. Wipes focus first (the new tree starts
45
43
  # fresh), detaches the old content, then attaches the new one and
46
44
  # re-lays out.
@@ -51,10 +49,9 @@ module Tuile
51
49
  return if @content == content
52
50
 
53
51
  screen.focused = nil
54
- old = @content
55
- old&.parent = nil
52
+ remove_child(@content) unless @content.nil?
56
53
  @content = content
57
- content.parent = self
54
+ add_child(content, at: 0) # the tiled layer paints beneath everything else
58
55
  layout
59
56
  end
60
57
 
@@ -63,6 +60,12 @@ module Tuile
63
60
  # left wherever the caller positions it and does *not* take focus, so the
64
61
  # component that was focused keeps the cursor and keeps receiving keys —
65
62
  # the overlay floats above the content, driven from app code.
63
+ #
64
+ # The *whole subtree* is invalidated, not just the popup wrapper (which
65
+ # paints nothing on its own): a reopened popup may land on cells that the
66
+ # tiled content has since overpainted, and if its rect is unchanged from
67
+ # last time its content components won't re-invalidate themselves — so
68
+ # without this the popup's contents would stay blank on reopen.
66
69
  # @param window [Component::Popup]
67
70
  # @return [void]
68
71
  def add_popup(window)
@@ -71,12 +74,12 @@ module Tuile
71
74
 
72
75
  @popup_prior_focus[window] = screen.focused
73
76
  @popups << window
74
- window.parent = self
77
+ add_child(window, at: @children.index(@status_bar))
75
78
  if window.modal?
76
79
  window.center
77
80
  screen.focused = window
78
81
  end
79
- screen.invalidate(window)
82
+ window.on_tree { |c| screen.invalidate(c) }
80
83
  end
81
84
 
82
85
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
@@ -88,21 +91,35 @@ module Tuile
88
91
  raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
89
92
 
90
93
  prior = @popup_prior_focus.delete(window)
91
- # Detach first so the popup becomes its own root; then any prior
92
- # pointing *inside* that popup is detectable via `p.root == window`.
93
- window.parent = nil
94
- # If any other popup recorded its prior focus inside the popup we're
95
- # removing, forward it to *our* prior so chained closures still climb
96
- # back to the original owner instead of stopping at a detached
97
- # component.
98
- @popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
99
-
100
94
  @removing_popup_prior = prior
101
- on_child_removed(window)
95
+ remove_child(window)
96
+ # Runs after the detach, so a prior pointing *inside* the removed popup is
97
+ # detectable via `p.root == window`: forward it to *our* prior, so chained
98
+ # closures climb back to the original owner instead of stopping at a
99
+ # detached component.
100
+ @popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
102
101
  ensure
103
102
  @removing_popup_prior = nil
104
103
  end
105
104
 
105
+ # Unmounts everything: each child is detached — firing {Component#on_detached}
106
+ # down its subtree — and every slot is emptied. Terminal; the pane isn't
107
+ # reusable afterwards, and {Screen#close} is its only caller.
108
+ #
109
+ # Deliberately not named `close` ({Component::Popup#close} already means
110
+ # "remove *me* from the pane"), and deliberately not a generic
111
+ # `Component#remove_all_children`: a slot container calling that would empty
112
+ # `@children` while `#content` / `#footer` still pointed at detached
113
+ # components, which is the desync the tree API exists to prevent.
114
+ # @return [void]
115
+ def detach_all
116
+ screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
117
+ children.dup.each { detach_child(_1) }
118
+ @content = nil
119
+ @popups.clear
120
+ @popup_prior_focus.clear
121
+ end
122
+
106
123
  # @param window [Component]
107
124
  # @return [Boolean] true if this pane currently hosts the popup.
108
125
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
@@ -140,34 +157,27 @@ module Tuile
140
157
  # @return [void]
141
158
  def repaint; end
142
159
 
143
- # Dispatches a key in two phases, both scoped to the topmost *modal* popup
144
- # (when one is open) or else the tiled {#content}. Non-modal overlays are
145
- # never the scope: focus stays in the content beneath them, and the overlay
146
- # is driven by app code (which forwards keys to it explicitly), so it
147
- # doesn't appear in this path at all.
160
+ # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
161
+ # the first component whose `handle_key` returns true wins.
162
+ #
163
+ # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
164
+ # open, else the tiled {#content}. Focus that is nil or sits outside the
165
+ # scope receives nothing, which is what keeps an open modal popup modal.
166
+ # Non-modal overlays are never the scope: focus stays in the content
167
+ # beneath them, and the overlay is driven by app code (which forwards keys
168
+ # to it explicitly), so it doesn't appear in this path at all.
148
169
  #
149
- # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
150
- # focuses that component and consumes the key. Suppressed while a
151
- # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
152
- # {Component::TextField} isn't hijacked by a sibling's shortcut.
153
- # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
154
- # ancestor chain to the scope root; the first component to return true
155
- # wins. Focus that is nil or sits outside the scope receives nothing,
156
- # which is what keeps an open modal popup modal.
170
+ # Because an ancestor sees a key only after every descendant on the chain
171
+ # declined it, the scope root is the natural home for scope-wide fallbacks
172
+ # — a form's default button, or a layout's one-key jumps to its panes (a
173
+ # focused {Component::TextField} consumes the key first, so typing is never
174
+ # hijacked).
157
175
  # @param key [String]
158
176
  # @return [Boolean] true if the key was handled.
159
177
  def handle_key(key)
160
178
  scope = modal_popup || @content
161
179
  return false if scope.nil?
162
180
 
163
- if screen.cursor_position.nil?
164
- target = scope.find_shortcut_component(key)
165
- unless target.nil?
166
- screen.focused = target
167
- return true
168
- end
169
- end
170
-
171
181
  bubble_key(key, scope)
172
182
  end
173
183