tuile 0.13.0 → 0.15.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 +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- 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 +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- 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 +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- metadata +25 -1
data/lib/tuile/screen.rb
CHANGED
|
@@ -16,13 +16,13 @@ module Tuile
|
|
|
16
16
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
17
17
|
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
18
18
|
# sized from their content — each carries its own top-down
|
|
19
|
-
# {Component::Popup#
|
|
19
|
+
# {Component::Popup#declared_size} — and they deliberately overdraw the content
|
|
20
20
|
# without clipping.
|
|
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
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
|
|
28
28
|
#
|
|
@@ -71,12 +71,20 @@ module Tuile
|
|
|
71
71
|
# during :idle, at both ends of the screen's life. See {#check_locked}.
|
|
72
72
|
@ui_thread = Thread.current
|
|
73
73
|
@closed = false
|
|
74
|
-
|
|
74
|
+
background = detect_background
|
|
75
|
+
@color_scheme = background.scheme
|
|
76
|
+
@background_color = background.color
|
|
77
|
+
@color_depth = detect_color_depth
|
|
78
|
+
@locale = detect_locale
|
|
75
79
|
@theme_def = ThemeDef.default
|
|
76
80
|
@theme = @theme_def.for(@color_scheme)
|
|
77
81
|
# Structural root of the component tree: holds tiled content and the
|
|
78
|
-
# popup stack.
|
|
82
|
+
# popup stack. Sized here rather than waiting for the first {#layout},
|
|
83
|
+
# for the same reason {#size} is seeded from {EventQueue::TTYSizeEvent}:
|
|
84
|
+
# an empty pane rect is an *ancestor* empty rect, and {#repaint}'s drain
|
|
85
|
+
# filter would take the whole tree with it.
|
|
79
86
|
@pane = ScreenPane.new
|
|
87
|
+
@pane.rect = Rect.new(0, 0, @size.width, @size.height)
|
|
80
88
|
@on_error = ->(e) { raise e }
|
|
81
89
|
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
82
90
|
# reach the pane. See {#register_global_shortcut}.
|
|
@@ -84,7 +92,7 @@ module Tuile
|
|
|
84
92
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
85
93
|
# the terminal, so only changed cells are emitted (flicker-free on any
|
|
86
94
|
# terminal). Sized to the current viewport; {#layout} resizes it.
|
|
87
|
-
@buffer = Buffer.new(@size)
|
|
95
|
+
@buffer = Buffer.new(@size, color_depth: @color_depth)
|
|
88
96
|
end
|
|
89
97
|
|
|
90
98
|
# Entry in the global shortcut registry: the block to run, and whether it
|
|
@@ -118,6 +126,17 @@ module Tuile
|
|
|
118
126
|
# @return [Symbol] `:light` or `:dark`
|
|
119
127
|
attr_reader :color_scheme
|
|
120
128
|
|
|
129
|
+
# How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
|
|
130
|
+
# at construction. {Buffer#flush} degrades every color it emits to this,
|
|
131
|
+
# so an app may compute an RGB tint — say from {#background_color} — and
|
|
132
|
+
# paint with it, whatever the terminal turns out to understand.
|
|
133
|
+
#
|
|
134
|
+
# Deliberately read-only: detection runs once and the answer can't change
|
|
135
|
+
# mid-session. Override a terminal that reports itself wrong through
|
|
136
|
+
# {ColorDepth::OVERRIDE_ENV} instead.
|
|
137
|
+
# @return [Symbol]
|
|
138
|
+
attr_reader :color_depth
|
|
139
|
+
|
|
121
140
|
# @return [Buffer] the back buffer components paint into
|
|
122
141
|
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
123
142
|
attr_reader :buffer
|
|
@@ -149,6 +168,12 @@ module Tuile
|
|
|
149
168
|
@@instance
|
|
150
169
|
end
|
|
151
170
|
|
|
171
|
+
# Whether {.instance} would answer rather than raise — for code that must
|
|
172
|
+
# work with no screen in the process at all, which a detached component
|
|
173
|
+
# tree legitimately is (`Component#locale` is the one caller in the gem).
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def self.instance? = !@@instance.nil?
|
|
176
|
+
|
|
152
177
|
# @return [Component, nil] tiled content (forwarded to {ScreenPane}).
|
|
153
178
|
def content = @pane.content
|
|
154
179
|
|
|
@@ -183,6 +208,26 @@ module Tuile
|
|
|
183
208
|
# @return [ThemeDef]
|
|
184
209
|
attr_reader :theme_def
|
|
185
210
|
|
|
211
|
+
# The terminal's own background, as it reported it — for deriving a
|
|
212
|
+
# color *from* the background rather than picking one against it. A pane
|
|
213
|
+
# tinted a few percent off it sits right on any terminal, where a fixed
|
|
214
|
+
# near-neutral only sits right near the one it was tuned on:
|
|
215
|
+
#
|
|
216
|
+
# bg = screen.background_color
|
|
217
|
+
# sidebar.bg_color =
|
|
218
|
+
# bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
219
|
+
#
|
|
220
|
+
# **Nil is the normal case, not an edge** — a terminal answering only
|
|
221
|
+
# `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
|
|
222
|
+
#
|
|
223
|
+
# Kept current across OS appearance flips, a frame behind: the flip
|
|
224
|
+
# report carries light/dark only, so the screen re-probes and this
|
|
225
|
+
# updates when the reply lands. A changed color then fires
|
|
226
|
+
# {Component#on_theme_changed} across the tree exactly as a theme swap
|
|
227
|
+
# does — a background-derived tint *is* a theme-derived color.
|
|
228
|
+
# @return [Color, nil]
|
|
229
|
+
attr_reader :background_color
|
|
230
|
+
|
|
186
231
|
# Replaces the theme definition and immediately applies the member
|
|
187
232
|
# matching the current color scheme (via {#theme=}, so the whole UI
|
|
188
233
|
# restyles — or nothing repaints if that member equals the current
|
|
@@ -211,12 +256,50 @@ module Tuile
|
|
|
211
256
|
return if @theme == new_theme
|
|
212
257
|
|
|
213
258
|
@theme = new_theme
|
|
214
|
-
|
|
259
|
+
# `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
|
|
260
|
+
# subclass may narrow it further (`D_hook_visibility`).
|
|
261
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
262
|
+
needs_full_repaint
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The formatting conventions this session renders and parses by — date
|
|
266
|
+
# formats, the calendar, month and weekday names, the decimal separator.
|
|
267
|
+
# Detected once at construction from {Locale.system}; {Locale::ISO} when
|
|
268
|
+
# the environment says nothing.
|
|
269
|
+
#
|
|
270
|
+
# Read it at paint or parse time and never cache it in an ivar, for the
|
|
271
|
+
# same reason {#theme} says so: {#locale=} can replace it mid-session.
|
|
272
|
+
# @return [Locale]
|
|
273
|
+
attr_reader :locale
|
|
274
|
+
|
|
275
|
+
# Replaces the locale: fires {Component#on_locale_changed} across the
|
|
276
|
+
# attached tree and invalidates every attached component. No-op when
|
|
277
|
+
# `new_locale` equals the current one.
|
|
278
|
+
#
|
|
279
|
+
# screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"])
|
|
280
|
+
#
|
|
281
|
+
# Note this can invalidate what a user has half-typed: a field's buffer
|
|
282
|
+
# reparses under the new grammar, and one that no longer parses reads as
|
|
283
|
+
# bad input.
|
|
284
|
+
# @param new_locale [Locale]
|
|
285
|
+
# @return [void]
|
|
286
|
+
# @raise [TypeError] unless `new_locale` is a {Locale}.
|
|
287
|
+
def locale=(new_locale)
|
|
288
|
+
raise TypeError, "expected Locale, got #{new_locale.inspect}" unless new_locale.is_a?(Locale)
|
|
289
|
+
|
|
290
|
+
check_locked
|
|
291
|
+
return if @locale == new_locale
|
|
292
|
+
|
|
293
|
+
@locale = new_locale
|
|
294
|
+
# `__send__` for the same reason `theme=` uses it: the hook is protected
|
|
295
|
+
# (`D_hook_visibility`).
|
|
296
|
+
@pane&.on_tree { _1.__send__(:on_locale_changed) }
|
|
215
297
|
needs_full_repaint
|
|
216
298
|
end
|
|
217
299
|
|
|
218
|
-
# @return [Array<Component>]
|
|
219
|
-
#
|
|
300
|
+
# @return [Array<Component::Overlay>] the open overlay stack — both
|
|
301
|
+
# {Component::Popup} modals and bare {Component::Overlay}s — in stacking
|
|
302
|
+
# order (forwarded to {ScreenPane}). The array must not be modified!
|
|
220
303
|
def popups = @pane.popups
|
|
221
304
|
|
|
222
305
|
# @return [EventQueue] the event queue.
|
|
@@ -282,6 +365,15 @@ module Tuile
|
|
|
282
365
|
# Sets the focused {Component}. Focused component receives keyboard events.
|
|
283
366
|
# All focusable components live under {#pane}, so this is a single uniform
|
|
284
367
|
# path (no separate popup-vs-content branches).
|
|
368
|
+
#
|
|
369
|
+
# A hidden target — or one under a hidden ancestor — raises, exactly as a
|
|
370
|
+
# detached one does: focusing it would park the hardware cursor inside
|
|
371
|
+
# whatever is painted over it and feed it every keystroke.
|
|
372
|
+
#
|
|
373
|
+
# Once the pointer and the active flags are settled, three notices fire in
|
|
374
|
+
# order: {Component#on_blur} on what lost focus, {Component#on_focus} on
|
|
375
|
+
# what took it, then {#on_focus_changed}. The outer two are edge-triggered
|
|
376
|
+
# and `on_focus` is not — see there.
|
|
285
377
|
# @param focused [Component, nil] the new component to be focused.
|
|
286
378
|
def focused=(focused)
|
|
287
379
|
unless focused.nil? || focused.is_a?(Component)
|
|
@@ -295,6 +387,7 @@ module Tuile
|
|
|
295
387
|
@pane.on_tree { _1.active = false }
|
|
296
388
|
else
|
|
297
389
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
390
|
+
raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
|
|
298
391
|
|
|
299
392
|
@focused = focused
|
|
300
393
|
active = Set[focused]
|
|
@@ -304,9 +397,8 @@ module Tuile
|
|
|
304
397
|
cursor = cursor.parent
|
|
305
398
|
end
|
|
306
399
|
@pane.on_tree { _1.active = active.include?(_1) }
|
|
307
|
-
@focused.on_focus
|
|
308
400
|
end
|
|
309
|
-
|
|
401
|
+
fire_focus_hooks(previous, focused)
|
|
310
402
|
end
|
|
311
403
|
|
|
312
404
|
# Called after the focused component *changes* — including to and from
|
|
@@ -316,7 +408,7 @@ module Tuile
|
|
|
316
408
|
#
|
|
317
409
|
# This is the hook an app drives its own status line from. Tuile owns no
|
|
318
410
|
# status bar and reserves no row: build a {Component::Label} into your own
|
|
319
|
-
# layout and fill it here (`
|
|
411
|
+
# layout and fill it here (`D_status_bar`).
|
|
320
412
|
#
|
|
321
413
|
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
322
414
|
#
|
|
@@ -335,10 +427,10 @@ module Tuile
|
|
|
335
427
|
# @return [Proc, nil]
|
|
336
428
|
attr_accessor :on_focus_changed
|
|
337
429
|
|
|
338
|
-
# Internal — use {Component::
|
|
339
|
-
# {#pane}
|
|
430
|
+
# Internal — use {Component::Overlay#open} instead. Adds the overlay to
|
|
431
|
+
# {#pane}; a {Component::Popup} is additionally centered and focused.
|
|
340
432
|
# @api private
|
|
341
|
-
# @param window [Component::
|
|
433
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
342
434
|
# @return [void]
|
|
343
435
|
def add_popup(window)
|
|
344
436
|
check_locked
|
|
@@ -469,12 +561,12 @@ module Tuile
|
|
|
469
561
|
@global_shortcuts.delete(key)
|
|
470
562
|
end
|
|
471
563
|
|
|
472
|
-
# Internal — use {Component::
|
|
564
|
+
# Internal — use {Component::Overlay#close} instead. Removes the overlay
|
|
473
565
|
# from {#pane}, repairs focus, and repaints the scene.
|
|
474
566
|
#
|
|
475
|
-
# Does nothing if the
|
|
567
|
+
# Does nothing if the overlay is not open on this screen.
|
|
476
568
|
# @api private
|
|
477
|
-
# @param window [Component::
|
|
569
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
478
570
|
# @return [void]
|
|
479
571
|
def remove_popup(window)
|
|
480
572
|
check_locked
|
|
@@ -497,10 +589,10 @@ module Tuile
|
|
|
497
589
|
@pane&.on_tree { invalidate _1 }
|
|
498
590
|
end
|
|
499
591
|
|
|
500
|
-
# Internal — use {Component::
|
|
592
|
+
# Internal — use {Component::Overlay#open?} instead.
|
|
501
593
|
# @api private
|
|
502
|
-
# @param window [Component::
|
|
503
|
-
# @return [Boolean] true if this
|
|
594
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
595
|
+
# @return [Boolean] true if this overlay is currently mounted.
|
|
504
596
|
def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
|
|
505
597
|
check_locked
|
|
506
598
|
@pane.has_popup?(window)
|
|
@@ -546,15 +638,20 @@ module Tuile
|
|
|
546
638
|
end
|
|
547
639
|
|
|
548
640
|
# Writes terminal-housekeeping escapes straight to stdout: {#clear},
|
|
549
|
-
# mouse-tracking start/stop, the color-scheme notify toggles,
|
|
550
|
-
# on teardown. Component painting does
|
|
551
|
-
# writes into {#buffer}, which
|
|
552
|
-
#
|
|
553
|
-
# test runner's stdout.
|
|
641
|
+
# mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
|
|
642
|
+
# background re-probe, cursor-show on teardown. Component painting does
|
|
643
|
+
# *not* go through here anymore — it writes into {#buffer}, which
|
|
644
|
+
# {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
|
|
645
|
+
# {#emit}) to capture into `@prints` instead of the test runner's stdout.
|
|
646
|
+
#
|
|
647
|
+
# Flushed, like {#emit}: none of these escapes ends in a newline, and a
|
|
648
|
+
# buffered stdout would hold a *query* — one whose reply the app is
|
|
649
|
+
# waiting on — until the next frame went out.
|
|
554
650
|
# @param args [String] stuff to print.
|
|
555
651
|
# @return [void]
|
|
556
652
|
def print(*args)
|
|
557
653
|
Kernel.print(*args)
|
|
654
|
+
$stdout.flush
|
|
558
655
|
end
|
|
559
656
|
|
|
560
657
|
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
@@ -594,12 +691,31 @@ module Tuile
|
|
|
594
691
|
|
|
595
692
|
did_paint = false
|
|
596
693
|
until @invalidated.empty?
|
|
597
|
-
# Defensive filter
|
|
598
|
-
#
|
|
599
|
-
#
|
|
600
|
-
#
|
|
601
|
-
#
|
|
602
|
-
|
|
694
|
+
# Defensive filter, two ways a queued component has no place on the
|
|
695
|
+
# screen by the time we drain it. It became *detached* since (popup
|
|
696
|
+
# close, sibling removed mid-event-handling, focus repair) — which
|
|
697
|
+
# Component#invalidate gates at enqueue, so this catches only a change
|
|
698
|
+
# since. Or it sits under an *empty rect*, its own or any ancestor's,
|
|
699
|
+
# which is how a subtree is collapsed.
|
|
700
|
+
#
|
|
701
|
+
# The self half is belt-and-braces — every #repaint opens with the same
|
|
702
|
+
# `return if rect.empty?`, and that stays: it is each component's own
|
|
703
|
+
# contract, local to the method an app subclass calls `super` on. The
|
|
704
|
+
# ancestor half is the one that does work no component can do for
|
|
705
|
+
# itself, so a container that forgets to zero its children leaves them
|
|
706
|
+
# inert rather than painting them at stale coordinates
|
|
707
|
+
# (`D_empty_ancestor`).
|
|
708
|
+
#
|
|
709
|
+
# {Component#visible?} rides the same walk, one more term in the same
|
|
710
|
+
# AND — a different question (geometry says *where*, the flag says
|
|
711
|
+
# *whether*) with the same answer for this frame (`D_visibility`).
|
|
712
|
+
@invalidated.delete_if do |c|
|
|
713
|
+
next true unless c.attached?
|
|
714
|
+
|
|
715
|
+
blocked = c
|
|
716
|
+
blocked = blocked.parent while blocked && !blocked.rect.empty? && blocked.visible?
|
|
717
|
+
!blocked.nil?
|
|
718
|
+
end
|
|
603
719
|
break if @invalidated.empty?
|
|
604
720
|
|
|
605
721
|
did_paint = true
|
|
@@ -624,12 +740,22 @@ module Tuile
|
|
|
624
740
|
tiled_invalidated = true
|
|
625
741
|
end
|
|
626
742
|
|
|
627
|
-
# Popups on top:
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
743
|
+
# Popups on top: a layer repaints whole whenever anything *beneath* it —
|
|
744
|
+
# the tiled tree, or a lower popup — repaints, else just its invalidated
|
|
745
|
+
# members. Layer-by-layer rather than one tiled_invalidated bool because
|
|
746
|
+
# the drain loops: a lower popup's repaint cascade re-invalidates
|
|
747
|
+
# children into the *next* iteration (its gap-clearing Layout wipes and
|
|
748
|
+
# re-queues a button, say), and that iteration has no tiled repaint —
|
|
749
|
+
# only the full re-assert of every layer above keeps the stacking order
|
|
750
|
+
# true across iterations (screen_spec pins it with two overlapping
|
|
751
|
+
# popups). Overdraw into the buffer is free (only net-visible cell
|
|
752
|
+
# changes reach the terminal), so reasserting layers is cheap.
|
|
753
|
+
below_repainted = tiled_invalidated
|
|
631
754
|
popups.each do |p|
|
|
632
|
-
|
|
755
|
+
layer_invalidated = false
|
|
756
|
+
p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
|
|
757
|
+
p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
|
|
758
|
+
below_repainted ||= layer_invalidated
|
|
633
759
|
end
|
|
634
760
|
|
|
635
761
|
@repainting = repaint.to_set
|
|
@@ -654,25 +780,93 @@ module Tuile
|
|
|
654
780
|
|
|
655
781
|
private
|
|
656
782
|
|
|
657
|
-
#
|
|
658
|
-
#
|
|
659
|
-
#
|
|
660
|
-
#
|
|
661
|
-
#
|
|
662
|
-
#
|
|
663
|
-
#
|
|
664
|
-
# @
|
|
665
|
-
|
|
666
|
-
|
|
783
|
+
# Whether `component` is out of the user's reach because it or an ancestor
|
|
784
|
+
# is {Component#visible? hidden}.
|
|
785
|
+
#
|
|
786
|
+
# Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
|
|
787
|
+
# declined a `Component#paintable?`: it reads as a component-level concept
|
|
788
|
+
# and is really this class's question. A *walk* needs no such predicate —
|
|
789
|
+
# it prunes at the hidden subtree's root ({Component#on_shown_tree}).
|
|
790
|
+
# @param component [Component]
|
|
791
|
+
# @return [Boolean]
|
|
792
|
+
def hidden?(component)
|
|
793
|
+
cursor = component
|
|
794
|
+
cursor = cursor.parent while cursor&.visible?
|
|
795
|
+
!cursor.nil?
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# The tail of {#focused=}: blur, then focus, then the app notice.
|
|
799
|
+
#
|
|
800
|
+
# A hook may reassign {#focused}; that nested call has already run this whole
|
|
801
|
+
# sequence for the target it chose, so this one stops rather than announcing
|
|
802
|
+
# a focus that no longer holds.
|
|
803
|
+
# @param previous [Component, nil] what held focus before the assignment.
|
|
804
|
+
# @param focused [Component, nil] what the assignment asked for.
|
|
805
|
+
# @return [void]
|
|
806
|
+
def fire_focus_hooks(previous, focused)
|
|
807
|
+
unless focused.equal?(previous)
|
|
808
|
+
previous&.__send__(:on_blur)
|
|
809
|
+
return unless @focused.equal?(focused)
|
|
810
|
+
end
|
|
811
|
+
@focused&.__send__(:on_focus)
|
|
812
|
+
@on_focus_changed&.call unless @focused.equal?(previous)
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# The startup background probe, seeding {#theme} and
|
|
816
|
+
# {#background_color}. Inconclusive detection means `:dark` with no
|
|
817
|
+
# color. Runs in the constructor — the OSC 11 reply arrives on stdin,
|
|
818
|
+
# which is only safe to read before {EventQueue#start_key_thread} owns
|
|
819
|
+
# it. {FakeScreen} overrides this to pin the result, keeping specs
|
|
820
|
+
# deterministic and off the test runner's TTY.
|
|
821
|
+
# @return [TerminalBackground::Result]
|
|
822
|
+
def detect_background
|
|
823
|
+
TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
|
|
667
824
|
end
|
|
668
825
|
|
|
826
|
+
# The startup color-depth probe, seeding {#color_depth}. Reads the
|
|
827
|
+
# environment only, so unlike {#detect_background} it has no timing
|
|
828
|
+
# constraint. {FakeScreen} overrides it to pin the result, keeping specs
|
|
829
|
+
# off whatever `COLORTERM` the test runner happens to carry.
|
|
830
|
+
# @return [Symbol]
|
|
831
|
+
def detect_color_depth = ColorDepth.detect
|
|
832
|
+
|
|
833
|
+
# The startup locale probe, seeding {#locale}. Shells out (see
|
|
834
|
+
# {Locale.system}), so unlike {#detect_background} it touches no terminal
|
|
835
|
+
# stream and has no timing constraint. {FakeScreen} overrides it to pin
|
|
836
|
+
# {Locale::ISO}, keeping specs off whatever `LC_TIME` the test runner
|
|
837
|
+
# happens to carry — and out of a subprocess per example.
|
|
838
|
+
# @return [Locale]
|
|
839
|
+
def detect_locale = Locale.system
|
|
840
|
+
|
|
669
841
|
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
670
|
-
# scheme
|
|
842
|
+
# scheme, apply the matching member of {#theme_def}, and re-probe for
|
|
843
|
+
# the new background RGB, which the report does not carry.
|
|
844
|
+
#
|
|
845
|
+
# The query goes out from *this* thread — the event-loop thread, which
|
|
846
|
+
# also owns {#emit} — so its bytes can never land inside a frame's
|
|
847
|
+
# synchronized-output batch. The reply comes back through the key
|
|
848
|
+
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
671
849
|
# @param scheme [Symbol] `:dark` or `:light`.
|
|
672
850
|
# @return [void]
|
|
673
851
|
def on_color_scheme(scheme)
|
|
674
852
|
@color_scheme = scheme
|
|
675
853
|
self.theme = @theme_def.for(@color_scheme)
|
|
854
|
+
print TerminalBackground::QUERY
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# The re-probe answered: adopt the color and restyle, since an app's
|
|
858
|
+
# background-derived tints are now a scheme behind. Deliberately keeps
|
|
859
|
+
# the previous color until the reply lands rather than blanking it on
|
|
860
|
+
# the flip — a terminal that reports mode-2031 flips but not OSC 11
|
|
861
|
+
# would otherwise lose the color it gave us at startup, permanently.
|
|
862
|
+
# @param color [Color]
|
|
863
|
+
# @return [void]
|
|
864
|
+
def on_background_color(color)
|
|
865
|
+
return if @background_color == color
|
|
866
|
+
|
|
867
|
+
@background_color = color
|
|
868
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
869
|
+
needs_full_repaint
|
|
676
870
|
end
|
|
677
871
|
|
|
678
872
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
@@ -688,7 +882,7 @@ module Tuile
|
|
|
688
882
|
return false if scope.nil?
|
|
689
883
|
|
|
690
884
|
stops = []
|
|
691
|
-
scope.
|
|
885
|
+
scope.on_shown_tree { |c| stops << c if c.tab_stop? }
|
|
692
886
|
return false if stops.empty?
|
|
693
887
|
|
|
694
888
|
idx = @focused.nil? ? nil : stops.index(@focused)
|
|
@@ -774,15 +968,15 @@ module Tuile
|
|
|
774
968
|
# @return [void]
|
|
775
969
|
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
776
970
|
|
|
777
|
-
# Delivers pasted text
|
|
971
|
+
# Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
|
|
778
972
|
#
|
|
779
|
-
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
780
|
-
#
|
|
781
|
-
#
|
|
782
|
-
# that replays it as keys, which
|
|
783
|
-
# 2004 exists to remove.
|
|
973
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
|
|
974
|
+
# Tab traversal and the global-shortcut registry entirely, goes straight to
|
|
975
|
+
# delivery, and does not bubble to ancestors the way a key does. Unhandled
|
|
976
|
+
# text is dropped — there is no fallback that replays it as keys, which
|
|
977
|
+
# would put back the very ambiguity mode 2004 exists to remove.
|
|
784
978
|
# @param text [String]
|
|
785
|
-
# @return [Boolean] true if
|
|
979
|
+
# @return [Boolean] true if the focused component consumed it.
|
|
786
980
|
def handle_paste(text) = @pane.handle_paste(text)
|
|
787
981
|
|
|
788
982
|
# @return [void]
|
|
@@ -802,6 +996,8 @@ module Tuile
|
|
|
802
996
|
layout
|
|
803
997
|
when EventQueue::ColorSchemeEvent
|
|
804
998
|
on_color_scheme(event.scheme)
|
|
999
|
+
when EventQueue::BackgroundColorEvent
|
|
1000
|
+
on_background_color(event.color)
|
|
805
1001
|
when EventQueue::EmptyQueueEvent
|
|
806
1002
|
repaint
|
|
807
1003
|
when Proc
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Tuile
|
|
|
13
13
|
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
14
14
|
# {#content} gets the full pane rect, and an app that wants a status line
|
|
15
15
|
# builds one into its own layout and drives it from
|
|
16
|
-
# {Screen#on_focus_changed=} (`
|
|
16
|
+
# {Screen#on_focus_changed=} (`D_status_bar`).
|
|
17
17
|
#
|
|
18
18
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
19
19
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -31,9 +31,10 @@ module Tuile
|
|
|
31
31
|
|
|
32
32
|
# @return [Component, nil] the tiled content component.
|
|
33
33
|
attr_reader :content
|
|
34
|
-
# @return [Array<Component>]
|
|
35
|
-
# topmost. Holds both
|
|
36
|
-
# ({Component::
|
|
34
|
+
# @return [Array<Component::Overlay>] the open overlays in stacking order;
|
|
35
|
+
# last is topmost. Holds both {Component::Popup} modals and bare
|
|
36
|
+
# {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
|
|
37
|
+
# be mutated by callers.
|
|
37
38
|
attr_reader :popups
|
|
38
39
|
|
|
39
40
|
def focusable? = false
|
|
@@ -54,21 +55,21 @@ module Tuile
|
|
|
54
55
|
layout
|
|
55
56
|
end
|
|
56
57
|
|
|
57
|
-
# Adds
|
|
58
|
-
# and grabs focus; a
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
58
|
+
# Adds an overlay and invalidates it for repaint. A {Component::Popup} is
|
|
59
|
+
# centered and grabs focus; a bare {Component::Overlay} is left wherever
|
|
60
|
+
# the caller positioned it and does *not* take focus, so the component that
|
|
61
|
+
# was focused keeps the cursor and keeps receiving keys — the overlay
|
|
62
|
+
# floats above the content, driven from app code.
|
|
62
63
|
#
|
|
63
|
-
# The *whole subtree* is invalidated, not just the
|
|
64
|
+
# The *whole subtree* is invalidated, not just the overlay wrapper (which
|
|
64
65
|
# paints nothing on its own): a reopened popup may land on cells that the
|
|
65
66
|
# tiled content has since overpainted, and if its rect is unchanged from
|
|
66
67
|
# last time its content components won't re-invalidate themselves — so
|
|
67
|
-
# without this the
|
|
68
|
-
# @param window [Component::
|
|
68
|
+
# without this the overlay's contents would stay blank on reopen.
|
|
69
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
69
70
|
# @return [void]
|
|
70
71
|
def add_popup(window)
|
|
71
|
-
raise TypeError, "expected
|
|
72
|
+
raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
|
|
72
73
|
raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
|
|
73
74
|
|
|
74
75
|
@popup_prior_focus[window] = screen.focused
|
|
@@ -123,11 +124,11 @@ module Tuile
|
|
|
123
124
|
# @return [Boolean] true if this pane currently hosts the popup.
|
|
124
125
|
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
125
126
|
|
|
126
|
-
# @return [Component::Popup, nil] the topmost
|
|
127
|
-
# only
|
|
127
|
+
# @return [Component::Popup, nil] the topmost modal overlay, or nil when
|
|
128
|
+
# only bare {Component::Overlay}s (or none) are open. This is the "modal
|
|
128
129
|
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
129
|
-
# confines Tab cycling.
|
|
130
|
-
#
|
|
130
|
+
# confines Tab cycling. Bare overlays are excluded — they float above the
|
|
131
|
+
# content without capturing input.
|
|
131
132
|
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
132
133
|
|
|
133
134
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -139,7 +140,7 @@ module Tuile
|
|
|
139
140
|
end
|
|
140
141
|
|
|
141
142
|
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
142
|
-
# itself. Each popup re-resolves its {Component::Popup#
|
|
143
|
+
# itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
|
|
143
144
|
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
144
145
|
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
145
146
|
# keep the top-left their owner assigned).
|
|
@@ -179,11 +180,15 @@ module Tuile
|
|
|
179
180
|
bubble_key(key, scope)
|
|
180
181
|
end
|
|
181
182
|
|
|
182
|
-
# Delivers pasted text
|
|
183
|
-
#
|
|
184
|
-
#
|
|
183
|
+
# Delivers pasted text to {Screen#focused} — and to nobody else.
|
|
184
|
+
#
|
|
185
|
+
# Scoped exactly like {#handle_key} (focus that is nil or sits outside the
|
|
186
|
+
# modal scope receives nothing, which is what keeps a popup modal) but
|
|
187
|
+
# **not bubbled**: an ancestor is never offered a paste its descendant
|
|
188
|
+
# declined, and unhandled text is dropped. Why keys bubble and pastes
|
|
189
|
+
# don't: `D_bracketed_paste`.
|
|
185
190
|
# @param text [String]
|
|
186
|
-
# @return [Boolean] true if the
|
|
191
|
+
# @return [Boolean] true if the focused component consumed it.
|
|
187
192
|
def handle_paste(text)
|
|
188
193
|
scope = modal_popup || @content
|
|
189
194
|
return false if scope.nil?
|
|
@@ -191,8 +196,7 @@ module Tuile
|
|
|
191
196
|
chain = focus_chain(scope)
|
|
192
197
|
return false if chain.nil?
|
|
193
198
|
|
|
194
|
-
chain.
|
|
195
|
-
false
|
|
199
|
+
chain.first.handle_paste(text)
|
|
196
200
|
end
|
|
197
201
|
|
|
198
202
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
@@ -203,12 +207,12 @@ module Tuile
|
|
|
203
207
|
# content beneath.
|
|
204
208
|
#
|
|
205
209
|
# A left click also *dismisses* the open popups it landed outside of that
|
|
206
|
-
# asked for it ({Component::
|
|
210
|
+
# asked for it ({Component::Overlay#close_on_outside_click?}). That is a
|
|
207
211
|
# second thing happening on a click, but not a second dispatch: the click is
|
|
208
212
|
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
209
213
|
# closed rather than told.
|
|
210
214
|
#
|
|
211
|
-
# "Outside" is measured against the {Component::
|
|
215
|
+
# "Outside" is measured against the {Component::Overlay#owner} chain, not
|
|
212
216
|
# against one rect and not against stacking order: the popup the click hit
|
|
213
217
|
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
214
218
|
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
@@ -278,29 +282,30 @@ module Tuile
|
|
|
278
282
|
|
|
279
283
|
private
|
|
280
284
|
|
|
281
|
-
# The
|
|
282
|
-
#
|
|
283
|
-
# is any component, so it is resolved to the
|
|
284
|
-
# resolves to itself) — which keeps the
|
|
285
|
-
# rather than one frozen when the overlay
|
|
286
|
-
# a mis-wired cycle terminate instead of
|
|
287
|
-
#
|
|
288
|
-
# @
|
|
285
|
+
# The overlays a click counts as landing *inside*: the one it hit, plus
|
|
286
|
+
# every overlay that one belongs to, up the {Component::Overlay#owner}
|
|
287
|
+
# chain. An owner is any component, so it is resolved to the overlay
|
|
288
|
+
# enclosing it (an overlay resolves to itself) — which keeps the
|
|
289
|
+
# relationship a live tree question rather than one frozen when the overlay
|
|
290
|
+
# opened. The `include?` guard makes a mis-wired cycle terminate instead of
|
|
291
|
+
# hanging the UI thread.
|
|
292
|
+
# @param hit [Component::Overlay, nil] the overlay the click landed in, if any.
|
|
293
|
+
# @return [Array<Component::Overlay>]
|
|
289
294
|
def kept_by(hit)
|
|
290
295
|
kept = []
|
|
291
|
-
|
|
292
|
-
while
|
|
293
|
-
kept <<
|
|
294
|
-
|
|
296
|
+
overlay = hit
|
|
297
|
+
while overlay && !kept.include?(overlay)
|
|
298
|
+
kept << overlay
|
|
299
|
+
overlay = enclosing_popup(overlay.owner)
|
|
295
300
|
end
|
|
296
301
|
kept
|
|
297
302
|
end
|
|
298
303
|
|
|
299
304
|
# @param component [Component, nil]
|
|
300
|
-
# @return [Component::
|
|
301
|
-
# else the nearest
|
|
305
|
+
# @return [Component::Overlay, nil] `component` itself when it is an
|
|
306
|
+
# overlay, else the nearest overlay above it, else nil.
|
|
302
307
|
def enclosing_popup(component)
|
|
303
|
-
component = component.parent until component.nil? || component.is_a?(Component::
|
|
308
|
+
component = component.parent until component.nil? || component.is_a?(Component::Overlay)
|
|
304
309
|
component
|
|
305
310
|
end
|
|
306
311
|
|
|
@@ -338,13 +343,14 @@ module Tuile
|
|
|
338
343
|
|
|
339
344
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
340
345
|
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
341
|
-
# if `root` is `nil
|
|
346
|
+
# if `root` is `nil`, or if `root` is itself hidden — there is nothing in
|
|
347
|
+
# there to focus, so the caller falls through to its next candidate.
|
|
342
348
|
# @param root [Component, nil]
|
|
343
349
|
# @return [Component, nil]
|
|
344
350
|
def first_tab_stop_or_root(root)
|
|
345
|
-
return nil if root.nil?
|
|
351
|
+
return nil if root.nil? || !root.visible?
|
|
346
352
|
|
|
347
|
-
root.
|
|
353
|
+
root.on_shown_tree { |c| return c if c.tab_stop? }
|
|
348
354
|
root
|
|
349
355
|
end
|
|
350
356
|
end
|