tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/screen.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Tuile
|
|
|
21
21
|
#
|
|
22
22
|
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
23
23
|
# row, so {#content} gets the whole terminal. An app that wants a status line
|
|
24
|
-
# builds one into its own layout and drives it from {#on_focus_changed
|
|
24
|
+
# builds one into its own layout and drives it from {#on_focus_changed}
|
|
25
25
|
# (`D_status_bar`).
|
|
26
26
|
#
|
|
27
27
|
# ## Repaint model
|
|
@@ -55,15 +55,32 @@ module Tuile
|
|
|
55
55
|
# {FakeScreen} — which captures output in memory — is what
|
|
56
56
|
# {Screen.instance} returns under test.
|
|
57
57
|
class Screen
|
|
58
|
+
extend Listeners::Declare
|
|
59
|
+
|
|
58
60
|
# Class variable (not class instance var) so the singleton survives
|
|
59
61
|
# subclassing — `FakeScreen < Screen` and `Screen.instance` see the same slot.
|
|
60
62
|
@@instance = nil # rubocop:disable Style/ClassVars
|
|
61
63
|
|
|
64
|
+
# What {#on_focus_changed} fires.
|
|
65
|
+
#
|
|
66
|
+
# @!attribute [r] source
|
|
67
|
+
# @return [Screen] the screen whose {#focused} changed; read {#focused}
|
|
68
|
+
# for what it changed to.
|
|
69
|
+
FocusChangedEvent = Data.define(:source) { include Tuile::Event }
|
|
70
|
+
|
|
62
71
|
def initialize
|
|
63
72
|
@@instance = self # rubocop:disable Style/ClassVars
|
|
64
73
|
@event_queue = EventQueue.new
|
|
65
74
|
@size = EventQueue::TTYSizeEvent.create.size
|
|
66
75
|
@invalidated = Set.new
|
|
76
|
+
# Whether some container may owe a {Component#relayout} — the O(1) gate
|
|
77
|
+
# that lets a no-op {#flush_layout} skip the walk; the containers' own
|
|
78
|
+
# flags are the queue.
|
|
79
|
+
@layout_pending = false
|
|
80
|
+
# A {#focused=} made inside a pass whose scroll and notice wait for the
|
|
81
|
+
# drain, and what was focused before the first such call.
|
|
82
|
+
@focus_deferred = false
|
|
83
|
+
@focus_deferred_from = nil
|
|
67
84
|
# Components being repainted right now. A component may invalidate its
|
|
68
85
|
# children during its repaint phase; this prevents double-draw.
|
|
69
86
|
@repainting = Set.new
|
|
@@ -77,23 +94,26 @@ module Tuile
|
|
|
77
94
|
@color_depth = detect_color_depth
|
|
78
95
|
@locale = detect_locale
|
|
79
96
|
@theme_def = ThemeDef.default
|
|
80
|
-
|
|
97
|
+
# The theme as assigned, derivation Procs and all; {#theme} is this
|
|
98
|
+
# resolved against {#background_color}, re-resolved when either changes.
|
|
99
|
+
@theme_source = @theme_def.for(@color_scheme)
|
|
100
|
+
@theme = @theme_source.resolve(@background_color)
|
|
81
101
|
# Structural root of the component tree: holds tiled content and the
|
|
82
|
-
# popup stack. Sized here rather than waiting for the first {#
|
|
102
|
+
# popup stack. Sized here rather than waiting for the first {#resize},
|
|
83
103
|
# for the same reason {#size} is seeded from {EventQueue::TTYSizeEvent}:
|
|
84
104
|
# an empty pane rect is an *ancestor* empty rect, and {#repaint}'s drain
|
|
85
105
|
# filter would take the whole tree with it.
|
|
86
106
|
@pane = ScreenPane.new
|
|
87
|
-
|
|
107
|
+
size_pane
|
|
88
108
|
@mouse_router = Mouse::Router.new(self)
|
|
89
|
-
@on_error = ->(e) { raise e }
|
|
90
109
|
# App-level keyboard shortcuts dispatched by {#handle_key?} before keys
|
|
91
110
|
# reach the pane. See {#register_global_shortcut}.
|
|
92
111
|
@global_shortcuts = {}
|
|
93
112
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
94
113
|
# the terminal, so only changed cells are emitted (flicker-free on any
|
|
95
|
-
# terminal). Sized to the current viewport; {#
|
|
114
|
+
# terminal). Sized to the current viewport; {#resize} resizes it.
|
|
96
115
|
@buffer = Buffer.new(@size, color_depth: @color_depth)
|
|
116
|
+
@canvas = Canvas.new(@buffer)
|
|
97
117
|
end
|
|
98
118
|
|
|
99
119
|
# Entry in the global shortcut registry: the block to run, and whether it
|
|
@@ -142,25 +162,112 @@ module Tuile
|
|
|
142
162
|
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
143
163
|
attr_reader :buffer
|
|
144
164
|
|
|
145
|
-
#
|
|
146
|
-
#
|
|
165
|
+
# The untinted root canvas over {#buffer}, at the buffer's own origin,
|
|
166
|
+
# which {#canvas_for} derives every component's from. Nothing in `lib/`
|
|
167
|
+
# paints through it: a component paints onto the canvas it is *given*,
|
|
168
|
+
# never this one by name (`D_canvas`).
|
|
169
|
+
# @return [Canvas]
|
|
170
|
+
attr_reader :canvas
|
|
171
|
+
|
|
172
|
+
# The canvas `component` paints onto: one over {#buffer} carrying that
|
|
173
|
+
# component's resolved background, positioned where it sits on screen and
|
|
174
|
+
# bounded by what its ancestors allow — so it writes at `(0, 0)`, an
|
|
175
|
+
# inherited tint shows through and a scrolled-away row goes nowhere, with
|
|
176
|
+
# the component doing none of it. Summing the offsets is this method's job,
|
|
177
|
+
# which is what leaves every {Component#rect} parent-relative
|
|
178
|
+
# (`D_relative_rect`).
|
|
147
179
|
#
|
|
148
|
-
#
|
|
149
|
-
# {#run_event_loop} and crashes the script with a stacktrace — unhandled
|
|
150
|
-
# exceptions are bugs and should be surfaced loudly.
|
|
180
|
+
# label.repaint(screen.canvas_for(label)) # paint one component, as a spec does
|
|
151
181
|
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
182
|
+
# With `root:` it is positioned and bounded within that ancestor instead of
|
|
183
|
+
# the screen, so a subtree paints into a buffer of its own:
|
|
154
184
|
#
|
|
155
|
-
# screen.
|
|
156
|
-
#
|
|
157
|
-
#
|
|
185
|
+
# screen.canvas_for(list, backend: Buffer.new(window.rect.size), root: window)
|
|
186
|
+
#
|
|
187
|
+
# The **sole converter** into backend coordinates: {#clip_for} answers in
|
|
188
|
+
# the component's own space and the one `moved_by` here puts it where
|
|
189
|
+
# {Canvas} keeps it, beside the origin (`D_clip`).
|
|
190
|
+
# @param component [Component]
|
|
191
|
+
# @param backend [Canvas::Backend] where the cells land; the screen's
|
|
192
|
+
# {#buffer} unless given.
|
|
193
|
+
# @param root [Component, nil] `component` itself or one of its ancestors;
|
|
194
|
+
# `nil` is the screen.
|
|
195
|
+
# @return [Canvas]
|
|
196
|
+
def canvas_for(component, backend: @buffer, root: nil)
|
|
197
|
+
# Built rather than derived from #canvas, since {Canvas#with} is
|
|
198
|
+
# block-only. __send__ because Component#bg is protected: the
|
|
199
|
+
# framework paints with it, an app never asks (`D_bg_surface`).
|
|
200
|
+
origin = component.to_screen(Point::ZERO)
|
|
201
|
+
unless root.nil?
|
|
202
|
+
base = root.to_screen(Point::ZERO)
|
|
203
|
+
origin = Point.new(origin.x - base.x, origin.y - base.y)
|
|
204
|
+
end
|
|
205
|
+
Canvas.new(backend, bg_color: component.__send__(:bg).effective,
|
|
206
|
+
origin:,
|
|
207
|
+
clip: clip_for(component, root:).moved_by(origin))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The cells `component` may write, in **its own** coordinates: its
|
|
211
|
+
# {Component#local_rect} intersected with every ancestor's, each folded in
|
|
212
|
+
# as the walk climbs.
|
|
213
|
+
#
|
|
214
|
+
# Every component is bounded, and a component cannot widen its own bounds —
|
|
215
|
+
# which is why this lives here rather than as a hook on {Component}. A
|
|
216
|
+
# container that wants to allow its children *less* than its own box has no
|
|
217
|
+
# way to say so yet; `clip_rect_for(child)` is the shape that would, and it
|
|
218
|
+
# is deferred with no caller (`D_clip`).
|
|
219
|
+
#
|
|
220
|
+
# Resolved per call and never cached, like the origin it rides beside: an
|
|
221
|
+
# ancestor may scroll between two frames. {#clipped?} skips the fold
|
|
222
|
+
# entirely in the common case, which is what keeps that affordable.
|
|
223
|
+
# @param component [Component]
|
|
224
|
+
# @param root [Component, nil] the last ancestor folded in — `component`
|
|
225
|
+
# itself or one of its ancestors; `nil` folds in every one.
|
|
226
|
+
# @return [Rect] never `nil`; {Rect#empty? empty} exactly when the component
|
|
227
|
+
# can show nothing — scrolled clean out of its viewport, collapsed, or
|
|
228
|
+
# under an ancestor allowing no cell. The own rect being folded in, that
|
|
229
|
+
# one predicate answers *is any of this visible*.
|
|
230
|
+
def clip_for(component, root: nil)
|
|
231
|
+
return component.local_rect unless clipped?(component, root)
|
|
232
|
+
|
|
233
|
+
clip = component.local_rect
|
|
234
|
+
x = 0
|
|
235
|
+
y = 0
|
|
236
|
+
node = component
|
|
237
|
+
while !node.equal?(root) && (up = node.parent)
|
|
238
|
+
# Intersection only ever shrinks, so the rest of the chain cannot change
|
|
239
|
+
# an empty answer. Worth the test: a scroller's off-screen children land
|
|
240
|
+
# here on every scroll, one per row it is not showing (`D_clip`).
|
|
241
|
+
return clip if clip.empty?
|
|
242
|
+
|
|
243
|
+
x -= node.rect.left
|
|
244
|
+
y -= node.rect.top
|
|
245
|
+
clip = clip.intersect(up.local_rect.moved_by(Point.new(x, y)))
|
|
246
|
+
node = up
|
|
247
|
+
end
|
|
248
|
+
clip
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @!method on_error
|
|
252
|
+
# Fired with an {EventQueue::ErrorEvent} when a {StandardError} escapes an
|
|
253
|
+
# event handler inside the event loop (e.g. a {Component::TextField}'s
|
|
254
|
+
# `on_value_change` listener raises). The one slot whose event carries no
|
|
255
|
+
# `source`: its listener wants `error`.
|
|
256
|
+
#
|
|
257
|
+
# **Empty means re-raise**, so the exception propagates out of
|
|
258
|
+
# {#run_event_loop} and crashes the script with a stacktrace — unhandled
|
|
259
|
+
# exceptions are bugs and should be surfaced loudly. Registering anything
|
|
260
|
+
# at all takes that over:
|
|
261
|
+
#
|
|
262
|
+
# screen.on_error do |e|
|
|
263
|
+
# Tuile.logger.error("#{e.error.class}: #{e.error.message}")
|
|
264
|
+
# end
|
|
158
265
|
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
|
|
266
|
+
# A listener runs on the event-loop thread with the UI lock held.
|
|
267
|
+
# Returning normally keeps the loop alive; raising tears the loop down and
|
|
268
|
+
# propagates out of {#run_event_loop}.
|
|
269
|
+
# @return [Listeners]
|
|
270
|
+
listener :on_error
|
|
164
271
|
|
|
165
272
|
# @return [Screen] the singleton instance.
|
|
166
273
|
def self.instance
|
|
@@ -185,7 +292,7 @@ module Tuile
|
|
|
185
292
|
# pane to forward to, and NoMethodError-for-nil is a poor error.
|
|
186
293
|
check_locked
|
|
187
294
|
@pane.content = content
|
|
188
|
-
|
|
295
|
+
resize
|
|
189
296
|
end
|
|
190
297
|
|
|
191
298
|
# @return [Size] current screen size.
|
|
@@ -197,6 +304,9 @@ module Tuile
|
|
|
197
304
|
# dark). While the event loop runs, terminals supporting mode 2031
|
|
198
305
|
# push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
|
|
199
306
|
# screen re-picks from {#theme_def}.
|
|
307
|
+
#
|
|
308
|
+
# Always resolved ({Theme#resolve}): a derived token is recomputed from
|
|
309
|
+
# {#background_color} whenever that changes, so no token here is a Proc.
|
|
200
310
|
# @return [Theme]
|
|
201
311
|
attr_reader :theme
|
|
202
312
|
|
|
@@ -212,20 +322,21 @@ module Tuile
|
|
|
212
322
|
# The terminal's own background, as it reported it — for deriving a
|
|
213
323
|
# color *from* the background rather than picking one against it. A pane
|
|
214
324
|
# tinted a few percent off it sits right on any terminal, where a fixed
|
|
215
|
-
# near-neutral only sits right near the one it was tuned on
|
|
325
|
+
# near-neutral only sits right near the one it was tuned on. Declare such
|
|
326
|
+
# a color as a derived theme token (see {Theme}) and point a slot at it:
|
|
216
327
|
#
|
|
217
|
-
# bg
|
|
218
|
-
# sidebar.bg_color =
|
|
219
|
-
# bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
328
|
+
# pane_bg: ->(bg) { bg ? Color.rgb(*bg.rgb.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT }
|
|
329
|
+
# sidebar.bg_color = Theme.ref(:pane_bg)
|
|
220
330
|
#
|
|
221
331
|
# **Nil is the normal case, not an edge** — a terminal answering only
|
|
222
332
|
# `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
|
|
223
333
|
#
|
|
224
334
|
# Kept current across OS appearance flips, a frame behind: the flip
|
|
225
335
|
# report carries light/dark only, so the screen re-probes and this
|
|
226
|
-
# updates when the reply lands. A changed color
|
|
227
|
-
# {Component#handle_theme_changed} across the tree
|
|
228
|
-
# does —
|
|
336
|
+
# updates when the reply lands. A changed color re-resolves {#theme} and
|
|
337
|
+
# then fires {Component#handle_theme_changed} across the tree once, as a
|
|
338
|
+
# theme swap does — even when no token is derived, since a component may
|
|
339
|
+
# read this directly.
|
|
229
340
|
# @return [Color, nil]
|
|
230
341
|
attr_reader :background_color
|
|
231
342
|
|
|
@@ -245,22 +356,22 @@ module Tuile
|
|
|
245
356
|
|
|
246
357
|
# Replaces the theme and restyles the whole UI: fires
|
|
247
358
|
# {Component#handle_theme_changed} across the attached tree and invalidates
|
|
248
|
-
# every attached component. No-op when `new_theme
|
|
359
|
+
# every attached component. No-op when `new_theme`, resolved, equals the
|
|
360
|
+
# current theme. A derived token keeps following {#background_color}.
|
|
249
361
|
# This is a *transient* override — the next OS appearance flip re-picks from
|
|
250
362
|
# {#theme_def}; assign {#theme_def=} for durable theming.
|
|
251
|
-
# @param new_theme [Theme]
|
|
363
|
+
# @param new_theme [Theme] resolved or not.
|
|
252
364
|
# @return [void]
|
|
253
365
|
def theme=(new_theme)
|
|
254
366
|
raise TypeError, "expected Theme, got #{new_theme.inspect}" unless new_theme.is_a?(Theme)
|
|
255
367
|
|
|
256
368
|
check_locked
|
|
257
|
-
|
|
369
|
+
@theme_source = new_theme
|
|
370
|
+
resolved = new_theme.resolve(@background_color)
|
|
371
|
+
return if @theme == resolved
|
|
258
372
|
|
|
259
|
-
@theme =
|
|
260
|
-
|
|
261
|
-
# subclass may narrow it further (`D_hook_visibility`).
|
|
262
|
-
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
263
|
-
needs_full_repaint
|
|
373
|
+
@theme = resolved
|
|
374
|
+
restyle
|
|
264
375
|
end
|
|
265
376
|
|
|
266
377
|
# The formatting conventions this session renders and parses by — date
|
|
@@ -323,6 +434,10 @@ module Tuile
|
|
|
323
434
|
# screen.check_locked # from a worker: raises; wrap the work in
|
|
324
435
|
# # screen.event_queue.submit { ... } instead
|
|
325
436
|
#
|
|
437
|
+
# Both halves of the test below are load-bearing — is a loop running
|
|
438
|
+
# *anywhere*, and is it mine: the loop need not run on the thread that
|
|
439
|
+
# created the screen, and the gem's own specs rely on that.
|
|
440
|
+
#
|
|
326
441
|
# @raise [Tuile::Error] if {#state} is `:closed`, or the calling thread
|
|
327
442
|
# isn't the current owner.
|
|
328
443
|
# @return [void]
|
|
@@ -360,6 +475,66 @@ module Tuile
|
|
|
360
475
|
@invalidated << component unless @repainting.include? component
|
|
361
476
|
end
|
|
362
477
|
|
|
478
|
+
# Marks a container as owing a {Component#relayout}, run by the next
|
|
479
|
+
# {#flush_layout}.
|
|
480
|
+
# @param component [Component]
|
|
481
|
+
# @return [void]
|
|
482
|
+
def invalidate_layout(component)
|
|
483
|
+
check_locked
|
|
484
|
+
raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component
|
|
485
|
+
|
|
486
|
+
@layout_pending = true
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Runs every pending {Component#relayout}, so rects are current again.
|
|
490
|
+
#
|
|
491
|
+
# form.add(field)
|
|
492
|
+
# screen.flush_layout
|
|
493
|
+
# field.rect # assigned, rather than whatever it had before
|
|
494
|
+
#
|
|
495
|
+
# The loop calls this after every event ({#dispatch}), which is enough for
|
|
496
|
+
# app code that mutates in one handler and reads in the next. Call it by
|
|
497
|
+
# hand only to read a rect in the *same* turn that dirtied it — that is what
|
|
498
|
+
# Swing's `validate()` and Tk's `update idletasks` are for.
|
|
499
|
+
#
|
|
500
|
+
# == Implementation details
|
|
501
|
+
#
|
|
502
|
+
# {LayoutPass.drain} over the pane, the same fixpoint a detached tree's
|
|
503
|
+
# {Component#flush_layout} runs. Then two follow-ups the pane's tree owes
|
|
504
|
+
# once it has settled: the pane places anchored popups before the content
|
|
505
|
+
# they hang from, so a moved anchor re-marks it and the drain runs again
|
|
506
|
+
# ({ScreenPane#remark_moved_anchors}); and the half of a {#focused=} that was
|
|
507
|
+
# deferred because it happened inside a pass — see there.
|
|
508
|
+
# @raise [Tuile::Error] when the tree has not settled after
|
|
509
|
+
# {LayoutPass::MAX_ROUNDS} rounds — a relayout feeding its own input — or
|
|
510
|
+
# when called from inside a {Component#relayout}, where the running pass
|
|
511
|
+
# has not yet assigned the rects a nested drain would read.
|
|
512
|
+
# @return [void]
|
|
513
|
+
def flush_layout
|
|
514
|
+
check_locked
|
|
515
|
+
LayoutPass.refuse_nested
|
|
516
|
+
return if @pane.nil?
|
|
517
|
+
|
|
518
|
+
rounds = 0
|
|
519
|
+
loop do
|
|
520
|
+
if @layout_pending
|
|
521
|
+
rounds = LayoutPass.drain(@pane, rounds)
|
|
522
|
+
# Only once the drain returns: a raise leaves the gate open, so the
|
|
523
|
+
# next settle reports the same cycle rather than skipping the walk.
|
|
524
|
+
@layout_pending = false
|
|
525
|
+
end
|
|
526
|
+
# One round shared with the drain: an anchor chain that never settles
|
|
527
|
+
# hits the same cap.
|
|
528
|
+
next if @pane.__send__(:remark_moved_anchors)
|
|
529
|
+
break unless @focus_deferred
|
|
530
|
+
|
|
531
|
+
# Last, so the scroll sees popups at their final place; and looped, so
|
|
532
|
+
# what an on_focus_changed callback marks is settled before we return.
|
|
533
|
+
@focus_deferred = false
|
|
534
|
+
follow_up_focus(@focus_deferred_from)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
363
538
|
# @return [Component, nil] currently focused component.
|
|
364
539
|
attr_reader :focused
|
|
365
540
|
|
|
@@ -371,10 +546,25 @@ module Tuile
|
|
|
371
546
|
# detached one does: focusing it would park the hardware cursor inside
|
|
372
547
|
# whatever is painted over it and feed it every keystroke.
|
|
373
548
|
#
|
|
374
|
-
# Once the pointer and the active flags are settled,
|
|
375
|
-
# order: {Component#handle_blur} on what lost focus, {Component#handle_focus}
|
|
376
|
-
# what took it,
|
|
377
|
-
#
|
|
549
|
+
# Once the pointer and the active flags are settled, four steps run in
|
|
550
|
+
# order: {Component#handle_blur} on what lost focus, {Component#handle_focus}
|
|
551
|
+
# on what took it, that component's {Component#scroll_to_visible} so a
|
|
552
|
+
# scroller shows what Tab just reached, then {#on_focus_changed}, which
|
|
553
|
+
# therefore reads settled geometry. The outer two are edge-triggered and the
|
|
554
|
+
# middle two are not — see `handle_focus`.
|
|
555
|
+
#
|
|
556
|
+
# **Inside a {Component#relayout} the last two wait for the layout** — a
|
|
557
|
+
# focus repair from a child hidden there, a menu closed from
|
|
558
|
+
# {Component#handle_rect_changed}. The pass has not placed its children yet,
|
|
559
|
+
# and a scroll decided now is latched, so {#flush_layout} runs them once the
|
|
560
|
+
# tree has settled: the final target is scrolled into view, and
|
|
561
|
+
# {#on_focus_changed} fires once if focus ended somewhere other than where
|
|
562
|
+
# the pass found it.
|
|
563
|
+
#
|
|
564
|
+
# A target whose {#clip_for} is still empty after that request — a stale
|
|
565
|
+
# {Component::Scroller#content_rows}, say — logs a warning to
|
|
566
|
+
# {Tuile.logger} rather than raising: a terminal shrunk to nothing causes it
|
|
567
|
+
# legitimately.
|
|
378
568
|
# @param focused [Component, nil] the new component to be focused.
|
|
379
569
|
def focused=(focused)
|
|
380
570
|
unless focused.nil? || focused.is_a?(Component)
|
|
@@ -382,13 +572,21 @@ module Tuile
|
|
|
382
572
|
end
|
|
383
573
|
|
|
384
574
|
check_locked
|
|
575
|
+
# For the scroll-into-view request, whose answer is *latched* into a
|
|
576
|
+
# scroller's scroll_top_row and so is not re-derived by any later pass.
|
|
577
|
+
# Nil scrolls nothing, and skipping it there keeps {#close}'s teardown
|
|
578
|
+
# from tripping over a layout that never settles; inside a pass the
|
|
579
|
+
# request waits for the drain instead (#fire_focus_hooks).
|
|
580
|
+
flush_layout unless focused.nil? || LayoutPass.running?
|
|
385
581
|
previous = @focused
|
|
386
582
|
if focused.nil?
|
|
387
583
|
@focused = nil
|
|
388
584
|
@pane.walk_tree { _1.active = false }
|
|
389
585
|
else
|
|
390
586
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
391
|
-
|
|
587
|
+
unless ComponentUtil.effectively_visible?(focused)
|
|
588
|
+
raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor"
|
|
589
|
+
end
|
|
392
590
|
|
|
393
591
|
@focused = focused
|
|
394
592
|
active = Set[focused]
|
|
@@ -411,7 +609,7 @@ module Tuile
|
|
|
411
609
|
# status bar and reserves no row: build a {Component::Label} into your own
|
|
412
610
|
# layout and fill it here (`D_status_bar`).
|
|
413
611
|
#
|
|
414
|
-
# screen.on_focus_changed
|
|
612
|
+
# screen.on_focus_changed { bar.text = hint_for(screen.focused) }
|
|
415
613
|
#
|
|
416
614
|
# **Edge-triggered**, like {Component#handle_attached}: re-assigning the
|
|
417
615
|
# component that already has focus fires nothing, so a callback can be as
|
|
@@ -420,22 +618,27 @@ module Tuile
|
|
|
420
618
|
# clears focus on every content swap, which on a level-triggered hook would
|
|
421
619
|
# fire a nil→nil notification during assembly.
|
|
422
620
|
#
|
|
423
|
-
# It runs *after* the active-flag cascade
|
|
424
|
-
# settled. Two things a
|
|
425
|
-
#
|
|
621
|
+
# It runs *after* the active-flag cascade, `handle_focus` and
|
|
622
|
+
# {Component#scroll_to_visible}, so the tree is settled. Two things a
|
|
623
|
+
# callback must tolerate: {#focused} being `nil`, and firing during
|
|
624
|
+
# {#close} — teardown clears focus, exactly as it fires
|
|
426
625
|
# {Component#handle_detached}. A raising callback propagates out of {#focused=}
|
|
427
626
|
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
428
|
-
#
|
|
429
|
-
|
|
627
|
+
# @!method on_focus_changed
|
|
628
|
+
# Fired with a {FocusChangedEvent} after {#focused} changes.
|
|
629
|
+
# @return [Listeners]
|
|
630
|
+
listener :on_focus_changed
|
|
430
631
|
|
|
431
632
|
# Internal — use {Component::Overlay#open} instead. Adds the overlay to
|
|
432
|
-
# {#pane}
|
|
633
|
+
# {#pane} at `placement`; a {Component::Popup} is additionally focused.
|
|
433
634
|
# @api private
|
|
434
635
|
# @param window [Component::Overlay] any overlay, modal or not.
|
|
636
|
+
# @param placement [Component::Overlay::Placement, nil] where it wants to
|
|
637
|
+
# be; `nil` takes the overlay's default.
|
|
435
638
|
# @return [void]
|
|
436
|
-
def add_popup(window)
|
|
639
|
+
def add_popup(window, placement = nil)
|
|
437
640
|
check_locked
|
|
438
|
-
@pane.add_popup(window)
|
|
641
|
+
@pane.add_popup(window, placement)
|
|
439
642
|
# No need to fully repaint the scene: a popup simply paints over the
|
|
440
643
|
# current screen contents.
|
|
441
644
|
end
|
|
@@ -614,8 +817,12 @@ module Tuile
|
|
|
614
817
|
# redraws, so that test TTY is not painted over. {FakeScreen#initialize}
|
|
615
818
|
# self-installs as the singleton, so subsequent {Screen.instance} calls
|
|
616
819
|
# return the same object.
|
|
820
|
+
#
|
|
821
|
+
# before { Screen.fake(width: 40, height: 12) } # a narrow, short terminal
|
|
822
|
+
# @param width [Integer] the terminal's columns.
|
|
823
|
+
# @param height [Integer] the terminal's rows.
|
|
617
824
|
# @return [FakeScreen]
|
|
618
|
-
def self.fake = FakeScreen.new
|
|
825
|
+
def self.fake(width: 160, height: 50) = FakeScreen.new(width: width, height: height)
|
|
619
826
|
|
|
620
827
|
# Tears the screen down and vacates the singleton slot, moving {#state} to
|
|
621
828
|
# the terminal `:closed`. Unmounts the tree first, so every component gets
|
|
@@ -692,9 +899,14 @@ module Tuile
|
|
|
692
899
|
# of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
|
|
693
900
|
# components should {Component#invalidate} and let the loop coalesce rather
|
|
694
901
|
# than call this directly.
|
|
902
|
+
#
|
|
903
|
+
# Settles the layout first: painting is the heaviest reader of rects there
|
|
904
|
+
# is, and a pending {#flush_layout} would have it paint children where they
|
|
905
|
+
# no longer are.
|
|
695
906
|
# @return [void]
|
|
696
907
|
def repaint
|
|
697
908
|
check_locked
|
|
909
|
+
flush_layout
|
|
698
910
|
# The one site that runs after every mutation, so a component hidden,
|
|
699
911
|
# detached or reparented while hovered gets its exit exactly once.
|
|
700
912
|
@mouse_router.sync_hover
|
|
@@ -777,7 +989,7 @@ module Tuile
|
|
|
777
989
|
@repainting = repaint.to_set
|
|
778
990
|
@invalidated.clear
|
|
779
991
|
|
|
780
|
-
repaint.each(
|
|
992
|
+
repaint.each { _1.repaint(canvas_for(_1)) }
|
|
781
993
|
@repainting.clear
|
|
782
994
|
end
|
|
783
995
|
return unless did_paint
|
|
@@ -787,12 +999,27 @@ module Tuile
|
|
|
787
999
|
emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
|
|
788
1000
|
end
|
|
789
1001
|
|
|
790
|
-
#
|
|
791
|
-
#
|
|
792
|
-
#
|
|
793
|
-
#
|
|
1002
|
+
# Where the hardware cursor should sit in **screen** coordinates, or nil if
|
|
1003
|
+
# it should be hidden. Only the {#focused} component owns the cursor: there
|
|
1004
|
+
# can be multiple active components (the focus path), but only one focused.
|
|
1005
|
+
#
|
|
1006
|
+
# A component answers {Component#cursor_position} in its own coordinates and
|
|
1007
|
+
# this converts — the same split as painting, so a caret is a column and a
|
|
1008
|
+
# row and nothing more (`D_relative_rect`).
|
|
1009
|
+
#
|
|
1010
|
+
# A caret an ancestor clips away is **hidden**, not parked at a coordinate
|
|
1011
|
+
# nobody can see: the cursor is the one thing on screen that no clip can
|
|
1012
|
+
# reach, since the terminal draws it itself (`D_clip`).
|
|
794
1013
|
# @return [Point, nil]
|
|
795
|
-
def cursor_position
|
|
1014
|
+
def cursor_position
|
|
1015
|
+
focused = @focused
|
|
1016
|
+
local = focused&.cursor_position
|
|
1017
|
+
return nil if local.nil?
|
|
1018
|
+
|
|
1019
|
+
return nil unless clip_for(focused).contains?(local)
|
|
1020
|
+
|
|
1021
|
+
focused.to_screen(local)
|
|
1022
|
+
end
|
|
796
1023
|
|
|
797
1024
|
# Routes one mouse event into the tree ({Mouse::Router}) — what the event
|
|
798
1025
|
# loop does with every report the terminal sends, and how a spec drives the
|
|
@@ -818,22 +1045,48 @@ module Tuile
|
|
|
818
1045
|
|
|
819
1046
|
private
|
|
820
1047
|
|
|
821
|
-
#
|
|
822
|
-
#
|
|
1048
|
+
# Gives the pane the whole screen — the one rect no parent's pass assigns,
|
|
1049
|
+
# so the screen places it itself.
|
|
1050
|
+
# @return [void]
|
|
1051
|
+
def size_pane
|
|
1052
|
+
LayoutPass.run(self) { @pane.__send__(:rect=, Rect.new(0, 0, @size.width, @size.height)) }
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Whether anything on `component`'s ancestor chain actually cuts it — the
|
|
1056
|
+
# test that lets {#clip_for} answer {Component#local_rect} outright.
|
|
1057
|
+
#
|
|
1058
|
+
# If every node sits inside the box its parent gave it, then by induction
|
|
1059
|
+
# every ancestor's box contains `component`'s rect, the fold can remove
|
|
1060
|
+
# nothing, and the answer is its own `local_rect` — which still bounds the
|
|
1061
|
+
# component, so the short-circuit gives up no part of the guarantee.
|
|
823
1062
|
#
|
|
824
|
-
#
|
|
825
|
-
#
|
|
826
|
-
#
|
|
827
|
-
#
|
|
1063
|
+
# **Allocation-free on purpose, and that is the whole optimization.**
|
|
1064
|
+
# `local_rect`, `moved_by` and `intersect` each build a `Rect`; comparing
|
|
1065
|
+
# `node.rect` against the ancestor's stored *dimensions* builds nothing.
|
|
1066
|
+
# Spelled the obvious way, `up.local_rect.contains_rect?(node.rect)`, it
|
|
1067
|
+
# reads better and measures as no gain at all (`D_clip`).
|
|
828
1068
|
# @param component [Component]
|
|
1069
|
+
# @param root [Component, nil] see {#clip_for}.
|
|
829
1070
|
# @return [Boolean]
|
|
830
|
-
def
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1071
|
+
def clipped?(component, root)
|
|
1072
|
+
node = component
|
|
1073
|
+
while !node.equal?(root) && (up = node.parent)
|
|
1074
|
+
r = node.rect
|
|
1075
|
+
# An empty rect covers no cells, so it is trivially inside — matching
|
|
1076
|
+
# {Rect#contains_rect?}, whose place this takes.
|
|
1077
|
+
unless r.empty? || (r.left >= 0 && r.top >= 0 &&
|
|
1078
|
+
r.left + r.width <= up.rect.width &&
|
|
1079
|
+
r.top + r.height <= up.rect.height)
|
|
1080
|
+
return true
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
node = up
|
|
1084
|
+
end
|
|
1085
|
+
false
|
|
834
1086
|
end
|
|
835
1087
|
|
|
836
|
-
# The tail of {#focused=}: blur, then focus, then the
|
|
1088
|
+
# The tail of {#focused=}: blur, then focus, then the scroll-into-view
|
|
1089
|
+
# request, then the app notice.
|
|
837
1090
|
#
|
|
838
1091
|
# A hook may reassign {#focused}; that nested call has already run this whole
|
|
839
1092
|
# sequence for the target it chose, so this one stops rather than announcing
|
|
@@ -842,12 +1095,44 @@ module Tuile
|
|
|
842
1095
|
# @param focused [Component, nil] what the assignment asked for.
|
|
843
1096
|
# @return [void]
|
|
844
1097
|
def fire_focus_hooks(previous, focused)
|
|
1098
|
+
deferred = LayoutPass.running?
|
|
1099
|
+
# Recorded before the hooks, which may reassign focus themselves: the
|
|
1100
|
+
# first deferral in a drain keeps its `previous`, so the one notice fired
|
|
1101
|
+
# later compares against where focus stood before any of them.
|
|
1102
|
+
if deferred && !@focus_deferred
|
|
1103
|
+
@focus_deferred = true
|
|
1104
|
+
@focus_deferred_from = previous
|
|
1105
|
+
end
|
|
845
1106
|
unless focused.equal?(previous)
|
|
846
1107
|
previous&.__send__(:handle_blur)
|
|
847
1108
|
return unless @focused.equal?(focused)
|
|
848
1109
|
end
|
|
849
1110
|
@focused&.__send__(:handle_focus)
|
|
850
|
-
|
|
1111
|
+
follow_up_focus(previous) unless deferred
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
# The geometry half of {#focused=}: scroll the target into view, warn if it
|
|
1115
|
+
# still shows nothing, then fire the notice.
|
|
1116
|
+
# @param previous [Component, nil] what was focused before the change.
|
|
1117
|
+
# @return [void]
|
|
1118
|
+
def follow_up_focus(previous)
|
|
1119
|
+
# Level-triggered like `handle_focus`, not edge-triggered like the notice
|
|
1120
|
+
# below: re-focusing what already has focus is how an app says "bring it
|
|
1121
|
+
# back into view", and an already-satisfied request scrolls by zero.
|
|
1122
|
+
@focused&.scroll_to_visible
|
|
1123
|
+
warn_if_unseen(@focused) unless @focused.nil?
|
|
1124
|
+
on_focus_changed.fire(FocusChangedEvent.new(source: self)) unless @focused.equal?(previous)
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Unguarded on purpose: a container forwarding focus re-enters {#focused=},
|
|
1128
|
+
# so a target may be reported twice.
|
|
1129
|
+
# @param component [Component]
|
|
1130
|
+
# @return [void]
|
|
1131
|
+
def warn_if_unseen(component)
|
|
1132
|
+
return unless clip_for(component).empty?
|
|
1133
|
+
|
|
1134
|
+
Tuile.logger.warn("Screen: focused #{component} shows nothing, even after scroll_to_visible " \
|
|
1135
|
+
"(a stale Scroller#content_rows? Fixed[0] meant as visible = false?)")
|
|
851
1136
|
end
|
|
852
1137
|
|
|
853
1138
|
# The startup background probe, seeding {#theme} and
|
|
@@ -892,17 +1177,28 @@ module Tuile
|
|
|
892
1177
|
print TerminalBackground::QUERY
|
|
893
1178
|
end
|
|
894
1179
|
|
|
895
|
-
# The re-probe answered: adopt the color
|
|
896
|
-
# background-derived tints are now a scheme
|
|
897
|
-
# the previous color until the reply lands
|
|
898
|
-
# the flip — a terminal that reports mode-2031
|
|
899
|
-
# would otherwise lose the color it gave us at
|
|
1180
|
+
# The re-probe answered: adopt the color, re-derive the theme from it and
|
|
1181
|
+
# restyle, since an app's background-derived tints are now a scheme
|
|
1182
|
+
# behind. Deliberately keeps the previous color until the reply lands
|
|
1183
|
+
# rather than blanking it on the flip — a terminal that reports mode-2031
|
|
1184
|
+
# flips but not OSC 11 would otherwise lose the color it gave us at
|
|
1185
|
+
# startup, permanently.
|
|
900
1186
|
# @param color [Color]
|
|
901
1187
|
# @return [void]
|
|
902
1188
|
def handle_background_color(color)
|
|
903
1189
|
return if @background_color == color
|
|
904
1190
|
|
|
905
1191
|
@background_color = color
|
|
1192
|
+
@theme = @theme_source.resolve(color)
|
|
1193
|
+
restyle
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# Fires {Component#handle_theme_changed} across the attached tree and
|
|
1197
|
+
# repaints everything, after {#theme} or {#background_color} changed.
|
|
1198
|
+
# @return [void]
|
|
1199
|
+
def restyle
|
|
1200
|
+
# `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
|
|
1201
|
+
# subclass may narrow it further (`D_hook_visibility`).
|
|
906
1202
|
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
907
1203
|
needs_full_repaint
|
|
908
1204
|
end
|
|
@@ -916,7 +1212,7 @@ module Tuile
|
|
|
916
1212
|
# @return [Boolean] true if focus moved.
|
|
917
1213
|
def cycle_focus(forward:)
|
|
918
1214
|
check_locked
|
|
919
|
-
scope = @pane.
|
|
1215
|
+
scope = @pane.key_scope
|
|
920
1216
|
return false if scope.nil?
|
|
921
1217
|
|
|
922
1218
|
stops = []
|
|
@@ -956,13 +1252,14 @@ module Tuile
|
|
|
956
1252
|
# Resizes {#buffer} and {#pane} to the current {#size}, invalidates the
|
|
957
1253
|
# whole tree and repaints. Run whenever the terminal size changes (the
|
|
958
1254
|
# {EventQueue::TTYSizeEvent} path) and once at startup via the first
|
|
959
|
-
# {#content=}.
|
|
1255
|
+
# {#content=}. Not layout in the {Component#relayout} sense: assigning the
|
|
1256
|
+
# pane a rect only *marks* it, and the {#repaint} below settles the pass.
|
|
960
1257
|
# @return [void]
|
|
961
|
-
def
|
|
1258
|
+
def resize
|
|
962
1259
|
check_locked
|
|
963
1260
|
@buffer.resize(size) unless @buffer.size == size
|
|
964
1261
|
needs_full_repaint
|
|
965
|
-
|
|
1262
|
+
size_pane
|
|
966
1263
|
repaint
|
|
967
1264
|
end
|
|
968
1265
|
|
|
@@ -1016,21 +1313,36 @@ module Tuile
|
|
|
1016
1313
|
# @return [void]
|
|
1017
1314
|
def handle_paste(text) = @pane.handle_paste(text)
|
|
1018
1315
|
|
|
1019
|
-
#
|
|
1020
|
-
|
|
1021
|
-
|
|
1316
|
+
# Routes one event to its handler, then {#settle}s.
|
|
1317
|
+
#
|
|
1318
|
+
# The single seam every event passes through, which is why {FakeScreen}'s
|
|
1319
|
+
# gesture helpers drive it rather than calling {#handle_mouse} themselves: a
|
|
1320
|
+
# spec's `click` then reaches components exactly as the loop's own report
|
|
1321
|
+
# does, settle included.
|
|
1322
|
+
#
|
|
1323
|
+
# The `rescue` stays in {#event_loop}: only a *loop* has an `on_error` slot
|
|
1324
|
+
# to divert into, and a spec driving one event wants the raise.
|
|
1325
|
+
# @param event [Object] a {EventQueue::KeyEvent}, {Mouse::Event},
|
|
1326
|
+
# {EventQueue::PasteEvent}, {EventQueue::TTYSizeEvent},
|
|
1327
|
+
# {EventQueue::ColorSchemeEvent}, {EventQueue::BackgroundColorEvent},
|
|
1328
|
+
# {EventQueue::EmptyQueueEvent}, or a `Proc` from {EventQueue#submit}.
|
|
1329
|
+
# @return [Object] what the handler returned — a paste's is the Boolean
|
|
1330
|
+
# {ScreenPane#handle_paste} answers with; most are unspecified.
|
|
1331
|
+
def dispatch(event)
|
|
1332
|
+
result =
|
|
1022
1333
|
case event
|
|
1023
1334
|
when EventQueue::KeyEvent
|
|
1024
1335
|
key = event.key
|
|
1025
1336
|
handled = handle_key?(key)
|
|
1026
1337
|
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
1338
|
+
handled
|
|
1027
1339
|
when EventQueue::PasteEvent
|
|
1028
1340
|
handle_paste(event.text)
|
|
1029
1341
|
when Mouse::Event
|
|
1030
1342
|
handle_mouse(event)
|
|
1031
1343
|
when EventQueue::TTYSizeEvent
|
|
1032
1344
|
@size = event.size
|
|
1033
|
-
|
|
1345
|
+
resize
|
|
1034
1346
|
when EventQueue::ColorSchemeEvent
|
|
1035
1347
|
handle_color_scheme(event.scheme)
|
|
1036
1348
|
when EventQueue::BackgroundColorEvent
|
|
@@ -1040,8 +1352,31 @@ module Tuile
|
|
|
1040
1352
|
when Proc
|
|
1041
1353
|
event.call
|
|
1042
1354
|
end
|
|
1355
|
+
settle
|
|
1356
|
+
result
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
# Brings deferred work up to date, once per {#dispatch}ed event.
|
|
1360
|
+
#
|
|
1361
|
+
# The sequence point between "a handler mutated the tree" and "the next
|
|
1362
|
+
# event reads it": whatever a handler *marks*, this is where it is *done*,
|
|
1363
|
+
# so a queued mouse press never routes against what the key before it left
|
|
1364
|
+
# half-finished.
|
|
1365
|
+
# @return [void]
|
|
1366
|
+
def settle
|
|
1367
|
+
flush_layout
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# @return [void]
|
|
1371
|
+
def event_loop
|
|
1372
|
+
@event_queue.run_loop do |event|
|
|
1373
|
+
dispatch(event)
|
|
1043
1374
|
rescue StandardError => e
|
|
1044
|
-
|
|
1375
|
+
# Empty means re-raise: the generic fire cannot know that, so the one
|
|
1376
|
+
# slot with an executable empty branch carries it here.
|
|
1377
|
+
raise e if on_error.empty?
|
|
1378
|
+
|
|
1379
|
+
on_error.fire(EventQueue::ErrorEvent.new(error: e))
|
|
1045
1380
|
end
|
|
1046
1381
|
end
|
|
1047
1382
|
end
|