tuile 0.12.0 → 0.14.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/lib/tuile/screen.rb
CHANGED
|
@@ -14,10 +14,15 @@ module Tuile
|
|
|
14
14
|
# Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
|
|
15
15
|
# {#content} (set via {#content=}, filling the whole terminal and laying
|
|
16
16
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
17
|
-
# {Component::Popup#open}, drawn on top of the content)
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
17
|
+
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
18
|
+
# sized from their content — each carries its own top-down
|
|
19
|
+
# {Component::Popup#declared_size} — and they deliberately overdraw the content
|
|
20
|
+
# without clipping.
|
|
21
|
+
#
|
|
22
|
+
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
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=}
|
|
25
|
+
# (`D_status_bar`).
|
|
21
26
|
#
|
|
22
27
|
# ## Repaint model
|
|
23
28
|
#
|
|
@@ -66,11 +71,14 @@ module Tuile
|
|
|
66
71
|
# during :idle, at both ends of the screen's life. See {#check_locked}.
|
|
67
72
|
@ui_thread = Thread.current
|
|
68
73
|
@closed = false
|
|
69
|
-
|
|
74
|
+
background = detect_background
|
|
75
|
+
@color_scheme = background.scheme
|
|
76
|
+
@background_color = background.color
|
|
77
|
+
@color_depth = detect_color_depth
|
|
70
78
|
@theme_def = ThemeDef.default
|
|
71
79
|
@theme = @theme_def.for(@color_scheme)
|
|
72
|
-
# Structural root of the component tree: holds tiled content
|
|
73
|
-
# stack
|
|
80
|
+
# Structural root of the component tree: holds tiled content and the
|
|
81
|
+
# popup stack.
|
|
74
82
|
@pane = ScreenPane.new
|
|
75
83
|
@on_error = ->(e) { raise e }
|
|
76
84
|
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
@@ -79,13 +87,13 @@ module Tuile
|
|
|
79
87
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
80
88
|
# the terminal, so only changed cells are emitted (flicker-free on any
|
|
81
89
|
# terminal). Sized to the current viewport; {#layout} resizes it.
|
|
82
|
-
@buffer = Buffer.new(@size)
|
|
90
|
+
@buffer = Buffer.new(@size, color_depth: @color_depth)
|
|
83
91
|
end
|
|
84
92
|
|
|
85
|
-
# Entry in the global shortcut registry: the block to run, whether it
|
|
86
|
-
# pre-empts open popups
|
|
93
|
+
# Entry in the global shortcut registry: the block to run, and whether it
|
|
94
|
+
# pre-empts open popups.
|
|
87
95
|
# @api private
|
|
88
|
-
Shortcut = Data.define(:block, :over_popups
|
|
96
|
+
Shortcut = Data.define(:block, :over_popups)
|
|
89
97
|
private_constant :Shortcut
|
|
90
98
|
|
|
91
99
|
# Keys {#register_global_shortcut} refuses because every editable widget
|
|
@@ -113,6 +121,17 @@ module Tuile
|
|
|
113
121
|
# @return [Symbol] `:light` or `:dark`
|
|
114
122
|
attr_reader :color_scheme
|
|
115
123
|
|
|
124
|
+
# How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
|
|
125
|
+
# at construction. {Buffer#flush} degrades every color it emits to this,
|
|
126
|
+
# so an app may compute an RGB tint — say from {#background_color} — and
|
|
127
|
+
# paint with it, whatever the terminal turns out to understand.
|
|
128
|
+
#
|
|
129
|
+
# Deliberately read-only: detection runs once and the answer can't change
|
|
130
|
+
# mid-session. Override a terminal that reports itself wrong through
|
|
131
|
+
# {ColorDepth::OVERRIDE_ENV} instead.
|
|
132
|
+
# @return [Symbol]
|
|
133
|
+
attr_reader :color_depth
|
|
134
|
+
|
|
116
135
|
# @return [Buffer] the back buffer components paint into
|
|
117
136
|
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
118
137
|
attr_reader :buffer
|
|
@@ -178,6 +197,26 @@ module Tuile
|
|
|
178
197
|
# @return [ThemeDef]
|
|
179
198
|
attr_reader :theme_def
|
|
180
199
|
|
|
200
|
+
# The terminal's own background, as it reported it — for deriving a
|
|
201
|
+
# color *from* the background rather than picking one against it. A pane
|
|
202
|
+
# tinted a few percent off it sits right on any terminal, where a fixed
|
|
203
|
+
# near-neutral only sits right near the one it was tuned on:
|
|
204
|
+
#
|
|
205
|
+
# bg = screen.background_color
|
|
206
|
+
# sidebar.bg_color =
|
|
207
|
+
# bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
208
|
+
#
|
|
209
|
+
# **Nil is the normal case, not an edge** — a terminal answering only
|
|
210
|
+
# `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
|
|
211
|
+
#
|
|
212
|
+
# Kept current across OS appearance flips, a frame behind: the flip
|
|
213
|
+
# report carries light/dark only, so the screen re-probes and this
|
|
214
|
+
# updates when the reply lands. A changed color then fires
|
|
215
|
+
# {Component#on_theme_changed} across the tree exactly as a theme swap
|
|
216
|
+
# does — a background-derived tint *is* a theme-derived color.
|
|
217
|
+
# @return [Color, nil]
|
|
218
|
+
attr_reader :background_color
|
|
219
|
+
|
|
181
220
|
# Replaces the theme definition and immediately applies the member
|
|
182
221
|
# matching the current color scheme (via {#theme=}, so the whole UI
|
|
183
222
|
# restyles — or nothing repaints if that member equals the current
|
|
@@ -193,12 +232,10 @@ module Tuile
|
|
|
193
232
|
end
|
|
194
233
|
|
|
195
234
|
# Replaces the theme and restyles the whole UI: fires
|
|
196
|
-
# {Component#on_theme_changed} across the attached tree
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
# for durable theming. Preformatted status-bar hints (see
|
|
201
|
-
# {#register_global_shortcut}) have their colors baked in and aren't restyled.
|
|
235
|
+
# {Component#on_theme_changed} across the attached tree and invalidates
|
|
236
|
+
# every attached component. No-op when `new_theme` equals the current theme.
|
|
237
|
+
# This is a *transient* override — the next OS appearance flip re-picks from
|
|
238
|
+
# {#theme_def}; assign {#theme_def=} for durable theming.
|
|
202
239
|
# @param new_theme [Theme]
|
|
203
240
|
# @return [void]
|
|
204
241
|
def theme=(new_theme)
|
|
@@ -208,13 +245,15 @@ module Tuile
|
|
|
208
245
|
return if @theme == new_theme
|
|
209
246
|
|
|
210
247
|
@theme = new_theme
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
# `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
|
|
249
|
+
# subclass may narrow it further (`D_hook_visibility`).
|
|
250
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
213
251
|
needs_full_repaint
|
|
214
252
|
end
|
|
215
253
|
|
|
216
|
-
# @return [Array<Component>]
|
|
217
|
-
#
|
|
254
|
+
# @return [Array<Component::Overlay>] the open overlay stack — both
|
|
255
|
+
# {Component::Popup} modals and bare {Component::Overlay}s — in stacking
|
|
256
|
+
# order (forwarded to {ScreenPane}). The array must not be modified!
|
|
218
257
|
def popups = @pane.popups
|
|
219
258
|
|
|
220
259
|
# @return [EventQueue] the event queue.
|
|
@@ -287,6 +326,7 @@ module Tuile
|
|
|
287
326
|
end
|
|
288
327
|
|
|
289
328
|
check_locked
|
|
329
|
+
previous = @focused
|
|
290
330
|
if focused.nil?
|
|
291
331
|
@focused = nil
|
|
292
332
|
@pane.on_tree { _1.active = false }
|
|
@@ -303,50 +343,39 @@ module Tuile
|
|
|
303
343
|
@pane.on_tree { _1.active = active.include?(_1) }
|
|
304
344
|
@focused.on_focus
|
|
305
345
|
end
|
|
306
|
-
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
# Rebuild the status-bar text from the current focus and global-shortcut
|
|
310
|
-
# registry. Called from {#focused=} and whenever the global registry
|
|
311
|
-
# changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
|
|
312
|
-
# for the tiled case Screen tacks on the global "q quit" instead.
|
|
313
|
-
# Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
|
|
314
|
-
# for the over_popups filter rule.
|
|
315
|
-
# @api private
|
|
316
|
-
# @return [void]
|
|
317
|
-
def refresh_status_bar
|
|
318
|
-
top_popup = @pane.modal_popup
|
|
319
|
-
globals = global_shortcut_hints(popup_open: !top_popup.nil?)
|
|
320
|
-
@pane.status_bar.text = if top_popup.nil?
|
|
321
|
-
["q #{@theme.hint("quit")}", *globals,
|
|
322
|
-
active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
|
|
323
|
-
else
|
|
324
|
-
[*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
|
|
325
|
-
end
|
|
326
|
-
end
|
|
327
|
-
private :refresh_status_bar
|
|
328
|
-
|
|
329
|
-
# Status-bar hints from currently-registered global shortcuts.
|
|
330
|
-
# When a popup is open, only `over_popups: true` shortcuts contribute —
|
|
331
|
-
# the rest don't fire in that context, so showing them would be a lie.
|
|
332
|
-
# Insertion order is preserved (Hash iteration order).
|
|
333
|
-
# @api private
|
|
334
|
-
# @param popup_open [Boolean]
|
|
335
|
-
# @return [Array<String>]
|
|
336
|
-
def global_shortcut_hints(popup_open:)
|
|
337
|
-
@global_shortcuts.each_value.filter_map do |s|
|
|
338
|
-
next if s.hint.nil? || s.hint.empty?
|
|
339
|
-
next if popup_open && !s.over_popups
|
|
340
|
-
|
|
341
|
-
s.hint
|
|
342
|
-
end
|
|
346
|
+
@on_focus_changed&.call unless @focused.equal?(previous)
|
|
343
347
|
end
|
|
344
|
-
private :global_shortcut_hints
|
|
345
348
|
|
|
346
|
-
#
|
|
347
|
-
#
|
|
349
|
+
# Called after the focused component *changes* — including to and from
|
|
350
|
+
# `nil`, and including the focus repair that runs when a popup closes.
|
|
351
|
+
# Takes no arguments; read {#focused} (and walk its `parent` chain) for the
|
|
352
|
+
# new state.
|
|
353
|
+
#
|
|
354
|
+
# This is the hook an app drives its own status line from. Tuile owns no
|
|
355
|
+
# status bar and reserves no row: build a {Component::Label} into your own
|
|
356
|
+
# layout and fill it here (`D_status_bar`).
|
|
357
|
+
#
|
|
358
|
+
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
359
|
+
#
|
|
360
|
+
# **Edge-triggered**, like {Component#on_attached}: re-assigning the
|
|
361
|
+
# component that already has focus fires nothing, so a callback can be as
|
|
362
|
+
# expensive as rebuilding a hint string without a `did it really change?`
|
|
363
|
+
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
364
|
+
# clears focus on every content swap, which on a level-triggered hook would
|
|
365
|
+
# fire a nil→nil notification during assembly.
|
|
366
|
+
#
|
|
367
|
+
# It runs *after* the active-flag cascade and `on_focus`, so the tree is
|
|
368
|
+
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
369
|
+
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
370
|
+
# {Component#on_detached}. A raising callback propagates out of {#focused=}
|
|
371
|
+
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
372
|
+
# @return [Proc, nil]
|
|
373
|
+
attr_accessor :on_focus_changed
|
|
374
|
+
|
|
375
|
+
# Internal — use {Component::Overlay#open} instead. Adds the overlay to
|
|
376
|
+
# {#pane}; a {Component::Popup} is additionally centered and focused.
|
|
348
377
|
# @api private
|
|
349
|
-
# @param window [Component::
|
|
378
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
350
379
|
# @return [void]
|
|
351
380
|
def add_popup(window)
|
|
352
381
|
check_locked
|
|
@@ -370,9 +399,16 @@ module Tuile
|
|
|
370
399
|
# you want if the app benefits more from select-to-copy than from
|
|
371
400
|
# click-to-focus. Components' `handle_mouse` is simply never invoked
|
|
372
401
|
# from the loop in that mode (the terminal stops sending the bytes).
|
|
402
|
+
# @param bracketed_paste [Boolean] when true (default), enables DEC private
|
|
403
|
+
# mode 2004 so pasted text arrives whole, as {Component#handle_paste},
|
|
404
|
+
# instead of as one keystroke per character — which is the only way a
|
|
405
|
+
# pasted line break can be told from a typed Enter. When false, a paste
|
|
406
|
+
# streams in as keys again and a component that gives ENTER a meaning
|
|
407
|
+
# fires it once per pasted line. Turn it off only for a terminal that
|
|
408
|
+
# mishandles the mode.
|
|
373
409
|
# @raise [Tuile::Error] if the screen is already {#close}d.
|
|
374
410
|
# @return [void]
|
|
375
|
-
def run_event_loop(capture_mouse: true)
|
|
411
|
+
def run_event_loop(capture_mouse: true, bracketed_paste: true)
|
|
376
412
|
raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
|
|
377
413
|
|
|
378
414
|
# The guard above stays outside the begin: teardown for a setup that never
|
|
@@ -381,6 +417,7 @@ module Tuile
|
|
|
381
417
|
begin
|
|
382
418
|
$stdin.echo = false
|
|
383
419
|
print MouseEvent.start_tracking if capture_mouse
|
|
420
|
+
print Keys::BRACKETED_PASTE_ON if bracketed_paste
|
|
384
421
|
# Follow OS light/dark flips live: terminals supporting mode 2031
|
|
385
422
|
# push color-scheme reports that the key thread turns into
|
|
386
423
|
# {EventQueue::ColorSchemeEvent}s.
|
|
@@ -390,6 +427,7 @@ module Tuile
|
|
|
390
427
|
end
|
|
391
428
|
ensure
|
|
392
429
|
print TerminalBackground::NOTIFY_OFF
|
|
430
|
+
print Keys::BRACKETED_PASTE_OFF if bracketed_paste
|
|
393
431
|
print MouseEvent.stop_tracking if capture_mouse
|
|
394
432
|
print TTY::Cursor.show
|
|
395
433
|
$stdin.echo = true
|
|
@@ -425,9 +463,7 @@ module Tuile
|
|
|
425
463
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
426
464
|
# which every editable widget needs.
|
|
427
465
|
#
|
|
428
|
-
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
429
|
-
# over_popups: true,
|
|
430
|
-
# hint: "^L #{screen.theme.hint("log")}") do
|
|
466
|
+
# screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
|
|
431
467
|
# log_popup.open
|
|
432
468
|
# end
|
|
433
469
|
#
|
|
@@ -435,11 +471,9 @@ module Tuile
|
|
|
435
471
|
# @param over_popups [Boolean] when true, fires even while a modal popup is
|
|
436
472
|
# open (pre-empting the popup); when false (default), suppressed while any
|
|
437
473
|
# 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.
|
|
440
474
|
# @yield invoked with no arguments when `key` is pressed.
|
|
441
475
|
# @return [void]
|
|
442
|
-
def register_global_shortcut(key, over_popups: false,
|
|
476
|
+
def register_global_shortcut(key, over_popups: false, &block)
|
|
443
477
|
raise ArgumentError, "block required" if block.nil?
|
|
444
478
|
raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
|
|
445
479
|
raise ArgumentError, "key cannot be empty" if key.empty?
|
|
@@ -461,10 +495,7 @@ module Tuile
|
|
|
461
495
|
"button, handle ENTER in the form's own handle_key instead — a focused " \
|
|
462
496
|
"TextArea/TextField gets first refusal there."
|
|
463
497
|
end
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
|
|
467
|
-
refresh_status_bar
|
|
498
|
+
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
|
|
468
499
|
end
|
|
469
500
|
|
|
470
501
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -473,23 +504,14 @@ module Tuile
|
|
|
473
504
|
# @return [void]
|
|
474
505
|
def unregister_global_shortcut(key)
|
|
475
506
|
@global_shortcuts.delete(key)
|
|
476
|
-
refresh_status_bar
|
|
477
507
|
end
|
|
478
508
|
|
|
479
|
-
#
|
|
480
|
-
def active_window
|
|
481
|
-
check_locked
|
|
482
|
-
result = nil
|
|
483
|
-
@pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
|
|
484
|
-
result
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# Internal — use {Component::Popup#close} instead. Removes the popup
|
|
509
|
+
# Internal — use {Component::Overlay#close} instead. Removes the overlay
|
|
488
510
|
# from {#pane}, repairs focus, and repaints the scene.
|
|
489
511
|
#
|
|
490
|
-
# Does nothing if the
|
|
512
|
+
# Does nothing if the overlay is not open on this screen.
|
|
491
513
|
# @api private
|
|
492
|
-
# @param window [Component::
|
|
514
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
493
515
|
# @return [void]
|
|
494
516
|
def remove_popup(window)
|
|
495
517
|
check_locked
|
|
@@ -512,10 +534,10 @@ module Tuile
|
|
|
512
534
|
@pane&.on_tree { invalidate _1 }
|
|
513
535
|
end
|
|
514
536
|
|
|
515
|
-
# Internal — use {Component::
|
|
537
|
+
# Internal — use {Component::Overlay#open?} instead.
|
|
516
538
|
# @api private
|
|
517
|
-
# @param window [Component::
|
|
518
|
-
# @return [Boolean] true if this
|
|
539
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
540
|
+
# @return [Boolean] true if this overlay is currently mounted.
|
|
519
541
|
def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
|
|
520
542
|
check_locked
|
|
521
543
|
@pane.has_popup?(window)
|
|
@@ -561,15 +583,40 @@ module Tuile
|
|
|
561
583
|
end
|
|
562
584
|
|
|
563
585
|
# Writes terminal-housekeeping escapes straight to stdout: {#clear},
|
|
564
|
-
# mouse-tracking start/stop, the color-scheme notify toggles,
|
|
565
|
-
# on teardown. Component painting does
|
|
566
|
-
# writes into {#buffer}, which
|
|
567
|
-
#
|
|
568
|
-
# test runner's stdout.
|
|
586
|
+
# mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
|
|
587
|
+
# background re-probe, cursor-show on teardown. Component painting does
|
|
588
|
+
# *not* go through here anymore — it writes into {#buffer}, which
|
|
589
|
+
# {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
|
|
590
|
+
# {#emit}) to capture into `@prints` instead of the test runner's stdout.
|
|
591
|
+
#
|
|
592
|
+
# Flushed, like {#emit}: none of these escapes ends in a newline, and a
|
|
593
|
+
# buffered stdout would hold a *query* — one whose reply the app is
|
|
594
|
+
# waiting on — until the next frame went out.
|
|
569
595
|
# @param args [String] stuff to print.
|
|
570
596
|
# @return [void]
|
|
571
597
|
def print(*args)
|
|
572
598
|
Kernel.print(*args)
|
|
599
|
+
$stdout.flush
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
603
|
+
# went nowhere, e.g. a letter matching no menu mnemonic while a menu is
|
|
604
|
+
# open.
|
|
605
|
+
#
|
|
606
|
+
# return true if activate_mnemonic(key)
|
|
607
|
+
#
|
|
608
|
+
# screen.beep # no match: the key is swallowed, say so
|
|
609
|
+
# true
|
|
610
|
+
#
|
|
611
|
+
# Writes **immediately** rather than riding the next frame: a beep is not
|
|
612
|
+
# part of a frame, and the keystrokes worth beeping at are precisely the
|
|
613
|
+
# ones that invalidate nothing, so {#repaint} may never emit at all. Whether
|
|
614
|
+
# the user hears anything is the terminal's setting to make, so there is no
|
|
615
|
+
# Tuile-level enable/disable knob.
|
|
616
|
+
# @return [void]
|
|
617
|
+
def beep
|
|
618
|
+
check_locked
|
|
619
|
+
print(Ansi::BEL)
|
|
573
620
|
end
|
|
574
621
|
|
|
575
622
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
@@ -602,10 +649,9 @@ module Tuile
|
|
|
602
649
|
|
|
603
650
|
# Build the repaint list in z-order, leaning on the tree itself rather
|
|
604
651
|
# than a depth sort. The pane's pre-order traversal already orders the
|
|
605
|
-
# tiled layer (content subtree
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
# walk won't do — we collect the tiled layer first, then append popups.
|
|
652
|
+
# tiled layer (the content subtree) parent-before-child; the popups are
|
|
653
|
+
# the top layer and must paint last, so we collect the tiled layer first
|
|
654
|
+
# and append popups rather than taking a single pane.on_tree walk.
|
|
609
655
|
popup_members = Set.new
|
|
610
656
|
popups.each { |p| p.on_tree { popup_members << _1 } }
|
|
611
657
|
|
|
@@ -620,12 +666,22 @@ module Tuile
|
|
|
620
666
|
tiled_invalidated = true
|
|
621
667
|
end
|
|
622
668
|
|
|
623
|
-
# Popups on top:
|
|
624
|
-
#
|
|
625
|
-
#
|
|
626
|
-
#
|
|
669
|
+
# Popups on top: a layer repaints whole whenever anything *beneath* it —
|
|
670
|
+
# the tiled tree, or a lower popup — repaints, else just its invalidated
|
|
671
|
+
# members. Layer-by-layer rather than one tiled_invalidated bool because
|
|
672
|
+
# the drain loops: a lower popup's repaint cascade re-invalidates
|
|
673
|
+
# children into the *next* iteration (its gap-clearing Layout wipes and
|
|
674
|
+
# re-queues a button, say), and that iteration has no tiled repaint —
|
|
675
|
+
# only the full re-assert of every layer above keeps the stacking order
|
|
676
|
+
# true across iterations (screen_spec pins it with two overlapping
|
|
677
|
+
# popups). Overdraw into the buffer is free (only net-visible cell
|
|
678
|
+
# changes reach the terminal), so reasserting layers is cheap.
|
|
679
|
+
below_repainted = tiled_invalidated
|
|
627
680
|
popups.each do |p|
|
|
628
|
-
|
|
681
|
+
layer_invalidated = false
|
|
682
|
+
p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
|
|
683
|
+
p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
|
|
684
|
+
below_repainted ||= layer_invalidated
|
|
629
685
|
end
|
|
630
686
|
|
|
631
687
|
@repainting = repaint.to_set
|
|
@@ -650,25 +706,53 @@ module Tuile
|
|
|
650
706
|
|
|
651
707
|
private
|
|
652
708
|
|
|
653
|
-
#
|
|
654
|
-
#
|
|
655
|
-
#
|
|
656
|
-
#
|
|
657
|
-
#
|
|
658
|
-
#
|
|
659
|
-
#
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
TerminalBackground.detect == :light ? :light : :dark
|
|
709
|
+
# The startup background probe, seeding {#theme} and
|
|
710
|
+
# {#background_color}. Inconclusive detection means `:dark` with no
|
|
711
|
+
# color. Runs in the constructor — the OSC 11 reply arrives on stdin,
|
|
712
|
+
# which is only safe to read before {EventQueue#start_key_thread} owns
|
|
713
|
+
# it. {FakeScreen} overrides this to pin the result, keeping specs
|
|
714
|
+
# deterministic and off the test runner's TTY.
|
|
715
|
+
# @return [TerminalBackground::Result]
|
|
716
|
+
def detect_background
|
|
717
|
+
TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
|
|
663
718
|
end
|
|
664
719
|
|
|
720
|
+
# The startup color-depth probe, seeding {#color_depth}. Reads the
|
|
721
|
+
# environment only, so unlike {#detect_background} it has no timing
|
|
722
|
+
# constraint. {FakeScreen} overrides it to pin the result, keeping specs
|
|
723
|
+
# off whatever `COLORTERM` the test runner happens to carry.
|
|
724
|
+
# @return [Symbol]
|
|
725
|
+
def detect_color_depth = ColorDepth.detect
|
|
726
|
+
|
|
665
727
|
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
666
|
-
# scheme
|
|
728
|
+
# scheme, apply the matching member of {#theme_def}, and re-probe for
|
|
729
|
+
# the new background RGB, which the report does not carry.
|
|
730
|
+
#
|
|
731
|
+
# The query goes out from *this* thread — the event-loop thread, which
|
|
732
|
+
# also owns {#emit} — so its bytes can never land inside a frame's
|
|
733
|
+
# synchronized-output batch. The reply comes back through the key
|
|
734
|
+
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
667
735
|
# @param scheme [Symbol] `:dark` or `:light`.
|
|
668
736
|
# @return [void]
|
|
669
737
|
def on_color_scheme(scheme)
|
|
670
738
|
@color_scheme = scheme
|
|
671
739
|
self.theme = @theme_def.for(@color_scheme)
|
|
740
|
+
print TerminalBackground::QUERY
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# The re-probe answered: adopt the color and restyle, since an app's
|
|
744
|
+
# background-derived tints are now a scheme behind. Deliberately keeps
|
|
745
|
+
# the previous color until the reply lands rather than blanking it on
|
|
746
|
+
# the flip — a terminal that reports mode-2031 flips but not OSC 11
|
|
747
|
+
# would otherwise lose the color it gave us at startup, permanently.
|
|
748
|
+
# @param color [Color]
|
|
749
|
+
# @return [void]
|
|
750
|
+
def on_background_color(color)
|
|
751
|
+
return if @background_color == color
|
|
752
|
+
|
|
753
|
+
@background_color = color
|
|
754
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
755
|
+
needs_full_repaint
|
|
672
756
|
end
|
|
673
757
|
|
|
674
758
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
@@ -770,6 +854,17 @@ module Tuile
|
|
|
770
854
|
# @return [void]
|
|
771
855
|
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
772
856
|
|
|
857
|
+
# Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
|
|
858
|
+
#
|
|
859
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
860
|
+
# skips Tab traversal and the global-shortcut registry entirely and goes
|
|
861
|
+
# straight to delivery. Unhandled text is dropped — there is no fallback
|
|
862
|
+
# that replays it as keys, which would put back the very ambiguity mode
|
|
863
|
+
# 2004 exists to remove.
|
|
864
|
+
# @param text [String]
|
|
865
|
+
# @return [Boolean] true if some component consumed it.
|
|
866
|
+
def handle_paste(text) = @pane.handle_paste(text)
|
|
867
|
+
|
|
773
868
|
# @return [void]
|
|
774
869
|
def event_loop
|
|
775
870
|
@event_queue.run_loop do |event|
|
|
@@ -778,6 +873,8 @@ module Tuile
|
|
|
778
873
|
key = event.key
|
|
779
874
|
handled = handle_key(key)
|
|
780
875
|
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
876
|
+
when EventQueue::PasteEvent
|
|
877
|
+
handle_paste(event.text)
|
|
781
878
|
when MouseEvent
|
|
782
879
|
handle_mouse(event)
|
|
783
880
|
when EventQueue::TTYSizeEvent
|
|
@@ -785,6 +882,8 @@ module Tuile
|
|
|
785
882
|
layout
|
|
786
883
|
when EventQueue::ColorSchemeEvent
|
|
787
884
|
on_color_scheme(event.scheme)
|
|
885
|
+
when EventQueue::BackgroundColorEvent
|
|
886
|
+
on_background_color(event.color)
|
|
788
887
|
when EventQueue::EmptyQueueEvent
|
|
789
888
|
repaint
|
|
790
889
|
when Proc
|