tuile 0.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tuile
4
- # Testing only — a screen which doesn't paint anything and pretends that the
5
- # lock is held. This way, the TTY running the tests is not painted over.
4
+ # Testing only — a screen which doesn't paint anything, so the TTY running
5
+ # the tests is not painted over. It runs no event loop, so
6
+ # {Screen#check_locked} admits the thread that called {Screen.fake}: a spec
7
+ # mutating the UI from a *spawned* thread raises, exactly as an app would.
6
8
  #
7
9
  # Intended for unit-testing individual components: instantiate a component,
8
10
  # mutate it, and assert against {#prints} or {#invalidated?}. It does not
@@ -35,9 +37,6 @@ module Tuile
35
37
  # on `prints` for cursor and housekeeping escapes.
36
38
  attr_reader :prints
37
39
 
38
- # @return [void]
39
- def check_locked; end
40
-
41
40
  # @return [void]
42
41
  def clear
43
42
  @prints.clear
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A width/height ratio, each a float in `0.0..1.0` — the single relational
5
+ # sizing primitive in Tuile, scoped to one job: sizing a {Component::Popup}
6
+ # against the screen (a popup has no siblings competing for space, so "half
7
+ # the screen, centered" beats a hard-coded cell count that breaks on the next
8
+ # terminal size). It is deliberately *not* a general layout primitive — tiled
9
+ # components get explicit integer rects computed by their parent's `rect=`.
10
+ # See book ch3 for the layout model.
11
+ #
12
+ # Resolve it against a reference {Size} (the screen) to get concrete integer
13
+ # cells:
14
+ #
15
+ # Fraction::HALF.resolve(Size.new(80, 24)) # => 40x12
16
+ #
17
+ # Integer arguments are coerced to float, so `Fraction.new(1, 1) == FULL`.
18
+ class Fraction < Data.define(:width, :height)
19
+ # @param width [Numeric] fraction of the reference width, `0.0..1.0`.
20
+ # @param height [Numeric] fraction of the reference height, `0.0..1.0`.
21
+ def initialize(width:, height:)
22
+ super(width: width.to_f, height: height.to_f)
23
+ end
24
+
25
+ # Resolves this fraction against a reference size, rounding each axis to the
26
+ # nearest cell and flooring at 1 — so a fraction never yields a zero-size
27
+ # result on a tiny terminal.
28
+ # @param reference [Size] the size to take a fraction of (usually the screen).
29
+ # @return [Size]
30
+ def resolve(reference)
31
+ Size.new([(reference.width * width).round, 1].max,
32
+ [(reference.height * height).round, 1].max)
33
+ end
34
+
35
+ # Half the reference size on each axis — the default {Component::Popup} size.
36
+ # @return [Fraction]
37
+ HALF = new(0.5, 0.5)
38
+ # The full reference size on each axis — fullscreen for a {Component::Popup}.
39
+ # @return [Fraction]
40
+ FULL = new(1.0, 1.0)
41
+ end
42
+ end
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,11 +62,13 @@ 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
38
- @scheme = detect_scheme
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
69
+ @color_scheme = detect_scheme
39
70
  @theme_def = ThemeDef.default
40
- @theme = @theme_def.for(@scheme)
71
+ @theme = @theme_def.for(@color_scheme)
41
72
  # Structural root of the component tree: holds tiled content, popup
42
73
  # stack and status bar.
43
74
  @pane = ScreenPane.new
@@ -57,9 +88,31 @@ 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
 
113
+ # @return [Symbol] `:light` or `:dark`
114
+ attr_reader :color_scheme
115
+
63
116
  # @return [Buffer] the back buffer components paint into
64
117
  # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
65
118
  attr_reader :buffer
@@ -97,6 +150,9 @@ module Tuile
97
150
  # @param content [Component]
98
151
  # @return [void]
99
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
100
156
  @pane.content = content
101
157
  layout
102
158
  end
@@ -133,23 +189,16 @@ module Tuile
133
189
 
134
190
  check_locked
135
191
  @theme_def = theme_def
136
- self.theme = @theme_def.for(@scheme)
192
+ self.theme = @theme_def.for(@color_scheme)
137
193
  end
138
194
 
139
195
  # Replaces the theme and restyles the whole UI: fires
