tuile 0.15.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 +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -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 +9 -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 +6 -5
- data/lib/tuile/component/select.rb +11 -12
- 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 +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/screen.rb
CHANGED
|
@@ -85,8 +85,9 @@ module Tuile
|
|
|
85
85
|
# filter would take the whole tree with it.
|
|
86
86
|
@pane = ScreenPane.new
|
|
87
87
|
@pane.rect = Rect.new(0, 0, @size.width, @size.height)
|
|
88
|
+
@mouse_router = Mouse::Router.new(self)
|
|
88
89
|
@on_error = ->(e) { raise e }
|
|
89
|
-
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
90
|
+
# App-level keyboard shortcuts dispatched by {#handle_key?} before keys
|
|
90
91
|
# reach the pane. See {#register_global_shortcut}.
|
|
91
92
|
@global_shortcuts = {}
|
|
92
93
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
@@ -107,8 +108,8 @@ module Tuile
|
|
|
107
108
|
# {Component::TextArea}'s newline, a caret move, a deletion. `ENTER` is the
|
|
108
109
|
# trap worth naming: it is unprintable, so nothing else stops it, and
|
|
109
110
|
# "bind Enter to submit" is the obvious wrong way to build a default
|
|
110
|
-
# button. The right way is a `handle_key
|
|
111
|
-
# 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?}.
|
|
112
113
|
#
|
|
113
114
|
# Deliberately *not* reserved: `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN`. They
|
|
114
115
|
# move within a widget rather than mutate its value, and binding them
|
|
@@ -223,7 +224,7 @@ module Tuile
|
|
|
223
224
|
# Kept current across OS appearance flips, a frame behind: the flip
|
|
224
225
|
# report carries light/dark only, so the screen re-probes and this
|
|
225
226
|
# updates when the reply lands. A changed color then fires
|
|
226
|
-
# {Component#
|
|
227
|
+
# {Component#handle_theme_changed} across the tree exactly as a theme swap
|
|
227
228
|
# does — a background-derived tint *is* a theme-derived color.
|
|
228
229
|
# @return [Color, nil]
|
|
229
230
|
attr_reader :background_color
|
|
@@ -243,7 +244,7 @@ module Tuile
|
|
|
243
244
|
end
|
|
244
245
|
|
|
245
246
|
# Replaces the theme and restyles the whole UI: fires
|
|
246
|
-
# {Component#
|
|
247
|
+
# {Component#handle_theme_changed} across the attached tree and invalidates
|
|
247
248
|
# every attached component. No-op when `new_theme` equals the current theme.
|
|
248
249
|
# This is a *transient* override — the next OS appearance flip re-picks from
|
|
249
250
|
# {#theme_def}; assign {#theme_def=} for durable theming.
|
|
@@ -256,9 +257,9 @@ module Tuile
|
|
|
256
257
|
return if @theme == new_theme
|
|
257
258
|
|
|
258
259
|
@theme = new_theme
|
|
259
|
-
# `__send__`, not `&:
|
|
260
|
+
# `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
|
|
260
261
|
# subclass may narrow it further (`D_hook_visibility`).
|
|
261
|
-
@pane&.
|
|
262
|
+
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
262
263
|
needs_full_repaint
|
|
263
264
|
end
|
|
264
265
|
|
|
@@ -272,7 +273,7 @@ module Tuile
|
|
|
272
273
|
# @return [Locale]
|
|
273
274
|
attr_reader :locale
|
|
274
275
|
|
|
275
|
-
# Replaces the locale: fires {Component#
|
|
276
|
+
# Replaces the locale: fires {Component#handle_locale_changed} across the
|
|
276
277
|
# attached tree and invalidates every attached component. No-op when
|
|
277
278
|
# `new_locale` equals the current one.
|
|
278
279
|
#
|
|
@@ -293,7 +294,7 @@ module Tuile
|
|
|
293
294
|
@locale = new_locale
|
|
294
295
|
# `__send__` for the same reason `theme=` uses it: the hook is protected
|
|
295
296
|
# (`D_hook_visibility`).
|
|
296
|
-
@pane&.
|
|
297
|
+
@pane&.walk_tree { _1.__send__(:handle_locale_changed) }
|
|
297
298
|
needs_full_repaint
|
|
298
299
|
end
|
|
299
300
|
|
|
@@ -327,7 +328,7 @@ module Tuile
|
|
|
327
328
|
# @return [void]
|
|
328
329
|
def check_locked
|
|
329
330
|
raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
|
|
330
|
-
return if @event_queue.running? ? @event_queue.
|
|
331
|
+
return if @event_queue.running? ? @event_queue.in_loop_thread? : Thread.current.equal?(@ui_thread)
|
|
331
332
|
|
|
332
333
|
# `submit` is the wrong remedy with no loop running — nothing would drain
|
|
333
334
|
# the queue, so the block silently never fires.
|
|
@@ -371,9 +372,9 @@ module Tuile
|
|
|
371
372
|
# whatever is painted over it and feed it every keystroke.
|
|
372
373
|
#
|
|
373
374
|
# Once the pointer and the active flags are settled, three notices fire in
|
|
374
|
-
# order: {Component#
|
|
375
|
+
# order: {Component#handle_blur} on what lost focus, {Component#handle_focus} on
|
|
375
376
|
# what took it, then {#on_focus_changed}. The outer two are edge-triggered
|
|
376
|
-
# and `
|
|
377
|
+
# and `handle_focus` is not — see there.
|
|
377
378
|
# @param focused [Component, nil] the new component to be focused.
|
|
378
379
|
def focused=(focused)
|
|
379
380
|
unless focused.nil? || focused.is_a?(Component)
|
|
@@ -384,7 +385,7 @@ module Tuile
|
|
|
384
385
|
previous = @focused
|
|
385
386
|
if focused.nil?
|
|
386
387
|
@focused = nil
|
|
387
|
-
@pane.
|
|
388
|
+
@pane.walk_tree { _1.active = false }
|
|
388
389
|
else
|
|
389
390
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
390
391
|
raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
|
|
@@ -396,7 +397,7 @@ module Tuile
|
|
|
396
397
|
active << cursor
|
|
397
398
|
cursor = cursor.parent
|
|
398
399
|
end
|
|
399
|
-
@pane.
|
|
400
|
+
@pane.walk_tree { _1.active = active.include?(_1) }
|
|
400
401
|
end
|
|
401
402
|
fire_focus_hooks(previous, focused)
|
|
402
403
|
end
|
|
@@ -412,17 +413,17 @@ module Tuile
|
|
|
412
413
|
#
|
|
413
414
|
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
414
415
|
#
|
|
415
|
-
# **Edge-triggered**, like {Component#
|
|
416
|
+
# **Edge-triggered**, like {Component#handle_attached}: re-assigning the
|
|
416
417
|
# component that already has focus fires nothing, so a callback can be as
|
|
417
418
|
# expensive as rebuilding a hint string without a `did it really change?`
|
|
418
419
|
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
419
420
|
# clears focus on every content swap, which on a level-triggered hook would
|
|
420
421
|
# fire a nil→nil notification during assembly.
|
|
421
422
|
#
|
|
422
|
-
# It runs *after* the active-flag cascade and `
|
|
423
|
+
# It runs *after* the active-flag cascade and `handle_focus`, so the tree is
|
|
423
424
|
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
424
425
|
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
425
|
-
# {Component#
|
|
426
|
+
# {Component#handle_detached}. A raising callback propagates out of {#focused=}
|
|
426
427
|
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
427
428
|
# @return [Proc, nil]
|
|
428
429
|
attr_accessor :on_focus_changed
|
|
@@ -440,20 +441,25 @@ module Tuile
|
|
|
440
441
|
end
|
|
441
442
|
|
|
442
443
|
# Runs the event loop on the calling thread, taking over stdin (raw mode,
|
|
443
|
-
# echo off): keys and mouse events are dispatched via {#handle_key} /
|
|
444
|
+
# echo off): keys and mouse events are dispatched via {#handle_key?} /
|
|
444
445
|
# {#handle_mouse}, and the loop repaints once per drained tick. Returns
|
|
445
446
|
# when `q` or ESC is pressed unhandled. Restores terminal state on exit.
|
|
446
447
|
#
|
|
447
448
|
# For the duration this thread owns the UI ({#state} is `:running`);
|
|
448
449
|
# ownership reverts to the creating thread once it returns.
|
|
449
450
|
#
|
|
450
|
-
# @param capture_mouse [Boolean]
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
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.
|
|
457
463
|
# @param bracketed_paste [Boolean] when true (default), enables DEC private
|
|
458
464
|
# mode 2004 so pasted text arrives whole, as {Component#handle_paste},
|
|
459
465
|
# instead of as one keystroke per character — which is the only way a
|
|
@@ -462,16 +468,20 @@ module Tuile
|
|
|
462
468
|
# fires it once per pasted line. Turn it off only for a terminal that
|
|
463
469
|
# mishandles the mode.
|
|
464
470
|
# @raise [Tuile::Error] if the screen is already {#close}d.
|
|
471
|
+
# @raise [ArgumentError] on an unknown `capture_mouse:` level.
|
|
465
472
|
# @return [void]
|
|
466
473
|
def run_event_loop(capture_mouse: true, bracketed_paste: true)
|
|
467
474
|
raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
|
|
468
475
|
|
|
476
|
+
level = Mouse.level(capture_mouse)
|
|
477
|
+
|
|
469
478
|
# The guard above stays outside the begin: teardown for a setup that never
|
|
470
479
|
# happened restores echo on a non-TTY stdin, and the ENOTTY masks the
|
|
471
480
|
# real error.
|
|
472
481
|
begin
|
|
473
482
|
$stdin.echo = false
|
|
474
|
-
|
|
483
|
+
@mouse_router.level = level
|
|
484
|
+
print Mouse.start_tracking(level) if level
|
|
475
485
|
print Keys::BRACKETED_PASTE_ON if bracketed_paste
|
|
476
486
|
# Follow OS light/dark flips live: terminals supporting mode 2031
|
|
477
487
|
# push color-scheme reports that the key thread turns into
|
|
@@ -483,7 +493,9 @@ module Tuile
|
|
|
483
493
|
ensure
|
|
484
494
|
print TerminalBackground::NOTIFY_OFF
|
|
485
495
|
print Keys::BRACKETED_PASTE_OFF if bracketed_paste
|
|
486
|
-
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
|
|
487
499
|
print TTY::Cursor.show
|
|
488
500
|
$stdin.echo = true
|
|
489
501
|
end
|
|
@@ -510,9 +522,9 @@ module Tuile
|
|
|
510
522
|
#
|
|
511
523
|
# - **Printable keys** — they'd hijack typing into a
|
|
512
524
|
# {Component::TextField}. A scope-wide one-key binding belongs on the
|
|
513
|
-
# scope root's own `handle_key
|
|
514
|
-
# (see {ScreenPane#handle_key}).
|
|
515
|
-
# - **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
|
|
516
528
|
# navigation before the registry is consulted, so a binding would never
|
|
517
529
|
# fire.
|
|
518
530
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
@@ -535,7 +547,7 @@ module Tuile
|
|
|
535
547
|
if Keys.printable?(key)
|
|
536
548
|
raise ArgumentError,
|
|
537
549
|
"global shortcut key must be unprintable; got #{key.inspect}. " \
|
|
538
|
-
"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 " \
|
|
539
551
|
"(your content layout, or the popup) — a focused text field then " \
|
|
540
552
|
"consumes the key first, so typing isn't hijacked."
|
|
541
553
|
end
|
|
@@ -547,7 +559,7 @@ module Tuile
|
|
|
547
559
|
raise ArgumentError,
|
|
548
560
|
"#{key.inspect} is reserved: every editable widget needs it, and this registry " \
|
|
549
561
|
"sits above the component tree with nothing to suppress it. For a default " \
|
|
550
|
-
"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 " \
|
|
551
563
|
"TextArea/TextField gets first refusal there."
|
|
552
564
|
end
|
|
553
565
|
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
|
|
@@ -586,7 +598,7 @@ module Tuile
|
|
|
586
598
|
# @api private
|
|
587
599
|
# @return [void]
|
|
588
600
|
def needs_full_repaint
|
|
589
|
-
@pane&.
|
|
601
|
+
@pane&.walk_tree { invalidate _1 }
|
|
590
602
|
end
|
|
591
603
|
|
|
592
604
|
# Internal — use {Component::Overlay#open?} instead.
|
|
@@ -607,7 +619,7 @@ module Tuile
|
|
|
607
619
|
|
|
608
620
|
# Tears the screen down and vacates the singleton slot, moving {#state} to
|
|
609
621
|
# the terminal `:closed`. Unmounts the tree first, so every component gets
|
|
610
|
-
# its {Component#
|
|
622
|
+
# its {Component#handle_detached}. Idempotent.
|
|
611
623
|
# @raise [Tuile::Error] if an event loop is still running — stop it with
|
|
612
624
|
# `event_queue.stop` and let {#run_event_loop} return first, since closing
|
|
613
625
|
# under a live loop drops the pane it is still painting — or if the caller
|
|
@@ -622,11 +634,12 @@ module Tuile
|
|
|
622
634
|
begin
|
|
623
635
|
@pane.detach_all
|
|
624
636
|
ensure
|
|
625
|
-
# A raising
|
|
637
|
+
# A raising handle_detached propagates — it's a bug to fix, not something the
|
|
626
638
|
# framework guards — but teardown still has to finish, or one such bug
|
|
627
639
|
# leaves a half-closed screen behind and every later example fails with it.
|
|
628
640
|
clear
|
|
629
641
|
@pane = nil
|
|
642
|
+
@mouse_router = nil
|
|
630
643
|
@closed = true
|
|
631
644
|
@@instance = nil # rubocop:disable Style/ClassVars
|
|
632
645
|
end
|
|
@@ -682,6 +695,9 @@ module Tuile
|
|
|
682
695
|
# @return [void]
|
|
683
696
|
def repaint
|
|
684
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
|
|
685
701
|
# This simple TUI framework doesn't support window clipping since tiled
|
|
686
702
|
# windows are not expected to overlap. If there rarely is a popup, we
|
|
687
703
|
# just repaint all windows in correct order — sure they will paint over
|
|
@@ -725,14 +741,14 @@ module Tuile
|
|
|
725
741
|
# than a depth sort. The pane's pre-order traversal already orders the
|
|
726
742
|
# tiled layer (the content subtree) parent-before-child; the popups are
|
|
727
743
|
# the top layer and must paint last, so we collect the tiled layer first
|
|
728
|
-
# and append popups rather than taking a single pane.
|
|
744
|
+
# and append popups rather than taking a single pane.walk_tree walk.
|
|
729
745
|
popup_members = Set.new
|
|
730
|
-
popups.each { |p| p.
|
|
746
|
+
popups.each { |p| p.walk_tree { popup_members << _1 } }
|
|
731
747
|
|
|
732
748
|
# Tiled layer: invalidated non-popup components, in tree order.
|
|
733
749
|
repaint = []
|
|
734
750
|
tiled_invalidated = false
|
|
735
|
-
@pane.
|
|
751
|
+
@pane.walk_tree do |c|
|
|
736
752
|
next if popup_members.include?(c)
|
|
737
753
|
next unless @invalidated.include?(c)
|
|
738
754
|
|
|
@@ -753,8 +769,8 @@ module Tuile
|
|
|
753
769
|
below_repainted = tiled_invalidated
|
|
754
770
|
popups.each do |p|
|
|
755
771
|
layer_invalidated = false
|
|
756
|
-
p.
|
|
757
|
-
p.
|
|
772
|
+
p.walk_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
|
|
773
|
+
p.walk_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
|
|
758
774
|
below_repainted ||= layer_invalidated
|
|
759
775
|
end
|
|
760
776
|
|
|
@@ -778,6 +794,28 @@ module Tuile
|
|
|
778
794
|
# @return [Point, nil]
|
|
779
795
|
def cursor_position = @focused&.cursor_position
|
|
780
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
|
+
|
|
781
819
|
private
|
|
782
820
|
|
|
783
821
|
# Whether `component` is out of the user's reach because it or an ancestor
|
|
@@ -786,7 +824,7 @@ module Tuile
|
|
|
786
824
|
# Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
|
|
787
825
|
# declined a `Component#paintable?`: it reads as a component-level concept
|
|
788
826
|
# and is really this class's question. A *walk* needs no such predicate —
|
|
789
|
-
# it prunes at the hidden subtree's root ({Component#
|
|
827
|
+
# it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
|
|
790
828
|
# @param component [Component]
|
|
791
829
|
# @return [Boolean]
|
|
792
830
|
def hidden?(component)
|
|
@@ -805,10 +843,10 @@ module Tuile
|
|
|
805
843
|
# @return [void]
|
|
806
844
|
def fire_focus_hooks(previous, focused)
|
|
807
845
|
unless focused.equal?(previous)
|
|
808
|
-
previous&.__send__(:
|
|
846
|
+
previous&.__send__(:handle_blur)
|
|
809
847
|
return unless @focused.equal?(focused)
|
|
810
848
|
end
|
|
811
|
-
@focused&.__send__(:
|
|
849
|
+
@focused&.__send__(:handle_focus)
|
|
812
850
|
@on_focus_changed&.call unless @focused.equal?(previous)
|
|
813
851
|
end
|
|
814
852
|
|
|
@@ -848,7 +886,7 @@ module Tuile
|
|
|
848
886
|
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
849
887
|
# @param scheme [Symbol] `:dark` or `:light`.
|
|
850
888
|
# @return [void]
|
|
851
|
-
def
|
|
889
|
+
def handle_color_scheme(scheme)
|
|
852
890
|
@color_scheme = scheme
|
|
853
891
|
self.theme = @theme_def.for(@color_scheme)
|
|
854
892
|
print TerminalBackground::QUERY
|
|
@@ -861,11 +899,11 @@ module Tuile
|
|
|
861
899
|
# would otherwise lose the color it gave us at startup, permanently.
|
|
862
900
|
# @param color [Color]
|
|
863
901
|
# @return [void]
|
|
864
|
-
def
|
|
902
|
+
def handle_background_color(color)
|
|
865
903
|
return if @background_color == color
|
|
866
904
|
|
|
867
905
|
@background_color = color
|
|
868
|
-
@pane&.
|
|
906
|
+
@pane&.walk_tree { _1.__send__(:handle_theme_changed) }
|
|
869
907
|
needs_full_repaint
|
|
870
908
|
end
|
|
871
909
|
|
|
@@ -882,7 +920,7 @@ module Tuile
|
|
|
882
920
|
return false if scope.nil?
|
|
883
921
|
|
|
884
922
|
stops = []
|
|
885
|
-
scope.
|
|
923
|
+
scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
|
|
886
924
|
return false if stops.empty?
|
|
887
925
|
|
|
888
926
|
idx = @focused.nil? ? nil : stops.index(@focused)
|
|
@@ -940,11 +978,14 @@ module Tuile
|
|
|
940
978
|
# default `over_popups: false` fires only when no modal popup is open
|
|
941
979
|
# (otherwise the modal popup receives the key normally). A non-modal
|
|
942
980
|
# overlay doesn't suppress global shortcuts.
|
|
943
|
-
# 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
|
|
981
|
+
# 3. {ScreenPane#handle_key?} — delivery to {#focused}, bubbling up the
|
|
944
982
|
# focus chain to the scope root.
|
|
945
983
|
# @param key [String]
|
|
946
984
|
# @return [Boolean] true if the key was handled by some window.
|
|
947
|
-
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
|
|
948
989
|
case key
|
|
949
990
|
when Keys::TAB
|
|
950
991
|
focus_next
|
|
@@ -958,25 +999,21 @@ module Tuile
|
|
|
958
999
|
shortcut.block.call
|
|
959
1000
|
true
|
|
960
1001
|
else
|
|
961
|
-
@pane.handle_key(key)
|
|
1002
|
+
@pane.handle_key?(key)
|
|
962
1003
|
end
|
|
963
1004
|
end
|
|
964
1005
|
end
|
|
965
1006
|
|
|
966
|
-
# Finds target window and calls {Component::Window#handle_mouse}.
|
|
967
|
-
# @param event [MouseEvent]
|
|
968
|
-
# @return [void]
|
|
969
|
-
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
970
|
-
|
|
971
1007
|
# Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
|
|
972
1008
|
#
|
|
973
1009
|
# Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
|
|
974
1010
|
# Tab traversal and the global-shortcut registry entirely, goes straight to
|
|
975
1011
|
# delivery, and does not bubble to ancestors the way a key does. Unhandled
|
|
976
1012
|
# 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
|
|
1013
|
+
# would put back the very ambiguity mode 2004 exists to remove — and with no
|
|
1014
|
+
# alternative delivery, no verdict to carry either.
|
|
978
1015
|
# @param text [String]
|
|
979
|
-
# @return [
|
|
1016
|
+
# @return [void]
|
|
980
1017
|
def handle_paste(text) = @pane.handle_paste(text)
|
|
981
1018
|
|
|
982
1019
|
# @return [void]
|
|
@@ -985,19 +1022,19 @@ module Tuile
|
|
|
985
1022
|
case event
|
|
986
1023
|
when EventQueue::KeyEvent
|
|
987
1024
|
key = event.key
|
|
988
|
-
handled = handle_key(key)
|
|
1025
|
+
handled = handle_key?(key)
|
|
989
1026
|
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
990
1027
|
when EventQueue::PasteEvent
|
|
991
1028
|
handle_paste(event.text)
|
|
992
|
-
when
|
|
1029
|
+
when Mouse::Event
|
|
993
1030
|
handle_mouse(event)
|
|
994
1031
|
when EventQueue::TTYSizeEvent
|
|
995
1032
|
@size = event.size
|
|
996
1033
|
layout
|
|
997
1034
|
when EventQueue::ColorSchemeEvent
|
|
998
|
-
|
|
1035
|
+
handle_color_scheme(event.scheme)
|
|
999
1036
|
when EventQueue::BackgroundColorEvent
|
|
1000
|
-
|
|
1037
|
+
handle_background_color(event.color)
|
|
1001
1038
|
when EventQueue::EmptyQueueEvent
|
|
1002
1039
|
repaint
|
|
1003
1040
|
when Proc
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Tuile
|
|
|
8
8
|
# {#content} and the {#popups} stack. Putting them under a single Component
|
|
9
9
|
# parent gives focus traversal a real root, makes {Component#attached?} a
|
|
10
10
|
# one-liner, and lets popup-focus repair fall out of the standard
|
|
11
|
-
# {Component#
|
|
11
|
+
# {Component#handle_child_removed} hook.
|
|
12
12
|
#
|
|
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
|
|
@@ -79,7 +79,7 @@ module Tuile
|
|
|
79
79
|
window.center
|
|
80
80
|
screen.focused = window
|
|
81
81
|
end
|
|
82
|
-
window.
|
|
82
|
+
window.walk_tree { |c| screen.invalidate(c) }
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# Removes a popup. If the popup held focus, focus shifts to the now-topmost
|
|
@@ -102,7 +102,7 @@ module Tuile
|
|
|
102
102
|
@removing_popup_prior = nil
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
# Unmounts everything: each child is detached — firing {Component#
|
|
105
|
+
# Unmounts everything: each child is detached — firing {Component#handle_detached}
|
|
106
106
|
# down its subtree — and every slot is emptied. Terminal; the pane isn't
|
|
107
107
|
# reusable afterwards, and {Screen#close} is its only caller.
|
|
108
108
|
#
|
|
@@ -113,7 +113,7 @@ module Tuile
|
|
|
113
113
|
# components, which is the desync the tree API exists to prevent.
|
|
114
114
|
# @return [void]
|
|
115
115
|
def detach_all
|
|
116
|
-
screen.focused = nil # …so the focus repair in
|
|
116
|
+
screen.focused = nil # …so the focus repair in handle_child_removed has nothing to do
|
|
117
117
|
children.dup.each { detach_child(_1) }
|
|
118
118
|
@content = nil
|
|
119
119
|
@popups.clear
|
|
@@ -157,7 +157,7 @@ module Tuile
|
|
|
157
157
|
def repaint; end
|
|
158
158
|
|
|
159
159
|
# Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
|
|
160
|
-
# the first component whose `handle_key
|
|
160
|
+
# the first component whose `handle_key?` returns true wins.
|
|
161
161
|
#
|
|
162
162
|
# Bubbling stops at the *scope* root: the topmost *modal* popup when one is
|
|
163
163
|
# open, else the tiled {#content}. Focus that is nil or sits outside the
|
|
@@ -173,7 +173,7 @@ module Tuile
|
|
|
173
173
|
# hijacked).
|
|
174
174
|
# @param key [String]
|
|
175
175
|
# @return [Boolean] true if the key was handled.
|
|
176
|
-
def handle_key(key)
|
|
176
|
+
def handle_key?(key)
|
|
177
177
|
scope = modal_popup || @content
|
|
178
178
|
return false if scope.nil?
|
|
179
179
|
|
|
@@ -182,38 +182,39 @@ module Tuile
|
|
|
182
182
|
|
|
183
183
|
# Delivers pasted text to {Screen#focused} — and to nobody else.
|
|
184
184
|
#
|
|
185
|
-
# Scoped exactly like {#handle_key} (focus that is nil or sits outside the
|
|
185
|
+
# Scoped exactly like {#handle_key?} (focus that is nil or sits outside the
|
|
186
186
|
# modal scope receives nothing, which is what keeps a popup modal) but
|
|
187
187
|
# **not bubbled**: an ancestor is never offered a paste its descendant
|
|
188
188
|
# declined, and unhandled text is dropped. Why keys bubble and pastes
|
|
189
189
|
# don't: `D_bracketed_paste`.
|
|
190
190
|
# @param text [String]
|
|
191
|
-
# @return [
|
|
191
|
+
# @return [void]
|
|
192
192
|
def handle_paste(text)
|
|
193
193
|
scope = modal_popup || @content
|
|
194
|
-
return
|
|
194
|
+
return if scope.nil?
|
|
195
195
|
|
|
196
196
|
chain = focus_chain(scope)
|
|
197
|
-
return
|
|
197
|
+
return if chain.nil?
|
|
198
198
|
|
|
199
199
|
chain.first.handle_paste(text)
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
-
# Mouse
|
|
203
|
-
#
|
|
204
|
-
# open
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
#
|
|
212
|
-
#
|
|
202
|
+
# Where {Mouse::Router} starts its walk for a pointer at `point`: the
|
|
203
|
+
# topmost popup containing it, else {#content} — unless a modal popup is
|
|
204
|
+
# open, which eats the event even outside its rect. A non-modal overlay
|
|
205
|
+
# blocks nothing: a point outside it reaches the content beneath.
|
|
206
|
+
# @api private
|
|
207
|
+
# @param point [Point]
|
|
208
|
+
# @return [Component, nil]
|
|
209
|
+
def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?)
|
|
210
|
+
|
|
211
|
+
# Runs the press delivery in the block, then *dismisses* the open popups a
|
|
212
|
+
# left press landed outside of that asked for it
|
|
213
|
+
# ({Component::Overlay#close_on_outside_click?}). A dismissed popup is
|
|
213
214
|
# closed rather than told.
|
|
214
215
|
#
|
|
215
216
|
# "Outside" is measured against the {Component::Overlay#owner} chain, not
|
|
216
|
-
# against one rect and not against stacking order: the popup the
|
|
217
|
+
# against one rect and not against stacking order: the popup the press hit
|
|
217
218
|
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
218
219
|
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
219
220
|
# field opened, and a menu cascade being dismissed by a click on one of its
|
|
@@ -223,10 +224,10 @@ module Tuile
|
|
|
223
224
|
#
|
|
224
225
|
# Two halves of the ordering are load-bearing, and both are specced:
|
|
225
226
|
#
|
|
226
|
-
# - **Snapshot before
|
|
227
|
+
# - **Snapshot before the block.** A popup the delivered press *opens* must
|
|
227
228
|
# not be in the set (it would immediately dismiss itself — every
|
|
228
229
|
# {Component::Select} would be unopenable by mouse).
|
|
229
|
-
# - **Close after
|
|
230
|
+
# - **Close after the block.** A widget toggling its own overlay from a press
|
|
230
231
|
# on its face closes it during delivery, and {Component::Popup#close} is
|
|
231
232
|
# idempotent, so the dismissal no-ops. Close *first* and the widget sees
|
|
232
233
|
# a shut overlay and reopens it — a Select's dropdown could then never be
|
|
@@ -234,19 +235,19 @@ module Tuile
|
|
|
234
235
|
#
|
|
235
236
|
# The snapshot is a fresh array for a third reason: a handler may close
|
|
236
237
|
# further popups, and `@popups` must not be mutated mid-iteration.
|
|
237
|
-
# @
|
|
238
|
+
# @api private
|
|
239
|
+
# @param point [Point]
|
|
240
|
+
# @param left [Boolean] whether the press was the left button; no other
|
|
241
|
+
# button dismisses.
|
|
242
|
+
# @yield the press delivery.
|
|
238
243
|
# @return [void]
|
|
239
|
-
def
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
clicked = hit || (@content if modal_popup.nil?)
|
|
244
|
-
clicked&.handle_mouse(event)
|
|
245
|
-
|
|
244
|
+
def dismissing_popups_outside(point, left:)
|
|
245
|
+
dismissable = left ? @popups - kept_by(popup_at(point)) : []
|
|
246
|
+
yield
|
|
246
247
|
dismissable.each { _1.close if _1.close_on_outside_click? }
|
|
247
248
|
end
|
|
248
249
|
|
|
249
|
-
# Focus repair when a child detaches. Default {Component#
|
|
250
|
+
# Focus repair when a child detaches. Default {Component#handle_child_removed}
|
|
250
251
|
# would refocus to `self` (the pane), which isn't a useful focus target.
|
|
251
252
|
# Instead, route focus to the first interactable widget in the now-topmost
|
|
252
253
|
# modal popup; falling back to the focus snapshotted when this popup was opened
|
|
@@ -259,7 +260,7 @@ module Tuile
|
|
|
259
260
|
# `q`/ESC still has somewhere to dispatch from.
|
|
260
261
|
# @param child [Component]
|
|
261
262
|
# @return [void]
|
|
262
|
-
def
|
|
263
|
+
def handle_child_removed(child)
|
|
263
264
|
return unless attached?
|
|
264
265
|
|
|
265
266
|
f = screen.focused
|
|
@@ -301,6 +302,10 @@ module Tuile
|
|
|
301
302
|
kept
|
|
302
303
|
end
|
|
303
304
|
|
|
305
|
+
# @param point [Point]
|
|
306
|
+
# @return [Component::Overlay, nil] the topmost popup containing `point`.
|
|
307
|
+
def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) }
|
|
308
|
+
|
|
304
309
|
# @param component [Component, nil]
|
|
305
310
|
# @return [Component::Overlay, nil] `component` itself when it is an
|
|
306
311
|
# overlay, else the nearest overlay above it, else nil.
|
|
@@ -321,7 +326,7 @@ module Tuile
|
|
|
321
326
|
chain = focus_chain(scope)
|
|
322
327
|
return false if chain.nil?
|
|
323
328
|
|
|
324
|
-
chain.each { |c| return true if c.handle_key(key) }
|
|
329
|
+
chain.each { |c| return true if c.handle_key?(key) }
|
|
325
330
|
false
|
|
326
331
|
end
|
|
327
332
|
|
|
@@ -350,7 +355,7 @@ module Tuile
|
|
|
350
355
|
def first_tab_stop_or_root(root)
|
|
351
356
|
return nil if root.nil? || !root.visible?
|
|
352
357
|
|
|
353
|
-
root.
|
|
358
|
+
root.walk_shown_tree { |c| return c if c.tab_stop? }
|
|
354
359
|
root
|
|
355
360
|
end
|
|
356
361
|
end
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -410,11 +410,11 @@ module Tuile
|
|
|
410
410
|
#
|
|
411
411
|
# `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
|
|
412
412
|
# sequences — the ones vendors actually ship a single glyph for — and *sums*
|
|
413
|
-
# the parts of anything else
|
|
414
|
-
#
|
|
415
|
-
# shifts the rest of
|
|
416
|
-
#
|
|
417
|
-
# per-setting reasoning.
|
|
413
|
+
# the parts of anything else, erring only in the direction that cannot escape
|
|
414
|
+
# a component: {Buffer#flush} emits a dirty run contiguously, so a mis-measure
|
|
415
|
+
# of either sign shifts the rest of that run, but only an under-measure shifts
|
|
416
|
+
# it right, into a neighbour whose cells are clean and so never repainted.
|
|
417
|
+
# `D_cluster_width` has the per-setting reasoning.
|
|
418
418
|
# @return [Symbol]
|
|
419
419
|
EMOJI_WIDTH = :rgi
|
|
420
420
|
|