tuile 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/sig/tuile.rbs
CHANGED
|
@@ -23,6 +23,7 @@ module Tuile
|
|
|
23
23
|
# same form.
|
|
24
24
|
module Ansi
|
|
25
25
|
RESET: String
|
|
26
|
+
BEL: String
|
|
26
27
|
SYNC_BEGIN: String
|
|
27
28
|
SYNC_END: String
|
|
28
29
|
end
|
|
@@ -78,6 +79,10 @@ module Tuile
|
|
|
78
79
|
ENTER: String
|
|
79
80
|
TAB: String
|
|
80
81
|
SHIFT_TAB: String
|
|
82
|
+
BRACKETED_PASTE_ON: String
|
|
83
|
+
BRACKETED_PASTE_OFF: String
|
|
84
|
+
PASTE_START: String
|
|
85
|
+
PASTE_END: String
|
|
81
86
|
|
|
82
87
|
# True iff `key` is a single printable character — a one-character string
|
|
83
88
|
# whose codepoint is not in Unicode's C (Other) category. Rejects multi-
|
|
@@ -99,6 +104,43 @@ module Tuile
|
|
|
99
104
|
#
|
|
100
105
|
# _@return_ — key, such as {DOWN_ARROW}.
|
|
101
106
|
def self.getkey: () -> String
|
|
107
|
+
|
|
108
|
+
# Reads the body of a bracketed paste, having just read {PASTE_START}, and
|
|
109
|
+
# returns it {.normalize_paste}d:
|
|
110
|
+
#
|
|
111
|
+
# Keys.getkey # => "\e[200~"
|
|
112
|
+
# Keys.read_paste # => "one\ntwo" (terminator consumed)
|
|
113
|
+
#
|
|
114
|
+
# Reads **raw**, one byte at a time, rather than looping on {.getkey}: a
|
|
115
|
+
# pasted `\e` would send `getkey` gulping five bytes of the *payload* as an
|
|
116
|
+
# escape tail, surfacing them as phantom keypresses. One byte at a time is
|
|
117
|
+
# also what keeps the terminator from being over-read — nothing past
|
|
118
|
+
# {PASTE_END} is consumed, so typing that lands behind a paste survives.
|
|
119
|
+
#
|
|
120
|
+
# Blocks until the terminator arrives; returns what it has at EOF.
|
|
121
|
+
#
|
|
122
|
+
# _@return_ — the pasted text, UTF-8, without the brackets.
|
|
123
|
+
def self.read_paste: () -> String
|
|
124
|
+
|
|
125
|
+
# Rewrites a raw paste payload into the one convention callers see: `\n`
|
|
126
|
+
# line endings, valid UTF-8.
|
|
127
|
+
#
|
|
128
|
+
# Keys.normalize_paste("a\r\nb\rc") # => "a\nb\nc"
|
|
129
|
+
#
|
|
130
|
+
# Both are terminal-layer artifacts, not text: a terminal with bracketed
|
|
131
|
+
# paste *off* rewrites the clipboard's `\n` to `\r` so a paste looks like
|
|
132
|
+
# typing, and several keep doing it inside the brackets — so the byte a
|
|
133
|
+
# line break arrives as is not something a component should have to know.
|
|
134
|
+
# Invalid bytes are scrubbed to `U+FFFD`, which is what lets
|
|
135
|
+
# {Component#handle_paste} take any clipboard, a binary file included,
|
|
136
|
+
# without the grapheme-cluster walk raising downstream.
|
|
137
|
+
#
|
|
138
|
+
# Control characters other than `\n` are left alone: they are content, and
|
|
139
|
+
# what a *text buffer* may hold is {Component::AbstractStringField}'s call,
|
|
140
|
+
# not this layer's.
|
|
141
|
+
#
|
|
142
|
+
# _@param_ `text` — raw payload.
|
|
143
|
+
def self.normalize_paste: (String text) -> String
|
|
102
144
|
end
|
|
103
145
|
|
|
104
146
|
# A rectangle, with integer `left`, `top`, `width` and `height`, all 0-based.
|
|
@@ -232,6 +274,11 @@ module Tuile
|
|
|
232
274
|
# {Color} instances only.
|
|
233
275
|
class Color
|
|
234
276
|
COLOR_SYMBOLS: ::Array[Symbol]
|
|
277
|
+
CUBE_LEVELS: ::Array[Integer]
|
|
278
|
+
CUBE_INDEX: ::Array[Integer]
|
|
279
|
+
ANSI16_RGB: ::Array[::Array[Integer]]
|
|
280
|
+
ANSI16_COLORS: ::Array[Color]
|
|
281
|
+
PALETTE_COLORS: ::Array[Color]
|
|
235
282
|
PALETTE_NAMES: ::Hash[Symbol, Integer]
|
|
236
283
|
|
|
237
284
|
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
@@ -280,6 +327,36 @@ module Tuile
|
|
|
280
327
|
# _@param_ `target` — `:fg` or `:bg`.
|
|
281
328
|
def sgr_codes: (?Symbol target) -> ::Array[Integer]
|
|
282
329
|
|
|
330
|
+
# This color as the nearest one `depth` can actually show — the
|
|
331
|
+
# degradation {Buffer#flush} applies to every color on its way to the wire:
|
|
332
|
+
#
|
|
333
|
+
# Color.rgb(100, 100, 100).quantize(:palette256) # => Color.palette(241)
|
|
334
|
+
# Color.rgb(255, 0, 0).quantize(:ansi16) # => Color::BRIGHT_RED
|
|
335
|
+
# Color.rgb(255, 0, 0).quantize(:truecolor) # => itself, unchanged
|
|
336
|
+
#
|
|
337
|
+
# Returns **the same instance** whenever `depth` shows this color as-is —
|
|
338
|
+
# every named color at every depth, a palette index anywhere but
|
|
339
|
+
# `:ansi16`, RGB at `:truecolor` — so `color.quantize(depth).equal?(color)`
|
|
340
|
+
# *is* the "needs no translating" predicate, and the common path
|
|
341
|
+
# allocates nothing.
|
|
342
|
+
#
|
|
343
|
+
# == Implementation details
|
|
344
|
+
#
|
|
345
|
+
# RGB picks whichever is nearer in squared-RGB distance: the 6×6×6 cube
|
|
346
|
+
# (16..231, its per-channel nearest levels being the nearest cell outright
|
|
347
|
+
# — the axes are independent) or the 24-step grey ramp (232..255, whose
|
|
348
|
+
# nearest step is the one nearest the channel mean).
|
|
349
|
+
#
|
|
350
|
+
# Under `:ansi16` a color goes *direct* to the nearest of the 16, never
|
|
351
|
+
# via the 256-palette — two steps would compound the rounding — and the
|
|
352
|
+
# result is a *named* color, which keeps respecting the user's terminal
|
|
353
|
+
# scheme. Matching is against xterm's default RGBs for the 16, which that
|
|
354
|
+
# scheme may itself redefine: the one mapping here that can be honestly
|
|
355
|
+
# wrong.
|
|
356
|
+
#
|
|
357
|
+
# _@param_ `depth` — one of {ColorDepth::DEPTHS}.
|
|
358
|
+
def quantize: (Symbol depth) -> Color
|
|
359
|
+
|
|
283
360
|
# Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
|
|
284
361
|
# `print`-style direct emission; for composing with other attributes use
|
|
285
362
|
# {#sgr_codes} instead.
|
|
@@ -294,6 +371,30 @@ module Tuile
|
|
|
294
371
|
|
|
295
372
|
def inspect: () -> String
|
|
296
373
|
|
|
374
|
+
# This color's RGB — the palette cell's own coordinates when the value is
|
|
375
|
+
# an index. Only ever asked of a non-Symbol value; a named color has no
|
|
376
|
+
# RGB of its own, since the terminal's scheme decides what it looks like.
|
|
377
|
+
#
|
|
378
|
+
# _@return_ — red, green and blue, each 0..255.
|
|
379
|
+
def rgb_triple: () -> ::Array[Integer]
|
|
380
|
+
|
|
381
|
+
# Written flat — destructured rather than splatted, `x * x` rather than
|
|
382
|
+
# `x**2`, no distance helper — because it runs per style transition in
|
|
383
|
+
# {Buffer#flush}, and the tidy shape measures ~2x slower (see
|
|
384
|
+
# `benchmark/quantize.rb`).
|
|
385
|
+
#
|
|
386
|
+
# _@param_ `rgb` — red, green and blue, each 0..255.
|
|
387
|
+
#
|
|
388
|
+
# _@return_ — palette index, 16..255 — the nearer of this color's
|
|
389
|
+
# cube cell and its grey-ramp step. A tie goes to the cube, which spans
|
|
390
|
+
# the whole space where the ramp only covers the diagonal.
|
|
391
|
+
def nearest_palette: (::Array[Integer] rgb) -> Integer
|
|
392
|
+
|
|
393
|
+
# _@param_ `rgb` — red, green and blue, each 0..255.
|
|
394
|
+
#
|
|
395
|
+
# _@return_ — index into {COLOR_SYMBOLS} of the nearest of the 16.
|
|
396
|
+
def nearest_ansi16: (::Array[Integer] rgb) -> Integer
|
|
397
|
+
|
|
297
398
|
# The underlying raw representation — a Symbol, Integer, or frozen
|
|
298
399
|
# Array<Integer>.
|
|
299
400
|
attr_reader value: (Symbol | Integer | ::Array[Integer])
|
|
@@ -593,7 +694,9 @@ module Tuile
|
|
|
593
694
|
def self.display_width: (String grapheme) -> Integer
|
|
594
695
|
|
|
595
696
|
# _@param_ `size` — grid dimensions in columns × rows.
|
|
596
|
-
|
|
697
|
+
#
|
|
698
|
+
# _@param_ `color_depth` — what the terminal can show — one of {ColorDepth::DEPTHS}; {#flush} degrades every emitted color to it. Validated here rather than at paint time: a bad value would otherwise surface as an exception mid-frame, far from the mistake.
|
|
699
|
+
def initialize: (Size size, ?color_depth: Symbol) -> void
|
|
597
700
|
|
|
598
701
|
# _@return_ — grid dimensions.
|
|
599
702
|
def size: () -> Size
|
|
@@ -754,6 +857,26 @@ module Tuile
|
|
|
754
857
|
# _@param_ `style` — style the terminal currently holds.
|
|
755
858
|
def flush_row: (String _out, Integer y, StyledString::Style style) -> StyledString::Style
|
|
756
859
|
|
|
860
|
+
# `style` as {#color_depth} can actually show it, each color through
|
|
861
|
+
# {Color#quantize}. Applied *before* the {StyledString::Style#sgr_to}
|
|
862
|
+
# diff, so two RGBs that quantize onto the same cell emit nothing at all
|
|
863
|
+
# rather than a redundant SGR.
|
|
864
|
+
#
|
|
865
|
+
# The one-slot memo is load-bearing, not a micro-optimization: this runs
|
|
866
|
+
# per dirty *cell*, while a painted run shares one frozen
|
|
867
|
+
# {StyledString::Style} instance, so remembering just the last answer
|
|
868
|
+
# collapses the work onto actual style transitions. Without it a
|
|
869
|
+
# full-screen repaint of RGB-styled content measured 51 ms against 15 ms
|
|
870
|
+
# at `:truecolor` — a keyed cache is still the wrong answer
|
|
871
|
+
# (`D_color_depth`), but paying the arithmetic 8000 times for one span
|
|
872
|
+
# was too.
|
|
873
|
+
#
|
|
874
|
+
# _@param_ `style`
|
|
875
|
+
#
|
|
876
|
+
# _@return_ — `style` itself whenever nothing needed
|
|
877
|
+
# degrading — {Color#quantize}'s identity contract, extended.
|
|
878
|
+
def quantized_style: (StyledString::Style style) -> StyledString::Style
|
|
879
|
+
|
|
757
880
|
# _@param_ `rect`
|
|
758
881
|
#
|
|
759
882
|
# _@return_ — cells within `rect`, row-major, clamped to
|
|
@@ -818,6 +941,11 @@ module Tuile
|
|
|
818
941
|
|
|
819
942
|
attr_reader height: Integer
|
|
820
943
|
|
|
944
|
+
# What the terminal can show ({ColorDepth::DEPTHS}). Cells hold whatever
|
|
945
|
+
# color a component painted — {#region_ansi} and friends report that,
|
|
946
|
+
# unchanged — and only {#flush} degrades it on the way to the wire.
|
|
947
|
+
attr_reader color_depth: Symbol
|
|
948
|
+
|
|
821
949
|
# One screen cell: a single grapheme cluster, the {StyledString::Style} it's
|
|
822
950
|
# drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
|
|
823
951
|
# tracking") — the grid rewrites cells in place. A continuation cell (right
|
|
@@ -879,10 +1007,15 @@ module Tuile
|
|
|
879
1007
|
# Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
|
|
880
1008
|
# {#content} (set via {#content=}, filling the whole terminal and laying
|
|
881
1009
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
882
|
-
# {Component::Popup#open}, drawn on top of the content)
|
|
883
|
-
#
|
|
884
|
-
#
|
|
885
|
-
#
|
|
1010
|
+
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
1011
|
+
# sized from their content — each carries its own top-down
|
|
1012
|
+
# {Component::Popup#declared_size} — and they deliberately overdraw the content
|
|
1013
|
+
# without clipping.
|
|
1014
|
+
#
|
|
1015
|
+
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
1016
|
+
# row, so {#content} gets the whole terminal. An app that wants a status line
|
|
1017
|
+
# builds one into its own layout and drives it from {#on_focus_changed=}
|
|
1018
|
+
# (`D_status_bar`).
|
|
886
1019
|
#
|
|
887
1020
|
# ## Repaint model
|
|
888
1021
|
#
|
|
@@ -929,9 +1062,10 @@ module Tuile
|
|
|
929
1062
|
# _@param_ `content`
|
|
930
1063
|
def content=: (Component content) -> void
|
|
931
1064
|
|
|
932
|
-
# _@return_ —
|
|
933
|
-
#
|
|
934
|
-
|
|
1065
|
+
# _@return_ — the open overlay stack — both
|
|
1066
|
+
# {Component::Popup} modals and bare {Component::Overlay}s — in stacking
|
|
1067
|
+
# order (forwarded to {ScreenPane}). The array must not be modified!
|
|
1068
|
+
def popups: () -> ::Array[Component::Overlay]
|
|
935
1069
|
|
|
936
1070
|
# `:idle` covers *both* ends of the screen's life — before the first
|
|
937
1071
|
# {#run_event_loop} and after it returns — and a screen may cycle
|
|
@@ -957,27 +1091,11 @@ module Tuile
|
|
|
957
1091
|
# _@param_ `component`
|
|
958
1092
|
def invalidate: (Component component) -> void
|
|
959
1093
|
|
|
960
|
-
#
|
|
961
|
-
#
|
|
962
|
-
# changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
|
|
963
|
-
# for the tiled case Screen tacks on the global "q quit" instead.
|
|
964
|
-
# Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
|
|
965
|
-
# for the over_popups filter rule.
|
|
966
|
-
def refresh_status_bar: () -> void
|
|
967
|
-
|
|
968
|
-
# Status-bar hints from currently-registered global shortcuts.
|
|
969
|
-
# When a popup is open, only `over_popups: true` shortcuts contribute —
|
|
970
|
-
# the rest don't fire in that context, so showing them would be a lie.
|
|
971
|
-
# Insertion order is preserved (Hash iteration order).
|
|
972
|
-
#
|
|
973
|
-
# _@param_ `popup_open`
|
|
974
|
-
def global_shortcut_hints: (popup_open: bool) -> ::Array[String]
|
|
975
|
-
|
|
976
|
-
# Internal — use {Component::Popup#open} instead. Adds the popup to
|
|
977
|
-
# {#pane}, centers and focuses it.
|
|
1094
|
+
# Internal — use {Component::Overlay#open} instead. Adds the overlay to
|
|
1095
|
+
# {#pane}; a {Component::Popup} is additionally centered and focused.
|
|
978
1096
|
#
|
|
979
|
-
# _@param_ `window`
|
|
980
|
-
def add_popup: (Component::
|
|
1097
|
+
# _@param_ `window` — any overlay, modal or not.
|
|
1098
|
+
def add_popup: (Component::Overlay window) -> void
|
|
981
1099
|
|
|
982
1100
|
# Runs the event loop on the calling thread, taking over stdin (raw mode,
|
|
983
1101
|
# echo off): keys and mouse events are dispatched via {#handle_key} /
|
|
@@ -988,7 +1106,9 @@ module Tuile
|
|
|
988
1106
|
# ownership reverts to the creating thread once it returns.
|
|
989
1107
|
#
|
|
990
1108
|
# _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
|
|
991
|
-
|
|
1109
|
+
#
|
|
1110
|
+
# _@param_ `bracketed_paste` — when true (default), enables DEC private mode 2004 so pasted text arrives whole, as {Component#handle_paste}, instead of as one keystroke per character — which is the only way a pasted line break can be told from a typed Enter. When false, a paste streams in as keys again and a component that gives ENTER a meaning fires it once per pasted line. Turn it off only for a terminal that mishandles the mode.
|
|
1111
|
+
def run_event_loop: (?capture_mouse: bool, ?bracketed_paste: bool) -> void
|
|
992
1112
|
|
|
993
1113
|
# Advances focus to the next {Component#tab_stop?} in tree order, wrapping
|
|
994
1114
|
# around. Scope is the topmost popup if one is open, otherwise {#content}
|
|
@@ -1021,18 +1141,14 @@ module Tuile
|
|
|
1021
1141
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
1022
1142
|
# which every editable widget needs.
|
|
1023
1143
|
#
|
|
1024
|
-
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
1025
|
-
# over_popups: true,
|
|
1026
|
-
# hint: "^L #{screen.theme.hint("log")}") do
|
|
1144
|
+
# screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
|
|
1027
1145
|
# log_popup.open
|
|
1028
1146
|
# end
|
|
1029
1147
|
#
|
|
1030
1148
|
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
|
|
1031
1149
|
#
|
|
1032
1150
|
# _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup); when false (default), suppressed while any popup is open so the popup gets the key.
|
|
1033
|
-
|
|
1034
|
-
# _@param_ `hint` — preformatted status-bar hint; nil (default) is silent. Colors are baked in — re-register after a {#theme=} to recolor.
|
|
1035
|
-
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
1151
|
+
def register_global_shortcut: (String key, ?over_popups: bool) -> void
|
|
1036
1152
|
|
|
1037
1153
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
1038
1154
|
# No-op if `key` was not registered.
|
|
@@ -1040,16 +1156,13 @@ module Tuile
|
|
|
1040
1156
|
# _@param_ `key`
|
|
1041
1157
|
def unregister_global_shortcut: (String key) -> void
|
|
1042
1158
|
|
|
1043
|
-
#
|
|
1044
|
-
def active_window: () -> Component?
|
|
1045
|
-
|
|
1046
|
-
# Internal — use {Component::Popup#close} instead. Removes the popup
|
|
1159
|
+
# Internal — use {Component::Overlay#close} instead. Removes the overlay
|
|
1047
1160
|
# from {#pane}, repairs focus, and repaints the scene.
|
|
1048
1161
|
#
|
|
1049
|
-
# Does nothing if the
|
|
1162
|
+
# Does nothing if the overlay is not open on this screen.
|
|
1050
1163
|
#
|
|
1051
|
-
# _@param_ `window`
|
|
1052
|
-
def remove_popup: (Component::
|
|
1164
|
+
# _@param_ `window` — any overlay, modal or not.
|
|
1165
|
+
def remove_popup: (Component::Overlay window) -> void
|
|
1053
1166
|
|
|
1054
1167
|
# Invalidates the entire attached tree, forcing every component to repaint
|
|
1055
1168
|
# on the next cycle. Needed whenever something overdraws the scene without
|
|
@@ -1060,12 +1173,12 @@ module Tuile
|
|
|
1060
1173
|
# its own, so we accept the cost of a full repaint.
|
|
1061
1174
|
def needs_full_repaint: () -> void
|
|
1062
1175
|
|
|
1063
|
-
# Internal — use {Component::
|
|
1176
|
+
# Internal — use {Component::Overlay#open?} instead.
|
|
1064
1177
|
#
|
|
1065
|
-
# _@param_ `window`
|
|
1178
|
+
# _@param_ `window` — any overlay, modal or not.
|
|
1066
1179
|
#
|
|
1067
|
-
# _@return_ — true if this
|
|
1068
|
-
def has_popup?: (Component::
|
|
1180
|
+
# _@return_ — true if this overlay is currently mounted.
|
|
1181
|
+
def has_popup?: (Component::Overlay window) -> bool
|
|
1069
1182
|
|
|
1070
1183
|
# Testing only — creates new screen, locks the UI, and prevents any
|
|
1071
1184
|
# redraws, so that test TTY is not painted over. {FakeScreen#initialize}
|
|
@@ -1081,15 +1194,35 @@ module Tuile
|
|
|
1081
1194
|
def self.close: () -> void
|
|
1082
1195
|
|
|
1083
1196
|
# Writes terminal-housekeeping escapes straight to stdout: {#clear},
|
|
1084
|
-
# mouse-tracking start/stop, the color-scheme notify toggles,
|
|
1085
|
-
# on teardown. Component painting does
|
|
1086
|
-
# writes into {#buffer}, which
|
|
1087
|
-
#
|
|
1088
|
-
# test runner's stdout.
|
|
1197
|
+
# mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
|
|
1198
|
+
# background re-probe, cursor-show on teardown. Component painting does
|
|
1199
|
+
# *not* go through here anymore — it writes into {#buffer}, which
|
|
1200
|
+
# {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
|
|
1201
|
+
# {#emit}) to capture into `@prints` instead of the test runner's stdout.
|
|
1202
|
+
#
|
|
1203
|
+
# Flushed, like {#emit}: none of these escapes ends in a newline, and a
|
|
1204
|
+
# buffered stdout would hold a *query* — one whose reply the app is
|
|
1205
|
+
# waiting on — until the next frame went out.
|
|
1089
1206
|
#
|
|
1090
1207
|
# _@param_ `args` — stuff to print.
|
|
1091
1208
|
def print: (*String args) -> void
|
|
1092
1209
|
|
|
1210
|
+
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
1211
|
+
# went nowhere, e.g. a letter matching no menu mnemonic while a menu is
|
|
1212
|
+
# open.
|
|
1213
|
+
#
|
|
1214
|
+
# return true if activate_mnemonic(key)
|
|
1215
|
+
#
|
|
1216
|
+
# screen.beep # no match: the key is swallowed, say so
|
|
1217
|
+
# true
|
|
1218
|
+
#
|
|
1219
|
+
# Writes **immediately** rather than riding the next frame: a beep is not
|
|
1220
|
+
# part of a frame, and the keystrokes worth beeping at are precisely the
|
|
1221
|
+
# ones that invalidate nothing, so {#repaint} may never emit at all. Whether
|
|
1222
|
+
# the user hears anything is the terminal's setting to make, so there is no
|
|
1223
|
+
# Tuile-level enable/disable knob.
|
|
1224
|
+
def beep: () -> void
|
|
1225
|
+
|
|
1093
1226
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
1094
1227
|
# considering invalidated components and flushing just the changed cells
|
|
1095
1228
|
# of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
|
|
@@ -1103,23 +1236,41 @@ module Tuile
|
|
|
1103
1236
|
# but only one focused.
|
|
1104
1237
|
def cursor_position: () -> Point?
|
|
1105
1238
|
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
1108
|
-
#
|
|
1109
|
-
#
|
|
1110
|
-
#
|
|
1111
|
-
#
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
#
|
|
1115
|
-
|
|
1239
|
+
# The startup background probe, seeding {#theme} and
|
|
1240
|
+
# {#background_color}. Inconclusive detection means `:dark` with no
|
|
1241
|
+
# color. Runs in the constructor — the OSC 11 reply arrives on stdin,
|
|
1242
|
+
# which is only safe to read before {EventQueue#start_key_thread} owns
|
|
1243
|
+
# it. {FakeScreen} overrides this to pin the result, keeping specs
|
|
1244
|
+
# deterministic and off the test runner's TTY.
|
|
1245
|
+
def detect_background: () -> TerminalBackground::Result
|
|
1246
|
+
|
|
1247
|
+
# The startup color-depth probe, seeding {#color_depth}. Reads the
|
|
1248
|
+
# environment only, so unlike {#detect_background} it has no timing
|
|
1249
|
+
# constraint. {FakeScreen} overrides it to pin the result, keeping specs
|
|
1250
|
+
# off whatever `COLORTERM` the test runner happens to carry.
|
|
1251
|
+
def detect_color_depth: () -> Symbol
|
|
1116
1252
|
|
|
1117
1253
|
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
1118
|
-
# scheme
|
|
1254
|
+
# scheme, apply the matching member of {#theme_def}, and re-probe for
|
|
1255
|
+
# the new background RGB, which the report does not carry.
|
|
1256
|
+
#
|
|
1257
|
+
# The query goes out from *this* thread — the event-loop thread, which
|
|
1258
|
+
# also owns {#emit} — so its bytes can never land inside a frame's
|
|
1259
|
+
# synchronized-output batch. The reply comes back through the key
|
|
1260
|
+
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
1119
1261
|
#
|
|
1120
1262
|
# _@param_ `scheme` — `:dark` or `:light`.
|
|
1121
1263
|
def on_color_scheme: (Symbol scheme) -> void
|
|
1122
1264
|
|
|
1265
|
+
# The re-probe answered: adopt the color and restyle, since an app's
|
|
1266
|
+
# background-derived tints are now a scheme behind. Deliberately keeps
|
|
1267
|
+
# the previous color until the reply lands rather than blanking it on
|
|
1268
|
+
# the flip — a terminal that reports mode-2031 flips but not OSC 11
|
|
1269
|
+
# would otherwise lose the color it gave us at startup, permanently.
|
|
1270
|
+
#
|
|
1271
|
+
# _@param_ `color`
|
|
1272
|
+
def on_background_color: (Color color) -> void
|
|
1273
|
+
|
|
1123
1274
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
1124
1275
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
1125
1276
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -1173,6 +1324,19 @@ module Tuile
|
|
|
1173
1324
|
# _@param_ `event`
|
|
1174
1325
|
def handle_mouse: (MouseEvent event) -> void
|
|
1175
1326
|
|
|
1327
|
+
# Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
|
|
1328
|
+
#
|
|
1329
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
1330
|
+
# skips Tab traversal and the global-shortcut registry entirely and goes
|
|
1331
|
+
# straight to delivery. Unhandled text is dropped — there is no fallback
|
|
1332
|
+
# that replays it as keys, which would put back the very ambiguity mode
|
|
1333
|
+
# 2004 exists to remove.
|
|
1334
|
+
#
|
|
1335
|
+
# _@param_ `text`
|
|
1336
|
+
#
|
|
1337
|
+
# _@return_ — true if some component consumed it.
|
|
1338
|
+
def handle_paste: (String text) -> bool
|
|
1339
|
+
|
|
1176
1340
|
def event_loop: () -> void
|
|
1177
1341
|
|
|
1178
1342
|
# _@return_ — the structural root of the component tree.
|
|
@@ -1181,6 +1345,16 @@ module Tuile
|
|
|
1181
1345
|
# _@return_ — `:light` or `:dark`
|
|
1182
1346
|
attr_reader color_scheme: Symbol
|
|
1183
1347
|
|
|
1348
|
+
# How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
|
|
1349
|
+
# at construction. {Buffer#flush} degrades every color it emits to this,
|
|
1350
|
+
# so an app may compute an RGB tint — say from {#background_color} — and
|
|
1351
|
+
# paint with it, whatever the terminal turns out to understand.
|
|
1352
|
+
#
|
|
1353
|
+
# Deliberately read-only: detection runs once and the answer can't change
|
|
1354
|
+
# mid-session. Override a terminal that reports itself wrong through
|
|
1355
|
+
# {ColorDepth::OVERRIDE_ENV} instead.
|
|
1356
|
+
attr_reader color_depth: Symbol
|
|
1357
|
+
|
|
1184
1358
|
# _@return_ — the back buffer components paint into
|
|
1185
1359
|
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
1186
1360
|
attr_reader buffer: Buffer
|
|
@@ -1225,14 +1399,58 @@ module Tuile
|
|
|
1225
1399
|
# the user toggling the OS appearance.
|
|
1226
1400
|
attr_accessor theme_def: ThemeDef
|
|
1227
1401
|
|
|
1402
|
+
# The terminal's own background, as it reported it — for deriving a
|
|
1403
|
+
# color *from* the background rather than picking one against it. A pane
|
|
1404
|
+
# tinted a few percent off it sits right on any terminal, where a fixed
|
|
1405
|
+
# near-neutral only sits right near the one it was tuned on:
|
|
1406
|
+
#
|
|
1407
|
+
# bg = screen.background_color
|
|
1408
|
+
# sidebar.bg_color =
|
|
1409
|
+
# bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
1410
|
+
#
|
|
1411
|
+
# **Nil is the normal case, not an edge** — a terminal answering only
|
|
1412
|
+
# `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
|
|
1413
|
+
#
|
|
1414
|
+
# Kept current across OS appearance flips, a frame behind: the flip
|
|
1415
|
+
# report carries light/dark only, so the screen re-probes and this
|
|
1416
|
+
# updates when the reply lands. A changed color then fires
|
|
1417
|
+
# {Component#on_theme_changed} across the tree exactly as a theme swap
|
|
1418
|
+
# does — a background-derived tint *is* a theme-derived color.
|
|
1419
|
+
attr_reader background_color: Color?
|
|
1420
|
+
|
|
1228
1421
|
# _@return_ — the event queue.
|
|
1229
1422
|
attr_reader event_queue: EventQueue
|
|
1230
1423
|
|
|
1231
1424
|
# _@return_ — currently focused component.
|
|
1232
1425
|
attr_accessor focused: Component?
|
|
1233
1426
|
|
|
1234
|
-
#
|
|
1235
|
-
#
|
|
1427
|
+
# Called after the focused component *changes* — including to and from
|
|
1428
|
+
# `nil`, and including the focus repair that runs when a popup closes.
|
|
1429
|
+
# Takes no arguments; read {#focused} (and walk its `parent` chain) for the
|
|
1430
|
+
# new state.
|
|
1431
|
+
#
|
|
1432
|
+
# This is the hook an app drives its own status line from. Tuile owns no
|
|
1433
|
+
# status bar and reserves no row: build a {Component::Label} into your own
|
|
1434
|
+
# layout and fill it here (`D_status_bar`).
|
|
1435
|
+
#
|
|
1436
|
+
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
1437
|
+
#
|
|
1438
|
+
# **Edge-triggered**, like {Component#on_attached}: re-assigning the
|
|
1439
|
+
# component that already has focus fires nothing, so a callback can be as
|
|
1440
|
+
# expensive as rebuilding a hint string without a `did it really change?`
|
|
1441
|
+
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
1442
|
+
# clears focus on every content swap, which on a level-triggered hook would
|
|
1443
|
+
# fire a nil→nil notification during assembly.
|
|
1444
|
+
#
|
|
1445
|
+
# It runs *after* the active-flag cascade and `on_focus`, so the tree is
|
|
1446
|
+
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
1447
|
+
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
1448
|
+
# {Component#on_detached}. A raising callback propagates out of {#focused=}
|
|
1449
|
+
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
1450
|
+
attr_accessor on_focus_changed: Proc?
|
|
1451
|
+
|
|
1452
|
+
# Entry in the global shortcut registry: the block to run, and whether it
|
|
1453
|
+
# pre-empts open popups.
|
|
1236
1454
|
# @api private
|
|
1237
1455
|
class Shortcut < Data
|
|
1238
1456
|
# Returns the value of attribute block
|
|
@@ -1240,9 +1458,6 @@ module Tuile
|
|
|
1240
1458
|
|
|
1241
1459
|
# Returns the value of attribute over_popups
|
|
1242
1460
|
attr_reader over_popups: Object
|
|
1243
|
-
|
|
1244
|
-
# Returns the value of attribute hint
|
|
1245
|
-
attr_reader hint: Object
|
|
1246
1461
|
end
|
|
1247
1462
|
end
|
|
1248
1463
|
|
|
@@ -1285,8 +1500,68 @@ module Tuile
|
|
|
1285
1500
|
# any stale invalidation entries are filtered out at drain time. Subclasses
|
|
1286
1501
|
# can paint freely in {#repaint} without re-asserting attachment.
|
|
1287
1502
|
class Component
|
|
1503
|
+
FINAL_METHODS: ::Array[Symbol]
|
|
1504
|
+
|
|
1505
|
+
# Raises unless `klass` inherits every {FINAL_METHODS} entry from
|
|
1506
|
+
# {Component}; memoized, so it costs one hash lookup per construction.
|
|
1507
|
+
#
|
|
1508
|
+
# Comparing each *resolved* method's `owner` is what makes this catch all
|
|
1509
|
+
# four routes in — `def`, `define_method`, an included module, a `prepend`.
|
|
1510
|
+
# A `method_added` hook would fire earlier but see only the first two.
|
|
1511
|
+
#
|
|
1512
|
+
# _@param_ `klass` — the class being instantiated.
|
|
1513
|
+
def self.verify_final!: (Class klass) -> void
|
|
1514
|
+
|
|
1288
1515
|
def initialize: () -> void
|
|
1289
1516
|
|
|
1517
|
+
# _@return_ — `rect.size`.
|
|
1518
|
+
def size: () -> Size
|
|
1519
|
+
|
|
1520
|
+
# _@return_ — `rect.width`.
|
|
1521
|
+
def width: () -> Integer
|
|
1522
|
+
|
|
1523
|
+
# _@return_ — `rect.height`.
|
|
1524
|
+
def height: () -> Integer
|
|
1525
|
+
|
|
1526
|
+
# The size of the region this component paints, or `nil` (the default) to
|
|
1527
|
+
# declare nothing — in which case the whole {#rect} is treated as fair game
|
|
1528
|
+
# and the default {#repaint} blanks all of it. Override it when you paint
|
|
1529
|
+
# less: a one-row {Component::Checkbox} handed a tall column, or a
|
|
1530
|
+
# {Component::Select} used as a {Component::Popup}'s content and assigned the
|
|
1531
|
+
# whole inner box.
|
|
1532
|
+
#
|
|
1533
|
+
# It always sits at {#rect}'s top-left — which is why this is a {Size} and
|
|
1534
|
+
# not a {Rect}: an offset extent is not merely unsupported, it is
|
|
1535
|
+
# unrepresentable. Use {#extent_rect} where coordinates are wanted.
|
|
1536
|
+
#
|
|
1537
|
+
# **`nil` is not the same as `rect.size`.** `nil` says "I have not declared
|
|
1538
|
+
# what I paint, so clear everything before I do", which is what a
|
|
1539
|
+
# {Component::Label} with short text needs. A declared extent — even one that
|
|
1540
|
+
# happens to equal the rect, as a one-row {Component::Select} in a one-row
|
|
1541
|
+
# rect does — says "I paint this in full, don't blank it", which is what
|
|
1542
|
+
# keeps the default {#repaint} from dirtying cells it is about to redraw
|
|
1543
|
+
# (`D_progress_bar`). The base cannot tell those apart from the value alone;
|
|
1544
|
+
# that is what the `nil` carries.
|
|
1545
|
+
#
|
|
1546
|
+
# It flows **downward only**: no container consults it when dividing space,
|
|
1547
|
+
# so {#rect} still means exactly what the parent assigned (`D_extent`). Three
|
|
1548
|
+
# things read it, all of them this component or the framework painting it:
|
|
1549
|
+
# {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
|
|
1550
|
+
# against it so a click on that tail doesn't activate the widget, and a
|
|
1551
|
+
# dropdown anchors under it rather than under unused space.
|
|
1552
|
+
#
|
|
1553
|
+
# **An override promises to paint the extent in full**, so `super` in
|
|
1554
|
+
# {#repaint} blanks only what is outside it. The arithmetic is each widget's
|
|
1555
|
+
# own — caption width, painted strip, one row — and must not vary with
|
|
1556
|
+
# {#bg_color} (`D_boolean_fields`).
|
|
1557
|
+
def extent: () -> Size?
|
|
1558
|
+
|
|
1559
|
+
# {#extent} placed at {#rect}'s top-left, for the consumers that need
|
|
1560
|
+
# coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
|
|
1561
|
+
# the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
|
|
1562
|
+
# the whole {#rect}, so a generic caller never sees `nil`.
|
|
1563
|
+
def extent_rect: () -> Rect
|
|
1564
|
+
|
|
1290
1565
|
# _@return_ — the screen which owns this component.
|
|
1291
1566
|
def screen: () -> Screen
|
|
1292
1567
|
|
|
@@ -1301,14 +1576,29 @@ module Tuile
|
|
|
1301
1576
|
def effective_bg_color: () -> Color?
|
|
1302
1577
|
|
|
1303
1578
|
# Repaints the component. The default does the bookkeeping most components
|
|
1304
|
-
# need: it clears the background
|
|
1305
|
-
#
|
|
1306
|
-
#
|
|
1307
|
-
# children
|
|
1579
|
+
# need: it clears the background — unless the direct children already tile
|
|
1580
|
+
# {#rect}, in which case there is no gap to wipe and blanking cells they are
|
|
1581
|
+
# about to repaint would only make them dirty — and then re-invalidates
|
|
1582
|
+
# those children so they paint over the cleared area. That is what makes
|
|
1583
|
+
# mixed-width form layouts safe.
|
|
1308
1584
|
#
|
|
1309
1585
|
# Call `super` from your own `repaint` to inherit this. Skip it only if you
|
|
1310
1586
|
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
1311
1587
|
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
1588
|
+
#
|
|
1589
|
+
# **A widget that paints less than its rect declares an {#extent} rather than
|
|
1590
|
+
# skipping `super`.** The clear then covers only what is outside it, so the
|
|
1591
|
+
# cells it is about to repaint are not blanked first — blanking them would
|
|
1592
|
+
# mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
|
|
1593
|
+
#
|
|
1594
|
+
# **The children are re-invalidated whether or not they tile.** A container
|
|
1595
|
+
# that paints nothing of its own can only redraw its area *through* them, so
|
|
1596
|
+
# a tiling container that skipped this would be a dead end in the cascade: an
|
|
1597
|
+
# ancestor's `clear_background` wipes the whole ancestor rect — siblings and
|
|
1598
|
+
# grandchildren included — and re-invalidates only its *direct* children, so
|
|
1599
|
+
# the notice has to keep travelling down or the cleared cells are never
|
|
1600
|
+
# repainted. Cheap by construction: repainting the same glyphs leaves
|
|
1601
|
+
# {Buffer::Cell} unchanged, so nothing extra reaches the wire.
|
|
1312
1602
|
def repaint: () -> void
|
|
1313
1603
|
|
|
1314
1604
|
# Called when a key is pressed; override to act on keys you care about (the
|
|
@@ -1322,8 +1612,35 @@ module Tuile
|
|
|
1322
1612
|
# _@return_ — true if the key was handled, false if not.
|
|
1323
1613
|
def handle_key: (String _key) -> bool
|
|
1324
1614
|
|
|
1325
|
-
#
|
|
1326
|
-
#
|
|
1615
|
+
# Called when text is pasted while this component is on the focus chain;
|
|
1616
|
+
# override to accept it (the default reports every paste unhandled, and
|
|
1617
|
+
# unhandled text is dropped). Arrives whole and `\n`-normalized, so
|
|
1618
|
+
# `text.lines.size` is the paste's line count and a single mutation can
|
|
1619
|
+
# absorb it:
|
|
1620
|
+
#
|
|
1621
|
+
# def handle_paste(text)
|
|
1622
|
+
# self.caption = "[Pasted #{text.lines.size} lines]"
|
|
1623
|
+
# true
|
|
1624
|
+
# end
|
|
1625
|
+
#
|
|
1626
|
+
# Reaching here means the terminal said "this came from the clipboard" —
|
|
1627
|
+
# {Component::AbstractStringField} inserts it at the caret, which is why a
|
|
1628
|
+
# subclass that rebinds ENTER to submit needs no paste handling of its own
|
|
1629
|
+
# to stop firing once per pasted line.
|
|
1630
|
+
#
|
|
1631
|
+
# _@param_ `_text` — the pasted text.
|
|
1632
|
+
#
|
|
1633
|
+
# _@return_ — true if the paste was consumed.
|
|
1634
|
+
def handle_paste: (String _text) -> bool
|
|
1635
|
+
|
|
1636
|
+
# Focuses this component when left-clicked (if {#focusable?}), then hands the
|
|
1637
|
+
# event down to every child whose {#rect} contains the point — which is how a
|
|
1638
|
+
# click descends the tiled tree to a leaf.
|
|
1639
|
+
#
|
|
1640
|
+
# A widget that resolves clicks *inside* its own rect — mapping a point to a
|
|
1641
|
+
# row, or toggling an overlay — overrides this and does not call `super`.
|
|
1642
|
+
# Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
|
|
1643
|
+
# on the tail it doesn't paint never activates it.
|
|
1327
1644
|
#
|
|
1328
1645
|
# _@param_ `event`
|
|
1329
1646
|
def handle_mouse: (MouseEvent event) -> void
|
|
@@ -1399,15 +1716,10 @@ module Tuile
|
|
|
1399
1716
|
# _@return_ — absolute screen coordinates, or nil to hide.
|
|
1400
1717
|
def cursor_position: () -> Point?
|
|
1401
1718
|
|
|
1402
|
-
# _@return_ — formatted keyboard hint surfaced in the status bar by
|
|
1403
|
-
# {Screen} when this component is the active tiled window or the
|
|
1404
|
-
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
1405
|
-
def keyboard_hint: () -> String
|
|
1406
|
-
|
|
1407
1719
|
# Adopts `child`: places it in {#children} and wires its parent pointer.
|
|
1408
1720
|
#
|
|
1409
|
-
# add_child(
|
|
1410
|
-
# add_child(
|
|
1721
|
+
# add_child(content, at: 0) # the tiled layer, painted beneath …
|
|
1722
|
+
# add_child(@footer) # … and chrome appended, painted over it
|
|
1411
1723
|
#
|
|
1412
1724
|
# _@param_ `child` — must not already have a parent.
|
|
1413
1725
|
#
|
|
@@ -1506,6 +1818,14 @@ module Tuile
|
|
|
1506
1818
|
# Children with empty rects contribute zero, since they paint nothing.
|
|
1507
1819
|
def children_tile_rect?: () -> bool
|
|
1508
1820
|
|
|
1821
|
+
# Blanks the part of {#rect} outside {#extent} — the dead tail a widget that
|
|
1822
|
+
# paints less than it was given must not leave stale. Up to two regions,
|
|
1823
|
+
# since a narrowed extent leaves an L: the columns right of it, and the rows
|
|
1824
|
+
# below it. A `nil` extent declares nothing, so the whole rect is blanked.
|
|
1825
|
+
# Called by the default {#repaint}; a self-painter that skips `super` calls
|
|
1826
|
+
# it directly.
|
|
1827
|
+
def clear_outside_extent: () -> void
|
|
1828
|
+
|
|
1509
1829
|
# Clears the background: fills every cell with a blank in the
|
|
1510
1830
|
# {#effective_bg_color} (the terminal default when none is inherited).
|
|
1511
1831
|
#
|
|
@@ -1584,6 +1904,10 @@ module Tuile
|
|
|
1584
1904
|
# content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
|
|
1585
1905
|
# here. Subclasses overriding this must call `super` so an assigned
|
|
1586
1906
|
# {#on_theme_changed=} listener keeps firing.
|
|
1907
|
+
#
|
|
1908
|
+
# Plumbing an app overrides and never calls, hence protected — and
|
|
1909
|
+
# {Screen}, not being a {Component}, fans it out through `__send__`, so an
|
|
1910
|
+
# override is free to declare any visibility (`D_hook_visibility`).
|
|
1587
1911
|
attr_accessor on_theme_changed: Proc?
|
|
1588
1912
|
|
|
1589
1913
|
# A scrollable list of typed items, one row each, with cursor support.
|
|
@@ -1696,6 +2020,22 @@ module Tuile
|
|
|
1696
2020
|
# _@return_ — true if a match was found.
|
|
1697
2021
|
def select_prev: (String query, ?include_current: bool) -> bool
|
|
1698
2022
|
|
|
2023
|
+
# Moves the cursor to the item at `index`, scrolling it into view and
|
|
2024
|
+
# firing {#on_cursor_changed} — the positional member of the
|
|
2025
|
+
# {#select_next} / {#select_prev} family, for a caller that already knows
|
|
2026
|
+
# *which* item it wants:
|
|
2027
|
+
#
|
|
2028
|
+
# list.select(items.index(chosen))
|
|
2029
|
+
#
|
|
2030
|
+
# Refuses an index the current {#cursor} can't reach — out of range, or
|
|
2031
|
+
# anything at all under {Cursor::None} — rather than stranding the cursor
|
|
2032
|
+
# off-content.
|
|
2033
|
+
#
|
|
2034
|
+
# _@param_ `index`
|
|
2035
|
+
#
|
|
2036
|
+
# _@return_ — whether the cursor moved there.
|
|
2037
|
+
def select: (Integer index) -> bool
|
|
2038
|
+
|
|
1699
2039
|
# _@param_ `event`
|
|
1700
2040
|
def handle_mouse: (MouseEvent event) -> void
|
|
1701
2041
|
|
|
@@ -2037,6 +2377,364 @@ module Tuile
|
|
|
2037
2377
|
end
|
|
2038
2378
|
end
|
|
2039
2379
|
|
|
2380
|
+
# A one-child region: a place in the tree reserved for content that may be
|
|
2381
|
+
# absent, arrive late, or be swapped. The occupant fills the slot's {#rect}.
|
|
2382
|
+
#
|
|
2383
|
+
# Wire one per region at construction, then swap occupants through
|
|
2384
|
+
# {HasContent#content=} — which is how {Window} holds its footer:
|
|
2385
|
+
#
|
|
2386
|
+
# @footer_slot = Slot.new
|
|
2387
|
+
# add_child(@footer_slot) # the region, wired once
|
|
2388
|
+
# …
|
|
2389
|
+
# @footer_slot.content = new_footer # the occupant, swapped at will
|
|
2390
|
+
#
|
|
2391
|
+
# Because the slot never leaves {Component#children}, a swap's insert index
|
|
2392
|
+
# is always 0 — never a function of which *other* regions happen to be
|
|
2393
|
+
# occupied, which is the whole reason to reach for one (`D_slots`).
|
|
2394
|
+
#
|
|
2395
|
+
# An empty slot does not collapse: it keeps its rect and clears it, so a
|
|
2396
|
+
# dialog with no message shows the hole. Close the gap with a zero extent
|
|
2397
|
+
# from the parent, or assign an empty {Rect} to suppress the clear entirely
|
|
2398
|
+
# (what {Window} does with an absent footer); never detach it.
|
|
2399
|
+
#
|
|
2400
|
+
# Transparent to input: not {Component#focusable?},
|
|
2401
|
+
# {Component#handle_mouse} descends through it, and a departing occupant's
|
|
2402
|
+
# focus repair is handed to the container.
|
|
2403
|
+
class Slot < Component
|
|
2404
|
+
include Tuile::Component::HasContent
|
|
2405
|
+
|
|
2406
|
+
# _@param_ `content` — the initial occupant.
|
|
2407
|
+
def initialize: (?Component? content) -> void
|
|
2408
|
+
|
|
2409
|
+
# Hands the repair to the container rather than doing it here: the default
|
|
2410
|
+
# would move focus to `self`, and a slot is inert — no cursor, no keys,
|
|
2411
|
+
# nothing to bubble from.
|
|
2412
|
+
#
|
|
2413
|
+
# _@param_ `child` — the just-detached occupant.
|
|
2414
|
+
def on_child_removed: (Component child) -> void
|
|
2415
|
+
|
|
2416
|
+
# _@param_ `content`
|
|
2417
|
+
def layout: (Component content) -> void
|
|
2418
|
+
|
|
2419
|
+
# _@param_ `rect`
|
|
2420
|
+
def rect=: (Rect rect) -> void
|
|
2421
|
+
|
|
2422
|
+
def on_focus: () -> void
|
|
2423
|
+
end
|
|
2424
|
+
|
|
2425
|
+
# A one-row strip of captions with exactly one of them selected — the map of
|
|
2426
|
+
# where the user is. Knows nothing about content: pair it with
|
|
2427
|
+
# {Component::TabSheet} to swap panes, or swap views yourself from
|
|
2428
|
+
# {#on_tab_selected}.
|
|
2429
|
+
#
|
|
2430
|
+
# ␣Details␣│␣Payment␣│␣Shipping␣
|
|
2431
|
+
# ^^^^^^^ selected: bold, and highlighted while the strip has focus
|
|
2432
|
+
#
|
|
2433
|
+
# tabs = Component::Tabs.new
|
|
2434
|
+
# tabs.add_tab("Details") # the first tab is selected
|
|
2435
|
+
# payment = tabs.add_tab("Payment")
|
|
2436
|
+
# tabs.on_tab_selected = ->(index, tab) { show(index) }
|
|
2437
|
+
# tabs.selected = payment # fires the listener
|
|
2438
|
+
# payment.caption = "Payment ⚠" # repaints the strip
|
|
2439
|
+
#
|
|
2440
|
+
# LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter
|
|
2441
|
+
# to confirm — clamping at both ends rather than wrapping; a left click
|
|
2442
|
+
# selects the tab under the pointer. Everything else bubbles to an ancestor,
|
|
2443
|
+
# Enter, Space, Up, Down, Home and End included, so a form's default button
|
|
2444
|
+
# and the app's own keys keep working while the strip has focus.
|
|
2445
|
+
#
|
|
2446
|
+
# One tab stop for the whole strip: Tab moves *past* it, never between its
|
|
2447
|
+
# tabs. For a key of your own that switches tabs from elsewhere in the app,
|
|
2448
|
+
# bind it yourself and call {#select_next} / {#select_previous}.
|
|
2449
|
+
#
|
|
2450
|
+
# {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
|
|
2451
|
+
# `items=`: a tab is identity plus its own state, so the set grows and
|
|
2452
|
+
# shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
|
|
2453
|
+
#
|
|
2454
|
+
# == Sizing
|
|
2455
|
+
# Assign a {#rect} (typically from the surrounding {Layout}). One wider than
|
|
2456
|
+
# {#extent}`.width` leaves a dead tail; a narrower one **scrolls**. The strip
|
|
2457
|
+
# keeps the selected segment whole in view, moving its window by the minimum
|
|
2458
|
+
# needed, so arrowing into an off-screen tab brings that tab on screen — and
|
|
2459
|
+
# a click on a half-visible segment at an edge selects it and pulls it into
|
|
2460
|
+
# view. A `<` or `>` painted over an edge column says there is more strip
|
|
2461
|
+
# that way, as does the cut caption underneath it. The one thing that cannot
|
|
2462
|
+
# be shown whole is a caption wider than the entire rect: it shows its head
|
|
2463
|
+
# and clips its tail. The scroll offset itself is not API — the invariant is.
|
|
2464
|
+
#
|
|
2465
|
+
# == Implementation details
|
|
2466
|
+
# A segment is one space of padding, the caption, one space of padding, and
|
|
2467
|
+
# segments are joined by a single {DEFAULT_SEPARATOR} column. The padding
|
|
2468
|
+
# belongs to the segment: the highlight covers it and a click on it selects
|
|
2469
|
+
# the tab, while the separator column is chrome and selects nothing, like
|
|
2470
|
+
# the blank tail past {#extent}. One private `segments` method is the sole
|
|
2471
|
+
# source of that arithmetic — both the paint and the hit test read it, and
|
|
2472
|
+
# both offset it by the same scroll column, so a click cannot land on a tab
|
|
2473
|
+
# other than the one drawn under it — and it is
|
|
2474
|
+
# derived from the captions on each call rather than recorded during the
|
|
2475
|
+
# last paint, so a hit test is correct before the first paint.
|
|
2476
|
+
#
|
|
2477
|
+
# The selected caption is bold *always*, so the strip still says where you
|
|
2478
|
+
# are once focus has moved on, and additionally sits on
|
|
2479
|
+
# {Theme#active_bg_color} while the strip is on the focus chain. Bold is the
|
|
2480
|
+
# selection channel and not strip chrome: bolding every caption would leave
|
|
2481
|
+
# selection to the focus-gated background alone, and an unfocused strip
|
|
2482
|
+
# would then show no selection at all.
|
|
2483
|
+
class Tabs < Component
|
|
2484
|
+
DEFAULT_SEPARATOR: String
|
|
2485
|
+
|
|
2486
|
+
# _@param_ `separator` — see {#separator=}.
|
|
2487
|
+
def initialize: (?separator: (String | StyledString)) -> void
|
|
2488
|
+
|
|
2489
|
+
# _@return_ — `true` — the strip takes focus, so its arrows work.
|
|
2490
|
+
def focusable?: () -> bool
|
|
2491
|
+
|
|
2492
|
+
# _@return_ — `true` — one stop for the whole strip.
|
|
2493
|
+
def tab_stop?: () -> bool
|
|
2494
|
+
|
|
2495
|
+
# _@return_ — the selected tab; `nil` only while there are no tabs.
|
|
2496
|
+
def selected: () -> Tab?
|
|
2497
|
+
|
|
2498
|
+
# _@param_ `tab` — one of this strip's tabs.
|
|
2499
|
+
def selected=: (Tab tab) -> void
|
|
2500
|
+
|
|
2501
|
+
# Appends a tab and returns its handle. The first tab added becomes the
|
|
2502
|
+
# selection; later ones don't disturb it.
|
|
2503
|
+
#
|
|
2504
|
+
# _@param_ `caption` — parsed as {Tab#caption=} parses it.
|
|
2505
|
+
def add_tab: (?(String | StyledString)? caption) -> Tab
|
|
2506
|
+
|
|
2507
|
+
# Removes `tab` and detaches its handle permanently.
|
|
2508
|
+
#
|
|
2509
|
+
# The selection is never left dangling: removing the selected tab selects
|
|
2510
|
+
# whichever tab slid into its place (the new last tab, if it was the last),
|
|
2511
|
+
# and removing the final tab leaves {#selected} `nil`. Either way
|
|
2512
|
+
# {#on_tab_selected} fires — the empty case with `(nil, nil)`, since a
|
|
2513
|
+
# listener rendering from the selection has to be told to render nothing.
|
|
2514
|
+
#
|
|
2515
|
+
# _@param_ `tab` — one of this strip's tabs.
|
|
2516
|
+
def remove_tab: (Tab tab) -> void
|
|
2517
|
+
|
|
2518
|
+
# Selects the next tab, clamping at the last — the strip never wraps.
|
|
2519
|
+
# Public because it is the verb an app's own key binding drives.
|
|
2520
|
+
#
|
|
2521
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2522
|
+
def select_next: () -> bool
|
|
2523
|
+
|
|
2524
|
+
# Selects the previous tab, clamping at the first.
|
|
2525
|
+
#
|
|
2526
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2527
|
+
def select_previous: () -> bool
|
|
2528
|
+
|
|
2529
|
+
# The cells the strip actually paints: one row, as wide as its segments and
|
|
2530
|
+
# separators need, clipped to {#rect}. A layout routinely hands a strip a
|
|
2531
|
+
# window's full width for a 32-column strip — the extent is those 32
|
|
2532
|
+
# columns.
|
|
2533
|
+
#
|
|
2534
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
2535
|
+
# blank tail — or on a lower row, when the rect is taller than one —
|
|
2536
|
+
# selects nothing. It still *focuses*: {Component#handle_mouse}'s
|
|
2537
|
+
# click-to-focus is ungated by geometry.
|
|
2538
|
+
def extent: () -> Size
|
|
2539
|
+
|
|
2540
|
+
# Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
|
|
2541
|
+
# strip (the selection clamps). Every other key is left unhandled so it
|
|
2542
|
+
# bubbles to an ancestor; an empty strip handles nothing at all.
|
|
2543
|
+
#
|
|
2544
|
+
# _@param_ `key`
|
|
2545
|
+
def handle_key: (String key) -> bool
|
|
2546
|
+
|
|
2547
|
+
# Selects the tab under a left click; `super` runs first, so a click
|
|
2548
|
+
# anywhere in {#rect} still focuses.
|
|
2549
|
+
#
|
|
2550
|
+
# _@param_ `event`
|
|
2551
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2552
|
+
|
|
2553
|
+
def repaint: () -> void
|
|
2554
|
+
|
|
2555
|
+
# Re-syncs the scroll offset and repaints — what every change to the
|
|
2556
|
+
# captions, the separator or the selection ends in.
|
|
2557
|
+
def refresh: () -> void
|
|
2558
|
+
|
|
2559
|
+
# The rect's *width* is the only part of it the offset depends on, so this
|
|
2560
|
+
# hook is the whole geometry story; {Component#rect=} invalidates for us.
|
|
2561
|
+
def on_width_changed: () -> void
|
|
2562
|
+
|
|
2563
|
+
# Scrolls the minimum needed to show the selected segment whole, and is the
|
|
2564
|
+
# sole writer of {#left_column}. Idempotent, so every mutation site can
|
|
2565
|
+
# call it blindly; it returns the offset to `0` on its own once the strip
|
|
2566
|
+
# fits again, which is why no mutator owes a scroll-back branch.
|
|
2567
|
+
#
|
|
2568
|
+
# A segment wider than the whole rect cannot be shown whole: its head wins,
|
|
2569
|
+
# being the half of a caption that identifies it.
|
|
2570
|
+
def adjust_left_column: () -> void
|
|
2571
|
+
|
|
2572
|
+
# {StyledString#slice} *drops* a cluster straddling the window's edge
|
|
2573
|
+
# rather than half-painting it, which would leave the painted row a column
|
|
2574
|
+
# short and shift everything past the hole one column left — paint and hit
|
|
2575
|
+
# test would then disagree, silently and only for wide glyphs. So the
|
|
2576
|
+
# offset only ever lands on a cluster boundary. Snapping *forward* is the
|
|
2577
|
+
# safe direction: it gives up at most one column of the segment to the left
|
|
2578
|
+
# of the window, never of the one being revealed.
|
|
2579
|
+
#
|
|
2580
|
+
# _@param_ `column`
|
|
2581
|
+
#
|
|
2582
|
+
# _@return_ — the smallest cluster-boundary column `>= column`.
|
|
2583
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
2584
|
+
|
|
2585
|
+
# Paints the overflow cues over the windowed row's edge columns: `<` when
|
|
2586
|
+
# segments sit to the left of the window, `>` when more sit to the right.
|
|
2587
|
+
# ASCII by convention rather than by constant, as {Checkbox}'s brackets
|
|
2588
|
+
# are, and *overlaid* rather than given reserved columns — reserving would
|
|
2589
|
+
# make the window width a function of the offset computed from it.
|
|
2590
|
+
#
|
|
2591
|
+
# _@param_ `row` — the windowed row, as painted.
|
|
2592
|
+
def draw_cues: (StyledString row) -> void
|
|
2593
|
+
|
|
2594
|
+
# The cue keeps the style of the cell it covers, so one landing on the
|
|
2595
|
+
# selected segment doesn't punch a default-background hole in its
|
|
2596
|
+
# highlight.
|
|
2597
|
+
#
|
|
2598
|
+
# _@param_ `row` — the windowed row.
|
|
2599
|
+
#
|
|
2600
|
+
# _@param_ `column` — relative to {#rect}`.left`.
|
|
2601
|
+
#
|
|
2602
|
+
# _@param_ `glyph`
|
|
2603
|
+
def draw_cue: (StyledString row, Integer column, String glyph) -> void
|
|
2604
|
+
|
|
2605
|
+
# One `[tab, start_column, width]` triple per tab, in strip order, in
|
|
2606
|
+
# columns relative to {#rect}`.left`. A segment's width is its caption plus
|
|
2607
|
+
# the two padding columns; the separator columns between segments belong to
|
|
2608
|
+
# no segment.
|
|
2609
|
+
def segments: () -> ::Array[[Tab, Integer, Integer]]
|
|
2610
|
+
|
|
2611
|
+
# _@return_ — columns the strip would paint given an unlimited rect.
|
|
2612
|
+
def painted_width: () -> Integer
|
|
2613
|
+
|
|
2614
|
+
# _@param_ `point`
|
|
2615
|
+
#
|
|
2616
|
+
# _@return_ — the tab painted at `point`; `nil` for a separator
|
|
2617
|
+
# column, the blank tail, or a row the strip doesn't paint.
|
|
2618
|
+
def tab_at: (Point point) -> Tab?
|
|
2619
|
+
|
|
2620
|
+
# _@return_ — the whole strip as one row, unclipped: segments
|
|
2621
|
+
# left to right, joined by the separator. {#repaint} windows it to the
|
|
2622
|
+
# rect; nothing else may, since the window's own arithmetic is
|
|
2623
|
+
# {#adjust_left_column}'s.
|
|
2624
|
+
def strip_row: () -> StyledString
|
|
2625
|
+
|
|
2626
|
+
# _@param_ `tab`
|
|
2627
|
+
#
|
|
2628
|
+
# _@param_ `index`
|
|
2629
|
+
#
|
|
2630
|
+
# _@return_ — the caption between its padding columns, styled
|
|
2631
|
+
# for the selection.
|
|
2632
|
+
def segment_text: (Tab tab, Integer index) -> StyledString
|
|
2633
|
+
|
|
2634
|
+
# _@param_ `tab`
|
|
2635
|
+
#
|
|
2636
|
+
# _@return_ — the tab's position on this strip.
|
|
2637
|
+
def index_of!: (Tab tab) -> Integer
|
|
2638
|
+
|
|
2639
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
2640
|
+
#
|
|
2641
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2642
|
+
def step_selection: (Integer delta) -> bool
|
|
2643
|
+
|
|
2644
|
+
# _@param_ `index`
|
|
2645
|
+
def select_at: (Integer? index) -> void
|
|
2646
|
+
|
|
2647
|
+
# Stores the selection, repaints, and fires {#on_tab_selected} when the
|
|
2648
|
+
# selected *tab* changed.
|
|
2649
|
+
#
|
|
2650
|
+
# `previous` is passed in rather than read here because a removal can leave
|
|
2651
|
+
# the index numerically unchanged while a different tab sits under it —
|
|
2652
|
+
# remove the selected middle tab of three and index 1 now holds what used
|
|
2653
|
+
# to be index 2. Comparing indices would swallow that notification.
|
|
2654
|
+
#
|
|
2655
|
+
# _@param_ `index` — the new selection.
|
|
2656
|
+
#
|
|
2657
|
+
# _@param_ `previous` — the tab selected before the caller's change.
|
|
2658
|
+
def apply_selection: (Integer? index, Tab? previous) -> void
|
|
2659
|
+
|
|
2660
|
+
# Called with `@tabs` already shortened and `@selected_index` still holding
|
|
2661
|
+
# the pre-removal position.
|
|
2662
|
+
#
|
|
2663
|
+
# _@param_ `removed_index` — the position the removed tab held.
|
|
2664
|
+
#
|
|
2665
|
+
# _@return_ — where the selection lands.
|
|
2666
|
+
def selection_after_removing: (Integer removed_index) -> Integer?
|
|
2667
|
+
|
|
2668
|
+
# Called on every change of {#selected} with the new selection —
|
|
2669
|
+
# `(index, tab)`, or `(nil, nil)` once the last tab has been removed.
|
|
2670
|
+
#
|
|
2671
|
+
# It reports that the selection *changed*, not that the user pressed
|
|
2672
|
+
# something: arrows, a click, {#selected=} / {#selected_index=}, the
|
|
2673
|
+
# autoselect of the first {#add_tab} and the re-selection that follows
|
|
2674
|
+
# removing the selected tab all fire it. Re-selecting the tab already
|
|
2675
|
+
# selected fires nothing.
|
|
2676
|
+
attr_accessor on_tab_selected: Proc?
|
|
2677
|
+
|
|
2678
|
+
# _@return_ — the tabs, in strip order. Read-only by convention
|
|
2679
|
+
# (like {Component#children}) — grow and shrink it through {#add_tab} /
|
|
2680
|
+
# {#remove_tab}, which keep the selection consistent. Enumerate it to
|
|
2681
|
+
# find a tab: `tabs.find { |t| t.caption.to_s == "Payment" }`.
|
|
2682
|
+
attr_reader tabs: ::Array[Tab]
|
|
2683
|
+
|
|
2684
|
+
# _@return_ — the column painted between two segments.
|
|
2685
|
+
attr_accessor separator: (StyledString | String)
|
|
2686
|
+
|
|
2687
|
+
# _@return_ — the selected tab's position; `nil` only while
|
|
2688
|
+
# there are no tabs.
|
|
2689
|
+
attr_accessor selected_index: Integer?
|
|
2690
|
+
|
|
2691
|
+
# _@return_ — the strip column painted in {#rect}'s leftmost cell —
|
|
2692
|
+
# the horizontal scroll offset. `0` unless the strip overflows its rect;
|
|
2693
|
+
# {#adjust_left_column} is its sole writer.
|
|
2694
|
+
attr_reader left_column: Integer
|
|
2695
|
+
|
|
2696
|
+
# A single tab: a caption, plus its identity on the strip.
|
|
2697
|
+
#
|
|
2698
|
+
# tab = tabs.add_tab("Payment")
|
|
2699
|
+
# tab.caption = "Payment ⚠" # repaints the strip
|
|
2700
|
+
# tab.remove # `tab` now raises on every mutator
|
|
2701
|
+
#
|
|
2702
|
+
# Apps don't construct tabs; {Tabs#add_tab} mints them. A removed handle
|
|
2703
|
+
# raises {RuntimeError} on every mutator and on every reader that consults
|
|
2704
|
+
# the strip — answering confidently about a tab the strip no longer holds
|
|
2705
|
+
# would hide the bug. {#caption} and {#attached?} stay readable (the
|
|
2706
|
+
# caption lives here, so an error message can still name it), and
|
|
2707
|
+
# {#remove} is a silent no-op so a cleanup path can call it blindly.
|
|
2708
|
+
class Tab
|
|
2709
|
+
# _@param_ `strip` — the owning strip.
|
|
2710
|
+
#
|
|
2711
|
+
# _@param_ `caption` — already coerced by the caller.
|
|
2712
|
+
def initialize: (Tabs strip, StyledString caption) -> void
|
|
2713
|
+
|
|
2714
|
+
# _@return_ — `true` while the tab is owned by its {Tabs}; `false`
|
|
2715
|
+
# permanently once removed.
|
|
2716
|
+
def attached?: () -> bool
|
|
2717
|
+
|
|
2718
|
+
# _@return_ — whether this is the strip's selected tab.
|
|
2719
|
+
def selected?: () -> bool
|
|
2720
|
+
|
|
2721
|
+
# Removes this tab from its strip and detaches the handle permanently —
|
|
2722
|
+
# {Tabs#remove_tab} has what that does to the selection. Idempotent on an
|
|
2723
|
+
# already-removed tab, unlike the mutators.
|
|
2724
|
+
def remove: () -> void
|
|
2725
|
+
|
|
2726
|
+
def inspect: () -> String
|
|
2727
|
+
|
|
2728
|
+
def detach: () -> void
|
|
2729
|
+
|
|
2730
|
+
def check_attached: () -> void
|
|
2731
|
+
|
|
2732
|
+
# _@return_ — the label painted on the strip. Safe to read on
|
|
2733
|
+
# a removed tab.
|
|
2734
|
+
attr_accessor caption: (StyledString | String)?
|
|
2735
|
+
end
|
|
2736
|
+
end
|
|
2737
|
+
|
|
2040
2738
|
# A label which shows static text. No word-wrapping; long lines are
|
|
2041
2739
|
# truncated with an ellipsis. Text is modeled as a {StyledString};
|
|
2042
2740
|
# {#text=} accepts a {String} (parsed via {StyledString.parse}, so
|
|
@@ -2085,105 +2783,71 @@ module Tuile
|
|
|
2085
2783
|
attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
|
|
2086
2784
|
end
|
|
2087
2785
|
|
|
2088
|
-
#
|
|
2089
|
-
#
|
|
2090
|
-
#
|
|
2091
|
-
# {#size} the {Screen} applies.
|
|
2092
|
-
#
|
|
2093
|
-
# The popup does *not* size itself to its content. Its box is declared by
|
|
2094
|
-
# {#size} — a {Fraction} (resolved against the screen every layout pass, so
|
|
2095
|
-
# it tracks resize) or an absolute {Size} (clamped to the screen). The
|
|
2096
|
-
# default is {Fraction::HALF}: half the screen, centered. The wrapped
|
|
2097
|
-
# content then fills that box and handles its own overflow by wrapping and
|
|
2098
|
-
# scrolling, so use content that can — a {Component::TextView} or
|
|
2099
|
-
# {Component::TextArea} — for anything longer than fits. A
|
|
2100
|
-
# {Component::Label} only truncates.
|
|
2101
|
-
#
|
|
2102
|
-
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
2103
|
-
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
2104
|
-
# that floats above the content without taking focus or capturing input —
|
|
2105
|
-
# the caller positions it (via {#rect=}), sizes it, and drives it from app
|
|
2106
|
-
# code. That's the building block for an autocomplete/slash-command list
|
|
2107
|
-
# anchored to a text field's caret: typing keeps focus in the input while
|
|
2108
|
-
# the caller refills and drives the overlay.
|
|
2109
|
-
#
|
|
2110
|
-
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
2111
|
-
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
2112
|
-
# {Component::LogWindow}) and let it draw its own border:
|
|
2786
|
+
# A modal dialog: an {Component::Overlay} that centers itself on the screen,
|
|
2787
|
+
# grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
|
|
2788
|
+
# closes on ESC or `q`.
|
|
2113
2789
|
#
|
|
2114
2790
|
# window = Component::Window.new("Help")
|
|
2115
2791
|
# window.content = Component::List.new.tap { _1.lines = lines }
|
|
2116
2792
|
# Component::Popup.new(content: window).open
|
|
2117
2793
|
#
|
|
2118
2794
|
# Bare content also works (a {Component::Label}, a {Component::List}…), in
|
|
2119
|
-
# which case the popup is borderless.
|
|
2795
|
+
# which case the popup is borderless. For a floating layer that does *not*
|
|
2796
|
+
# take focus or capture input — an autocomplete list anchored to a field, a
|
|
2797
|
+
# toast — use {Component::Overlay} directly.
|
|
2798
|
+
#
|
|
2799
|
+
# The popup does *not* size itself to its content. Its box is set by
|
|
2800
|
+
# {#declared_size} — a {Fraction} (resolved against the screen every layout
|
|
2801
|
+
# pass, so it tracks resize) or an absolute {Size} (clamped to the screen).
|
|
2802
|
+
# The default is {Fraction::HALF}: half the screen, centered. The wrapped content
|
|
2803
|
+
# then fills that box and handles its own overflow by wrapping and scrolling,
|
|
2804
|
+
# so use content that can — a {Component::TextView} or
|
|
2805
|
+
# {Component::TextArea} — for anything longer than fits. A
|
|
2806
|
+
# {Component::Label} only truncates.
|
|
2807
|
+
#
|
|
2808
|
+
# == Implementation details
|
|
2120
2809
|
#
|
|
2121
2810
|
# `q` and ESC close the popup — handled here, at the top of the popup's own
|
|
2122
2811
|
# subtree, so the key only arrives after every component on the focus chain
|
|
2123
2812
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
2124
|
-
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
2125
|
-
#
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2813
|
+
# nested {Component::TextField} doesn't dismiss the popup: the field consumes
|
|
2814
|
+
# it first.
|
|
2815
|
+
#
|
|
2816
|
+
# A left click *outside* the popup closes it too — see
|
|
2817
|
+
# {Overlay#close_on_outside_click?} for the exact contract and
|
|
2818
|
+
# {Overlay#on_close=} for the notice a driver hears when it happens.
|
|
2819
|
+
#
|
|
2820
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
2821
|
+
class Popup < Tuile::Component::Overlay
|
|
2129
2822
|
# _@param_ `content` — initial content; can be set later via {#content=}. The content fills the popup's {#rect}; it does not determine the popup's size.
|
|
2130
2823
|
#
|
|
2131
|
-
# _@param_ `
|
|
2824
|
+
# _@param_ `declared_size` — the popup's box, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
2132
2825
|
#
|
|
2133
|
-
# _@param_ `
|
|
2134
|
-
def initialize: (?content: Component?, ?
|
|
2826
|
+
# _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this popup. See {Overlay#close_on_outside_click?}.
|
|
2827
|
+
def initialize: (?content: Component?, ?declared_size: (Size | Fraction), ?close_on_outside_click: bool) -> void
|
|
2135
2828
|
|
|
2136
|
-
# _@return_ —
|
|
2829
|
+
# _@return_ — true — a Popup scopes key dispatch, grabs focus on
|
|
2830
|
+
# open, and blocks clicks on the content beneath it.
|
|
2137
2831
|
def modal?: () -> bool
|
|
2138
2832
|
|
|
2833
|
+
# _@return_ — true — {ScreenPane#add_popup} focuses a popup on open,
|
|
2834
|
+
# and focus repair falls back to it when its subtree has no tab stop.
|
|
2139
2835
|
def focusable?: () -> bool
|
|
2140
2836
|
|
|
2141
|
-
#
|
|
2142
|
-
#
|
|
2143
|
-
#
|
|
2144
|
-
#
|
|
2145
|
-
# would repaint into the new rect and leave the vacated cells showing
|
|
2146
|
-
# stale content. When the new rect fully covers the old one (the popup
|
|
2147
|
-
# only grew), the fast path is correct and the full repaint is skipped.
|
|
2148
|
-
#
|
|
2149
|
-
# _@param_ `new_rect`
|
|
2150
|
-
def rect=: (Rect new_rect) -> void
|
|
2151
|
-
|
|
2152
|
-
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
2153
|
-
# current screen first.
|
|
2154
|
-
#
|
|
2155
|
-
# popup = Component::Popup.new(content: window).open # construct and mount
|
|
2156
|
-
#
|
|
2157
|
-
# There is deliberately no class-level `Popup.open` factory — see
|
|
2158
|
-
# `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
|
|
2159
|
-
# one-liner above available without one.
|
|
2160
|
-
def open: () -> self
|
|
2161
|
-
|
|
2162
|
-
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
2163
|
-
def close: () -> void
|
|
2164
|
-
|
|
2165
|
-
# _@return_ — true if this popup is currently mounted on the screen.
|
|
2166
|
-
def open?: () -> bool
|
|
2167
|
-
|
|
2168
|
-
# Re-resolves {#size} against the current screen and repositions the popup
|
|
2169
|
-
# *itself* (this is not laying out content — the popup's own rect): a
|
|
2170
|
-
# modal popup recenters; a non-modal overlay keeps its caller-assigned
|
|
2171
|
-
# top-left (only its size follows the screen). Called on {#open}, on
|
|
2172
|
-
# {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
|
|
2173
|
-
# SIGWINCH).
|
|
2837
|
+
# Re-resolves {#declared_size} against the current screen and recenters the popup
|
|
2838
|
+
# *itself* (this is not laying out content — the popup's own rect). Called
|
|
2839
|
+
# on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
|
|
2840
|
+
# so a {Fraction} tracks SIGWINCH.
|
|
2174
2841
|
#
|
|
2175
|
-
# The final rect is computed and assigned in one step rather than sizing
|
|
2176
|
-
#
|
|
2177
|
-
#
|
|
2842
|
+
# The final rect is computed and assigned in one step rather than sizing at
|
|
2843
|
+
# the origin and then centering: the intermediate origin rect rarely covers
|
|
2844
|
+
# the previous one, which would make {Overlay#rect=}'s shrink/move
|
|
2178
2845
|
# detection fire a full repaint on every resize.
|
|
2179
2846
|
def reposition: () -> void
|
|
2180
2847
|
|
|
2181
2848
|
# Recenters the popup on the screen, preserving its current width/height.
|
|
2182
2849
|
def center: () -> void
|
|
2183
2850
|
|
|
2184
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
2185
|
-
def keyboard_hint: () -> String
|
|
2186
|
-
|
|
2187
2851
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
2188
2852
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
2189
2853
|
# focused content after that content declined to handle it.
|
|
@@ -2193,18 +2857,14 @@ module Tuile
|
|
|
2193
2857
|
# _@return_ — true if the key was handled.
|
|
2194
2858
|
def handle_key: (String key) -> bool
|
|
2195
2859
|
|
|
2196
|
-
#
|
|
2860
|
+
# The box this popup asks for, *as set* — a {Fraction} comes back
|
|
2861
|
+
# unresolved. {#rect} is the resolved answer. Named apart from `size`
|
|
2862
|
+
# because a component's `size` is its `rect.size`, which this is not: it
|
|
2863
|
+
# is an input re-read on every layout pass, not a report of the current
|
|
2864
|
+
# geometry.
|
|
2197
2865
|
#
|
|
2198
|
-
# _@
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
# _@param_ `event`
|
|
2202
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
2203
|
-
|
|
2204
|
-
def on_focus: () -> void
|
|
2205
|
-
|
|
2206
|
-
# _@return_ — the popup's declared size. See {#size=}.
|
|
2207
|
-
attr_accessor size: (Size | Fraction)
|
|
2866
|
+
# _@return_ — see {#declared_size=}.
|
|
2867
|
+
attr_accessor declared_size: (Size | Fraction)
|
|
2208
2868
|
end
|
|
2209
2869
|
|
|
2210
2870
|
# A clickable button. Activated by Enter, Space, or a left mouse click;
|
|
@@ -2240,7 +2900,7 @@ module Tuile
|
|
|
2240
2900
|
# It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
|
|
2241
2901
|
# by geometry. Same rule as {Checkbox#extent}, which documents the two
|
|
2242
2902
|
# traps behind it.
|
|
2243
|
-
def extent: () ->
|
|
2903
|
+
def extent: () -> Size
|
|
2244
2904
|
|
|
2245
2905
|
# Fires {#on_click} on a left click within {#extent}; `super` runs first, so
|
|
2246
2906
|
# a click anywhere in {#rect} still focuses.
|
|
@@ -2303,11 +2963,6 @@ module Tuile
|
|
|
2303
2963
|
# _@param_ `child`
|
|
2304
2964
|
def remove: (Component child) -> void
|
|
2305
2965
|
|
|
2306
|
-
# Dispatches the event to the child under the mouse cursor.
|
|
2307
|
-
#
|
|
2308
|
-
# _@param_ `event`
|
|
2309
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
2310
|
-
|
|
2311
2966
|
def on_focus: () -> void
|
|
2312
2967
|
|
|
2313
2968
|
# How much space a child gets along one axis of a {Box}: exactly {#cells},
|
|
@@ -2739,7 +3394,7 @@ module Tuile
|
|
|
2739
3394
|
# Home/End are declined too, so they stay available app-wide.
|
|
2740
3395
|
#
|
|
2741
3396
|
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
2742
|
-
# the feedback removed (`DECISIONS.md` `
|
|
3397
|
+
# the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
|
|
2743
3398
|
# need no prefix-disambiguation.
|
|
2744
3399
|
#
|
|
2745
3400
|
# == Implementation details
|
|
@@ -2765,8 +3420,6 @@ module Tuile
|
|
|
2765
3420
|
|
|
2766
3421
|
def tab_stop?: () -> bool
|
|
2767
3422
|
|
|
2768
|
-
def keyboard_hint: () -> String
|
|
2769
|
-
|
|
2770
3423
|
# Re-anchors the (open) dropdown after a move or resize.
|
|
2771
3424
|
#
|
|
2772
3425
|
# _@param_ `new_rect`
|
|
@@ -2788,7 +3441,15 @@ module Tuile
|
|
|
2788
3441
|
# _@param_ `key`
|
|
2789
3442
|
def handle_key: (String key) -> bool
|
|
2790
3443
|
|
|
2791
|
-
#
|
|
3444
|
+
# The one row this Select paints — the full width, at the top of {#rect}.
|
|
3445
|
+
# A single-slot container ({Component::Window}, {Component::Popup}) hands
|
|
3446
|
+
# its content the whole inner rect, so a Select is routinely assigned more
|
|
3447
|
+
# height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
|
|
3448
|
+
# clicks in it, and the dropdown hangs under this rather than under the
|
|
3449
|
+
# unused space.
|
|
3450
|
+
def extent: () -> Size
|
|
3451
|
+
|
|
3452
|
+
# Toggles the dropdown on a left click anywhere in {#extent} — a field's
|
|
2792
3453
|
# affordance is its whole row, as the well advertises; `super` runs first,
|
|
2793
3454
|
# so the click also focuses.
|
|
2794
3455
|
#
|
|
@@ -2857,7 +3518,7 @@ module Tuile
|
|
|
2857
3518
|
# a read-only display field could override back to `false`. Only
|
|
2858
3519
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
2859
3520
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
2860
|
-
# `
|
|
3521
|
+
# `D_integer_field`).
|
|
2861
3522
|
def focusable?: () -> bool
|
|
2862
3523
|
|
|
2863
3524
|
# _@return_ — the options.
|
|
@@ -2887,8 +3548,18 @@ module Tuile
|
|
|
2887
3548
|
|
|
2888
3549
|
def focusable?: () -> bool
|
|
2889
3550
|
|
|
2890
|
-
# _@
|
|
2891
|
-
|
|
3551
|
+
# _@return_ — optional focusable component occupying the
|
|
3552
|
+
# bottom border row, always spanning the full inner width.
|
|
3553
|
+
def footer: () -> Component?
|
|
3554
|
+
|
|
3555
|
+
# Mounts a component in the bottom border row, spanning the full inner
|
|
3556
|
+
# width and positioned automatically; `nil` removes it.
|
|
3557
|
+
#
|
|
3558
|
+
# Precedence: a footer component present hides {#footer_text}; absent, the
|
|
3559
|
+
# text embeds into the bottom border. No window needs both at once.
|
|
3560
|
+
#
|
|
3561
|
+
# _@param_ `new_footer`
|
|
3562
|
+
def footer=: (Component? new_footer) -> void
|
|
2892
3563
|
|
|
2893
3564
|
# _@param_ `new_rect`
|
|
2894
3565
|
def rect=: (Rect new_rect) -> void
|
|
@@ -2939,9 +3610,12 @@ module Tuile
|
|
|
2939
3610
|
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2940
3611
|
def bottom_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2941
3612
|
|
|
2942
|
-
# Positions the footer over the bottom border row, spanning the full
|
|
3613
|
+
# Positions the footer slot over the bottom border row, spanning the full
|
|
2943
3614
|
# inner width (the only dimension a bottom-row widget needs — the window
|
|
2944
3615
|
# already knows it).
|
|
3616
|
+
#
|
|
3617
|
+
# An unoccupied slot gets an *empty* rect, not the row — a {Slot} clears
|
|
3618
|
+
# whatever it is given, which would blank the border underneath.
|
|
2945
3619
|
def layout_footer: () -> void
|
|
2946
3620
|
|
|
2947
3621
|
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
@@ -2959,17 +3633,169 @@ module Tuile
|
|
|
2959
3633
|
|
|
2960
3634
|
def on_focus: () -> void
|
|
2961
3635
|
|
|
2962
|
-
# _@return_ — optional focusable component occupying the
|
|
2963
|
-
# bottom border row, always spanning the full inner width.
|
|
2964
|
-
attr_accessor footer: Component?
|
|
2965
|
-
|
|
2966
3636
|
# _@return_ — optional chrome embedded into the bottom border
|
|
2967
3637
|
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
2968
3638
|
# whenever a {#footer} component is present.
|
|
2969
3639
|
attr_accessor footer_text: (StyledString | String)?
|
|
2970
3640
|
end
|
|
2971
3641
|
|
|
2972
|
-
# A
|
|
3642
|
+
# A component mounted on the {Screen}'s overlay stack rather than in the
|
|
3643
|
+
# tiled tree: it floats above the content at a rect the caller assigns, and
|
|
3644
|
+
# has an open/close lifecycle instead of a parent that lays it out.
|
|
3645
|
+
#
|
|
3646
|
+
# overlay = Component::Overlay.new(content: Component::Label.new("saved"))
|
|
3647
|
+
# overlay.rect = Rect.new(10, 4, 20, 1) # you place it — nothing else does
|
|
3648
|
+
# overlay.open # mounts it on the Screen
|
|
3649
|
+
# overlay.close
|
|
3650
|
+
#
|
|
3651
|
+
# That is the whole of it — floating, plus the lifecycle, {#on_close},
|
|
3652
|
+
# outside-click dismissal and {#owner}. {Component::Popup} is the subclass
|
|
3653
|
+
# that adds a declared size, self-centering, focus and key handling.
|
|
3654
|
+
#
|
|
3655
|
+
# The wrapped content fills the overlay's whole {#rect}; for a frame and a
|
|
3656
|
+
# caption, wrap a {Component::Window} and let it draw its own border.
|
|
3657
|
+
#
|
|
3658
|
+
# == Implementation details
|
|
3659
|
+
#
|
|
3660
|
+
# **{#focusable?} and {#modal?} move together — flip both or neither.** The
|
|
3661
|
+
# defaults here are inert (`false`, `false`): a bare overlay floats without
|
|
3662
|
+
# disturbing focus or key dispatch. {Component::Popup} flips both. What must
|
|
3663
|
+
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
|
|
3664
|
+
# scopes delivery to the topmost modal popup or else the tiled content, so
|
|
3665
|
+
# such an overlay would hold focus outside the key scope, where delivery
|
|
3666
|
+
# reaches nobody and *every* keystroke goes dead until Tab recovers. A
|
|
3667
|
+
# non-modal overlay is therefore driven from its owner's key handler
|
|
3668
|
+
# ({Component::Select} forwarding to its dropdown) instead of claiming focus.
|
|
3669
|
+
#
|
|
3670
|
+
# **A derived position needs a {#reposition} override.** The default is a
|
|
3671
|
+
# no-op: the rect is whatever the caller last assigned. An overlay whose
|
|
3672
|
+
# position is computed — from the screen, or from an anchor — must recompute
|
|
3673
|
+
# it there, or it keeps a stale rect after a SIGWINCH and sits off-screen
|
|
3674
|
+
# entirely if the terminal narrowed. Closing on resize is equally legal
|
|
3675
|
+
# ({Component::MenuBar} drops its cascade from `rect=` rather than walking
|
|
3676
|
+
# every level to re-anchor it).
|
|
3677
|
+
#
|
|
3678
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
3679
|
+
class Overlay < Component
|
|
3680
|
+
include Tuile::Component::HasContent
|
|
3681
|
+
|
|
3682
|
+
# _@param_ `content` — initial content; can be set later via {#content=}. It fills the overlay's {#rect} and does not determine it.
|
|
3683
|
+
#
|
|
3684
|
+
# _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this overlay. See {#close_on_outside_click?}.
|
|
3685
|
+
def initialize: (?content: Component?, ?close_on_outside_click: bool) -> void
|
|
3686
|
+
|
|
3687
|
+
# _@return_ — false — a bare overlay scopes no keys and blocks no
|
|
3688
|
+
# clicks. {Component::Popup} overrides it, together with {#focusable?}.
|
|
3689
|
+
def modal?: () -> bool
|
|
3690
|
+
|
|
3691
|
+
# _@return_ — false — a bare overlay leaves focus where it was. See
|
|
3692
|
+
# the class docs: override it only together with {#modal?}.
|
|
3693
|
+
def focusable?: () -> bool
|
|
3694
|
+
|
|
3695
|
+
# _@return_ — false — Tab never lands on the overlay wrapper itself
|
|
3696
|
+
# (its content may still carry stops).
|
|
3697
|
+
def tab_stop?: () -> bool
|
|
3698
|
+
|
|
3699
|
+
# Whether a left click outside this overlay closes it (default true). The
|
|
3700
|
+
# pane does the closing — {ScreenPane#handle_mouse} snapshots the open
|
|
3701
|
+
# overlays *before* routing the click and closes the dismissable ones
|
|
3702
|
+
# *after*, so a widget that toggles its own overlay from a click on its
|
|
3703
|
+
# face (a {Component::Select}, a {Component::MenuBar} title) still toggles
|
|
3704
|
+
# correctly: the delivered click closes the overlay and the dismissal then
|
|
3705
|
+
# no-ops on it, rather than closing and reopening it. Only `:left`
|
|
3706
|
+
# dismisses; scroll and right clicks never do.
|
|
3707
|
+
#
|
|
3708
|
+
# **"Outside" spans the {#owner} chain, not just this rect.** A click
|
|
3709
|
+
# counts as inside this overlay when it lands in its rect *or* in any
|
|
3710
|
+
# overlay that belongs to it — so a dialog is not dismissed by a click on a
|
|
3711
|
+
# dropdown its own field opened, and a menu cascade is not dismissed by a
|
|
3712
|
+
# click on one of its deeper panels. Overlays with no owner relationship
|
|
3713
|
+
# are independent: clicking one dismisses the other, which is what a
|
|
3714
|
+
# window-like overlay should do. One that must survive unrelated clicks
|
|
3715
|
+
# entirely ({Component::Notification}) sets this false.
|
|
3716
|
+
#
|
|
3717
|
+
# Every dismissable overlay closes, not just the topmost, and stacking
|
|
3718
|
+
# order plays no part: a {Component::MenuBar} cascade must vanish whole on
|
|
3719
|
+
# one click on the background, not peel one panel per click.
|
|
3720
|
+
def close_on_outside_click?: () -> bool
|
|
3721
|
+
|
|
3722
|
+
# Reassigns the overlay's rect, escalating to a full scene repaint when an
|
|
3723
|
+
# open overlay shrinks or moves so its new rect no longer covers the cells
|
|
3724
|
+
# it previously painted. An overlay overdraws the scene without clipping
|
|
3725
|
+
# and nothing clears underneath it, so {Screen#repaint}'s overlay-only fast
|
|
3726
|
+
# path would repaint into the new rect and leave the vacated cells showing
|
|
3727
|
+
# stale content. When the new rect fully covers the old one (the overlay
|
|
3728
|
+
# only grew), the fast path is correct and the full repaint is skipped.
|
|
3729
|
+
#
|
|
3730
|
+
# _@param_ `new_rect`
|
|
3731
|
+
def rect=: (Rect new_rect) -> void
|
|
3732
|
+
|
|
3733
|
+
# Mounts this overlay on the {Screen}.
|
|
3734
|
+
#
|
|
3735
|
+
# overlay = Component::Overlay.new(content: label).open # construct and mount
|
|
3736
|
+
#
|
|
3737
|
+
# There is deliberately no class-level `Overlay.open` factory — see
|
|
3738
|
+
# `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
|
|
3739
|
+
# one-liner above available without one.
|
|
3740
|
+
def open: () -> self
|
|
3741
|
+
|
|
3742
|
+
# Removes this overlay from the {Screen}. No-op if not currently open.
|
|
3743
|
+
def close: () -> void
|
|
3744
|
+
|
|
3745
|
+
# _@return_ — true if this overlay is currently mounted on the screen.
|
|
3746
|
+
def open?: () -> bool
|
|
3747
|
+
|
|
3748
|
+
# Recomputes this overlay's own rect (not its content's layout). A no-op
|
|
3749
|
+
# here — the rect is whatever the caller assigned — and the hook a subclass
|
|
3750
|
+
# with a *derived* position overrides; see the class docs. Called on
|
|
3751
|
+
# {#open} and by the screen's layout pass, so an override tracks SIGWINCH.
|
|
3752
|
+
def reposition: () -> void
|
|
3753
|
+
|
|
3754
|
+
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
3755
|
+
# the overlay's driver never hears that it closed.
|
|
3756
|
+
def on_detached: () -> void
|
|
3757
|
+
|
|
3758
|
+
# Content fills the overlay's full rect — an Overlay has no border to
|
|
3759
|
+
# subtract.
|
|
3760
|
+
#
|
|
3761
|
+
# _@param_ `content`
|
|
3762
|
+
def layout: (Component content) -> void
|
|
3763
|
+
|
|
3764
|
+
def on_focus: () -> void
|
|
3765
|
+
|
|
3766
|
+
# _@return_ — see {#close_on_outside_click?}.
|
|
3767
|
+
attr_writer close_on_outside_click: bool
|
|
3768
|
+
|
|
3769
|
+
# The component this overlay is *part of*, or `nil` (the default) when it
|
|
3770
|
+
# is an overlay in its own right. It exists for outside-click dismissal: a
|
|
3771
|
+
# click inside this overlay also counts as inside whatever overlay encloses
|
|
3772
|
+
# its owner, so the host is not dismissed by a click on a panel it put
|
|
3773
|
+
# there. See {#close_on_outside_click?}.
|
|
3774
|
+
#
|
|
3775
|
+
# Set it to the *driver* — {Component::ComboBox} hands its dropdown `self`
|
|
3776
|
+
# — rather than to the enclosing overlay: the driver knows what it is,
|
|
3777
|
+
# while the overlay above it is a tree relationship the pane resolves at
|
|
3778
|
+
# click time (so it cannot go stale). Any {Component} is accepted, and an
|
|
3779
|
+
# `Overlay` resolves to itself, which is how a
|
|
3780
|
+
# {Component::MenuBar::Cascade} chains each panel to the one it dropped out
|
|
3781
|
+
# of.
|
|
3782
|
+
attr_accessor owner: Component?
|
|
3783
|
+
|
|
3784
|
+
# A callback taking no arguments, fired once this overlay has left the
|
|
3785
|
+
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
3786
|
+
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
3787
|
+
# is the point, so it hangs off {#on_detached} rather than {#close}; a
|
|
3788
|
+
# driver keeping its own record of open overlays reconciles it here and
|
|
3789
|
+
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
3790
|
+
#
|
|
3791
|
+
# It fires *after* the overlay is detached, so {#open?} is already false
|
|
3792
|
+
# and the usual {Component#on_detached} caveats apply: release state, don't
|
|
3793
|
+
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
3794
|
+
# through closing a batch of overlays, and a raise propagates).
|
|
3795
|
+
attr_accessor on_close: Proc?
|
|
3796
|
+
end
|
|
3797
|
+
|
|
3798
|
+
# A boolean input on one row. Space, Enter or a left click toggles it:
|
|
2973
3799
|
#
|
|
2974
3800
|
# [x] Enable syslog forwarding
|
|
2975
3801
|
# [ ] Enable syslog forwarding
|
|
@@ -3051,7 +3877,7 @@ module Tuile
|
|
|
3051
3877
|
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
3052
3878
|
# tail, but a hit test that silently widened with a background would be a
|
|
3053
3879
|
# mode switch invisible in the code and untestable by inspection.
|
|
3054
|
-
def extent: () ->
|
|
3880
|
+
def extent: () -> Size
|
|
3055
3881
|
|
|
3056
3882
|
# Toggles on Space or Enter. Every other key is left unhandled so it bubbles
|
|
3057
3883
|
# to an ancestor.
|
|
@@ -3093,10 +3919,499 @@ module Tuile
|
|
|
3093
3919
|
# a read-only display field could override back to `false`. Only
|
|
3094
3920
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3095
3921
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3096
|
-
# `
|
|
3922
|
+
# `D_integer_field`).
|
|
3097
3923
|
def focusable?: () -> bool
|
|
3098
3924
|
end
|
|
3099
3925
|
|
|
3926
|
+
# A one-row strip of menu captions, each dropping open a cascade of submenus
|
|
3927
|
+
# that nests as deep as you build it.
|
|
3928
|
+
#
|
|
3929
|
+
# ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
|
|
3930
|
+
# ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
|
|
3931
|
+
# ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
|
|
3932
|
+
# ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
|
|
3933
|
+
#
|
|
3934
|
+
# bar = Component::MenuBar.new
|
|
3935
|
+
# file = bar.add_item("File", mnemonic: "f")
|
|
3936
|
+
# file.add_item("New", mnemonic: "n") { new_document }
|
|
3937
|
+
# recent = file.add_item("Recent") # no block ⇒ a submenu holder
|
|
3938
|
+
# recent.add_item("notes.txt") { open("notes.txt") }
|
|
3939
|
+
# bar.add_item("Quit") { screen.close } # a top-level leaf: a button
|
|
3940
|
+
#
|
|
3941
|
+
# LEFT / RIGHT move along the strip; Enter, Space or Down opens the
|
|
3942
|
+
# highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
|
|
3943
|
+
# the highlight, Enter or Space activates a row or opens its submenu, RIGHT
|
|
3944
|
+
# opens a submenu, LEFT returns to the previous menu, ESC closes one level.
|
|
3945
|
+
# LEFT at the first level and RIGHT on a row with no submenu step to the
|
|
3946
|
+
# sibling menu, as they do in every menu bar. Book ch7 has the table.
|
|
3947
|
+
#
|
|
3948
|
+
# == Mnemonics
|
|
3949
|
+
# An item given a `mnemonic:` answers to that letter, underlined in its
|
|
3950
|
+
# caption wherever it occurs — on the strip and in the panels, focused or
|
|
3951
|
+
# not (there is no Alt key to reveal them with). Matching is **level-scoped
|
|
3952
|
+
# with no fallback**: the top-level items while the cascade is closed, the
|
|
3953
|
+
# deepest open panel's items while it is open, and nothing else is ever
|
|
3954
|
+
# consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
|
|
3955
|
+
# two items on *different* levels may share a letter with nothing to
|
|
3956
|
+
# arbitrate, and only siblings compete — a duplicate among them raises at
|
|
3957
|
+
# {#add_item}. A letter matching nothing on the live level is swallowed and
|
|
3958
|
+
# rings {Screen#beep}; it never falls out to a shallower level and switches
|
|
3959
|
+
# menus. A mnemonic shadows what the app (or an ancestor, including a
|
|
3960
|
+
# {Popup}'s `q`-to-close) would do with that key while the bar has focus.
|
|
3961
|
+
# A paste can never fire one — pasted text rides its own path off the key
|
|
3962
|
+
# ladder.
|
|
3963
|
+
#
|
|
3964
|
+
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
3965
|
+
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
3966
|
+
# rebuilding: a menu is built once, at construction. See `DECISIONS.md`
|
|
3967
|
+
# `D_menu_bar`.
|
|
3968
|
+
#
|
|
3969
|
+
# == Sizing
|
|
3970
|
+
# Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
|
|
3971
|
+
# {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
|
|
3972
|
+
# narrower one **scrolls** to keep the highlighted segment whole, cueing the
|
|
3973
|
+
# hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
|
|
3974
|
+
# does — so a bar wider than its terminal stays wholly reachable by arrow,
|
|
3975
|
+
# mnemonic and click. Reassigning the rect
|
|
3976
|
+
# **closes** an open cascade: every panel position is derived from a segment
|
|
3977
|
+
# or a parent row, so after a resize they would all sit at stale columns, and
|
|
3978
|
+
# a resize with a menu open is rare enough that closing beats re-anchoring
|
|
3979
|
+
# every level.
|
|
3980
|
+
#
|
|
3981
|
+
# == Implementation details
|
|
3982
|
+
# Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
|
|
3983
|
+
# share: no separator between segments, no bold, and no highlight at all
|
|
3984
|
+
# while unfocused — a menu bar has no persistent selection to show, and a
|
|
3985
|
+
# reader should not have to work out which of the two controls they are
|
|
3986
|
+
# looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
|
|
3987
|
+
# both the paint and the click and both offset it by the same scroll column,
|
|
3988
|
+
# so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
|
|
3989
|
+
# call so a hit test is correct before the first paint.
|
|
3990
|
+
#
|
|
3991
|
+
# The open panels are overlays owned by a private {Cascade}, not children:
|
|
3992
|
+
# focus stays here for the whole interaction, so the strip receives every key
|
|
3993
|
+
# and forwards it. An open cascade swallows keys the cascade doesn't
|
|
3994
|
+
# recognize; a *closed* strip lets every printable bubble, so an app's
|
|
3995
|
+
# `s`-to-save keeps working while the bar has focus.
|
|
3996
|
+
#
|
|
3997
|
+
# A click outside an open cascade is not blocked — non-modal overlays block
|
|
3998
|
+
# nothing — but any click on a focusable component moves focus, and losing
|
|
3999
|
+
# focus closes the cascade.
|
|
4000
|
+
#
|
|
4001
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4002
|
+
class MenuBar < Component
|
|
4003
|
+
def initialize: () -> void
|
|
4004
|
+
|
|
4005
|
+
# _@return_ — `true` — the strip takes focus, so its keys work.
|
|
4006
|
+
def focusable?: () -> bool
|
|
4007
|
+
|
|
4008
|
+
# _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
|
|
4009
|
+
def tab_stop?: () -> bool
|
|
4010
|
+
|
|
4011
|
+
# _@return_ — the top-level items, in strip order. Read-only by
|
|
4012
|
+
# convention; grow it through {#add_item}.
|
|
4013
|
+
def items: () -> ::Array[Item]
|
|
4014
|
+
|
|
4015
|
+
# Appends a top-level item and returns its handle; nest submenus into it
|
|
4016
|
+
# with {Item#add_item}.
|
|
4017
|
+
#
|
|
4018
|
+
# _@param_ `caption` — parsed as {StyledString.parse} parses it.
|
|
4019
|
+
#
|
|
4020
|
+
# _@param_ `mnemonic` — a single one-column printable character that activates this item while the strip is focused and *closed*, underlined in the caption where it occurs. Matched case-insensitively; it shadows whatever the app would otherwise do with that key while the bar has focus.
|
|
4021
|
+
def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
|
|
4022
|
+
|
|
4023
|
+
# The cells the strip actually paints: one row, as wide as its segments
|
|
4024
|
+
# need, clipped to {#rect}.
|
|
4025
|
+
#
|
|
4026
|
+
# Both the highlight and the click hit test use it, so a click on the blank
|
|
4027
|
+
# tail — or on a lower row, when the rect is taller than one — opens
|
|
4028
|
+
# nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
|
|
4029
|
+
# ungated by geometry.
|
|
4030
|
+
def extent: () -> Size
|
|
4031
|
+
|
|
4032
|
+
# _@param_ `new_rect`
|
|
4033
|
+
def rect=: (Rect new_rect) -> void
|
|
4034
|
+
|
|
4035
|
+
# Closes the cascade when the strip leaves the focus chain, so tabbing (or
|
|
4036
|
+
# clicking) away doesn't strand an open menu.
|
|
4037
|
+
#
|
|
4038
|
+
# _@param_ `flag`
|
|
4039
|
+
def active=: (bool flag) -> void
|
|
4040
|
+
|
|
4041
|
+
# Closes the cascade, so a bar removed from the tree can't strand its
|
|
4042
|
+
# panels on the pane — they are the {ScreenPane}'s children, not the bar's,
|
|
4043
|
+
# so nothing else would take them down.
|
|
4044
|
+
def on_detached: () -> void
|
|
4045
|
+
|
|
4046
|
+
# Offers the key to the open cascade first, then to the strip's own
|
|
4047
|
+
# LEFT/RIGHT/Enter/Space/Down.
|
|
4048
|
+
#
|
|
4049
|
+
# With a cascade open, the only keys reaching the strip are the two the
|
|
4050
|
+
# cascade declines — LEFT at the first level, RIGHT on a row with no
|
|
4051
|
+
# submenu — and both step to the sibling menu.
|
|
4052
|
+
#
|
|
4053
|
+
# _@param_ `key`
|
|
4054
|
+
def handle_key: (String key) -> bool
|
|
4055
|
+
|
|
4056
|
+
# Opens the menu under a left click, or closes it when it is already the
|
|
4057
|
+
# open one; `super` runs first, so a click anywhere in {#rect} still
|
|
4058
|
+
# focuses.
|
|
4059
|
+
#
|
|
4060
|
+
# _@param_ `event`
|
|
4061
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4062
|
+
|
|
4063
|
+
def repaint: () -> void
|
|
4064
|
+
|
|
4065
|
+
# The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
|
|
4066
|
+
# offset and repaints. Every path that moves the highlight — arrow,
|
|
4067
|
+
# mnemonic, click — goes through it, so the highlighted segment is on
|
|
4068
|
+
# screen *before* {Cascade} anchors a panel to it.
|
|
4069
|
+
#
|
|
4070
|
+
# _@param_ `index`
|
|
4071
|
+
def highlight=: (Integer index) -> void
|
|
4072
|
+
|
|
4073
|
+
# Re-syncs the scroll offset and repaints — what every change to the items
|
|
4074
|
+
# or the highlight ends in.
|
|
4075
|
+
def refresh: () -> void
|
|
4076
|
+
|
|
4077
|
+
# The rect's *width* is the only part of it the offset depends on, so this
|
|
4078
|
+
# hook is the whole geometry story; {Component#rect=} invalidates for us,
|
|
4079
|
+
# and {#rect=} closes the cascade rather than re-anchoring it.
|
|
4080
|
+
def on_width_changed: () -> void
|
|
4081
|
+
|
|
4082
|
+
# Scrolls the minimum needed to show the highlighted segment whole, and is
|
|
4083
|
+
# the sole writer of {#left_column}. Idempotent, so every mutation site can
|
|
4084
|
+
# call it blindly; it returns the offset to `0` on its own once the strip
|
|
4085
|
+
# fits again, which is why no mutator owes a scroll-back branch.
|
|
4086
|
+
#
|
|
4087
|
+
# A segment wider than the whole rect cannot be shown whole: its head wins,
|
|
4088
|
+
# being the half of a caption that identifies it.
|
|
4089
|
+
def adjust_left_column: () -> void
|
|
4090
|
+
|
|
4091
|
+
# {StyledString#slice} *drops* a cluster straddling the window's edge
|
|
4092
|
+
# rather than half-painting it, which would leave the painted row a column
|
|
4093
|
+
# short and shift everything past the hole one column left — paint and hit
|
|
4094
|
+
# test would then disagree, silently and only for wide glyphs. So the
|
|
4095
|
+
# offset only ever lands on a cluster boundary. Snapping *forward* is the
|
|
4096
|
+
# safe direction: it gives up at most one column of the segment to the left
|
|
4097
|
+
# of the window, never of the one being revealed.
|
|
4098
|
+
#
|
|
4099
|
+
# _@param_ `column`
|
|
4100
|
+
#
|
|
4101
|
+
# _@return_ — the smallest cluster-boundary column `>= column`.
|
|
4102
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
4103
|
+
|
|
4104
|
+
# Paints the overflow cues over the windowed row's edge columns: `<` when
|
|
4105
|
+
# segments sit to the left of the window, `>` when more sit to the right.
|
|
4106
|
+
# ASCII by convention rather than by constant, as {Checkbox}'s brackets
|
|
4107
|
+
# are, and *overlaid* rather than given reserved columns — reserving would
|
|
4108
|
+
# make the window width a function of the offset computed from it. Painted
|
|
4109
|
+
# focused or not: overflow is a fact about the captions and the rect, not
|
|
4110
|
+
# about focus.
|
|
4111
|
+
#
|
|
4112
|
+
# _@param_ `row` — the windowed row, as painted.
|
|
4113
|
+
def draw_cues: (StyledString row) -> void
|
|
4114
|
+
|
|
4115
|
+
# The cue keeps the style of the cell it covers, so one landing on the
|
|
4116
|
+
# highlighted segment doesn't punch a default-background hole in its
|
|
4117
|
+
# highlight.
|
|
4118
|
+
#
|
|
4119
|
+
# _@param_ `row` — the windowed row.
|
|
4120
|
+
#
|
|
4121
|
+
# _@param_ `column` — relative to {#rect}`.left`.
|
|
4122
|
+
#
|
|
4123
|
+
# _@param_ `glyph`
|
|
4124
|
+
def draw_cue: (StyledString row, Integer column, String glyph) -> void
|
|
4125
|
+
|
|
4126
|
+
# One `[item, start_column, width]` triple per top-level item, in strip
|
|
4127
|
+
# order, in columns relative to {#rect}`.left`. A segment is its caption
|
|
4128
|
+
# between two padding columns, and neighbours abut — the two blank columns
|
|
4129
|
+
# between captions are the segments' own padding, so a click on either
|
|
4130
|
+
# opens the menu it belongs to.
|
|
4131
|
+
def segments: () -> ::Array[[Item, Integer, Integer]]
|
|
4132
|
+
|
|
4133
|
+
# _@return_ — columns the strip would paint given an unlimited rect.
|
|
4134
|
+
def painted_width: () -> Integer
|
|
4135
|
+
|
|
4136
|
+
# _@param_ `point`
|
|
4137
|
+
#
|
|
4138
|
+
# _@return_ — the index of the item painted at `point`; `nil`
|
|
4139
|
+
# for the blank tail or a row the strip doesn't paint.
|
|
4140
|
+
def index_at: (Point point) -> Integer?
|
|
4141
|
+
|
|
4142
|
+
# _@param_ `index`
|
|
4143
|
+
#
|
|
4144
|
+
# _@return_ — the segment's cells on screen — the cascade's anchor.
|
|
4145
|
+
def segment_rect: (Integer index) -> Rect
|
|
4146
|
+
|
|
4147
|
+
# _@return_ — the whole strip as one row, unclipped. {#repaint}
|
|
4148
|
+
# windows it to the rect; nothing else may, since the window's own
|
|
4149
|
+
# arithmetic is {#adjust_left_column}'s.
|
|
4150
|
+
def strip_row: () -> StyledString
|
|
4151
|
+
|
|
4152
|
+
# _@param_ `item`
|
|
4153
|
+
#
|
|
4154
|
+
# _@param_ `index`
|
|
4155
|
+
#
|
|
4156
|
+
# _@return_ — the caption between its padding columns,
|
|
4157
|
+
# highlighted when it is the one Enter would open *and* the strip has
|
|
4158
|
+
# focus. An unfocused strip shows no highlight at all: there is no
|
|
4159
|
+
# persistent selection to report.
|
|
4160
|
+
def segment_text: (Item item, Integer index) -> StyledString
|
|
4161
|
+
|
|
4162
|
+
# Activates the item bound to `key` on the *live* level — the deepest open
|
|
4163
|
+
# panel while the cascade is open, the top-level strip while it is closed.
|
|
4164
|
+
# No fallback between the two: a letter matching nothing in the live set is
|
|
4165
|
+
# not offered to any other level.
|
|
4166
|
+
#
|
|
4167
|
+
# _@param_ `key`
|
|
4168
|
+
#
|
|
4169
|
+
# _@return_ — whether a mnemonic claimed the key.
|
|
4170
|
+
def handle_mnemonic: (String key) -> bool
|
|
4171
|
+
|
|
4172
|
+
# Moves the highlight along the strip, clamping at both ends. Consumes the
|
|
4173
|
+
# key even at an end, as {Tabs} does.
|
|
4174
|
+
#
|
|
4175
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
4176
|
+
#
|
|
4177
|
+
# _@return_ — `false` only when there are no items.
|
|
4178
|
+
def move_highlight: (Integer delta) -> bool
|
|
4179
|
+
|
|
4180
|
+
# Steps to the neighbouring menu, showing *its* menu instead — or closing
|
|
4181
|
+
# the cascade, when the neighbour is a top-level button with no menu to
|
|
4182
|
+
# show. The cascade is left alone when the highlight is already at an end:
|
|
4183
|
+
# reopening the same menu would throw away the submenu the user is standing
|
|
4184
|
+
# in.
|
|
4185
|
+
#
|
|
4186
|
+
# It deliberately never *activates*. An item arrowed past is highlighted,
|
|
4187
|
+
# not pressed, so a top-level button waits for Enter or Space — otherwise
|
|
4188
|
+
# walking the strip would fire every button on it.
|
|
4189
|
+
#
|
|
4190
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
4191
|
+
#
|
|
4192
|
+
# _@return_ — always `true`: an open menu swallows the key either way.
|
|
4193
|
+
def step_menu: (Integer delta) -> bool
|
|
4194
|
+
|
|
4195
|
+
# Opens the highlighted item's menu, or fires it when it is a top-level
|
|
4196
|
+
# button — the Enter/Space/Down/click path, and the only one that fires a
|
|
4197
|
+
# listener.
|
|
4198
|
+
#
|
|
4199
|
+
# _@return_ — `false` only when there are no items.
|
|
4200
|
+
def open_highlighted: () -> bool
|
|
4201
|
+
|
|
4202
|
+
# Shows the highlighted item's menu, closing the cascade when it has none.
|
|
4203
|
+
def show_highlighted_menu: () -> void
|
|
4204
|
+
|
|
4205
|
+
# _@return_ — which top-level item the strip highlights while
|
|
4206
|
+
# focused, and which menu Enter opens. `0` until the user moves.
|
|
4207
|
+
attr_reader highlighted_index: Integer
|
|
4208
|
+
|
|
4209
|
+
# _@return_ — the strip column painted in {#rect}'s leftmost cell —
|
|
4210
|
+
# the horizontal scroll offset. `0` unless the strip overflows its rect;
|
|
4211
|
+
# {#adjust_left_column} is its sole writer.
|
|
4212
|
+
attr_reader left_column: Integer
|
|
4213
|
+
|
|
4214
|
+
# One menu item: a caption, an optional click listener, and its children.
|
|
4215
|
+
#
|
|
4216
|
+
# file = bar.add_item("File") # minted by the bar
|
|
4217
|
+
# file.add_item("New") { create } # …and nested by the same method
|
|
4218
|
+
# file.items.size # => 1
|
|
4219
|
+
#
|
|
4220
|
+
# An item with children is a submenu and its own listener is dead
|
|
4221
|
+
# ({#submenu?} decides). An item with **neither** children nor a listener is
|
|
4222
|
+
# legal and inert: it highlights, Enter closes the menu, nothing happens —
|
|
4223
|
+
# an item that looks live but does nothing is the app's error to fix, not
|
|
4224
|
+
# the framework's to raise on.
|
|
4225
|
+
#
|
|
4226
|
+
# Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
|
|
4227
|
+
class Item
|
|
4228
|
+
# _@param_ `caption` — already coerced by the caller.
|
|
4229
|
+
#
|
|
4230
|
+
# _@param_ `mnemonic` — already validated by the caller, in the case it was given in.
|
|
4231
|
+
#
|
|
4232
|
+
# _@param_ `on_click`
|
|
4233
|
+
def initialize: (StyledString caption, String? mnemonic, (Proc | Method)? on_click) -> void
|
|
4234
|
+
|
|
4235
|
+
# _@return_ — whether this item opens a submenu, i.e. has children.
|
|
4236
|
+
def submenu?: () -> bool
|
|
4237
|
+
|
|
4238
|
+
# Appends a child and returns its handle.
|
|
4239
|
+
#
|
|
4240
|
+
# _@param_ `caption` — parsed as {StyledString.parse} parses it.
|
|
4241
|
+
#
|
|
4242
|
+
# _@param_ `mnemonic` — the letter that activates this child while *this* item's children are the live level; see {MenuBar#add_item}.
|
|
4243
|
+
def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
|
|
4244
|
+
|
|
4245
|
+
def inspect: () -> String
|
|
4246
|
+
|
|
4247
|
+
# Rejects a mnemonic that couldn't work, or that would make two siblings
|
|
4248
|
+
# ambiguous — all three at *registration*, since none has a sane answer
|
|
4249
|
+
# at keypress time.
|
|
4250
|
+
#
|
|
4251
|
+
# _@param_ `mnemonic`
|
|
4252
|
+
def validate_mnemonic: (String? mnemonic) -> void
|
|
4253
|
+
|
|
4254
|
+
# {StyledString#slice} counts **columns** while a caption search yields a
|
|
4255
|
+
# **character** index, so the prefix is measured, never counted.
|
|
4256
|
+
#
|
|
4257
|
+
# _@param_ `caption`
|
|
4258
|
+
#
|
|
4259
|
+
# _@param_ `mnemonic` — in the case it was given in.
|
|
4260
|
+
def build_cued_caption: (StyledString caption, String? mnemonic) -> StyledString
|
|
4261
|
+
|
|
4262
|
+
# _@return_ — the label painted on the strip or the row.
|
|
4263
|
+
attr_reader caption: StyledString
|
|
4264
|
+
|
|
4265
|
+
# _@return_ — the downcased letter that activates this item
|
|
4266
|
+
# while its own level is the live one; `nil` when it has none.
|
|
4267
|
+
attr_reader mnemonic: String?
|
|
4268
|
+
|
|
4269
|
+
# _@return_ — {#caption} with the {#mnemonic} underlined —
|
|
4270
|
+
# what both paint sites draw. Equal to {#caption} when there is no
|
|
4271
|
+
# mnemonic or the caption doesn't contain it. Computed once, at
|
|
4272
|
+
# construction: caption and mnemonic are both fixed there, and
|
|
4273
|
+
# underline is a plain attribute with no theme or `bg_color` input, so
|
|
4274
|
+
# this is not a cached theme value.
|
|
4275
|
+
attr_reader cued_caption: StyledString
|
|
4276
|
+
|
|
4277
|
+
# _@return_ — this item's children, in menu order. Read-only by
|
|
4278
|
+
# convention, like {Component#children} — grow it through {#add_item}.
|
|
4279
|
+
attr_reader items: ::Array[Item]
|
|
4280
|
+
|
|
4281
|
+
# _@return_ — no-arg callable fired when the item is
|
|
4282
|
+
# activated (Enter, Space or a left click), exactly as
|
|
4283
|
+
# {Button#on_click}. Never fired on an item with children.
|
|
4284
|
+
attr_accessor on_click: (Proc | Method)?
|
|
4285
|
+
end
|
|
4286
|
+
|
|
4287
|
+
# The stack of open menu panels — one {ListDropdown} per level, the last
|
|
4288
|
+
# deepest — and the drill/pop/activate logic driving them. Private
|
|
4289
|
+
# machinery of {MenuBar}; an app never names it.
|
|
4290
|
+
#
|
|
4291
|
+
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
4292
|
+
# return true if cascade.handle_key(key) # MenuBar#handle_key, first
|
|
4293
|
+
# cascade.close # focus lost, or rect changed
|
|
4294
|
+
#
|
|
4295
|
+
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
4296
|
+
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
4297
|
+
# arrives via {MenuBar#handle_key}, which offers it here first. That is
|
|
4298
|
+
# {Component::Select}'s architecture extended to N levels, and it is why
|
|
4299
|
+
# nothing in the key-dispatch ladder changes.
|
|
4300
|
+
#
|
|
4301
|
+
# Widths are measured here, per level — the panel is as wide as the level's
|
|
4302
|
+
# widest label — because {ListDropdown} deliberately measures nothing
|
|
4303
|
+
# itself (`DECISIONS.md` `D_select`).
|
|
4304
|
+
#
|
|
4305
|
+
# == Implementation details
|
|
4306
|
+
# While open it consumes **everything** except the two keys that mean
|
|
4307
|
+
# "leave this menu sideways", which only the strip can answer: LEFT at
|
|
4308
|
+
# depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
|
|
4309
|
+
# — firing an app's `s`-to-save behind a visible panel is worse than a dead
|
|
4310
|
+
# keystroke.
|
|
4311
|
+
#
|
|
4312
|
+
# UI-thread-confined, like everything in the tree (see {Screen}).
|
|
4313
|
+
class Cascade
|
|
4314
|
+
SUBMENU_ARROW: String
|
|
4315
|
+
ARROW_WIDTH: Integer
|
|
4316
|
+
|
|
4317
|
+
def initialize: () -> void
|
|
4318
|
+
|
|
4319
|
+
# _@return_ — whether any panel is open.
|
|
4320
|
+
def open?: () -> bool
|
|
4321
|
+
|
|
4322
|
+
# _@return_ — how many panels are open; `0` when closed.
|
|
4323
|
+
def depth: () -> Integer
|
|
4324
|
+
|
|
4325
|
+
# Opens `item`'s children directly beneath `anchor`, closing anything
|
|
4326
|
+
# already open first.
|
|
4327
|
+
#
|
|
4328
|
+
# _@param_ `anchor` — the strip segment the menu drops from.
|
|
4329
|
+
#
|
|
4330
|
+
# _@param_ `item` — a childless one opens nothing.
|
|
4331
|
+
def open_below: (Rect anchor, Item item) -> void
|
|
4332
|
+
|
|
4333
|
+
# Closes every open panel, deepest first.
|
|
4334
|
+
def close: () -> void
|
|
4335
|
+
|
|
4336
|
+
# Offers a key to the deepest panel and to the cascade's own verbs.
|
|
4337
|
+
#
|
|
4338
|
+
# _@param_ `key`
|
|
4339
|
+
#
|
|
4340
|
+
# _@return_ — `true` when consumed — almost always, while open.
|
|
4341
|
+
# `false` when closed, and for the two sideways keys {MenuBar} answers
|
|
4342
|
+
# (see the class docs).
|
|
4343
|
+
def handle_key: (String key) -> bool
|
|
4344
|
+
|
|
4345
|
+
# Activates the deepest level's item bound to `key` — the drill-or-fire
|
|
4346
|
+
# the mnemonic shares with Enter. The highlight moves there *first*, so a
|
|
4347
|
+
# submenu anchors beside the row that opened it rather than beside
|
|
4348
|
+
# wherever the cursor happened to be.
|
|
4349
|
+
#
|
|
4350
|
+
# _@param_ `key` — a single printable, already downcased.
|
|
4351
|
+
#
|
|
4352
|
+
# _@return_ — whether an item on the deepest level claimed it. A
|
|
4353
|
+
# miss is never offered to a shallower level.
|
|
4354
|
+
def handle_mnemonic: (String key) -> bool
|
|
4355
|
+
|
|
4356
|
+
# _@return_ — the deepest open panel.
|
|
4357
|
+
def deepest: () -> ListDropdown
|
|
4358
|
+
|
|
4359
|
+
def activate_highlighted: () -> void
|
|
4360
|
+
|
|
4361
|
+
# Drills into `item`, or fires it and closes the cascade.
|
|
4362
|
+
#
|
|
4363
|
+
# _@param_ `level` — the panel the item belongs to.
|
|
4364
|
+
#
|
|
4365
|
+
# _@param_ `item` — `nil` (an off-content cursor) does nothing.
|
|
4366
|
+
def activate: (Integer level, Item? item) -> void
|
|
4367
|
+
|
|
4368
|
+
# Opens `item`'s children beside the row highlighted in `level`.
|
|
4369
|
+
#
|
|
4370
|
+
# _@param_ `level`
|
|
4371
|
+
#
|
|
4372
|
+
# _@param_ `item`
|
|
4373
|
+
def push_beside: (Integer level, Item item) -> void
|
|
4374
|
+
|
|
4375
|
+
# Mounts a panel for `item`'s children and yields it for geometry.
|
|
4376
|
+
#
|
|
4377
|
+
# _@param_ `item`
|
|
4378
|
+
def push: (Item item) ?{ (ListDropdown drop, Integer rows, Integer width) -> void } -> void
|
|
4379
|
+
|
|
4380
|
+
# Closes the deepest panel; at depth 1 that closes the cascade.
|
|
4381
|
+
def pop: () -> void
|
|
4382
|
+
|
|
4383
|
+
# _@param_ `count` — how many panels to keep.
|
|
4384
|
+
def truncate: (Integer count) -> void
|
|
4385
|
+
|
|
4386
|
+
# _@param_ `level`
|
|
4387
|
+
#
|
|
4388
|
+
# _@return_ — the item under `level`'s cursor; `nil` when it sits
|
|
4389
|
+
# off-content. The range guard matters: a cursor at `-1` would
|
|
4390
|
+
# otherwise index the *last* child.
|
|
4391
|
+
def highlighted: (Integer level) -> Item?
|
|
4392
|
+
|
|
4393
|
+
# _@param_ `items`
|
|
4394
|
+
#
|
|
4395
|
+
# _@return_ — item -> row: the label padded to the level's widest, plus
|
|
4396
|
+
# an arrow column when any sibling has a submenu — so every arrow lands
|
|
4397
|
+
# in the same column without asking the {List} how wide it ended up.
|
|
4398
|
+
def renderer_for: (::Array[Item] items) -> Proc
|
|
4399
|
+
|
|
4400
|
+
# _@param_ `items`
|
|
4401
|
+
#
|
|
4402
|
+
# _@return_ — the panel width: the rendered row plus {List}'s two
|
|
4403
|
+
# row gutters, plus a scrollbar column when the rows can't all be shown.
|
|
4404
|
+
# As in {Component::Select}, the scrollbar is predicted from the item
|
|
4405
|
+
# count rather than the final height — a panel the screen clamps
|
|
4406
|
+
# shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
|
|
4407
|
+
# bought that column, and ellipsizes one character early.
|
|
4408
|
+
def width_for: (::Array[Item] items) -> Integer
|
|
4409
|
+
|
|
4410
|
+
# _@param_ `items`
|
|
4411
|
+
def label_width_of: (::Array[Item] items) -> Integer
|
|
4412
|
+
end
|
|
4413
|
+
end
|
|
4414
|
+
|
|
3100
4415
|
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
3101
4416
|
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
3102
4417
|
# the *selected item* — of whatever type the items are — not the display
|
|
@@ -3109,7 +4424,7 @@ module Tuile
|
|
|
3109
4424
|
# combo.value = some_user # selects it; field shows its label
|
|
3110
4425
|
#
|
|
3111
4426
|
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
3112
|
-
#
|
|
4427
|
+
# an {Overlay} over a {List} — promoted to one component. Give it a
|
|
3113
4428
|
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
3114
4429
|
# last column and floats the dropdown above or below.
|
|
3115
4430
|
#
|
|
@@ -3144,8 +4459,6 @@ module Tuile
|
|
|
3144
4459
|
# hardware cursor to its field).
|
|
3145
4460
|
def cursor_position: () -> Point?
|
|
3146
4461
|
|
|
3147
|
-
def keyboard_hint: () -> String
|
|
3148
|
-
|
|
3149
4462
|
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
3150
4463
|
# field via {#layout}.
|
|
3151
4464
|
#
|
|
@@ -3166,6 +4479,12 @@ module Tuile
|
|
|
3166
4479
|
|
|
3167
4480
|
def repaint: () -> void
|
|
3168
4481
|
|
|
4482
|
+
# The one row this combo paints — the full width, at the top of {#rect}.
|
|
4483
|
+
# A single-slot container hands its content the whole inner rect, so a
|
|
4484
|
+
# ComboBox is routinely assigned more height than it uses; the dropdown
|
|
4485
|
+
# hangs under this rather than under the unused space below it.
|
|
4486
|
+
def extent: () -> Size
|
|
4487
|
+
|
|
3169
4488
|
# Field spans the row bar the last column, which the `▾` occupies
|
|
3170
4489
|
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
3171
4490
|
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
@@ -3248,7 +4567,7 @@ module Tuile
|
|
|
3248
4567
|
# a read-only display field could override back to `false`. Only
|
|
3249
4568
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3250
4569
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3251
|
-
# `
|
|
4570
|
+
# `D_integer_field`).
|
|
3252
4571
|
def focusable?: () -> bool
|
|
3253
4572
|
|
|
3254
4573
|
def on_focus: () -> void
|
|
@@ -3304,7 +4623,7 @@ module Tuile
|
|
|
3304
4623
|
# a read-only display field could override back to `false`. Only
|
|
3305
4624
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3306
4625
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3307
|
-
# `
|
|
4626
|
+
# `D_integer_field`).
|
|
3308
4627
|
def focusable?: () -> bool
|
|
3309
4628
|
|
|
3310
4629
|
# _@return_ — one-arg callable fired with the new value
|
|
@@ -3312,6 +4631,158 @@ module Tuile
|
|
|
3312
4631
|
attr_accessor on_value_change: (Proc | Method)?
|
|
3313
4632
|
end
|
|
3314
4633
|
|
|
4634
|
+
# A {Component::Tabs} strip on its top row plus the pane belonging to the
|
|
4635
|
+
# selected tab underneath it:
|
|
4636
|
+
#
|
|
4637
|
+
# ␣Details␣│␣Payment␣│␣Shipping␣
|
|
4638
|
+
# the selected tab's pane fills the rest of the rect
|
|
4639
|
+
#
|
|
4640
|
+
# sheet = Component::TabSheet.new
|
|
4641
|
+
# sheet.add_tab("Details", details_form) # selected, and shown
|
|
4642
|
+
# sheet.add_tab("Payment", payment_form)
|
|
4643
|
+
# sheet.select_next # shows payment_form
|
|
4644
|
+
# sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
|
|
4645
|
+
#
|
|
4646
|
+
# Tab lands on the strip first and enters the pane on the next press, which
|
|
4647
|
+
# is the browser's order; switching tabs does *not* move focus into the new
|
|
4648
|
+
# pane, but if focus was inside the pane that just went away it lands back
|
|
4649
|
+
# on the strip.
|
|
4650
|
+
#
|
|
4651
|
+
# == Hidden panes are detached
|
|
4652
|
+
# Only the selected tab's pane is in the component tree — the others are
|
|
4653
|
+
# detached, which is how Tuile hides a component (there is no visibility
|
|
4654
|
+
# flag, and an empty rect gates painting only). Consequences worth
|
|
4655
|
+
# designing around:
|
|
4656
|
+
#
|
|
4657
|
+
# - A hidden pane is invisible to *everything*: the Tab cycle, focus
|
|
4658
|
+
# cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
|
|
4659
|
+
# - Its state survives, because state is ivars — scroll position, caret,
|
|
4660
|
+
# list cursor, text are all exactly as the user left them, and mutating a
|
|
4661
|
+
# hidden pane is safe (`invalidate` while detached is a silent no-op).
|
|
4662
|
+
# - {Component#on_detached} / {Component#on_attached} fire on every switch,
|
|
4663
|
+
# so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
|
|
4664
|
+
# ticker — releases it while hidden and re-acquires it on return. A pane
|
|
4665
|
+
# that must keep something alive while hidden can't; that something
|
|
4666
|
+
# belongs in the model the pane renders, not in the pane.
|
|
4667
|
+
#
|
|
4668
|
+
# == Implementation details
|
|
4669
|
+
# `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
|
|
4670
|
+
# traversal gives the strip-then-pane Tab order for free. The swap follows
|
|
4671
|
+
# the slot-swap recipe {Component#detach_child} documents — detach, rewire,
|
|
4672
|
+
# `on_child_removed` last, so the focus repair sees the new occupant.
|
|
4673
|
+
#
|
|
4674
|
+
# Panes live in an identity-keyed `Tab => Component` map here rather than in
|
|
4675
|
+
# a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
|
|
4676
|
+
# authority, and the strip itself stays ignorant of panes. One idempotent
|
|
4677
|
+
# `sync_pane` is the sole writer of the visible pane, deriving it from
|
|
4678
|
+
# `strip.selected` on every call, so registering a pane and selecting a tab
|
|
4679
|
+
# can happen in either order.
|
|
4680
|
+
#
|
|
4681
|
+
# The sheet owns the strip's `on_tab_selected` (that is what drives the
|
|
4682
|
+
# swap); an app's listener goes on {#on_tab_selected} here, which fires
|
|
4683
|
+
# after the pane has been swapped in.
|
|
4684
|
+
class TabSheet < Component
|
|
4685
|
+
# _@param_ `separator` — the strip's separator; see {Tabs#separator=}.
|
|
4686
|
+
def initialize: (?separator: (String | StyledString)) -> void
|
|
4687
|
+
|
|
4688
|
+
# Adds a tab and the pane to show while it is selected. The first tab
|
|
4689
|
+
# added becomes the selection, so its pane is shown immediately.
|
|
4690
|
+
#
|
|
4691
|
+
# _@param_ `caption` — parsed as {Tabs::Tab#caption=} parses it.
|
|
4692
|
+
#
|
|
4693
|
+
# _@param_ `pane` — shown while this tab is selected, detached while it isn't.
|
|
4694
|
+
#
|
|
4695
|
+
# _@return_ — the new tab's handle.
|
|
4696
|
+
def add_tab: ((String | StyledString)? caption, Component pane) -> Tabs::Tab
|
|
4697
|
+
|
|
4698
|
+
# Removes a tab and forgets its pane, detaching it if it was the visible
|
|
4699
|
+
# one. The strip re-selects as {Tabs#remove_tab} describes, and this
|
|
4700
|
+
# sheet shows whatever it lands on.
|
|
4701
|
+
#
|
|
4702
|
+
# _@param_ `tab` — one of this sheet's tabs.
|
|
4703
|
+
#
|
|
4704
|
+
# _@return_ — the pane that tab owned.
|
|
4705
|
+
def remove_tab: (Tabs::Tab tab) -> Component?
|
|
4706
|
+
|
|
4707
|
+
# _@param_ `tab`
|
|
4708
|
+
#
|
|
4709
|
+
# _@return_ — the pane registered for `tab`; `nil` for a
|
|
4710
|
+
# removed tab, a tab of another sheet, or `nil`.
|
|
4711
|
+
def pane_for: (Tabs::Tab? tab) -> Component?
|
|
4712
|
+
|
|
4713
|
+
# _@return_ — the strip's tabs, in order.
|
|
4714
|
+
def tabs: () -> ::Array[Tabs::Tab]
|
|
4715
|
+
|
|
4716
|
+
# _@return_ — the selected tab.
|
|
4717
|
+
def selected: () -> Tabs::Tab?
|
|
4718
|
+
|
|
4719
|
+
# _@param_ `tab` — one of this sheet's tabs.
|
|
4720
|
+
def selected=: (Tabs::Tab tab) -> void
|
|
4721
|
+
|
|
4722
|
+
# _@return_ — the selected tab's position.
|
|
4723
|
+
def selected_index: () -> Integer?
|
|
4724
|
+
|
|
4725
|
+
# _@param_ `index` — a position in `0...tabs.size`.
|
|
4726
|
+
def selected_index=: (Integer index) -> void
|
|
4727
|
+
|
|
4728
|
+
# Selects the next tab, clamping at the last one.
|
|
4729
|
+
#
|
|
4730
|
+
# _@return_ — `false` only when there are no tabs.
|
|
4731
|
+
def select_next: () -> bool
|
|
4732
|
+
|
|
4733
|
+
# Selects the previous tab, clamping at the first one.
|
|
4734
|
+
#
|
|
4735
|
+
# _@return_ — `false` only when there are no tabs.
|
|
4736
|
+
def select_previous: () -> bool
|
|
4737
|
+
|
|
4738
|
+
# _@param_ `new_rect`
|
|
4739
|
+
def rect=: (Rect new_rect) -> void
|
|
4740
|
+
|
|
4741
|
+
# Sends focus to the strip: a sheet is a container, and the strip is where
|
|
4742
|
+
# a tab switch is driven from. The pane is a Tab press away.
|
|
4743
|
+
def on_focus: () -> void
|
|
4744
|
+
|
|
4745
|
+
# Lands focus on the strip rather than on `self` when the focused pane is
|
|
4746
|
+
# swapped out — a bare container can't use keys, and the user's last
|
|
4747
|
+
# action was a tab switch.
|
|
4748
|
+
#
|
|
4749
|
+
# _@param_ `child`
|
|
4750
|
+
def on_child_removed: (Component child) -> void
|
|
4751
|
+
|
|
4752
|
+
# Makes the visible pane match `strip.selected`, swapping if it doesn't.
|
|
4753
|
+
# Idempotent and the sole writer of `@pane`: it derives everything from
|
|
4754
|
+
# current state, so {#add_tab} can register a pane after the strip has
|
|
4755
|
+
# already selected its tab.
|
|
4756
|
+
def sync_pane: () -> void
|
|
4757
|
+
|
|
4758
|
+
# Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
|
|
4759
|
+
# strip without passing through {#remove_tab}, and a detached tab can never
|
|
4760
|
+
# be selected again, so its entry is dead weight — it pins the pane against
|
|
4761
|
+
# garbage collection and makes {#add_tab} reject that pane as still in use.
|
|
4762
|
+
# Idempotent and the only cleaner, because the rule it enforces is an
|
|
4763
|
+
# invariant ("every key is a live tab of my strip") rather than a step in
|
|
4764
|
+
# one code path.
|
|
4765
|
+
def forget_removed_tabs: () -> void
|
|
4766
|
+
|
|
4767
|
+
def layout_pane: () -> void
|
|
4768
|
+
|
|
4769
|
+
# An app's own selection listener, called after the pane has been swapped
|
|
4770
|
+
# in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
|
|
4771
|
+
# contract as {Tabs#on_tab_selected}: it reports that the selection
|
|
4772
|
+
# changed, whatever changed it.
|
|
4773
|
+
attr_accessor on_tab_selected: Proc?
|
|
4774
|
+
|
|
4775
|
+
# _@return_ — the strip. Reach through it for the rest of its API —
|
|
4776
|
+
# `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
|
|
4777
|
+
# the sheet drives the pane swap through it, and {#on_tab_selected} is
|
|
4778
|
+
# where an app's listener goes.
|
|
4779
|
+
attr_reader strip: Tabs
|
|
4780
|
+
|
|
4781
|
+
# _@return_ — the pane currently in the tree — the selected
|
|
4782
|
+
# tab's, `nil` while the sheet has no tabs.
|
|
4783
|
+
attr_reader pane: Component?
|
|
4784
|
+
end
|
|
4785
|
+
|
|
3315
4786
|
# A multi-line, word-wrapping text input.
|
|
3316
4787
|
#
|
|
3317
4788
|
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
@@ -3327,10 +4798,11 @@ module Tuile
|
|
|
3327
4798
|
# start of the next row in nearly all cases).
|
|
3328
4799
|
#
|
|
3329
4800
|
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
3330
|
-
# {#on_change} is wired.
|
|
3331
|
-
#
|
|
3332
|
-
#
|
|
3333
|
-
#
|
|
4801
|
+
# {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
|
|
4802
|
+
# byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
|
|
4803
|
+
# through {AbstractStringField#handle_paste} instead and never as a key at
|
|
4804
|
+
# all — so a subclass rebinding Enter to submit keeps working under a
|
|
4805
|
+
# multi-line paste, which lands as one draft.
|
|
3334
4806
|
#
|
|
3335
4807
|
# Up/Down move the caret between rows and, at the first/last row, snap to
|
|
3336
4808
|
# the start/end of the text. A subclass can claim the key at that edge
|
|
@@ -3756,8 +5228,10 @@ module Tuile
|
|
|
3756
5228
|
|
|
3757
5229
|
# Scrolls up half a viewport (`rect.height / 2`, at least one row),
|
|
3758
5230
|
# clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
|
|
3759
|
-
# What `Ctrl+U` does, minus the focus:
|
|
3760
|
-
#
|
|
5231
|
+
# What `Ctrl+U` does, minus the focus: dispatch delivers keys only along
|
|
5232
|
+
# the focus chain, so this is what a host with focus elsewhere — a chat
|
|
5233
|
+
# transcript under an input field — calls instead of forwarding a
|
|
5234
|
+
# synthetic keystroke that would lie about where focus is.
|
|
3761
5235
|
def scroll_half_page_up: () -> void
|
|
3762
5236
|
|
|
3763
5237
|
# The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
|
|
@@ -3768,6 +5242,11 @@ module Tuile
|
|
|
3768
5242
|
|
|
3769
5243
|
def tab_stop?: () -> bool
|
|
3770
5244
|
|
|
5245
|
+
# Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
|
|
5246
|
+
# `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
|
|
5247
|
+
# scroll at either edge is still a handled key, and hand-feeding a key to
|
|
5248
|
+
# an unfocused view scrolls it (dispatch gates on focus, this doesn't).
|
|
5249
|
+
#
|
|
3771
5250
|
# _@param_ `key`
|
|
3772
5251
|
def handle_key: (String key) -> bool
|
|
3773
5252
|
|
|
@@ -4132,42 +5611,23 @@ module Tuile
|
|
|
4132
5611
|
end
|
|
4133
5612
|
end
|
|
4134
5613
|
|
|
4135
|
-
#
|
|
4136
|
-
#
|
|
5614
|
+
# A {Window} framing a {LogTextView} — the framed log pane. All the log
|
|
5615
|
+
# behavior lives on the view (see {LogTextView} for the adapter and the
|
|
5616
|
+
# wrap rationale); the window adds the border, caption and delegation:
|
|
4137
5617
|
#
|
|
4138
5618
|
# log_window = Tuile::Component::LogWindow.new
|
|
4139
5619
|
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
4140
|
-
#
|
|
4141
|
-
# Any logger that writes formatted lines to an IO works the same way —
|
|
4142
|
-
# for example `TTY::Logger` configured with the `:console` handler and
|
|
4143
|
-
# `output: LogWindow::IO.new(window)`.
|
|
4144
5620
|
class LogWindow < Tuile::Component::Window
|
|
5621
|
+
IO: Class
|
|
5622
|
+
|
|
4145
5623
|
# _@param_ `caption`
|
|
4146
5624
|
def initialize: (?String caption) -> void
|
|
4147
5625
|
|
|
4148
|
-
# Appends given line to the log
|
|
5626
|
+
# Appends the given line to the log, from any thread — delegates to
|
|
5627
|
+
# {LogTextView#log}.
|
|
4149
5628
|
#
|
|
4150
|
-
# _@param_ `string` — the line (or multiple lines) to log.
|
|
5629
|
+
# _@param_ `string` — the line (or multiple lines) to log; `nil` is a no-op.
|
|
4151
5630
|
def log: (String? string) -> void
|
|
4152
|
-
|
|
4153
|
-
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
4154
|
-
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
4155
|
-
# call `output.puts`, e.g. `TTY::Logger`).
|
|
4156
|
-
class IO
|
|
4157
|
-
# _@param_ `window`
|
|
4158
|
-
def initialize: (LogWindow window) -> void
|
|
4159
|
-
|
|
4160
|
-
# _@param_ `string`
|
|
4161
|
-
def write: (String string) -> void
|
|
4162
|
-
|
|
4163
|
-
# _@param_ `string`
|
|
4164
|
-
def puts: (String string) -> void
|
|
4165
|
-
|
|
4166
|
-
# Stdlib `Logger` only treats an object as an IO target when it
|
|
4167
|
-
# responds to both {#write} and {#close}; otherwise it tries to
|
|
4168
|
-
# interpret it as a filename. This is a no-op.
|
|
4169
|
-
def close: () -> void
|
|
4170
|
-
end
|
|
4171
5631
|
end
|
|
4172
5632
|
|
|
4173
5633
|
# A single-line text input with a real hardware caret, scrolling
|
|
@@ -4188,8 +5648,8 @@ module Tuile
|
|
|
4188
5648
|
#
|
|
4189
5649
|
# - an **index** counts characters into {#text} — {#caret},
|
|
4190
5650
|
# {#max_text_length}, `text[i]`, every edit;
|
|
4191
|
-
# - a **column** counts terminal cells — {#rect}, {#
|
|
4192
|
-
# {
|
|
5651
|
+
# - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
|
|
5652
|
+
# {MouseEvent}, and the private horizontal scroll offset `left_column`.
|
|
4193
5653
|
#
|
|
4194
5654
|
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
4195
5655
|
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
@@ -4223,6 +5683,14 @@ module Tuile
|
|
|
4223
5683
|
# _@param_ `key`
|
|
4224
5684
|
def handle_text_input_key: (String key) -> bool
|
|
4225
5685
|
|
|
5686
|
+
# Flattens the paste onto the field's one row — newlines become spaces —
|
|
5687
|
+
# and trims it to what {#max_text_length} still allows. Trimming rather
|
|
5688
|
+
# than rejecting: a paste that overshoots the cap fills the field, which
|
|
5689
|
+
# is what typing the same characters would have done.
|
|
5690
|
+
#
|
|
5691
|
+
# _@param_ `text`
|
|
5692
|
+
def preprocess_paste: (String text) -> String
|
|
5693
|
+
|
|
4226
5694
|
def on_text_mutated: () -> void
|
|
4227
5695
|
|
|
4228
5696
|
def on_caret_mutated: () -> void
|
|
@@ -4290,10 +5758,6 @@ module Tuile
|
|
|
4290
5758
|
# _@return_ — maximum characters, or nil for unbounded (default).
|
|
4291
5759
|
attr_accessor max_text_length: Integer?
|
|
4292
5760
|
|
|
4293
|
-
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
4294
|
-
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
4295
|
-
attr_reader left_column: Integer
|
|
4296
|
-
|
|
4297
5761
|
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
4298
5762
|
# is consumed by the field; when nil, UP falls through to the parent
|
|
4299
5763
|
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
@@ -4316,6 +5780,15 @@ module Tuile
|
|
|
4316
5780
|
#
|
|
4317
5781
|
# _@return_ — no-arg callable, or nil.
|
|
4318
5782
|
attr_accessor on_enter: (Proc | Method)?
|
|
5783
|
+
|
|
5784
|
+
# Internal — the field's own scroll state, with no caller outside this
|
|
5785
|
+
# class: the paint, the cursor and the hit test all read the ivar, and
|
|
5786
|
+
# nothing above the field has a column to spend it on. Specs assert the
|
|
5787
|
+
# scrolling through `send`.
|
|
5788
|
+
#
|
|
5789
|
+
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
5790
|
+
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
5791
|
+
attr_reader left_column: Integer
|
|
4319
5792
|
end
|
|
4320
5793
|
|
|
4321
5794
|
# A single-line field whose {#value} is a `Float` (or `nil` when empty) —
|
|
@@ -4425,12 +5898,9 @@ module Tuile
|
|
|
4425
5898
|
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4426
5899
|
# a read-only display field could override back to `false`. Only
|
|
4427
5900
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4428
|
-
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4429
|
-
# `
|
|
4430
|
-
def focusable?: () -> bool
|
|
4431
|
-
|
|
4432
|
-
# _@param_ `event`
|
|
4433
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
5901
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5902
|
+
# `D_integer_field`).
|
|
5903
|
+
def focusable?: () -> bool
|
|
4434
5904
|
|
|
4435
5905
|
# _@param_ `rect`
|
|
4436
5906
|
def rect=: (Rect rect) -> void
|
|
@@ -4472,13 +5942,29 @@ module Tuile
|
|
|
4472
5942
|
def caption=: ((String | StyledString)? new_caption) -> void
|
|
4473
5943
|
end
|
|
4474
5944
|
|
|
4475
|
-
# A
|
|
4476
|
-
#
|
|
4477
|
-
# content
|
|
5945
|
+
# A component that owns exactly one child *directly*, under the name
|
|
5946
|
+
# `content`. The includer initializes `@content` to nil and provides a
|
|
5947
|
+
# protected `layout(content)` positioning the child; the mixin owns the swap:
|
|
5948
|
+
#
|
|
5949
|
+
# class Slot < Component
|
|
5950
|
+
# include Component::HasContent
|
|
5951
|
+
#
|
|
5952
|
+
# def initialize = (super; @content = nil)
|
|
5953
|
+
#
|
|
5954
|
+
# protected
|
|
5955
|
+
#
|
|
5956
|
+
# def layout(content) = content.rect = rect
|
|
5957
|
+
# end
|
|
5958
|
+
#
|
|
5959
|
+
# Include it when the child is **permanent and integral** — a typed field's
|
|
5960
|
+
# inner {TextField}, an {Overlay}'s body. It does *not* mean "a component
|
|
5961
|
+
# with one child": an includer may hold others alongside, as {Window} does
|
|
5962
|
+
# with its footer. For a region an app swaps, hold a {Slot} instead.
|
|
5963
|
+
#
|
|
5964
|
+
# A tree walk finds content generically through `is_a?(HasContent)` plus a
|
|
5965
|
+
# `content` compare, which is why this is a mixin rather than a per-class
|
|
5966
|
+
# accessor — the same reason {HasCaption} is one.
|
|
4478
5967
|
module HasContent
|
|
4479
|
-
# _@param_ `event`
|
|
4480
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
4481
|
-
|
|
4482
5968
|
# _@param_ `rect`
|
|
4483
5969
|
def rect=: (Rect rect) -> void
|
|
4484
5970
|
|
|
@@ -4488,27 +5974,46 @@ module Tuile
|
|
|
4488
5974
|
attr_accessor content: Component?
|
|
4489
5975
|
end
|
|
4490
5976
|
|
|
4491
|
-
# A {Window}
|
|
4492
|
-
#
|
|
5977
|
+
# A {Window} with a read-only body, in one of two presentations:
|
|
5978
|
+
# **prose** ({#message=}) wraps in a scrollable {TextView}, **rows**
|
|
5979
|
+
# ({#lines=}) stay one per row in a {List}, truncating instead of
|
|
5980
|
+
# wrapping — the presentation for columnar output, where a wrap would
|
|
5981
|
+
# destroy the alignment. Usable tiled (add it to a {Layout}) or as a
|
|
5982
|
+
# popup via {.open}; the constructor and {.open} pick the presentation
|
|
5983
|
+
# from the body's type:
|
|
5984
|
+
#
|
|
5985
|
+
# Component::InfoWindow.open("Help", "A long explanation, wrapped to fit.")
|
|
5986
|
+
# Component::InfoWindow.open("Files", ["drwx src/", "-rw- README.md"])
|
|
4493
5987
|
#
|
|
4494
|
-
#
|
|
4495
|
-
# wraps it in a {Popup}.
|
|
5988
|
+
# Both write the same body slot — the last writer wins.
|
|
4496
5989
|
class InfoWindow < Tuile::Component::Window
|
|
4497
|
-
# _@param_ `caption`
|
|
5990
|
+
# _@param_ `caption` — the border title.
|
|
5991
|
+
#
|
|
5992
|
+
# _@param_ `body` — an `Array` is assigned through {#lines=}, anything else through {#message=}.
|
|
5993
|
+
def initialize: (?(String | StyledString)? caption, ?(String | StyledString | Component | ::Array[untyped])? body) -> void
|
|
5994
|
+
|
|
5995
|
+
# Sets the rows body: a {List} populated via {List#lines=} (so entries
|
|
5996
|
+
# are coerced, `\n`-split and rstripped exactly as there), one item per
|
|
5997
|
+
# row, long rows truncated — never wrapped. For prose use {#message=}.
|
|
4498
5998
|
#
|
|
4499
|
-
# _@param_ `lines` —
|
|
4500
|
-
def
|
|
5999
|
+
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
6000
|
+
def lines=: (::Array[untyped] lines) -> void
|
|
4501
6001
|
|
|
4502
6002
|
# Opens the info window as a popup.
|
|
4503
6003
|
#
|
|
4504
|
-
# _@param_ `caption`
|
|
6004
|
+
# _@param_ `caption` — the border title.
|
|
4505
6005
|
#
|
|
4506
|
-
# _@param_ `
|
|
6006
|
+
# _@param_ `body` — dispatched by type as in {#initialize}.
|
|
4507
6007
|
#
|
|
4508
|
-
# _@param_ `
|
|
6008
|
+
# _@param_ `declared_size` — the popup's box, applied top-down; the body wraps or scrolls within it. Defaults to {Fraction::HALF}.
|
|
4509
6009
|
#
|
|
4510
6010
|
# _@return_ — the opened popup.
|
|
4511
|
-
def self.open: (String caption, ::Array[
|
|
6011
|
+
def self.open: ((String | StyledString)? caption, ?(String | StyledString | Component | ::Array[untyped])? body, ?declared_size: (Size | Fraction)) -> Popup
|
|
6012
|
+
|
|
6013
|
+
# _@return_ — whatever {#message=}
|
|
6014
|
+
# was given — set a `String`, read that `String` back. After
|
|
6015
|
+
# {#lines=} it is the {List} that setter mounted.
|
|
6016
|
+
attr_accessor message: (String | StyledString | Component)?
|
|
4512
6017
|
end
|
|
4513
6018
|
|
|
4514
6019
|
# Single-select from a set of typed items, one row each. Arrows move a
|
|
@@ -4649,12 +6154,9 @@ module Tuile
|
|
|
4649
6154
|
# a read-only display field could override back to `false`. Only
|
|
4650
6155
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4651
6156
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4652
|
-
# `
|
|
6157
|
+
# `D_integer_field`).
|
|
4653
6158
|
def focusable?: () -> bool
|
|
4654
6159
|
|
|
4655
|
-
# _@param_ `event`
|
|
4656
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
4657
|
-
|
|
4658
6160
|
# _@param_ `rect`
|
|
4659
6161
|
def rect=: (Rect rect) -> void
|
|
4660
6162
|
|
|
@@ -4691,19 +6193,23 @@ module Tuile
|
|
|
4691
6193
|
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
4692
6194
|
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
4693
6195
|
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
4694
|
-
# `DECISIONS.md` `
|
|
6196
|
+
# `DECISIONS.md` `D_notification` has why each of those is what it is.
|
|
4695
6197
|
#
|
|
4696
6198
|
# Three things it deliberately doesn't do:
|
|
4697
6199
|
#
|
|
4698
|
-
# - **Take focus, or receive keys.**
|
|
4699
|
-
# key-dispatch scope ({ScreenPane#handle_key}), so
|
|
4700
|
-
#
|
|
4701
|
-
# wanting a key registers a global shortcut and calls {#close}.
|
|
6200
|
+
# - **Take focus, or receive keys.** An {Overlay} sits off the
|
|
6201
|
+
# key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
|
|
6202
|
+
# all. A left click *on the box* dismisses ({#handle_mouse}); an app
|
|
6203
|
+
# wanting a key registers a global shortcut and calls {Overlay#close}. A
|
|
6204
|
+
# click *elsewhere* does not — this is the one overlay with
|
|
6205
|
+
# {Overlay#close_on_outside_click?} false, since a toast is timed and an
|
|
6206
|
+
# unrelated click is not about it.
|
|
4702
6207
|
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
4703
6208
|
# the message is added — a toast lives seconds, so there is no
|
|
4704
6209
|
# {Component#on_theme_changed} rebuild.
|
|
4705
|
-
# - **Take a size.** {
|
|
4706
|
-
|
|
6210
|
+
# - **Take a size.** An {Overlay} has no declared box; the messages decide
|
|
6211
|
+
# this one's, in {#reposition}.
|
|
6212
|
+
class Notification < Tuile::Component::Overlay
|
|
4707
6213
|
MAX_MESSAGES: Integer
|
|
4708
6214
|
MAX_ROWS_PER_MESSAGE: Integer
|
|
4709
6215
|
DISPLAY_SECONDS: Float
|
|
@@ -4726,21 +6232,6 @@ module Tuile
|
|
|
4726
6232
|
|
|
4727
6233
|
def initialize: () -> void
|
|
4728
6234
|
|
|
4729
|
-
# Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
|
|
4730
|
-
# outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
|
|
4731
|
-
# nobody — every keystroke would go dead until the user pressed Tab.
|
|
4732
|
-
#
|
|
4733
|
-
# _@return_ — false.
|
|
4734
|
-
def focusable?: () -> bool
|
|
4735
|
-
|
|
4736
|
-
# _@return_ — false — see {#focusable?}.
|
|
4737
|
-
def tab_stop?: () -> bool
|
|
4738
|
-
|
|
4739
|
-
# Empty: a non-modal popup never owns the status bar, and {Popup}'s
|
|
4740
|
-
# inherited `q Close` hint would be a lie here — no key ever reaches a
|
|
4741
|
-
# notification.
|
|
4742
|
-
def keyboard_hint: () -> String
|
|
4743
|
-
|
|
4744
6235
|
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
4745
6236
|
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
4746
6237
|
# append without repeating {show}'s lookup.
|
|
@@ -4752,7 +6243,7 @@ module Tuile
|
|
|
4752
6243
|
|
|
4753
6244
|
# Recomputes the box from its messages and re-anchors it to the screen's
|
|
4754
6245
|
# top-right corner — so a SIGWINCH re-wraps and re-anchors, where
|
|
4755
|
-
# {
|
|
6246
|
+
# {Overlay#reposition} would have kept the stale left column of a *derived*
|
|
4756
6247
|
# position (off-screen entirely if the terminal narrowed).
|
|
4757
6248
|
#
|
|
4758
6249
|
# Rebuilds the {TextView}'s text too, and every mutation routes through
|
|
@@ -4761,19 +6252,13 @@ module Tuile
|
|
|
4761
6252
|
# from the width.
|
|
4762
6253
|
def reposition: () -> void
|
|
4763
6254
|
|
|
4764
|
-
# A notification is sized by its messages, so this always raises. Failing
|
|
4765
|
-
# loudly beats accepting a size the next {#reposition} would discard.
|
|
4766
|
-
#
|
|
4767
|
-
# _@param_ `_new_size`
|
|
4768
|
-
def size=: ((Size | Fraction) _new_size) -> void
|
|
4769
|
-
|
|
4770
6255
|
# A left click dismisses the whole box, every message with it. Other buttons
|
|
4771
6256
|
# are consumed and inert — including the scroll wheel, which would otherwise
|
|
4772
6257
|
# nuke the box on a stray spin.
|
|
4773
6258
|
#
|
|
4774
6259
|
# Deliberately *replaces* rather than augments: neither `super` nor
|
|
4775
6260
|
# {HasContent#handle_mouse} may run, since both end at a
|
|
4776
|
-
# `screen.focused = …` inside this subtree (see {#focusable?}).
|
|
6261
|
+
# `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
|
|
4777
6262
|
#
|
|
4778
6263
|
# _@param_ `event`
|
|
4779
6264
|
def handle_mouse: (MouseEvent event) -> void
|
|
@@ -4874,7 +6359,7 @@ module Tuile
|
|
|
4874
6359
|
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
4875
6360
|
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
4876
6361
|
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
4877
|
-
# `DECISIONS.md` `
|
|
6362
|
+
# `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
|
|
4878
6363
|
# beats a third convention, and if that bet is ever reversed both swap
|
|
4879
6364
|
# together.
|
|
4880
6365
|
class ProgressBar < Component
|
|
@@ -5080,12 +6565,9 @@ module Tuile
|
|
|
5080
6565
|
# a read-only display field could override back to `false`. Only
|
|
5081
6566
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5082
6567
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5083
|
-
# `
|
|
6568
|
+
# `D_integer_field`).
|
|
5084
6569
|
def focusable?: () -> bool
|
|
5085
6570
|
|
|
5086
|
-
# _@param_ `event`
|
|
5087
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
5088
|
-
|
|
5089
6571
|
# _@param_ `rect`
|
|
5090
6572
|
def rect=: (Rect rect) -> void
|
|
5091
6573
|
|
|
@@ -5094,7 +6576,7 @@ module Tuile
|
|
|
5094
6576
|
|
|
5095
6577
|
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
5096
6578
|
# a *driver* drops open, drives by forwarding movement keys, and commits a
|
|
5097
|
-
# pick from:
|
|
6579
|
+
# pick from: an {Overlay} wrapping a {List} that never takes focus, so
|
|
5098
6580
|
# focus stays on the driver while the caller refills the rows, moves the
|
|
5099
6581
|
# highlight, and reads the pick.
|
|
5100
6582
|
#
|
|
@@ -5109,9 +6591,10 @@ module Tuile
|
|
|
5109
6591
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
5110
6592
|
#
|
|
5111
6593
|
# It owns only what every such dropdown shares — *placement* included, via
|
|
5112
|
-
# {#anchor_to}
|
|
5113
|
-
#
|
|
5114
|
-
#
|
|
6594
|
+
# {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
|
|
6595
|
+
# a cascading submenu). What stays with the driver: the width **policy**
|
|
6596
|
+
# (neither placement method measures anything itself), filtering, row
|
|
6597
|
+
# rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
|
|
5115
6598
|
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
5116
6599
|
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
5117
6600
|
# from its own branches.
|
|
@@ -5123,7 +6606,7 @@ module Tuile
|
|
|
5123
6606
|
# different tint (a `Theme.ref(:token)` keeps the flip-tracking).
|
|
5124
6607
|
#
|
|
5125
6608
|
# UI-thread-confined, like every component (see {Screen}).
|
|
5126
|
-
class ListDropdown < Tuile::Component::
|
|
6609
|
+
class ListDropdown < Tuile::Component::Overlay
|
|
5127
6610
|
MOVE_KEYS: ::Array[String]
|
|
5128
6611
|
MAX_VISIBLE_ROWS: Integer
|
|
5129
6612
|
|
|
@@ -5141,12 +6624,24 @@ module Tuile
|
|
|
5141
6624
|
# _@param_ `proc` — commit callback; see {List#on_item_chosen}.
|
|
5142
6625
|
def on_item_chosen=: ((Proc | Method)? proc) -> void
|
|
5143
6626
|
|
|
6627
|
+
# _@param_ `proc` — highlight-moved callback; see {List#on_cursor_changed}. A cascading driver needs it to drop the panels that belonged to the row the highlight just left.
|
|
6628
|
+
def on_cursor_changed=: ((Proc | Method)? proc) -> void
|
|
6629
|
+
|
|
5144
6630
|
# _@param_ `cursor` — the highlight; see {List#cursor=}.
|
|
5145
6631
|
def cursor=: (List::Cursor cursor) -> void
|
|
5146
6632
|
|
|
5147
6633
|
# _@return_ — the list's cursor (the current highlight).
|
|
5148
6634
|
def cursor: () -> List::Cursor
|
|
5149
6635
|
|
|
6636
|
+
# Moves the highlight to the item at `index`, scrolling it into view; see
|
|
6637
|
+
# {List#select}. The positional counterpart of {#move}, for a driver that
|
|
6638
|
+
# picked a row by something other than a key — a mnemonic letter, say.
|
|
6639
|
+
#
|
|
6640
|
+
# _@param_ `index`
|
|
6641
|
+
#
|
|
6642
|
+
# _@return_ — whether the highlight moved there.
|
|
6643
|
+
def select: (Integer index) -> bool
|
|
6644
|
+
|
|
5150
6645
|
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
5151
6646
|
# flipped above when `rows` won't fit below, clamped — with the list
|
|
5152
6647
|
# scrolling — when neither side has room. Horizontally the left edges line
|
|
@@ -5158,7 +6653,15 @@ module Tuile
|
|
|
5158
6653
|
# Vertical flips but horizontal slides because covering the driver would
|
|
5159
6654
|
# hide what is being chosen, while sharing its columns is the point.
|
|
5160
6655
|
#
|
|
5161
|
-
#
|
|
6656
|
+
# **`anchor` is the region actually occupied, and may be taller than one
|
|
6657
|
+
# row** — "beneath" means the row *after* it, so a multi-row driver (a
|
|
6658
|
+
# {Component::TextArea} carrying an autocomplete menu) is cleared entirely
|
|
6659
|
+
# rather than overdrawn from its second row down. A widget that paints one
|
|
6660
|
+
# row but may be *assigned* more height passes its face, not its rect:
|
|
6661
|
+
# {ComboBox} and {Select} both do, since a {Window} content slot hands them
|
|
6662
|
+
# the full inner height.
|
|
6663
|
+
#
|
|
6664
|
+
# _@param_ `anchor` — the region the driver occupies, of any height; the dropdown never covers it.
|
|
5162
6665
|
#
|
|
5163
6666
|
# _@param_ `rows` — how many rows there are to show — the content count, not the height: more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
|
|
5164
6667
|
#
|
|
@@ -5172,6 +6675,49 @@ module Tuile
|
|
|
5172
6675
|
?max_rows: Integer
|
|
5173
6676
|
) -> void
|
|
5174
6677
|
|
|
6678
|
+
# Sizes and places the dropdown *beside* `anchor` — the placement a
|
|
6679
|
+
# cascading submenu wants, where {#anchor_to} is the placement a field's
|
|
6680
|
+
# dropdown wants.
|
|
6681
|
+
#
|
|
6682
|
+
# sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
|
|
6683
|
+
#
|
|
6684
|
+
# Horizontally it sits against `anchor`'s right edge, **flipping** to its
|
|
6685
|
+
# left when the right has no room (and clamping to the screen when neither
|
|
6686
|
+
# side does). Vertically it **slides**: the panel's first row lines up with
|
|
6687
|
+
# the anchored row, sliding up only far enough to keep the panel on screen.
|
|
6688
|
+
#
|
|
6689
|
+
# The two axes are the mirror image of {#anchor_to}'s, for the same reason:
|
|
6690
|
+
# never cover the thing being chosen from. A field's dropdown must not
|
|
6691
|
+
# cover the field, so it flips *vertically* and shares its columns; a
|
|
6692
|
+
# submenu must not cover its parent panel, so it flips *horizontally* and
|
|
6693
|
+
# shares its rows.
|
|
6694
|
+
#
|
|
6695
|
+
# _@param_ `anchor` — the row the submenu belongs to — typically the parent dropdown's {#cursor_row_rect}. Its width is the parent panel's, which is what the submenu clears.
|
|
6696
|
+
#
|
|
6697
|
+
# _@param_ `rows` — how many rows there are to show — the content count, not the height; more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
|
|
6698
|
+
#
|
|
6699
|
+
# _@param_ `width` — the panel's width in columns, clamped to the screen. **Required, with no default:** `anchor.width` is the *parent's* width and would be meaningless here, so the caller measures (see `DECISIONS.md` `D_select` on why the width policy stays with the driver).
|
|
6700
|
+
#
|
|
6701
|
+
# _@param_ `max_rows` — rows shown before the list scrolls.
|
|
6702
|
+
def anchor_beside: (
|
|
6703
|
+
Rect anchor,
|
|
6704
|
+
rows: Integer,
|
|
6705
|
+
width: Integer,
|
|
6706
|
+
?max_rows: Integer
|
|
6707
|
+
) -> void
|
|
6708
|
+
|
|
6709
|
+
# The highlighted row's rect on screen — what a cascading submenu anchors
|
|
6710
|
+
# against, via {#anchor_beside}.
|
|
6711
|
+
#
|
|
6712
|
+
# It lives here rather than in the driver because {ListDropdown} owns the
|
|
6713
|
+
# list's geometry: a driver computing `top + position - scroll_top_row`
|
|
6714
|
+
# itself would have to reach through to the private list.
|
|
6715
|
+
#
|
|
6716
|
+
# _@return_ — one row spanning the panel's width, or `nil` when
|
|
6717
|
+
# the cursor is off-content ({List::Cursor::None}, an empty list) or its
|
|
6718
|
+
# row is scrolled out of the viewport.
|
|
6719
|
+
def cursor_row_rect: () -> Rect?
|
|
6720
|
+
|
|
5175
6721
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
5176
6722
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
5177
6723
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -5203,6 +6749,54 @@ module Tuile
|
|
|
5203
6749
|
end
|
|
5204
6750
|
end
|
|
5205
6751
|
|
|
6752
|
+
# A {TextView} purpose-built for log output: auto-scroll is on, the
|
|
6753
|
+
# scrollbar is visible, and lines arrive via {#log} — from any thread.
|
|
6754
|
+
# Point a logger at a {LogTextView::IO} to route its lines here:
|
|
6755
|
+
#
|
|
6756
|
+
# view = Tuile::Component::LogTextView.new
|
|
6757
|
+
# logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
|
|
6758
|
+
# logger.info("started") # appears in the view, from any thread
|
|
6759
|
+
#
|
|
6760
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
6761
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
6762
|
+
# `output: LogTextView::IO.new(view)`.
|
|
6763
|
+
#
|
|
6764
|
+
# Add it to a layout as-is for a frameless log pane, or use {LogWindow}
|
|
6765
|
+
# for the framed assembly. A {TextView} rather than a {List}: long lines
|
|
6766
|
+
# (stacktraces, wide log records) word-wrap rather than ellipsize — a
|
|
6767
|
+
# truncated log line hides the very detail you opened the log to read.
|
|
6768
|
+
class LogTextView < Tuile::Component::TextView
|
|
6769
|
+
def initialize: () -> void
|
|
6770
|
+
|
|
6771
|
+
# Appends the given line to the log. Safe to call from any thread —
|
|
6772
|
+
# the one exception to the UI-thread confinement every component
|
|
6773
|
+
# carries: the append is marshalled through `event_queue.submit`, so
|
|
6774
|
+
# it lands once a loop drains the queue (deferred before the first
|
|
6775
|
+
# loop; silently dropped after the loop has returned).
|
|
6776
|
+
#
|
|
6777
|
+
# _@param_ `string` — the line (or multiple lines) to log; `nil` is a no-op.
|
|
6778
|
+
def log: (String? string) -> void
|
|
6779
|
+
|
|
6780
|
+
# IO-shaped adapter that forwards each log line to its sink's `#log`.
|
|
6781
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
6782
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
6783
|
+
class IO
|
|
6784
|
+
# _@param_ `sink` — anything responding to `#log`.
|
|
6785
|
+
def initialize: ((LogTextView | LogWindow) sink) -> void
|
|
6786
|
+
|
|
6787
|
+
# _@param_ `string`
|
|
6788
|
+
def write: (String string) -> void
|
|
6789
|
+
|
|
6790
|
+
# _@param_ `string`
|
|
6791
|
+
def puts: (String string) -> void
|
|
6792
|
+
|
|
6793
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
6794
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
6795
|
+
# interpret it as a filename. This is a no-op.
|
|
6796
|
+
def close: () -> void
|
|
6797
|
+
end
|
|
6798
|
+
end
|
|
6799
|
+
|
|
5206
6800
|
# A {Window} that lists options identified by single keyboard keys, asks
|
|
5207
6801
|
# the user to pick one, and fires a callback with the picked key.
|
|
5208
6802
|
#
|
|
@@ -5225,8 +6819,6 @@ module Tuile
|
|
|
5225
6819
|
# _@param_ `key`
|
|
5226
6820
|
def handle_key: (String key) -> bool
|
|
5227
6821
|
|
|
5228
|
-
def keyboard_hint: () -> String
|
|
5229
|
-
|
|
5230
6822
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
5231
6823
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
5232
6824
|
#
|
|
@@ -5395,12 +6987,9 @@ module Tuile
|
|
|
5395
6987
|
# a read-only display field could override back to `false`. Only
|
|
5396
6988
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5397
6989
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5398
|
-
# `
|
|
6990
|
+
# `D_integer_field`).
|
|
5399
6991
|
def focusable?: () -> bool
|
|
5400
6992
|
|
|
5401
|
-
# _@param_ `event`
|
|
5402
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
5403
|
-
|
|
5404
6993
|
# _@param_ `rect`
|
|
5405
6994
|
def rect=: (Rect rect) -> void
|
|
5406
6995
|
|
|
@@ -5411,6 +7000,254 @@ module Tuile
|
|
|
5411
7000
|
attr_accessor item_label: (Proc | Method)
|
|
5412
7001
|
end
|
|
5413
7002
|
|
|
7003
|
+
# The confirm dialog: a {Window} asking a question — a caption, a prose
|
|
7004
|
+
# {#message}, a centered row of buttons — plus the one-button degenerate
|
|
7005
|
+
# case, the alert. Three factories cover the common shapes and open the
|
|
7006
|
+
# dialog as a centered, content-sized {Popup}:
|
|
7007
|
+
#
|
|
7008
|
+
# Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
|
|
7009
|
+
# Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
7010
|
+
# confirm: "Delete") { delete! }
|
|
7011
|
+
# Component::ConfirmWindow.yes_no("Overwrite file?", "target.txt already exists.") { overwrite! }
|
|
7012
|
+
#
|
|
7013
|
+
# The component itself is the builder — declare any button set through
|
|
7014
|
+
# {#button}, then {#open}:
|
|
7015
|
+
#
|
|
7016
|
+
# dialog = Component::ConfirmWindow.new("Unsaved changes")
|
|
7017
|
+
# dialog.message = "Save your changes before leaving?"
|
|
7018
|
+
# dialog.button("Save") { save! }
|
|
7019
|
+
# dialog.button("Discard") { discard! }
|
|
7020
|
+
# dialog.button("Cancel") # no action: pressing it dismisses
|
|
7021
|
+
# dialog.on_dismiss = -> { stay_put }
|
|
7022
|
+
# dialog.open
|
|
7023
|
+
#
|
|
7024
|
+
# **Every button closes the dialog.** A button with a block then fires it; a
|
|
7025
|
+
# button without one is a Cancel. ESC, `q`, an outside click and a Cancel
|
|
7026
|
+
# button are all one outcome — {#on_dismiss}, fired exactly once, and only
|
|
7027
|
+
# when no action button was chosen. There is deliberately no keep-open knob:
|
|
7028
|
+
# a dialog that leads somewhere opens the next window from its callback.
|
|
7029
|
+
#
|
|
7030
|
+
# Keys: Left/Right (and Tab) move between the buttons, Enter/Space press the
|
|
7031
|
+
# focused one, and each button answers to its underlined mnemonic letter
|
|
7032
|
+
# (see {#button}). The message scrolls without taking focus —
|
|
7033
|
+
# {BODY_SCROLL_KEYS} are handed to it from anywhere in the dialog. Focus
|
|
7034
|
+
# opens on the first button; Shift+Tab reaches the message, a tab stop of
|
|
7035
|
+
# its own.
|
|
7036
|
+
#
|
|
7037
|
+
# The popup sizes itself from what the dialog owns — caption, message,
|
|
7038
|
+
# buttons — capped at half the screen ({#measured_size}), re-measured when
|
|
7039
|
+
# any of them changes. Usable tiled too (add it to a {Layout}): buttons then
|
|
7040
|
+
# fire their callbacks with nothing to close.
|
|
7041
|
+
#
|
|
7042
|
+
# There is deliberately no content slot: the body is prose ({#message=}
|
|
7043
|
+
# takes a component for the rare rich body, but the dialog then cannot
|
|
7044
|
+
# measure it). A dialog collecting *input* is not a confirm dialog — build a
|
|
7045
|
+
# `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
|
|
7046
|
+
# for the API rationale.
|
|
7047
|
+
class ConfirmWindow < Tuile::Component::Window
|
|
7048
|
+
BODY_SCROLL_KEYS: ::Array[String]
|
|
7049
|
+
RESERVED_MNEMONICS: ::Array[String]
|
|
7050
|
+
HEIGHT_CHROME: Integer
|
|
7051
|
+
WIDTH_CHROME: Integer
|
|
7052
|
+
|
|
7053
|
+
# _@param_ `caption` — the border title, coerced the same way {HasCaption#caption=} coerces it.
|
|
7054
|
+
def initialize: (?(String | StyledString)? caption) -> void
|
|
7055
|
+
|
|
7056
|
+
# Also re-measures the popup: the caption participates in the width.
|
|
7057
|
+
#
|
|
7058
|
+
# _@param_ `new_caption`
|
|
7059
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
7060
|
+
|
|
7061
|
+
# Appends a button and returns it. A button with a block is an action
|
|
7062
|
+
# button: pressing it closes the dialog, then fires the block. A button
|
|
7063
|
+
# without one is a Cancel: pressing it closes the dialog, then fires
|
|
7064
|
+
# {#on_dismiss}.
|
|
7065
|
+
#
|
|
7066
|
+
# dialog.button("Delete") { delete! } # mnemonic d, underlined
|
|
7067
|
+
# dialog.button("Keep", mnemonic: "e") # explicit letter
|
|
7068
|
+
# dialog.button("Cancel", mnemonic: nil) # no mnemonic
|
|
7069
|
+
#
|
|
7070
|
+
# The mnemonic — a printable letter activating the button from anywhere in
|
|
7071
|
+
# the dialog, case-insensitively — is underlined in the caption. Any case
|
|
7072
|
+
# is accepted, and the underline prefers the exact case given, so the case
|
|
7073
|
+
# picks which occurrence is cued (`"Save As"` with `"A"` underlines the
|
|
7074
|
+
# *As*), exactly as on {MenuBar#add_item}. The default `:auto` derives the
|
|
7075
|
+
# caption's first letter (cueing it as displayed) and is best-effort:
|
|
7076
|
+
# silently skipped when that letter is reserved, taken, or unusable. An
|
|
7077
|
+
# explicit letter is a promise and raises when it cannot be kept.
|
|
7078
|
+
#
|
|
7079
|
+
# Declare buttons before {#open}: adding one to an open dialog re-measures
|
|
7080
|
+
# the popup, but momentarily bounces focus off the button row.
|
|
7081
|
+
#
|
|
7082
|
+
# _@param_ `caption` — the button label.
|
|
7083
|
+
#
|
|
7084
|
+
# _@param_ `mnemonic` — `:auto` (default), a printable one-column character, or `nil` for none.
|
|
7085
|
+
#
|
|
7086
|
+
# _@return_ — the appended button.
|
|
7087
|
+
def button: ((String | StyledString) caption, ?mnemonic: (Symbol | String)?) -> Button
|
|
7088
|
+
|
|
7089
|
+
# Opens the dialog as a centered modal {Popup} sized by {#measured_size}
|
|
7090
|
+
# and returns that popup. ESC, `q` and an outside click dismiss it
|
|
7091
|
+
# (firing {#on_dismiss}); every button closes it too.
|
|
7092
|
+
#
|
|
7093
|
+
# _@return_ — the mounted popup; `popup.close` closes programmatically,
|
|
7094
|
+
# as {#close} also does.
|
|
7095
|
+
def open: () -> Popup
|
|
7096
|
+
|
|
7097
|
+
# Closes the popup {#open} mounted, which counts as a dismissal
|
|
7098
|
+
# ({#on_dismiss} fires). No-op when not open, or when used tiled.
|
|
7099
|
+
def close: () -> void
|
|
7100
|
+
|
|
7101
|
+
# The popup box this dialog wants: wide enough for the caption, the widest
|
|
7102
|
+
# message line and the button row, tall enough for the wrapped message
|
|
7103
|
+
# plus the button row — each capped at half of `reference`. The re-grow
|
|
7104
|
+
# rule's caller-side measure query: the dialog measures only content it
|
|
7105
|
+
# *owns*, so a {Component} assigned to {#message=} yields the full
|
|
7106
|
+
# half-screen box.
|
|
7107
|
+
#
|
|
7108
|
+
# _@param_ `reference` — the screen size to cap against.
|
|
7109
|
+
def measured_size: (?Size reference) -> Size
|
|
7110
|
+
|
|
7111
|
+
# Focus lands on the first button rather than cascading into the message
|
|
7112
|
+
# body, which sits before the button row in the tree.
|
|
7113
|
+
def on_focus: () -> void
|
|
7114
|
+
|
|
7115
|
+
# Handles the dialog-wide keys: Left/Right move between the buttons,
|
|
7116
|
+
# {BODY_SCROLL_KEYS} are hand-fed to the message body, and a mnemonic
|
|
7117
|
+
# letter presses its button. Reached by bubbling — the focused button or
|
|
7118
|
+
# body sees the key first, so a focused body consumes its own scroll keys
|
|
7119
|
+
# before this runs.
|
|
7120
|
+
#
|
|
7121
|
+
# _@param_ `key`
|
|
7122
|
+
#
|
|
7123
|
+
# _@return_ — true if the key was handled.
|
|
7124
|
+
def handle_key: (String key) -> bool
|
|
7125
|
+
|
|
7126
|
+
# Opens an acknowledgement dialog: a message and one button, whose press
|
|
7127
|
+
# is the dismissal. The button exists for discoverability — ESC and `q`
|
|
7128
|
+
# close too, but Tuile advertises no keys anywhere, and the button is
|
|
7129
|
+
# clickable.
|
|
7130
|
+
#
|
|
7131
|
+
# _@param_ `caption` — the border title.
|
|
7132
|
+
#
|
|
7133
|
+
# _@param_ `message` — see {#message=}.
|
|
7134
|
+
#
|
|
7135
|
+
# _@param_ `button` — the button label.
|
|
7136
|
+
#
|
|
7137
|
+
# _@return_ — the mounted popup.
|
|
7138
|
+
def self.alert: ((String | StyledString)? caption, (String | StyledString | Component)? message, ?button: (String | StyledString)) -> Popup
|
|
7139
|
+
|
|
7140
|
+
# Opens a two-button question: the block is the action, the cancel button
|
|
7141
|
+
# (with ESC, `q` and an outside click) is the dismissal.
|
|
7142
|
+
#
|
|
7143
|
+
# Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
7144
|
+
# confirm: "Delete") { delete! }
|
|
7145
|
+
#
|
|
7146
|
+
# _@param_ `caption` — the border title.
|
|
7147
|
+
#
|
|
7148
|
+
# _@param_ `message` — see {#message=}.
|
|
7149
|
+
#
|
|
7150
|
+
# _@param_ `confirm` — the action button's label.
|
|
7151
|
+
#
|
|
7152
|
+
# _@param_ `cancel` — the dismissal button's label.
|
|
7153
|
+
#
|
|
7154
|
+
# _@param_ `on_dismiss` — see {#on_dismiss}.
|
|
7155
|
+
#
|
|
7156
|
+
# _@return_ — the mounted popup.
|
|
7157
|
+
def self.confirm: (
|
|
7158
|
+
(String | StyledString)? caption,
|
|
7159
|
+
(String | StyledString | Component)? message,
|
|
7160
|
+
?confirm: (String | StyledString),
|
|
7161
|
+
?cancel: (String | StyledString),
|
|
7162
|
+
?on_dismiss: Proc?
|
|
7163
|
+
) -> Popup
|
|
7164
|
+
|
|
7165
|
+
# {.confirm} with Yes/No labels — the other canonical phrasing.
|
|
7166
|
+
#
|
|
7167
|
+
# _@param_ `caption` — the border title.
|
|
7168
|
+
#
|
|
7169
|
+
# _@param_ `message` — see {#message=}.
|
|
7170
|
+
#
|
|
7171
|
+
# _@param_ `on_dismiss` — see {#on_dismiss}.
|
|
7172
|
+
#
|
|
7173
|
+
# _@return_ — the mounted popup.
|
|
7174
|
+
def self.yes_no: ((String | StyledString)? caption, (String | StyledString | Component)? message, ?on_dismiss: Proc?) -> Popup
|
|
7175
|
+
|
|
7176
|
+
# The chosen-button path, in the settled order: mark chosen, close the
|
|
7177
|
+
# popup (whose on_close then skips the dismissal), fire the callback last
|
|
7178
|
+
# so it sees the dialog already gone — a callback opening a follow-up
|
|
7179
|
+
# popup gets clean focus-repair state.
|
|
7180
|
+
#
|
|
7181
|
+
# _@param_ `btn`
|
|
7182
|
+
def activate: (Button btn) -> void
|
|
7183
|
+
|
|
7184
|
+
# The popup's on_close: fires {#on_dismiss} unless a button was chosen —
|
|
7185
|
+
# which makes ESC, `q`, an outside click, {#close}, a direct
|
|
7186
|
+
# {Screen#remove_popup} and teardown all one event, fired exactly once.
|
|
7187
|
+
def notice_dismissed: () -> void
|
|
7188
|
+
|
|
7189
|
+
# _@param_ `delta` — -1 or 1.
|
|
7190
|
+
#
|
|
7191
|
+
# _@return_ — false when focus is not on a button (the key bubbles on).
|
|
7192
|
+
def focus_button_step: (Integer delta) -> bool
|
|
7193
|
+
|
|
7194
|
+
# Validates an explicit mnemonic (raising, as {MenuBar#add_item} does —
|
|
7195
|
+
# none of these has a sane answer at keypress time) or best-effort derives
|
|
7196
|
+
# one from the caption's first letter (returning nil where the explicit
|
|
7197
|
+
# path would raise).
|
|
7198
|
+
#
|
|
7199
|
+
# _@param_ `caption`
|
|
7200
|
+
#
|
|
7201
|
+
# _@param_ `mnemonic`
|
|
7202
|
+
#
|
|
7203
|
+
# _@return_ — the downcased letter, or nil for none.
|
|
7204
|
+
def resolve_mnemonic: (StyledString caption, (Symbol | String)? mnemonic) -> String?
|
|
7205
|
+
|
|
7206
|
+
# _@param_ `caption`
|
|
7207
|
+
def derive_mnemonic: (StyledString caption) -> String?
|
|
7208
|
+
|
|
7209
|
+
# {StyledString#slice} counts **columns** while a caption search yields a
|
|
7210
|
+
# **character** index, so the prefix is measured, never counted (the
|
|
7211
|
+
# {MenuBar::Item} cue, duplicated per `D_float_field`'s shallow-shell rule).
|
|
7212
|
+
#
|
|
7213
|
+
# _@param_ `caption`
|
|
7214
|
+
#
|
|
7215
|
+
# _@param_ `cue` — the letter in the case it was given in — exact case first, so the case picks which occurrence is underlined.
|
|
7216
|
+
def underline_mnemonic: (StyledString caption, String cue) -> StyledString
|
|
7217
|
+
|
|
7218
|
+
# Re-declares the button row's cross extent — the buttons' summed natural
|
|
7219
|
+
# width — so the {Layout::Vertical} centers it. Remove-and-re-add is the
|
|
7220
|
+
# only way to change a {Layout::Box} constraint; the row stays after the
|
|
7221
|
+
# body because both `add`s append.
|
|
7222
|
+
def recenter_button_row: () -> void
|
|
7223
|
+
|
|
7224
|
+
# _@return_ — columns of the button row at its natural width.
|
|
7225
|
+
def button_row_width: () -> Integer
|
|
7226
|
+
|
|
7227
|
+
# Re-measures the popup while open; a no-op tiled or before {#open}.
|
|
7228
|
+
def resize_popup: () -> void
|
|
7229
|
+
|
|
7230
|
+
# Callback taking no arguments, fired when the dialog is dismissed — ESC,
|
|
7231
|
+
# `q`, an outside click, or a {#button} declared without a block. Fires
|
|
7232
|
+
# exactly once per {#open}, and never when an action button was chosen.
|
|
7233
|
+
attr_accessor on_dismiss: Proc?
|
|
7234
|
+
|
|
7235
|
+
# _@return_ — whatever {#message=} was
|
|
7236
|
+
# given — set a `String`, read that `String` back. The component
|
|
7237
|
+
# rendering it is derived, never returned.
|
|
7238
|
+
attr_accessor message: (String | StyledString | Component)?
|
|
7239
|
+
|
|
7240
|
+
# The {Popup} that {#open} wraps the dialog in: its declared size is
|
|
7241
|
+
# derived from the dialog on every {#reposition}, so a message change and
|
|
7242
|
+
# a SIGWINCH both re-measure against the current screen.
|
|
7243
|
+
class MeasuredPopup < Tuile::Component::Popup
|
|
7244
|
+
# _@param_ `window`
|
|
7245
|
+
def initialize: (ConfirmWindow window) -> void
|
|
7246
|
+
|
|
7247
|
+
def reposition: () -> void
|
|
7248
|
+
end
|
|
7249
|
+
end
|
|
7250
|
+
|
|
5414
7251
|
# A {TextField} that paints one mask glyph per character instead of the
|
|
5415
7252
|
# text. Editing, caret, clicks and horizontal scrolling are the field's,
|
|
5416
7253
|
# unchanged:
|
|
@@ -5585,12 +7422,9 @@ module Tuile
|
|
|
5585
7422
|
# a read-only display field could override back to `false`. Only
|
|
5586
7423
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5587
7424
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5588
|
-
# `
|
|
7425
|
+
# `D_integer_field`).
|
|
5589
7426
|
def focusable?: () -> bool
|
|
5590
7427
|
|
|
5591
|
-
# _@param_ `event`
|
|
5592
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
5593
|
-
|
|
5594
7428
|
# _@param_ `rect`
|
|
5595
7429
|
def rect=: (Rect rect) -> void
|
|
5596
7430
|
|
|
@@ -5637,6 +7471,8 @@ module Tuile
|
|
|
5637
7471
|
#
|
|
5638
7472
|
# - {#preprocess_text} — input filter (e.g. {TextField} truncates to
|
|
5639
7473
|
# fit `rect.width - 1`).
|
|
7474
|
+
# - {#preprocess_paste} — the same for {#handle_paste}, which lands a
|
|
7475
|
+
# whole clipboard at the caret in one mutation.
|
|
5640
7476
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
5641
7477
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
5642
7478
|
# keep the caret visible).
|
|
@@ -5668,6 +7504,34 @@ module Tuile
|
|
|
5668
7504
|
# _@param_ `key`
|
|
5669
7505
|
def handle_key: (String key) -> bool
|
|
5670
7506
|
|
|
7507
|
+
# Inserts pasted text at the caret as **one** mutation, so {#on_change}
|
|
7508
|
+
# fires once for the whole paste rather than once per character.
|
|
7509
|
+
# {#preprocess_paste} filters it first.
|
|
7510
|
+
#
|
|
7511
|
+
# _@param_ `text`
|
|
7512
|
+
#
|
|
7513
|
+
# _@return_ — always true — a field consumes every paste, an empty
|
|
7514
|
+
# one included.
|
|
7515
|
+
def handle_paste: (String text) -> bool
|
|
7516
|
+
|
|
7517
|
+
# Input filter for {#handle_paste}, the paste-side counterpart of
|
|
7518
|
+
# {#preprocess_text}. Strips the C0 control characters a text buffer
|
|
7519
|
+
# cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
|
|
7520
|
+
# terminal cursor mid-frame — keeping `\n`, and turning a tab into a
|
|
7521
|
+
# single space so pasted code keeps its word gaps. {TextField} narrows it
|
|
7522
|
+
# further; an app wanting tab *expansion* overrides {#handle_paste}.
|
|
7523
|
+
#
|
|
7524
|
+
# _@param_ `text`
|
|
7525
|
+
def preprocess_paste: (String text) -> String
|
|
7526
|
+
|
|
7527
|
+
# Inserts `str` at the caret, leaving the caret behind it. The bulk
|
|
7528
|
+
# counterpart of a subclass's per-key insert.
|
|
7529
|
+
#
|
|
7530
|
+
# _@param_ `str`
|
|
7531
|
+
#
|
|
7532
|
+
# _@return_ — true if the text changed.
|
|
7533
|
+
def insert_text: (String str) -> bool
|
|
7534
|
+
|
|
5671
7535
|
# Renders `text` on the field's background well, looked up from the
|
|
5672
7536
|
# current {Screen#theme} at paint time: {Theme#active_bg_color} when this
|
|
5673
7537
|
# input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
|
|
@@ -5769,7 +7633,7 @@ module Tuile
|
|
|
5769
7633
|
# a read-only display field could override back to `false`. Only
|
|
5770
7634
|
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5771
7635
|
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5772
|
-
# `
|
|
7636
|
+
# `D_integer_field`).
|
|
5773
7637
|
def focusable?: () -> bool
|
|
5774
7638
|
|
|
5775
7639
|
# _@return_ — current text contents.
|
|
@@ -5877,6 +7741,48 @@ module Tuile
|
|
|
5877
7741
|
attr_reader light: Theme
|
|
5878
7742
|
end
|
|
5879
7743
|
|
|
7744
|
+
# How many colors the terminal on the other end can actually show — the
|
|
7745
|
+
# depth {Color#quantize} degrades a color to:
|
|
7746
|
+
#
|
|
7747
|
+
# ColorDepth.detect # => :truecolor
|
|
7748
|
+
# ColorDepth.detect(env: { "TERM" => "xterm-256color" }) # => :palette256
|
|
7749
|
+
# ColorDepth.detect(env: {}) # => :ansi16
|
|
7750
|
+
#
|
|
7751
|
+
# Env-only: no terminal round-trip, so unlike {TerminalBackground.detect}
|
|
7752
|
+
# there is no stdin timing to respect, and the answer cannot go stale
|
|
7753
|
+
# mid-session the way a background color can.
|
|
7754
|
+
#
|
|
7755
|
+
# Terminals lie in both directions — `COLORTERM` frequently doesn't survive
|
|
7756
|
+
# ssh (it isn't in the default `SendEnv` set) or tmux — so {OVERRIDE_ENV}
|
|
7757
|
+
# beats every other signal, the escape hatch for a terminal detected wrong.
|
|
7758
|
+
# Misdetection otherwise lands *conservatively*: a truecolor tmux
|
|
7759
|
+
# advertising only `tmux-256color` reads as `:palette256`, which renders
|
|
7760
|
+
# coarser but never mangled.
|
|
7761
|
+
#
|
|
7762
|
+
# == Implementation details
|
|
7763
|
+
#
|
|
7764
|
+
# Terminfo is deliberately not consulted — its `RGB` boolean and
|
|
7765
|
+
# `colors#0x1000000` would mean shelling out to `tput`/`infocmp` at every
|
|
7766
|
+
# startup, and the env ladder plus the override already covers the real
|
|
7767
|
+
# terminal matrix.
|
|
7768
|
+
module ColorDepth
|
|
7769
|
+
DEPTHS: ::Array[Symbol]
|
|
7770
|
+
OVERRIDE_ENV: String
|
|
7771
|
+
TRUECOLOR_COLORTERM: ::Array[String]
|
|
7772
|
+
|
|
7773
|
+
# The terminal's color depth, from {OVERRIDE_ENV}, else `COLORTERM`,
|
|
7774
|
+
# else `TERM` (a `-direct` entry means 24-bit, a `256color` one the
|
|
7775
|
+
# palette), else the 16-color floor.
|
|
7776
|
+
#
|
|
7777
|
+
# _@param_ `env` — environment to read; defaults to `ENV` (which duck-types the `[]` lookup).
|
|
7778
|
+
#
|
|
7779
|
+
# _@return_ — one of {DEPTHS}.
|
|
7780
|
+
def self.detect: (?env: ::Hash[String, String]) -> Symbol
|
|
7781
|
+
|
|
7782
|
+
# _@param_ `value` — the raw {OVERRIDE_ENV} value.
|
|
7783
|
+
def self.parse_override: (String value) -> Symbol
|
|
7784
|
+
end
|
|
7785
|
+
|
|
5880
7786
|
# An event queue. The idea is that all UI-related updates run from the thread
|
|
5881
7787
|
# which runs the event queue only; this removes any need for locking and/or
|
|
5882
7788
|
# need for thread-safety mechanisms.
|
|
@@ -5974,6 +7880,28 @@ module Tuile
|
|
|
5974
7880
|
attr_reader key: String
|
|
5975
7881
|
end
|
|
5976
7882
|
|
|
7883
|
+
# Text arrived from the clipboard rather than the keyboard: the terminal
|
|
7884
|
+
# bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
|
|
7885
|
+
# {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
|
|
7886
|
+
# so a component sees one mutation instead of a keystroke per character —
|
|
7887
|
+
# and a pasted line break can no longer be mistaken for a typed Enter.
|
|
7888
|
+
#
|
|
7889
|
+
# {Screen#event_loop} routes it to {Component#handle_paste} down the focus
|
|
7890
|
+
# chain. It never enters the key ladder: no Tab traversal, no global
|
|
7891
|
+
# shortcut, no {Component#handle_key}.
|
|
7892
|
+
#
|
|
7893
|
+
# @!attribute [r] text
|
|
7894
|
+
# @return [String] the pasted text, `\n`-normalized by
|
|
7895
|
+
# {Keys.normalize_paste}.
|
|
7896
|
+
class PasteEvent
|
|
7897
|
+
# _@param_ `text`
|
|
7898
|
+
def initialize: (text: String) -> void
|
|
7899
|
+
|
|
7900
|
+
# _@return_ — the pasted text, `\n`-normalized by
|
|
7901
|
+
# {Keys.normalize_paste}.
|
|
7902
|
+
attr_reader text: String
|
|
7903
|
+
end
|
|
7904
|
+
|
|
5977
7905
|
# An error event, causes {EventQueue#run_loop} to throw `StandardError` with
|
|
5978
7906
|
# {#error} as its origin.
|
|
5979
7907
|
#
|
|
@@ -6032,6 +7960,26 @@ module Tuile
|
|
|
6032
7960
|
attr_reader scheme: Symbol
|
|
6033
7961
|
end
|
|
6034
7962
|
|
|
7963
|
+
# The terminal reported its background color — an OSC 11 reply, read
|
|
7964
|
+
# back as a whole "key" by {Keys.getkey}.
|
|
7965
|
+
#
|
|
7966
|
+
# Only ever an answer to a {TerminalBackground::QUERY} someone wrote,
|
|
7967
|
+
# and only the color: a {ColorSchemeEvent} is what prompts the re-probe
|
|
7968
|
+
# and what carries the light/dark half. See {Screen#background_color}.
|
|
7969
|
+
#
|
|
7970
|
+
# @!attribute [r] color
|
|
7971
|
+
# @return [Color] the reported background, 24-bit RGB.
|
|
7972
|
+
class BackgroundColorEvent
|
|
7973
|
+
# _@param_ `key` — key read via {Keys.getkey}.
|
|
7974
|
+
#
|
|
7975
|
+
# _@return_ — nil when `key` is not an OSC 11
|
|
7976
|
+
# background reply.
|
|
7977
|
+
def self.parse: (String key) -> BackgroundColorEvent?
|
|
7978
|
+
|
|
7979
|
+
# _@return_ — the reported background, 24-bit RGB.
|
|
7980
|
+
attr_reader color: Color
|
|
7981
|
+
end
|
|
7982
|
+
|
|
6035
7983
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
6036
7984
|
# event loop will block waiting for more messages. Perfect time for
|
|
6037
7985
|
# repainting windows.
|
|
@@ -6110,15 +8058,52 @@ module Tuile
|
|
|
6110
8058
|
# _@param_ `str`
|
|
6111
8059
|
def emit: (String str) -> void
|
|
6112
8060
|
|
|
8061
|
+
# Pastes `text` into the focused component, as a real terminal would with
|
|
8062
|
+
# bracketed paste on:
|
|
8063
|
+
#
|
|
8064
|
+
# area.focus
|
|
8065
|
+
# Screen.instance.paste("one\r\ntwo")
|
|
8066
|
+
# area.text # => "one\ntwo" — one mutation, no ENTER anywhere
|
|
8067
|
+
#
|
|
8068
|
+
# Goes through {Keys.normalize_paste} first, so a spec can hand it the
|
|
8069
|
+
# CR-flavored line endings terminals actually deliver and still assert
|
|
8070
|
+
# against `\n`.
|
|
8071
|
+
#
|
|
8072
|
+
# _@param_ `text`
|
|
8073
|
+
#
|
|
8074
|
+
# _@return_ — true if some component consumed it.
|
|
8075
|
+
def paste: (String text) -> bool
|
|
8076
|
+
|
|
6113
8077
|
# _@param_ `component` — the component to check.
|
|
6114
8078
|
def invalidated?: (Component component) -> bool
|
|
6115
8079
|
|
|
6116
8080
|
def invalidated_clear: () -> void
|
|
6117
8081
|
|
|
8082
|
+
# Plays the terminal answering the OSC 11 re-probe, so a spec can
|
|
8083
|
+
# exercise app code that derives colors from {#background_color}:
|
|
8084
|
+
#
|
|
8085
|
+
# Screen.instance.background_color = Color.rgb(30, 30, 46)
|
|
8086
|
+
#
|
|
8087
|
+
# Takes the same path a real reply does — a changed color fires
|
|
8088
|
+
# {Component#on_theme_changed} across the tree and invalidates it.
|
|
8089
|
+
# There is no such writer on {Screen}: the value is a report from the
|
|
8090
|
+
# terminal, not a setting.
|
|
8091
|
+
#
|
|
8092
|
+
# _@param_ `color`
|
|
8093
|
+
def background_color=: (Color color) -> void
|
|
8094
|
+
|
|
6118
8095
|
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
6119
8096
|
# (which would write an OSC 11 query to the test runner's TTY and
|
|
6120
|
-
# steal its input) and pin the deterministic default.
|
|
6121
|
-
|
|
8097
|
+
# steal its input) and pin the deterministic default. The color is nil
|
|
8098
|
+
# — the case every app must handle anyway — until a spec assigns one
|
|
8099
|
+
# through {#background_color=}.
|
|
8100
|
+
def detect_background: () -> TerminalBackground::Result
|
|
8101
|
+
|
|
8102
|
+
# Pins the depth rather than reading the test runner's environment, so a
|
|
8103
|
+
# spec asserting flushed bytes gets the same answer on a truecolor
|
|
8104
|
+
# terminal, under `TERM=dumb` in CI, and inside tmux. A spec exercising
|
|
8105
|
+
# degradation builds its own {Buffer} with the depth it wants.
|
|
8106
|
+
def detect_color_depth: () -> Symbol
|
|
6122
8107
|
|
|
6123
8108
|
# _@return_ — whatever {#print} / {#emit} produced so far.
|
|
6124
8109
|
# Component painting lands in {#buffer}, not here — assert on
|
|
@@ -6184,33 +8169,40 @@ module Tuile
|
|
|
6184
8169
|
#
|
|
6185
8170
|
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
6186
8171
|
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
6187
|
-
# {#content}
|
|
6188
|
-
#
|
|
6189
|
-
#
|
|
6190
|
-
#
|
|
8172
|
+
# {#content} and the {#popups} stack. Putting them under a single Component
|
|
8173
|
+
# parent gives focus traversal a real root, makes {Component#attached?} a
|
|
8174
|
+
# one-liner, and lets popup-focus repair fall out of the standard
|
|
8175
|
+
# {Component#on_child_removed} hook.
|
|
8176
|
+
#
|
|
8177
|
+
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
8178
|
+
# {#content} gets the full pane rect, and an app that wants a status line
|
|
8179
|
+
# builds one into its own layout and drives it from
|
|
8180
|
+
# {Screen#on_focus_changed=} (`D_status_bar`).
|
|
6191
8181
|
#
|
|
6192
8182
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
6193
8183
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
6194
8184
|
# modal-popup rules rather than active-child dispatch.
|
|
6195
8185
|
class ScreenPane < Tuile::Component
|
|
8186
|
+
FINAL_METHODS: ::Array[Symbol]
|
|
8187
|
+
|
|
6196
8188
|
def initialize: () -> void
|
|
6197
8189
|
|
|
6198
8190
|
def focusable?: () -> bool
|
|
6199
8191
|
|
|
6200
|
-
# Adds
|
|
6201
|
-
# and grabs focus; a
|
|
6202
|
-
#
|
|
6203
|
-
#
|
|
6204
|
-
#
|
|
8192
|
+
# Adds an overlay and invalidates it for repaint. A {Component::Popup} is
|
|
8193
|
+
# centered and grabs focus; a bare {Component::Overlay} is left wherever
|
|
8194
|
+
# the caller positioned it and does *not* take focus, so the component that
|
|
8195
|
+
# was focused keeps the cursor and keeps receiving keys — the overlay
|
|
8196
|
+
# floats above the content, driven from app code.
|
|
6205
8197
|
#
|
|
6206
|
-
# The *whole subtree* is invalidated, not just the
|
|
8198
|
+
# The *whole subtree* is invalidated, not just the overlay wrapper (which
|
|
6207
8199
|
# paints nothing on its own): a reopened popup may land on cells that the
|
|
6208
8200
|
# tiled content has since overpainted, and if its rect is unchanged from
|
|
6209
8201
|
# last time its content components won't re-invalidate themselves — so
|
|
6210
|
-
# without this the
|
|
8202
|
+
# without this the overlay's contents would stay blank on reopen.
|
|
6211
8203
|
#
|
|
6212
|
-
# _@param_ `window`
|
|
6213
|
-
def add_popup: (Component::
|
|
8204
|
+
# _@param_ `window` — any overlay, modal or not.
|
|
8205
|
+
def add_popup: (Component::Overlay window) -> void
|
|
6214
8206
|
|
|
6215
8207
|
# Removes a popup. If the popup held focus, focus shifts to the now-topmost
|
|
6216
8208
|
# remaining popup, falling back to the focus snapshotted when the popup
|
|
@@ -6235,11 +8227,11 @@ module Tuile
|
|
|
6235
8227
|
# _@return_ — true if this pane currently hosts the popup.
|
|
6236
8228
|
def has_popup?: (Component window) -> bool
|
|
6237
8229
|
|
|
6238
|
-
# _@return_ — the topmost
|
|
6239
|
-
# only
|
|
6240
|
-
# owner": the popup that scopes key dispatch, blocks mouse clicks,
|
|
6241
|
-
#
|
|
6242
|
-
#
|
|
8230
|
+
# _@return_ — the topmost modal overlay, or nil when
|
|
8231
|
+
# only bare {Component::Overlay}s (or none) are open. This is the "modal
|
|
8232
|
+
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
8233
|
+
# confines Tab cycling. Bare overlays are excluded — they float above the
|
|
8234
|
+
# content without capturing input.
|
|
6243
8235
|
def modal_popup: () -> Component::Popup?
|
|
6244
8236
|
|
|
6245
8237
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -6247,11 +8239,11 @@ module Tuile
|
|
|
6247
8239
|
# _@param_ `new_rect`
|
|
6248
8240
|
def rect=: (Rect new_rect) -> void
|
|
6249
8241
|
|
|
6250
|
-
#
|
|
6251
|
-
#
|
|
6252
|
-
#
|
|
6253
|
-
#
|
|
6254
|
-
#
|
|
8242
|
+
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
8243
|
+
# itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
|
|
8244
|
+
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
8245
|
+
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
8246
|
+
# keep the top-left their owner assigned).
|
|
6255
8247
|
def layout: () -> void
|
|
6256
8248
|
|
|
6257
8249
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
@@ -6278,6 +8270,15 @@ module Tuile
|
|
|
6278
8270
|
# _@return_ — true if the key was handled.
|
|
6279
8271
|
def handle_key: (String key) -> bool
|
|
6280
8272
|
|
|
8273
|
+
# Delivers pasted text along the same focus chain {#handle_key} bubbles
|
|
8274
|
+
# along, and with the same scoping — first {Component#handle_paste}
|
|
8275
|
+
# returning true wins.
|
|
8276
|
+
#
|
|
8277
|
+
# _@param_ `text`
|
|
8278
|
+
#
|
|
8279
|
+
# _@return_ — true if the text was consumed.
|
|
8280
|
+
def handle_paste: (String text) -> bool
|
|
8281
|
+
|
|
6281
8282
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
6282
8283
|
# fall through to content only when no popup is hit *and* no modal popup is
|
|
6283
8284
|
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
@@ -6285,6 +8286,35 @@ module Tuile
|
|
|
6285
8286
|
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
6286
8287
|
# content beneath.
|
|
6287
8288
|
#
|
|
8289
|
+
# A left click also *dismisses* the open popups it landed outside of that
|
|
8290
|
+
# asked for it ({Component::Overlay#close_on_outside_click?}). That is a
|
|
8291
|
+
# second thing happening on a click, but not a second dispatch: the click is
|
|
8292
|
+
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
8293
|
+
# closed rather than told.
|
|
8294
|
+
#
|
|
8295
|
+
# "Outside" is measured against the {Component::Overlay#owner} chain, not
|
|
8296
|
+
# against one rect and not against stacking order: the popup the click hit
|
|
8297
|
+
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
8298
|
+
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
8299
|
+
# field opened, and a menu cascade being dismissed by a click on one of its
|
|
8300
|
+
# own deeper panels. Order carries no meaning here — between unrelated
|
|
8301
|
+
# overlays it is merely the order they opened in — so ownership is declared
|
|
8302
|
+
# rather than inferred from the stack.
|
|
8303
|
+
#
|
|
8304
|
+
# Two halves of the ordering are load-bearing, and both are specced:
|
|
8305
|
+
#
|
|
8306
|
+
# - **Snapshot before routing.** A popup the delivered click *opens* must
|
|
8307
|
+
# not be in the set (it would immediately dismiss itself — every
|
|
8308
|
+
# {Component::Select} would be unopenable by mouse).
|
|
8309
|
+
# - **Close after routing.** A widget toggling its own overlay from a click
|
|
8310
|
+
# on its face closes it during delivery, and {Component::Popup#close} is
|
|
8311
|
+
# idempotent, so the dismissal no-ops. Close *first* and the widget sees
|
|
8312
|
+
# a shut overlay and reopens it — a Select's dropdown could then never be
|
|
8313
|
+
# dismissed by clicking the Select.
|
|
8314
|
+
#
|
|
8315
|
+
# The snapshot is a fresh array for a third reason: a handler may close
|
|
8316
|
+
# further popups, and `@popups` must not be mutated mid-iteration.
|
|
8317
|
+
#
|
|
6288
8318
|
# _@param_ `event`
|
|
6289
8319
|
def handle_mouse: (MouseEvent event) -> void
|
|
6290
8320
|
|
|
@@ -6303,6 +8333,23 @@ module Tuile
|
|
|
6303
8333
|
# _@param_ `child`
|
|
6304
8334
|
def on_child_removed: (Component child) -> void
|
|
6305
8335
|
|
|
8336
|
+
# The overlays a click counts as landing *inside*: the one it hit, plus
|
|
8337
|
+
# every overlay that one belongs to, up the {Component::Overlay#owner}
|
|
8338
|
+
# chain. An owner is any component, so it is resolved to the overlay
|
|
8339
|
+
# enclosing it (an overlay resolves to itself) — which keeps the
|
|
8340
|
+
# relationship a live tree question rather than one frozen when the overlay
|
|
8341
|
+
# opened. The `include?` guard makes a mis-wired cycle terminate instead of
|
|
8342
|
+
# hanging the UI thread.
|
|
8343
|
+
#
|
|
8344
|
+
# _@param_ `hit` — the overlay the click landed in, if any.
|
|
8345
|
+
def kept_by: (Component::Overlay? hit) -> ::Array[Component::Overlay]
|
|
8346
|
+
|
|
8347
|
+
# _@param_ `component`
|
|
8348
|
+
#
|
|
8349
|
+
# _@return_ — `component` itself when it is an
|
|
8350
|
+
# overlay, else the nearest overlay above it, else nil.
|
|
8351
|
+
def enclosing_popup: (Component? component) -> Component::Overlay?
|
|
8352
|
+
|
|
6306
8353
|
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
6307
8354
|
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
6308
8355
|
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
@@ -6316,6 +8363,14 @@ module Tuile
|
|
|
6316
8363
|
# _@return_ — true if some component on the chain handled the key.
|
|
6317
8364
|
def bubble_key: (String key, Component scope) -> bool
|
|
6318
8365
|
|
|
8366
|
+
# {Screen#focused} and its ancestors up to and including `scope`.
|
|
8367
|
+
#
|
|
8368
|
+
# _@param_ `scope` — the modal scope root (topmost popup or content).
|
|
8369
|
+
#
|
|
8370
|
+
# _@return_ — the chain, innermost first; nil when
|
|
8371
|
+
# focus is nil or sits outside `scope`.
|
|
8372
|
+
def focus_chain: (Component scope) -> ::Array[Component]?
|
|
8373
|
+
|
|
6319
8374
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
6320
8375
|
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
6321
8376
|
# if `root` is `nil`.
|
|
@@ -6326,18 +8381,16 @@ module Tuile
|
|
|
6326
8381
|
# _@return_ — the tiled content component.
|
|
6327
8382
|
attr_accessor content: Component?
|
|
6328
8383
|
|
|
6329
|
-
# _@return_ —
|
|
6330
|
-
# topmost. Holds both
|
|
6331
|
-
# ({Component::
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
# _@return_ — the bottom status bar.
|
|
6335
|
-
attr_reader status_bar: Component::Label
|
|
8384
|
+
# _@return_ — the open overlays in stacking order;
|
|
8385
|
+
# last is topmost. Holds both {Component::Popup} modals and bare
|
|
8386
|
+
# {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
|
|
8387
|
+
# be mutated by callers.
|
|
8388
|
+
attr_reader popups: ::Array[Component::Overlay]
|
|
6336
8389
|
end
|
|
6337
8390
|
|
|
6338
8391
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
6339
8392
|
# each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
|
|
6340
|
-
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
8393
|
+
# `underline`, `strikethrough`, `inverse`). Spans are non-overlapping and fully tile
|
|
6341
8394
|
# the string — every character has exactly one resolved style, no overlay
|
|
6342
8395
|
# layers to merge, so the style at any column is just its span's `style`
|
|
6343
8396
|
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
@@ -6368,7 +8421,7 @@ module Tuile
|
|
|
6368
8421
|
# ## Parser
|
|
6369
8422
|
#
|
|
6370
8423
|
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
6371
|
-
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
8424
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and
|
|
6372
8425
|
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
6373
8426
|
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
6374
8427
|
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
@@ -6503,6 +8556,10 @@ module Tuile
|
|
|
6503
8556
|
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
6504
8557
|
# inherited panel tint.
|
|
6505
8558
|
#
|
|
8559
|
+
# An `inverse` span counts as backgrounded and is skipped even when its `bg`
|
|
8560
|
+
# member is nil: SGR 7 swaps the pair in effect, so a bg filled under it
|
|
8561
|
+
# would recolor the span's *glyphs*, not the ground behind them.
|
|
8562
|
+
#
|
|
6506
8563
|
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
6507
8564
|
def under_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
6508
8565
|
|
|
@@ -6514,6 +8571,54 @@ module Tuile
|
|
|
6514
8571
|
# _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
|
|
6515
8572
|
def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
|
|
6516
8573
|
|
|
8574
|
+
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
8575
|
+
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
8576
|
+
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
8577
|
+
# {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
|
|
8578
|
+
# possibly multi-span content — a widget marking one caption out of several
|
|
8579
|
+
# as selected, where the caption may already carry its own colors.
|
|
8580
|
+
#
|
|
8581
|
+
# There is deliberately no `under_bold` (the fill-unset counterpart
|
|
8582
|
+
# {#under_bg} provides for backgrounds): a background is inherited down the
|
|
8583
|
+
# component tree, so a span with none has a meaningful "unset" state to
|
|
8584
|
+
# fill, while `bold` is a plain per-span attribute that is either on or
|
|
8585
|
+
# off. Pass `bold: false` to clear it.
|
|
8586
|
+
#
|
|
8587
|
+
# _@param_ `bold` — whether the spans should be bold.
|
|
8588
|
+
def with_bold: (?bold: bool) -> StyledString
|
|
8589
|
+
|
|
8590
|
+
# Returns a new {StyledString} with `underline` applied to every span,
|
|
8591
|
+
# preserving each span's text and other style attributes (`fg`, `bg`,
|
|
8592
|
+
# `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
|
|
8593
|
+
# of a string, which is what a one-character cue needs:
|
|
8594
|
+
#
|
|
8595
|
+
# cap = StyledString.parse("File")
|
|
8596
|
+
# cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
|
|
8597
|
+
# # => "File" with the F underlined — a menu mnemonic
|
|
8598
|
+
#
|
|
8599
|
+
# Note {#slice} counts **columns**, not characters, so a caption with a
|
|
8600
|
+
# wide glyph before the cue needs the prefix measured rather than counted.
|
|
8601
|
+
#
|
|
8602
|
+
# There is deliberately no `under_underline`, for the reason {#with_bold}
|
|
8603
|
+
# spells out. Pass `underline: false` to clear it.
|
|
8604
|
+
#
|
|
8605
|
+
# _@param_ `underline` — whether the spans should be underlined.
|
|
8606
|
+
def with_underline: (?underline: bool) -> StyledString
|
|
8607
|
+
|
|
8608
|
+
# Returns a new {StyledString} with `inverse` applied to every span,
|
|
8609
|
+
# preserving each span's text and other style attributes. Inverse swaps
|
|
8610
|
+
# whatever fg/bg are actually in effect at each cell — terminal defaults
|
|
8611
|
+
# included — so a focus chip built with it reads as "backgrounded" on any
|
|
8612
|
+
# terminal palette without picking a single color:
|
|
8613
|
+
#
|
|
8614
|
+
# StyledString.plain(" 1 VMs ").with_inverse # the inverted-chip idiom
|
|
8615
|
+
#
|
|
8616
|
+
# There is deliberately no `under_inverse`, for the reason {#with_bold}
|
|
8617
|
+
# spells out. Pass `inverse: false` to clear it.
|
|
8618
|
+
#
|
|
8619
|
+
# _@param_ `inverse` — whether the spans should be inverted.
|
|
8620
|
+
def with_inverse: (?inverse: bool) -> StyledString
|
|
8621
|
+
|
|
6517
8622
|
def inspect: () -> String
|
|
6518
8623
|
|
|
6519
8624
|
def build_ansi: () -> String
|
|
@@ -6607,6 +8712,9 @@ module Tuile
|
|
|
6607
8712
|
# @return [Boolean]
|
|
6608
8713
|
# @!attribute [r] strikethrough
|
|
6609
8714
|
# @return [Boolean]
|
|
8715
|
+
# @!attribute [r] inverse
|
|
8716
|
+
# @return [Boolean] swap the fg/bg *in effect* at the cell (SGR 7) —
|
|
8717
|
+
# including terminal defaults, which `fg`/`bg` can't name.
|
|
6610
8718
|
class Style
|
|
6611
8719
|
DEFAULT: Style
|
|
6612
8720
|
|
|
@@ -6621,13 +8729,16 @@ module Tuile
|
|
|
6621
8729
|
# _@param_ `underline`
|
|
6622
8730
|
#
|
|
6623
8731
|
# _@param_ `strikethrough`
|
|
8732
|
+
#
|
|
8733
|
+
# _@param_ `inverse`
|
|
6624
8734
|
def self.new: (
|
|
6625
8735
|
?fg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
6626
8736
|
?bg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
6627
8737
|
?bold: bool,
|
|
6628
8738
|
?italic: bool,
|
|
6629
8739
|
?underline: bool,
|
|
6630
|
-
?strikethrough: bool
|
|
8740
|
+
?strikethrough: bool,
|
|
8741
|
+
?inverse: bool
|
|
6631
8742
|
) -> Style
|
|
6632
8743
|
|
|
6633
8744
|
def default?: () -> bool
|
|
@@ -6665,6 +8776,10 @@ module Tuile
|
|
|
6665
8776
|
attr_reader underline: bool
|
|
6666
8777
|
|
|
6667
8778
|
attr_reader strikethrough: bool
|
|
8779
|
+
|
|
8780
|
+
# _@return_ — swap the fg/bg *in effect* at the cell (SGR 7) —
|
|
8781
|
+
# including terminal defaults, which `fg`/`bg` can't name.
|
|
8782
|
+
attr_reader inverse: bool
|
|
6668
8783
|
end
|
|
6669
8784
|
|
|
6670
8785
|
# A maximal run of text sharing a single {Style}. `text` is plain — it
|
|
@@ -6823,8 +8938,9 @@ module Tuile
|
|
|
6823
8938
|
end
|
|
6824
8939
|
end
|
|
6825
8940
|
|
|
6826
|
-
# Detects
|
|
6827
|
-
#
|
|
8941
|
+
# Detects the terminal's background color — both the light/dark scheme
|
|
8942
|
+
# {Screen} picks {Theme::LIGHT} or {Theme::DARK} from, and, when the
|
|
8943
|
+
# terminal answers the query, the actual RGB behind it.
|
|
6828
8944
|
#
|
|
6829
8945
|
# Two mechanisms, in order of reliability:
|
|
6830
8946
|
#
|
|
@@ -6837,13 +8953,19 @@ module Tuile
|
|
|
6837
8953
|
# bounded by a short timeout.
|
|
6838
8954
|
# 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
|
|
6839
8955
|
# indices. Less reliable (stale across SSH/tmux, often unset); used
|
|
6840
|
-
# only when OSC 11 yields nothing.
|
|
8956
|
+
# only when OSC 11 yields nothing. Palette indices carry no RGB, so
|
|
8957
|
+
# this path fills in {Result#scheme} and leaves {Result#color} nil.
|
|
6841
8958
|
#
|
|
6842
8959
|
# **Timing matters**: the OSC 11 reply arrives on stdin, so the query
|
|
6843
8960
|
# must complete before {EventQueue#start_key_thread} owns stdin —
|
|
6844
8961
|
# otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
|
|
6845
8962
|
# calls {.detect} from its constructor, which apps run before
|
|
6846
8963
|
# {Screen#run_event_loop}; don't call this after the event loop started.
|
|
8964
|
+
#
|
|
8965
|
+
# Once the loop *is* running the query is still available, from the other
|
|
8966
|
+
# side: {Screen} writes {QUERY} on every OS appearance flip and the key
|
|
8967
|
+
# thread — which owns stdin by then — reads the reply back through
|
|
8968
|
+
# {Keys.getkey} and {.parse}. See {Screen#background_color}.
|
|
6847
8969
|
module TerminalBackground
|
|
6848
8970
|
QUERY_TIMEOUT: Float
|
|
6849
8971
|
QUERY: String
|
|
@@ -6854,6 +8976,9 @@ module Tuile
|
|
|
6854
8976
|
# Detects the terminal background. Queries OSC 11 when both `input`
|
|
6855
8977
|
# and `output` are TTYs, falling back to `COLORFGBG`.
|
|
6856
8978
|
#
|
|
8979
|
+
# TerminalBackground.detect
|
|
8980
|
+
# # => #<data Result scheme=:dark, color=#<Tuile::Color [30, 30, 46]>>
|
|
8981
|
+
#
|
|
6857
8982
|
# _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
|
|
6858
8983
|
#
|
|
6859
8984
|
# _@param_ `output` — where the query is written (the TTY output).
|
|
@@ -6862,25 +8987,41 @@ module Tuile
|
|
|
6862
8987
|
#
|
|
6863
8988
|
# _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
|
|
6864
8989
|
#
|
|
6865
|
-
# _@return_ —
|
|
8990
|
+
# _@return_ — nil when the background is undetectable —
|
|
8991
|
+
# neither mechanism answered.
|
|
6866
8992
|
def self.detect: (
|
|
6867
8993
|
?input: IO,
|
|
6868
8994
|
?output: IO,
|
|
6869
8995
|
?env: ::Hash[String, String],
|
|
6870
8996
|
?timeout: Numeric
|
|
6871
|
-
) ->
|
|
8997
|
+
) -> Result?
|
|
6872
8998
|
|
|
6873
|
-
#
|
|
6874
|
-
#
|
|
6875
|
-
#
|
|
6876
|
-
#
|
|
8999
|
+
# Parses an OSC 11 reply — the terminal's answer to {QUERY}, matched
|
|
9000
|
+
# anywhere in `reply`.
|
|
9001
|
+
#
|
|
9002
|
+
# TerminalBackground.parse("\e]11;rgb:1e1e/1e1e/2e2e\a").color
|
|
9003
|
+
# # => #<Tuile::Color [30, 30, 46]>
|
|
9004
|
+
#
|
|
9005
|
+
# Public because a reply also arrives *mid-session*, long after
|
|
9006
|
+
# {.detect}'s own bounded read: once the key thread owns stdin, a
|
|
9007
|
+
# whole reply surfaces as one "key" from {Keys.getkey}.
|
|
9008
|
+
#
|
|
9009
|
+
# _@param_ `reply` — raw terminal output that may contain a reply.
|
|
9010
|
+
#
|
|
9011
|
+
# _@return_ — nil when `reply` holds no OSC 11 reply.
|
|
9012
|
+
def self.parse: (String reply) -> Result?
|
|
9013
|
+
|
|
9014
|
+
# Writes the OSC 11 query and parses the reply. The whole exchange
|
|
9015
|
+
# runs with `input` in raw mode: the reply has no trailing newline,
|
|
9016
|
+
# so a canonical-mode read would block past the timeout, and echo
|
|
9017
|
+
# would smear the reply bytes onto the screen.
|
|
6877
9018
|
#
|
|
6878
9019
|
# _@param_ `input`
|
|
6879
9020
|
#
|
|
6880
9021
|
# _@param_ `output`
|
|
6881
9022
|
#
|
|
6882
9023
|
# _@param_ `timeout`
|
|
6883
|
-
def self.query_osc11: (IO input, IO output, Numeric timeout) ->
|
|
9024
|
+
def self.query_osc11: (IO input, IO output, Numeric timeout) -> Result?
|
|
6884
9025
|
|
|
6885
9026
|
# Accumulates reply bytes until a BEL/ST terminator or the deadline.
|
|
6886
9027
|
# Terminals that don't support OSC 11 never reply — returning
|
|
@@ -6891,13 +9032,20 @@ module Tuile
|
|
|
6891
9032
|
# _@param_ `timeout`
|
|
6892
9033
|
def self.read_reply: (IO input, Numeric timeout) -> String
|
|
6893
9034
|
|
|
6894
|
-
#
|
|
6895
|
-
# component hex width (xterm replies 4 digits per channel, others 2).
|
|
9035
|
+
# Light or dark, by relative luminance against a 0.5 threshold.
|
|
6896
9036
|
#
|
|
6897
|
-
# _@param_ `components` —
|
|
9037
|
+
# _@param_ `components` — red, green and blue, each 0.0..1.0.
|
|
6898
9038
|
#
|
|
6899
9039
|
# _@return_ — `:light` or `:dark`.
|
|
6900
|
-
def self.classify: (::Array[
|
|
9040
|
+
def self.classify: (::Array[Float] components) -> Symbol
|
|
9041
|
+
|
|
9042
|
+
# The reported background as a 24-bit {Color}. Quantizing xterm's
|
|
9043
|
+
# 16-bit-per-channel reply down to 8 loses nothing a terminal can
|
|
9044
|
+
# display, and keeps the exposed value in the type the rest of Tuile
|
|
9045
|
+
# paints with.
|
|
9046
|
+
#
|
|
9047
|
+
# _@param_ `components` — red, green and blue, each 0.0..1.0.
|
|
9048
|
+
def self.to_color: (::Array[Float] components) -> Color
|
|
6901
9049
|
|
|
6902
9050
|
# `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
|
|
6903
9051
|
# ANSI palette indices. White-ish backgrounds — 7 (white) and the
|
|
@@ -6906,6 +9054,27 @@ module Tuile
|
|
|
6906
9054
|
#
|
|
6907
9055
|
# _@param_ `value`
|
|
6908
9056
|
def self.from_colorfgbg: (String? value) -> Symbol?
|
|
9057
|
+
|
|
9058
|
+
# What a detection found: the light/dark `scheme`, and the background
|
|
9059
|
+
# `color` when a terminal actually reported one.
|
|
9060
|
+
#
|
|
9061
|
+
# `color` is nil whenever the scheme came from the `COLORFGBG`
|
|
9062
|
+
# fallback, so a consumer deriving a tint from the background must
|
|
9063
|
+
# handle nil — plenty of terminals answer neither query.
|
|
9064
|
+
#
|
|
9065
|
+
# @!attribute [r] scheme
|
|
9066
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
9067
|
+
# @!attribute [r] color
|
|
9068
|
+
# @return [Color, nil] the reported background as 24-bit RGB, or nil
|
|
9069
|
+
# when only `COLORFGBG` answered.
|
|
9070
|
+
class Result < Data
|
|
9071
|
+
# _@return_ — `:light` or `:dark`.
|
|
9072
|
+
attr_reader scheme: Symbol
|
|
9073
|
+
|
|
9074
|
+
# _@return_ — the reported background as 24-bit RGB, or nil
|
|
9075
|
+
# when only `COLORFGBG` answered.
|
|
9076
|
+
attr_reader color: Color?
|
|
9077
|
+
end
|
|
6909
9078
|
end
|
|
6910
9079
|
|
|
6911
9080
|
# A vertical scrollbar that computes which character to draw at each row.
|