tuile 0.14.0 → 0.16.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 +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -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 +32 -13
- 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/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- 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 +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/screen.rb
CHANGED
|
@@ -75,13 +75,19 @@ module Tuile
|
|
|
75
75
|
@color_scheme = background.scheme
|
|
76
76
|
@background_color = background.color
|
|
77
77
|
@color_depth = detect_color_depth
|
|
78
|
+
@locale = detect_locale
|
|
78
79
|
@theme_def = ThemeDef.default
|
|
79
80
|
@theme = @theme_def.for(@color_scheme)
|
|
80
81
|
# Structural root of the component tree: holds tiled content and the
|
|
81
|
-
# 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.
|
|
82
86
|
@pane = ScreenPane.new
|
|
87
|
+
@pane.rect = Rect.new(0, 0, @size.width, @size.height)
|
|
88
|
+
@mouse_router = Mouse::Router.new(self)
|
|
83
89
|
@on_error = ->(e) { raise e }
|
|
84
|
-
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
90
|
+
# App-level keyboard shortcuts dispatched by {#handle_key?} before keys
|
|
85
91
|
# reach the pane. See {#register_global_shortcut}.
|
|
86
92
|
@global_shortcuts = {}
|
|
87
93
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
@@ -102,8 +108,8 @@ module Tuile
|
|
|
102
108
|
# {Component::TextArea}'s newline, a caret move, a deletion. `ENTER` is the
|
|
103
109
|
# trap worth naming: it is unprintable, so nothing else stops it, and
|
|
104
110
|
# "bind Enter to submit" is the obvious wrong way to build a default
|
|
105
|
-
# button. The right way is a `handle_key
|
|
106
|
-
# focused field still gets first refusal — see {ScreenPane#handle_key}.
|
|
111
|
+
# button. The right way is a `handle_key?` on the form itself, where a
|
|
112
|
+
# focused field still gets first refusal — see {ScreenPane#handle_key?}.
|
|
107
113
|
#
|
|
108
114
|
# Deliberately *not* reserved: `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN`. They
|
|
109
115
|
# move within a widget rather than mutate its value, and binding them
|
|
@@ -163,6 +169,12 @@ module Tuile
|
|
|
163
169
|
@@instance
|
|
164
170
|
end
|
|
165
171
|
|
|
172
|
+
# Whether {.instance} would answer rather than raise — for code that must
|
|
173
|
+
# work with no screen in the process at all, which a detached component
|
|
174
|
+
# tree legitimately is (`Component#locale` is the one caller in the gem).
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
def self.instance? = !@@instance.nil?
|
|
177
|
+
|
|
166
178
|
# @return [Component, nil] tiled content (forwarded to {ScreenPane}).
|
|
167
179
|
def content = @pane.content
|
|
168
180
|
|
|
@@ -212,7 +224,7 @@ module Tuile
|
|
|
212
224
|
# Kept current across OS appearance flips, a frame behind: the flip
|
|
213
225
|
# report carries light/dark only, so the screen re-probes and this
|
|
214
226
|
# updates when the reply lands. A changed color then fires
|
|
215
|
-
# {Component#
|
|
227
|
+
# {Component#handle_theme_changed} across the tree exactly as a theme swap
|
|
216
228
|
# does — a background-derived tint *is* a theme-derived color.
|
|
217
229
|
# @return [Color, nil]
|
|
218
230
|
attr_reader :background_color
|
|
@@ -232,7 +244,7 @@ module Tuile
|
|
|
232
244
|
end
|
|
233
245
|
|
|
234
246
|
# Replaces the theme and restyles the whole UI: fires
|
|
235
|
-
# {Component#
|
|
247
|
+
# {Component#handle_theme_changed} across the attached tree and invalidates
|
|
236
248
|
# every attached component. No-op when `new_theme` equals the current theme.
|
|
237
249
|
# This is a *transient* override — the next OS appearance flip re-picks from
|
|
238
250
|
# {#theme_def}; assign {#theme_def=} for durable theming.
|
|
@@ -245,9 +257,44 @@ module Tuile
|
|
|
245
257
|
return if @theme == new_theme
|
|
246
258
|
|
|
247
259
|
@theme = new_theme
|
|
248
|
-
# `__send__`, not `&:
|
|
260
|
+
# `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
|
|
249
261
|
# subclass may narrow it further (`D_hook_visibility`).
|
|
250
|
-
@pane&.
|
|
262
|
+
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
263
|
+
needs_full_repaint
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The formatting conventions this session renders and parses by — date
|
|
267
|
+
# formats, the calendar, month and weekday names, the decimal separator.
|
|
268
|
+
# Detected once at construction from {Locale.system}; {Locale::ISO} when
|
|
269
|
+
# the environment says nothing.
|
|
270
|
+
#
|
|
271
|
+
# Read it at paint or parse time and never cache it in an ivar, for the
|
|
272
|
+
# same reason {#theme} says so: {#locale=} can replace it mid-session.
|
|
273
|
+
# @return [Locale]
|
|
274
|
+
attr_reader :locale
|
|
275
|
+
|
|
276
|
+
# Replaces the locale: fires {Component#handle_locale_changed} across the
|
|
277
|
+
# attached tree and invalidates every attached component. No-op when
|
|
278
|
+
# `new_locale` equals the current one.
|
|
279
|
+
#
|
|
280
|
+
# screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"])
|
|
281
|
+
#
|
|
282
|
+
# Note this can invalidate what a user has half-typed: a field's buffer
|
|
283
|
+
# reparses under the new grammar, and one that no longer parses reads as
|
|
284
|
+
# bad input.
|
|
285
|
+
# @param new_locale [Locale]
|
|
286
|
+
# @return [void]
|
|
287
|
+
# @raise [TypeError] unless `new_locale` is a {Locale}.
|
|
288
|
+
def locale=(new_locale)
|
|
289
|
+
raise TypeError, "expected Locale, got #{new_locale.inspect}" unless new_locale.is_a?(Locale)
|
|
290
|
+
|
|
291
|
+
check_locked
|
|
292
|
+
return if @locale == new_locale
|
|
293
|
+
|
|
294
|
+
@locale = new_locale
|
|
295
|
+
# `__send__` for the same reason `theme=` uses it: the hook is protected
|
|
296
|
+
# (`D_hook_visibility`).
|
|
297
|
+
@pane&.walk_tree { _1.__send__(:handle_locale_changed) }
|
|
251
298
|
needs_full_repaint
|
|
252
299
|
end
|
|
253
300
|
|
|
@@ -281,7 +328,7 @@ module Tuile
|
|
|
281
328
|
# @return [void]
|
|
282
329
|
def check_locked
|
|
283
330
|
raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
|
|
284
|
-
return if @event_queue.running? ? @event_queue.
|
|
331
|
+
return if @event_queue.running? ? @event_queue.in_loop_thread? : Thread.current.equal?(@ui_thread)
|
|
285
332
|
|
|
286
333
|
# `submit` is the wrong remedy with no loop running — nothing would drain
|
|
287
334
|
# the queue, so the block silently never fires.
|
|
@@ -319,6 +366,15 @@ module Tuile
|
|
|
319
366
|
# Sets the focused {Component}. Focused component receives keyboard events.
|
|
320
367
|
# All focusable components live under {#pane}, so this is a single uniform
|
|
321
368
|
# path (no separate popup-vs-content branches).
|
|
369
|
+
#
|
|
370
|
+
# A hidden target — or one under a hidden ancestor — raises, exactly as a
|
|
371
|
+
# detached one does: focusing it would park the hardware cursor inside
|
|
372
|
+
# whatever is painted over it and feed it every keystroke.
|
|
373
|
+
#
|
|
374
|
+
# Once the pointer and the active flags are settled, three notices fire in
|
|
375
|
+
# order: {Component#handle_blur} on what lost focus, {Component#handle_focus} on
|
|
376
|
+
# what took it, then {#on_focus_changed}. The outer two are edge-triggered
|
|
377
|
+
# and `handle_focus` is not — see there.
|
|
322
378
|
# @param focused [Component, nil] the new component to be focused.
|
|
323
379
|
def focused=(focused)
|
|
324
380
|
unless focused.nil? || focused.is_a?(Component)
|
|
@@ -329,9 +385,10 @@ module Tuile
|
|
|
329
385
|
previous = @focused
|
|
330
386
|
if focused.nil?
|
|
331
387
|
@focused = nil
|
|
332
|
-
@pane.
|
|
388
|
+
@pane.walk_tree { _1.active = false }
|
|
333
389
|
else
|
|
334
390
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
391
|
+
raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
|
|
335
392
|
|
|
336
393
|
@focused = focused
|
|
337
394
|
active = Set[focused]
|
|
@@ -340,10 +397,9 @@ module Tuile
|
|
|
340
397
|
active << cursor
|
|
341
398
|
cursor = cursor.parent
|
|
342
399
|
end
|
|
343
|
-
@pane.
|
|
344
|
-
@focused.on_focus
|
|
400
|
+
@pane.walk_tree { _1.active = active.include?(_1) }
|
|
345
401
|
end
|
|
346
|
-
|
|
402
|
+
fire_focus_hooks(previous, focused)
|
|
347
403
|
end
|
|
348
404
|
|
|
349
405
|
# Called after the focused component *changes* — including to and from
|
|
@@ -357,17 +413,17 @@ module Tuile
|
|
|
357
413
|
#
|
|
358
414
|
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
359
415
|
#
|
|
360
|
-
# **Edge-triggered**, like {Component#
|
|
416
|
+
# **Edge-triggered**, like {Component#handle_attached}: re-assigning the
|
|
361
417
|
# component that already has focus fires nothing, so a callback can be as
|
|
362
418
|
# expensive as rebuilding a hint string without a `did it really change?`
|
|
363
419
|
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
364
420
|
# clears focus on every content swap, which on a level-triggered hook would
|
|
365
421
|
# fire a nil→nil notification during assembly.
|
|
366
422
|
#
|
|
367
|
-
# It runs *after* the active-flag cascade and `
|
|
423
|
+
# It runs *after* the active-flag cascade and `handle_focus`, so the tree is
|
|
368
424
|
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
369
425
|
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
370
|
-
# {Component#
|
|
426
|
+
# {Component#handle_detached}. A raising callback propagates out of {#focused=}
|
|
371
427
|
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
372
428
|
# @return [Proc, nil]
|
|
373
429
|
attr_accessor :on_focus_changed
|
|
@@ -385,20 +441,25 @@ module Tuile
|
|
|
385
441
|
end
|
|
386
442
|
|
|
387
443
|
# Runs the event loop on the calling thread, taking over stdin (raw mode,
|
|
388
|
-
# echo off): keys and mouse events are dispatched via {#handle_key} /
|
|
444
|
+
# echo off): keys and mouse events are dispatched via {#handle_key?} /
|
|
389
445
|
# {#handle_mouse}, and the loop repaints once per drained tick. Returns
|
|
390
446
|
# when `q` or ESC is pressed unhandled. Restores terminal state on exit.
|
|
391
447
|
#
|
|
392
448
|
# For the duration this thread owns the UI ({#state} is `:running`);
|
|
393
449
|
# ownership reverts to the creating thread once it returns.
|
|
394
450
|
#
|
|
395
|
-
# @param capture_mouse [Boolean]
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
451
|
+
# @param capture_mouse [Boolean, Symbol] how much mouse tracking to ask the
|
|
452
|
+
# terminal for — each level unlocking one tier of {Mouse}'s events, and
|
|
453
|
+
# costing what that tier reports:
|
|
454
|
+
#
|
|
455
|
+
# - `false` — none. The terminal keeps its native click handling, which is
|
|
456
|
+
# what you want if the app benefits more from select-to-copy.
|
|
457
|
+
# - `true` (default), `:clicks` — presses, releases and the wheel.
|
|
458
|
+
# - `:drag` — plus motion while a button is held, as
|
|
459
|
+
# {Component#handle_mouse_drag}.
|
|
460
|
+
# - `:hover` — plus motion with no button, as
|
|
461
|
+
# {Component#handle_mouse_move?} and the enter/exit hooks. ~84 reports a
|
|
462
|
+
# second (`R_mouse_reporting`), so ask for it only if something uses it.
|
|
402
463
|
# @param bracketed_paste [Boolean] when true (default), enables DEC private
|
|
403
464
|
# mode 2004 so pasted text arrives whole, as {Component#handle_paste},
|
|
404
465
|
# instead of as one keystroke per character — which is the only way a
|
|
@@ -407,16 +468,20 @@ module Tuile
|
|
|
407
468
|
# fires it once per pasted line. Turn it off only for a terminal that
|
|
408
469
|
# mishandles the mode.
|
|
409
470
|
# @raise [Tuile::Error] if the screen is already {#close}d.
|
|
471
|
+
# @raise [ArgumentError] on an unknown `capture_mouse:` level.
|
|
410
472
|
# @return [void]
|
|
411
473
|
def run_event_loop(capture_mouse: true, bracketed_paste: true)
|
|
412
474
|
raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
|
|
413
475
|
|
|
476
|
+
level = Mouse.level(capture_mouse)
|
|
477
|
+
|
|
414
478
|
# The guard above stays outside the begin: teardown for a setup that never
|
|
415
479
|
# happened restores echo on a non-TTY stdin, and the ENOTTY masks the
|
|
416
480
|
# real error.
|
|
417
481
|
begin
|
|
418
482
|
$stdin.echo = false
|
|
419
|
-
|
|
483
|
+
@mouse_router.level = level
|
|
484
|
+
print Mouse.start_tracking(level) if level
|
|
420
485
|
print Keys::BRACKETED_PASTE_ON if bracketed_paste
|
|
421
486
|
# Follow OS light/dark flips live: terminals supporting mode 2031
|
|
422
487
|
# push color-scheme reports that the key thread turns into
|
|
@@ -428,7 +493,9 @@ module Tuile
|
|
|
428
493
|
ensure
|
|
429
494
|
print TerminalBackground::NOTIFY_OFF
|
|
430
495
|
print Keys::BRACKETED_PASTE_OFF if bracketed_paste
|
|
431
|
-
print
|
|
496
|
+
print Mouse.stop_tracking(level) if level
|
|
497
|
+
# Back to delivering everything a spec posts once no loop owns the wire.
|
|
498
|
+
@mouse_router.level = :hover
|
|
432
499
|
print TTY::Cursor.show
|
|
433
500
|
$stdin.echo = true
|
|
434
501
|
end
|
|
@@ -455,9 +522,9 @@ module Tuile
|
|
|
455
522
|
#
|
|
456
523
|
# - **Printable keys** — they'd hijack typing into a
|
|
457
524
|
# {Component::TextField}. A scope-wide one-key binding belongs on the
|
|
458
|
-
# scope root's own `handle_key
|
|
459
|
-
# (see {ScreenPane#handle_key}).
|
|
460
|
-
# - **TAB / SHIFT_TAB** — {#handle_key} intercepts them for focus
|
|
525
|
+
# scope root's own `handle_key?`, where a focused field consumes it first
|
|
526
|
+
# (see {ScreenPane#handle_key?}).
|
|
527
|
+
# - **TAB / SHIFT_TAB** — {#handle_key?} intercepts them for focus
|
|
461
528
|
# navigation before the registry is consulted, so a binding would never
|
|
462
529
|
# fire.
|
|
463
530
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
@@ -480,7 +547,7 @@ module Tuile
|
|
|
480
547
|
if Keys.printable?(key)
|
|
481
548
|
raise ArgumentError,
|
|
482
549
|
"global shortcut key must be unprintable; got #{key.inspect}. " \
|
|
483
|
-
"For a one-key binding, override handle_key on the scope root " \
|
|
550
|
+
"For a one-key binding, override handle_key? on the scope root " \
|
|
484
551
|
"(your content layout, or the popup) — a focused text field then " \
|
|
485
552
|
"consumes the key first, so typing isn't hijacked."
|
|
486
553
|
end
|
|
@@ -492,7 +559,7 @@ module Tuile
|
|
|
492
559
|
raise ArgumentError,
|
|
493
560
|
"#{key.inspect} is reserved: every editable widget needs it, and this registry " \
|
|
494
561
|
"sits above the component tree with nothing to suppress it. For a default " \
|
|
495
|
-
"button, handle ENTER in the form's own handle_key instead — a focused " \
|
|
562
|
+
"button, handle ENTER in the form's own handle_key? instead — a focused " \
|
|
496
563
|
"TextArea/TextField gets first refusal there."
|
|
497
564
|
end
|
|
498
565
|
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
|
|
@@ -531,7 +598,7 @@ module Tuile
|
|
|
531
598
|
# @api private
|
|
532
599
|
# @return [void]
|
|
533
600
|
def needs_full_repaint
|
|
534
|
-
@pane&.
|
|
601
|
+
@pane&.walk_tree { invalidate _1 }
|
|
535
602
|
end
|
|
536
603
|
|
|
537
604
|
# Internal — use {Component::Overlay#open?} instead.
|
|
@@ -552,7 +619,7 @@ module Tuile
|
|
|
552
619
|
|
|
553
620
|
# Tears the screen down and vacates the singleton slot, moving {#state} to
|
|
554
621
|
# the terminal `:closed`. Unmounts the tree first, so every component gets
|
|
555
|
-
# its {Component#
|
|
622
|
+
# its {Component#handle_detached}. Idempotent.
|
|
556
623
|
# @raise [Tuile::Error] if an event loop is still running — stop it with
|
|
557
624
|
# `event_queue.stop` and let {#run_event_loop} return first, since closing
|
|
558
625
|
# under a live loop drops the pane it is still painting — or if the caller
|
|
@@ -567,11 +634,12 @@ module Tuile
|
|
|
567
634
|
begin
|
|
568
635
|
@pane.detach_all
|
|
569
636
|
ensure
|
|
570
|
-
# A raising
|
|
637
|
+
# A raising handle_detached propagates — it's a bug to fix, not something the
|
|
571
638
|
# framework guards — but teardown still has to finish, or one such bug
|
|
572
639
|
# leaves a half-closed screen behind and every later example fails with it.
|
|
573
640
|
clear
|
|
574
641
|
@pane = nil
|
|
642
|
+
@mouse_router = nil
|
|
575
643
|
@closed = true
|
|
576
644
|
@@instance = nil # rubocop:disable Style/ClassVars
|
|
577
645
|
end
|
|
@@ -627,6 +695,9 @@ module Tuile
|
|
|
627
695
|
# @return [void]
|
|
628
696
|
def repaint
|
|
629
697
|
check_locked
|
|
698
|
+
# The one site that runs after every mutation, so a component hidden,
|
|
699
|
+
# detached or reparented while hovered gets its exit exactly once.
|
|
700
|
+
@mouse_router.sync_hover
|
|
630
701
|
# This simple TUI framework doesn't support window clipping since tiled
|
|
631
702
|
# windows are not expected to overlap. If there rarely is a popup, we
|
|
632
703
|
# just repaint all windows in correct order — sure they will paint over
|
|
@@ -636,12 +707,31 @@ module Tuile
|
|
|
636
707
|
|
|
637
708
|
did_paint = false
|
|
638
709
|
until @invalidated.empty?
|
|
639
|
-
# Defensive filter
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
644
|
-
|
|
710
|
+
# Defensive filter, two ways a queued component has no place on the
|
|
711
|
+
# screen by the time we drain it. It became *detached* since (popup
|
|
712
|
+
# close, sibling removed mid-event-handling, focus repair) — which
|
|
713
|
+
# Component#invalidate gates at enqueue, so this catches only a change
|
|
714
|
+
# since. Or it sits under an *empty rect*, its own or any ancestor's,
|
|
715
|
+
# which is how a subtree is collapsed.
|
|
716
|
+
#
|
|
717
|
+
# The self half is belt-and-braces — every #repaint opens with the same
|
|
718
|
+
# `return if rect.empty?`, and that stays: it is each component's own
|
|
719
|
+
# contract, local to the method an app subclass calls `super` on. The
|
|
720
|
+
# ancestor half is the one that does work no component can do for
|
|
721
|
+
# itself, so a container that forgets to zero its children leaves them
|
|
722
|
+
# inert rather than painting them at stale coordinates
|
|
723
|
+
# (`D_empty_ancestor`).
|
|
724
|
+
#
|
|
725
|
+
# {Component#visible?} rides the same walk, one more term in the same
|
|
726
|
+
# AND — a different question (geometry says *where*, the flag says
|
|
727
|
+
# *whether*) with the same answer for this frame (`D_visibility`).
|
|
728
|
+
@invalidated.delete_if do |c|
|
|
729
|
+
next true unless c.attached?
|
|
730
|
+
|
|
731
|
+
blocked = c
|
|
732
|
+
blocked = blocked.parent while blocked && !blocked.rect.empty? && blocked.visible?
|
|
733
|
+
!blocked.nil?
|
|
734
|
+
end
|
|
645
735
|
break if @invalidated.empty?
|
|
646
736
|
|
|
647
737
|
did_paint = true
|
|
@@ -651,14 +741,14 @@ module Tuile
|
|
|
651
741
|
# than a depth sort. The pane's pre-order traversal already orders the
|
|
652
742
|
# tiled layer (the content subtree) parent-before-child; the popups are
|
|
653
743
|
# the top layer and must paint last, so we collect the tiled layer first
|
|
654
|
-
# and append popups rather than taking a single pane.
|
|
744
|
+
# and append popups rather than taking a single pane.walk_tree walk.
|
|
655
745
|
popup_members = Set.new
|
|
656
|
-
popups.each { |p| p.
|
|
746
|
+
popups.each { |p| p.walk_tree { popup_members << _1 } }
|
|
657
747
|
|
|
658
748
|
# Tiled layer: invalidated non-popup components, in tree order.
|
|
659
749
|
repaint = []
|
|
660
750
|
tiled_invalidated = false
|
|
661
|
-
@pane.
|
|
751
|
+
@pane.walk_tree do |c|
|
|
662
752
|
next if popup_members.include?(c)
|
|
663
753
|
next unless @invalidated.include?(c)
|
|
664
754
|
|
|
@@ -679,8 +769,8 @@ module Tuile
|
|
|
679
769
|
below_repainted = tiled_invalidated
|
|
680
770
|
popups.each do |p|
|
|
681
771
|
layer_invalidated = false
|
|
682
|
-
p.
|
|
683
|
-
p.
|
|
772
|
+
p.walk_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
|
|
773
|
+
p.walk_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
|
|
684
774
|
below_repainted ||= layer_invalidated
|
|
685
775
|
end
|
|
686
776
|
|
|
@@ -704,8 +794,62 @@ module Tuile
|
|
|
704
794
|
# @return [Point, nil]
|
|
705
795
|
def cursor_position = @focused&.cursor_position
|
|
706
796
|
|
|
797
|
+
# Routes one mouse event into the tree ({Mouse::Router}) — what the event
|
|
798
|
+
# loop does with every report the terminal sends, and how a spec drives the
|
|
799
|
+
# mouse:
|
|
800
|
+
#
|
|
801
|
+
# screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2))
|
|
802
|
+
#
|
|
803
|
+
# @param event [Mouse::Event]
|
|
804
|
+
# @return [void]
|
|
805
|
+
def handle_mouse(event)
|
|
806
|
+
check_locked
|
|
807
|
+
@mouse_router.dispatch(event)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# @return [Component, nil] the innermost component the pointer is over, as
|
|
811
|
+
# of the last move. Always nil below `capture_mouse: :hover`, and frozen
|
|
812
|
+
# at its last value while a press is grabbed.
|
|
813
|
+
def hovered = @mouse_router&.hovered
|
|
814
|
+
|
|
815
|
+
# @return [Component, nil] the component holding the mouse grab — the one
|
|
816
|
+
# whose {Component#handle_mouse_down?} claimed the press still held.
|
|
817
|
+
def grabbed = @mouse_router&.grabbed
|
|
818
|
+
|
|
707
819
|
private
|
|
708
820
|
|
|
821
|
+
# Whether `component` is out of the user's reach because it or an ancestor
|
|
822
|
+
# is {Component#visible? hidden}.
|
|
823
|
+
#
|
|
824
|
+
# Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
|
|
825
|
+
# declined a `Component#paintable?`: it reads as a component-level concept
|
|
826
|
+
# and is really this class's question. A *walk* needs no such predicate —
|
|
827
|
+
# it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
|
|
828
|
+
# @param component [Component]
|
|
829
|
+
# @return [Boolean]
|
|
830
|
+
def hidden?(component)
|
|
831
|
+
cursor = component
|
|
832
|
+
cursor = cursor.parent while cursor&.visible?
|
|
833
|
+
!cursor.nil?
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# The tail of {#focused=}: blur, then focus, then the app notice.
|
|
837
|
+
#
|
|
838
|
+
# A hook may reassign {#focused}; that nested call has already run this whole
|
|
839
|
+
# sequence for the target it chose, so this one stops rather than announcing
|
|
840
|
+
# a focus that no longer holds.
|
|
841
|
+
# @param previous [Component, nil] what held focus before the assignment.
|
|
842
|
+
# @param focused [Component, nil] what the assignment asked for.
|
|
843
|
+
# @return [void]
|
|
844
|
+
def fire_focus_hooks(previous, focused)
|
|
845
|
+
unless focused.equal?(previous)
|
|
846
|
+
previous&.__send__(:handle_blur)
|
|
847
|
+
return unless @focused.equal?(focused)
|
|
848
|
+
end
|
|
849
|
+
@focused&.__send__(:handle_focus)
|
|
850
|
+
@on_focus_changed&.call unless @focused.equal?(previous)
|
|
851
|
+
end
|
|
852
|
+
|
|
709
853
|
# The startup background probe, seeding {#theme} and
|
|
710
854
|
# {#background_color}. Inconclusive detection means `:dark` with no
|
|
711
855
|
# color. Runs in the constructor — the OSC 11 reply arrives on stdin,
|
|
@@ -724,6 +868,14 @@ module Tuile
|
|
|
724
868
|
# @return [Symbol]
|
|
725
869
|
def detect_color_depth = ColorDepth.detect
|
|
726
870
|
|
|
871
|
+
# The startup locale probe, seeding {#locale}. Shells out (see
|
|
872
|
+
# {Locale.system}), so unlike {#detect_background} it touches no terminal
|
|
873
|
+
# stream and has no timing constraint. {FakeScreen} overrides it to pin
|
|
874
|
+
# {Locale::ISO}, keeping specs off whatever `LC_TIME` the test runner
|
|
875
|
+
# happens to carry — and out of a subprocess per example.
|
|
876
|
+
# @return [Locale]
|
|
877
|
+
def detect_locale = Locale.system
|
|
878
|
+
|
|
727
879
|
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
728
880
|
# scheme, apply the matching member of {#theme_def}, and re-probe for
|
|
729
881
|
# the new background RGB, which the report does not carry.
|
|
@@ -734,7 +886,7 @@ module Tuile
|
|
|
734
886
|
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
735
887
|
# @param scheme [Symbol] `:dark` or `:light`.
|
|
736
888
|
# @return [void]
|
|
737
|
-
def
|
|
889
|
+
def handle_color_scheme(scheme)
|
|
738
890
|
@color_scheme = scheme
|
|
739
891
|
self.theme = @theme_def.for(@color_scheme)
|
|
740
892
|
print TerminalBackground::QUERY
|
|
@@ -747,11 +899,11 @@ module Tuile
|
|
|
747
899
|
# would otherwise lose the color it gave us at startup, permanently.
|
|
748
900
|
# @param color [Color]
|
|
749
901
|
# @return [void]
|
|
750
|
-
def
|
|
902
|
+
def handle_background_color(color)
|
|
751
903
|
return if @background_color == color
|
|
752
904
|
|
|
753
905
|
@background_color = color
|
|
754
|
-
@pane&.
|
|
906
|
+
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
755
907
|
needs_full_repaint
|
|
756
908
|
end
|
|
757
909
|
|
|
@@ -768,7 +920,7 @@ module Tuile
|
|
|
768
920
|
return false if scope.nil?
|
|
769
921
|
|
|
770
922
|
stops = []
|
|
771
|
-
scope.
|
|
923
|
+
scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
|
|
772
924
|
return false if stops.empty?
|
|
773
925
|
|
|
774
926
|
idx = @focused.nil? ? nil : stops.index(@focused)
|
|
@@ -826,11 +978,14 @@ module Tuile
|
|
|
826
978
|
# default `over_popups: false` fires only when no modal popup is open
|
|
827
979
|
# (otherwise the modal popup receives the key normally). A non-modal
|
|
828
980
|
# overlay doesn't suppress global shortcuts.
|
|
829
|
-
# 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
|
|
981
|
+
# 3. {ScreenPane#handle_key?} — delivery to {#focused}, bubbling up the
|
|
830
982
|
# focus chain to the scope root.
|
|
831
983
|
# @param key [String]
|
|
832
984
|
# @return [Boolean] true if the key was handled by some window.
|
|
833
|
-
def handle_key(key)
|
|
985
|
+
def handle_key?(key)
|
|
986
|
+
# A key is one of the grab's releases: a terminal loses a release over ssh
|
|
987
|
+
# and tmux, and a stuck grab would swallow every later drag.
|
|
988
|
+
@mouse_router.release_grab
|
|
834
989
|
case key
|
|
835
990
|
when Keys::TAB
|
|
836
991
|
focus_next
|
|
@@ -844,25 +999,21 @@ module Tuile
|
|
|
844
999
|
shortcut.block.call
|
|
845
1000
|
true
|
|
846
1001
|
else
|
|
847
|
-
@pane.handle_key(key)
|
|
1002
|
+
@pane.handle_key?(key)
|
|
848
1003
|
end
|
|
849
1004
|
end
|
|
850
1005
|
end
|
|
851
1006
|
|
|
852
|
-
#
|
|
853
|
-
# @param event [MouseEvent]
|
|
854
|
-
# @return [void]
|
|
855
|
-
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
856
|
-
|
|
857
|
-
# Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
|
|
1007
|
+
# Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
|
|
858
1008
|
#
|
|
859
|
-
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
# that replays it as keys, which
|
|
863
|
-
# 2004 exists to remove
|
|
1009
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
|
|
1010
|
+
# Tab traversal and the global-shortcut registry entirely, goes straight to
|
|
1011
|
+
# delivery, and does not bubble to ancestors the way a key does. Unhandled
|
|
1012
|
+
# text is dropped — there is no fallback that replays it as keys, which
|
|
1013
|
+
# would put back the very ambiguity mode 2004 exists to remove — and with no
|
|
1014
|
+
# alternative delivery, no verdict to carry either.
|
|
864
1015
|
# @param text [String]
|
|
865
|
-
# @return [
|
|
1016
|
+
# @return [void]
|
|
866
1017
|
def handle_paste(text) = @pane.handle_paste(text)
|
|
867
1018
|
|
|
868
1019
|
# @return [void]
|
|
@@ -871,19 +1022,19 @@ module Tuile
|
|
|
871
1022
|
case event
|
|
872
1023
|
when EventQueue::KeyEvent
|
|
873
1024
|
key = event.key
|
|
874
|
-
handled = handle_key(key)
|
|
1025
|
+
handled = handle_key?(key)
|
|
875
1026
|
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
876
1027
|
when EventQueue::PasteEvent
|
|
877
1028
|
handle_paste(event.text)
|
|
878
|
-
when
|
|
1029
|
+
when Mouse::Event
|
|
879
1030
|
handle_mouse(event)
|
|
880
1031
|
when EventQueue::TTYSizeEvent
|
|
881
1032
|
@size = event.size
|
|
882
1033
|
layout
|
|
883
1034
|
when EventQueue::ColorSchemeEvent
|
|
884
|
-
|
|
1035
|
+
handle_color_scheme(event.scheme)
|
|
885
1036
|
when EventQueue::BackgroundColorEvent
|
|
886
|
-
|
|
1037
|
+
handle_background_color(event.color)
|
|
887
1038
|
when EventQueue::EmptyQueueEvent
|
|
888
1039
|
repaint
|
|
889
1040
|
when Proc
|