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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- 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 +113 -43
- 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 -29
- 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/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- 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 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- 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 +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
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
|
|
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,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
|
-
#
|
|
37
|
-
|
|
38
|
-
@
|
|
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(@
|
|
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(@
|
|
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
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
# the next
|
|
144
|
-
#
|
|
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
|
-
#
|
|
175
|
-
#
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
284
|
-
#
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
324
|
-
#
|
|
325
|
-
#
|
|
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
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
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
|
-
#
|
|
347
|
-
#
|
|
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
|
-
#
|
|
357
|
-
#
|
|
358
|
-
# is open
|
|
359
|
-
#
|
|
360
|
-
#
|
|
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
|
-
"
|
|
375
|
-
"
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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
|
-
@
|
|
569
|
-
self.theme = @theme_def.for(@
|
|
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
|
-
#
|
|
619
|
-
#
|
|
620
|
-
#
|
|
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
|
|
636
|
-
#
|
|
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}
|
|
644
|
-
#
|
|
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)
|