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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- 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
|
|
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
|
-
#
|
|
12
|
+
# ## The component tree
|
|
7
13
|
#
|
|
8
|
-
#
|
|
9
|
-
# the
|
|
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
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
#
|
|
37
|
-
|
|
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
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
# the next
|
|
147
|
-
#
|
|
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
|
-
#
|
|
178
|
-
#
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
287
|
-
#
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
327
|
-
#
|
|
328
|
-
#
|
|
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
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
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
|
-
#
|
|
350
|
-
#
|
|
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
|
-
#
|
|
360
|
-
#
|
|
361
|
-
# is open
|
|
362
|
-
#
|
|
363
|
-
#
|
|
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
|
-
"
|
|
378
|
-
"
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
#
|
|
622
|
-
#
|
|
623
|
-
#
|
|
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
|
|
639
|
-
#
|
|
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}
|
|
647
|
-
#
|
|
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)
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
55
|
-
old&.parent = nil
|
|
52
|
+
remove_child(@content) unless @content.nil?
|
|
56
53
|
@content = content
|
|
57
|
-
content
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
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
|
|