140
- # {Component#on_theme_changed} across the attached tree (so the app can
141
- # rebuild styled content whose colors were derived from the old theme),
142
- # refreshes the status bar and invalidates every attached component so
143
- # the next repaint uses the new colors. No-op when `new_theme` equals
144
- # the current theme.
145
- #
146
- # This is a transient override: the next OS appearance flip re-picks
147
- # from {#theme_def} and replaces it. To theme an app durably, assign
148
- # {#theme_def=} instead.
149
- #
150
- # Note status-bar hints supplied by the host as preformatted strings
151
- # (see {#register_global_shortcut}) have their colors baked in and are
152
- # 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.
153
202
  # @param new_theme [Theme]
154
203
  # @return [void]
155
204
  def theme=(new_theme)
@@ -171,15 +220,41 @@ module Tuile
171
220
  # @return [EventQueue] the event queue.
172
221
  attr_reader :event_queue
173
222
 
174
- # Checks that the UI lock is held and the current code runs in the "UI
175
- # 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.
176
242
  # @return [void]
177
243
  def check_locked
178
- return if @pretend_ui_lock || @event_queue.locked?
179
-
180
- raise Tuile::Error,
181
- "UI lock not held: UI mutations must run on the event-loop thread; " \
182
- "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
183
258
  end
184
259
 
185
260
  # Clears the TTY screen.
@@ -280,8 +355,13 @@ module Tuile
280
355
  # current screen contents.
281
356
  end
282
357
 
283
- # Runs event loop – waits for keys and sends them to active window. The
284
- # 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.
285
365
  #
286
366
  # @param capture_mouse [Boolean] when true (default), enables xterm mouse
287
367
  # tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
@@ -290,23 +370,30 @@ module Tuile
290
370
  # you want if the app benefits more from select-to-copy than from
291
371
  # click-to-focus. Components' `handle_mouse` is simply never invoked
292
372
  # from the loop in that mode (the terminal stops sending the bytes).
373
+ # @raise [Tuile::Error] if the screen is already {#close}d.
293
374
  # @return [void]
294
375
  def run_event_loop(capture_mouse: true)
295
- @pretend_ui_lock = false
296
- $stdin.echo = false
297
- print MouseEvent.start_tracking if capture_mouse
298
- # Follow OS light/dark flips live: terminals supporting mode 2031
299
- # push color-scheme reports that the key thread turns into
300
- # {EventQueue::ColorSchemeEvent}s.
301
- print TerminalBackground::NOTIFY_ON
302
- $stdin.raw do
303
- 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
304
396
  end
305
- ensure
306
- print TerminalBackground::NOTIFY_OFF
307
- print MouseEvent.stop_tracking if capture_mouse
308
- print TTY::Cursor.show
309
- $stdin.echo = true
310
397
  end
311
398
 
312
399
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
@@ -320,31 +407,23 @@ module Tuile
320
407
  # @return [Boolean] true if focus moved.
321
408
  def focus_previous = cycle_focus(forward: false)
322
409
 
323
- # Registers an app-level keyboard shortcut. When `key` arrives, the block
324
- # is invoked on the event-loop thread (so it may freely mutate UI) before
325
- # the key reaches any component. Re-registering the same key replaces the
326
- # previous binding; use {#unregister_global_shortcut} to remove one.
327
- #
328
- # Only unprintable keys are accepted — control characters (Ctrl+letter,
329
- # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
330
- # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
331
- # into a {Component::TextField} and should be expressed as
332
- # {Component#key_shortcut} instead, which the dispatcher suppresses while
333
- # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
334
- # rejected because {#handle_key} intercepts them for focus navigation
335
- # before the global registry is consulted, so a binding on them would
336
- # 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.
337
413
  #
338
- # Pass `hint:` to surface the shortcut in the status bar. It's a
339
- # preformatted string the caller fully owns (so colors and the key label
340
- # style stay consistent with whatever the host app uses elsewhere). The
341
- # framework splices it in like any other status hint: in the tiled case,
342
- # right after `q quit` and before the active window's own hint; while a
343
- # popup is open, only hints from `over_popups: true` shortcuts are
344
- # 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:
345
417
  #
346
- # Example — open a log popup with Ctrl+L from anywhere, even while a
347
- # 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.
348
427
  #
349
428
  # screen.register_global_shortcut(Keys::CTRL_L,
350
429
  # over_popups: true,
@@ -352,16 +431,12 @@ module Tuile
352
431
  # log_popup.open
353
432
  # end
354
433
  #
355
- # @param key [String] unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC},
356
- # {Keys::PAGE_UP}).
357
- # @param over_popups [Boolean] when true, fires even while a modal popup
358
- # is open (pre-empting the popup's own key handling). When false
359
- # (default), the shortcut is suppressed while any popup is open and
360
- # the popup gets the key instead.
361
- # @param hint [String, nil] preformatted status-bar hint (e.g.
362
- # `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut
363
- # is silent in the status bar. The colors are baked into the string,
364
- # 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.
365
440
  # @yield invoked with no arguments when `key` is pressed.
366
441
  # @return [void]
367
442
  def register_global_shortcut(key, over_popups: false, hint: nil, &block)
@@ -371,13 +446,21 @@ module Tuile
371
446
  if Keys.printable?(key)
372
447
  raise ArgumentError,
373
448
  "global shortcut key must be unprintable; got #{key.inspect}. " \
374
- "Use Component#key_shortcut for printable keys (it's suppressed " \
375
- "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."
376
452
  end
377
453
  if [Keys::TAB, Keys::SHIFT_TAB].include?(key)
378
454
  raise ArgumentError,
379
455
  "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
380
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
381
464
  raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
382
465
 
383
466
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
@@ -445,11 +528,31 @@ module Tuile
445
528
  # @return [FakeScreen]
446
529
  def self.fake = FakeScreen.new
447
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.
448
538
  # @return [void]
449
539
  def close
450
- clear
451
- @pane = nil
452
- @@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
453
556
  end
454
557
 
455
558
  # @return [void]
@@ -470,7 +573,10 @@ module Tuile
470
573
  end
471
574
 
472
575
  # Repaints the screen; tries to be as effective as possible, by only
473
- # 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.
474
580
  # @return [void]
475
581
  def repaint
476
582
  check_locked
@@ -525,11 +631,7 @@ module Tuile
525
631
  @repainting = repaint.to_set
526
632
  @invalidated.clear
527
633
 
528
- # Components write into @buffer; overdraw is free and correct here
529
- # because the buffer only diffs net-visible changes to the terminal.
530
634
  repaint.each(&:repaint)
531
-
532
- # Repaint done, mark all components as up-to-date.
533
635
  @repainting.clear
534
636
  end
535
637
  return unless did_paint
@@ -565,8 +667,8 @@ module Tuile
565
667
  # @param scheme [Symbol] `:dark` or `:light`.
566
668
  # @return [void]
567
669
  def on_color_scheme(scheme)
568
- @scheme = scheme
569
- self.theme = @theme_def.for(@scheme)
670
+ @color_scheme = scheme
671
+ self.theme = @theme_def.for(@color_scheme)
570
672
  end
571
673
 
572
674
  # Walks the current modal scope in pre-order, collects tab stops, and
@@ -615,9 +717,10 @@ module Tuile
615
717
  $stdout.flush
616
718
  end
617
719
 
618
- # Recalculates positions of all windows, and repaints the scene.
619
- # Automatically called whenever terminal size changes. Call when the app
620
- # 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=}.
621
724
  # @return [void]
622
725
  def layout
623
726
  check_locked
@@ -632,17 +735,15 @@ module Tuile
632
735
  #
633
736
  # Dispatch order:
634
737
  # 1. Tab / Shift+Tab — reserved focus navigation, intercepted before
635
- # anything else so a focused {Component::TextField} (which would
636
- # otherwise swallow printable keys via cursor-owner suppression)
637
- # doesn't trap them.
738
+ # anything else so a focused {Component::TextField} (which swallows
739
+ # printable keys) can't trap them.
638
740
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
639
741
  # registered with `over_popups: true` always fires; one with the
640
742
  # default `over_popups: false` fires only when no modal popup is open
641
743
  # (otherwise the modal popup receives the key normally). A non-modal
642
744
  # 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.
745
+ # 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
746
+ # focus chain to the scope root.
646
747
  # @param key [String]
647
748
  # @return [Boolean] true if the key was handled by some window.
648
749
  def handle_key(key)