tuile 0.12.0 → 0.13.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 +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- 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 +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -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.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -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.
|
|
@@ -879,10 +921,15 @@ module Tuile
|
|
|
879
921
|
# Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
|
|
880
922
|
# {#content} (set via {#content=}, filling the whole terminal and laying
|
|
881
923
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
882
|
-
# {Component::Popup#open}, drawn on top of the content)
|
|
883
|
-
#
|
|
884
|
-
#
|
|
885
|
-
#
|
|
924
|
+
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
925
|
+
# sized from their content — each carries its own top-down
|
|
926
|
+
# {Component::Popup#size} — and they deliberately overdraw the content
|
|
927
|
+
# without clipping.
|
|
928
|
+
#
|
|
929
|
+
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
930
|
+
# row, so {#content} gets the whole terminal. An app that wants a status line
|
|
931
|
+
# builds one into its own layout and drives it from {#on_focus_changed=}
|
|
932
|
+
# (`D-status-bar`).
|
|
886
933
|
#
|
|
887
934
|
# ## Repaint model
|
|
888
935
|
#
|
|
@@ -957,22 +1004,6 @@ module Tuile
|
|
|
957
1004
|
# _@param_ `component`
|
|
958
1005
|
def invalidate: (Component component) -> void
|
|
959
1006
|
|
|
960
|
-
# Rebuild the status-bar text from the current focus and global-shortcut
|
|
961
|
-
# registry. Called from {#focused=} and whenever the global registry
|
|
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
1007
|
# Internal — use {Component::Popup#open} instead. Adds the popup to
|
|
977
1008
|
# {#pane}, centers and focuses it.
|
|
978
1009
|
#
|
|
@@ -988,7 +1019,9 @@ module Tuile
|
|
|
988
1019
|
# ownership reverts to the creating thread once it returns.
|
|
989
1020
|
#
|
|
990
1021
|
# _@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
|
-
|
|
1022
|
+
#
|
|
1023
|
+
# _@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.
|
|
1024
|
+
def run_event_loop: (?capture_mouse: bool, ?bracketed_paste: bool) -> void
|
|
992
1025
|
|
|
993
1026
|
# Advances focus to the next {Component#tab_stop?} in tree order, wrapping
|
|
994
1027
|
# around. Scope is the topmost popup if one is open, otherwise {#content}
|
|
@@ -1021,18 +1054,14 @@ module Tuile
|
|
|
1021
1054
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
1022
1055
|
# which every editable widget needs.
|
|
1023
1056
|
#
|
|
1024
|
-
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
1025
|
-
# over_popups: true,
|
|
1026
|
-
# hint: "^L #{screen.theme.hint("log")}") do
|
|
1057
|
+
# screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
|
|
1027
1058
|
# log_popup.open
|
|
1028
1059
|
# end
|
|
1029
1060
|
#
|
|
1030
1061
|
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
|
|
1031
1062
|
#
|
|
1032
1063
|
# _@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
|
|
1064
|
+
def register_global_shortcut: (String key, ?over_popups: bool) -> void
|
|
1036
1065
|
|
|
1037
1066
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
1038
1067
|
# No-op if `key` was not registered.
|
|
@@ -1040,9 +1069,6 @@ module Tuile
|
|
|
1040
1069
|
# _@param_ `key`
|
|
1041
1070
|
def unregister_global_shortcut: (String key) -> void
|
|
1042
1071
|
|
|
1043
|
-
# _@return_ — current active tiled component.
|
|
1044
|
-
def active_window: () -> Component?
|
|
1045
|
-
|
|
1046
1072
|
# Internal — use {Component::Popup#close} instead. Removes the popup
|
|
1047
1073
|
# from {#pane}, repairs focus, and repaints the scene.
|
|
1048
1074
|
#
|
|
@@ -1090,6 +1116,22 @@ module Tuile
|
|
|
1090
1116
|
# _@param_ `args` — stuff to print.
|
|
1091
1117
|
def print: (*String args) -> void
|
|
1092
1118
|
|
|
1119
|
+
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
1120
|
+
# went nowhere, e.g. a letter matching no menu mnemonic while a menu is
|
|
1121
|
+
# open.
|
|
1122
|
+
#
|
|
1123
|
+
# return true if activate_mnemonic(key)
|
|
1124
|
+
#
|
|
1125
|
+
# screen.beep # no match: the key is swallowed, say so
|
|
1126
|
+
# true
|
|
1127
|
+
#
|
|
1128
|
+
# Writes **immediately** rather than riding the next frame: a beep is not
|
|
1129
|
+
# part of a frame, and the keystrokes worth beeping at are precisely the
|
|
1130
|
+
# ones that invalidate nothing, so {#repaint} may never emit at all. Whether
|
|
1131
|
+
# the user hears anything is the terminal's setting to make, so there is no
|
|
1132
|
+
# Tuile-level enable/disable knob.
|
|
1133
|
+
def beep: () -> void
|
|
1134
|
+
|
|
1093
1135
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
1094
1136
|
# considering invalidated components and flushing just the changed cells
|
|
1095
1137
|
# of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
|
|
@@ -1173,6 +1215,19 @@ module Tuile
|
|
|
1173
1215
|
# _@param_ `event`
|
|
1174
1216
|
def handle_mouse: (MouseEvent event) -> void
|
|
1175
1217
|
|
|
1218
|
+
# Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
|
|
1219
|
+
#
|
|
1220
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
1221
|
+
# skips Tab traversal and the global-shortcut registry entirely and goes
|
|
1222
|
+
# straight to delivery. Unhandled text is dropped — there is no fallback
|
|
1223
|
+
# that replays it as keys, which would put back the very ambiguity mode
|
|
1224
|
+
# 2004 exists to remove.
|
|
1225
|
+
#
|
|
1226
|
+
# _@param_ `text`
|
|
1227
|
+
#
|
|
1228
|
+
# _@return_ — true if some component consumed it.
|
|
1229
|
+
def handle_paste: (String text) -> bool
|
|
1230
|
+
|
|
1176
1231
|
def event_loop: () -> void
|
|
1177
1232
|
|
|
1178
1233
|
# _@return_ — the structural root of the component tree.
|
|
@@ -1231,8 +1286,33 @@ module Tuile
|
|
|
1231
1286
|
# _@return_ — currently focused component.
|
|
1232
1287
|
attr_accessor focused: Component?
|
|
1233
1288
|
|
|
1234
|
-
#
|
|
1235
|
-
#
|
|
1289
|
+
# Called after the focused component *changes* — including to and from
|
|
1290
|
+
# `nil`, and including the focus repair that runs when a popup closes.
|
|
1291
|
+
# Takes no arguments; read {#focused} (and walk its `parent` chain) for the
|
|
1292
|
+
# new state.
|
|
1293
|
+
#
|
|
1294
|
+
# This is the hook an app drives its own status line from. Tuile owns no
|
|
1295
|
+
# status bar and reserves no row: build a {Component::Label} into your own
|
|
1296
|
+
# layout and fill it here (`D-status-bar`).
|
|
1297
|
+
#
|
|
1298
|
+
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
1299
|
+
#
|
|
1300
|
+
# **Edge-triggered**, like {Component#on_attached}: re-assigning the
|
|
1301
|
+
# component that already has focus fires nothing, so a callback can be as
|
|
1302
|
+
# expensive as rebuilding a hint string without a `did it really change?`
|
|
1303
|
+
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
1304
|
+
# clears focus on every content swap, which on a level-triggered hook would
|
|
1305
|
+
# fire a nil→nil notification during assembly.
|
|
1306
|
+
#
|
|
1307
|
+
# It runs *after* the active-flag cascade and `on_focus`, so the tree is
|
|
1308
|
+
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
1309
|
+
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
1310
|
+
# {Component#on_detached}. A raising callback propagates out of {#focused=}
|
|
1311
|
+
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
1312
|
+
attr_accessor on_focus_changed: Proc?
|
|
1313
|
+
|
|
1314
|
+
# Entry in the global shortcut registry: the block to run, and whether it
|
|
1315
|
+
# pre-empts open popups.
|
|
1236
1316
|
# @api private
|
|
1237
1317
|
class Shortcut < Data
|
|
1238
1318
|
# Returns the value of attribute block
|
|
@@ -1240,9 +1320,6 @@ module Tuile
|
|
|
1240
1320
|
|
|
1241
1321
|
# Returns the value of attribute over_popups
|
|
1242
1322
|
attr_reader over_popups: Object
|
|
1243
|
-
|
|
1244
|
-
# Returns the value of attribute hint
|
|
1245
|
-
attr_reader hint: Object
|
|
1246
1323
|
end
|
|
1247
1324
|
end
|
|
1248
1325
|
|
|
@@ -1301,14 +1378,24 @@ module Tuile
|
|
|
1301
1378
|
def effective_bg_color: () -> Color?
|
|
1302
1379
|
|
|
1303
1380
|
# Repaints the component. The default does the bookkeeping most components
|
|
1304
|
-
# need: it clears the background
|
|
1305
|
-
#
|
|
1306
|
-
#
|
|
1307
|
-
# children
|
|
1381
|
+
# need: it clears the background — unless the direct children already tile
|
|
1382
|
+
# {#rect}, in which case there is no gap to wipe and blanking cells they are
|
|
1383
|
+
# about to repaint would only make them dirty — and then re-invalidates
|
|
1384
|
+
# those children so they paint over the cleared area. That is what makes
|
|
1385
|
+
# mixed-width form layouts safe.
|
|
1308
1386
|
#
|
|
1309
1387
|
# Call `super` from your own `repaint` to inherit this. Skip it only if you
|
|
1310
1388
|
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
1311
1389
|
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
1390
|
+
#
|
|
1391
|
+
# **The children are re-invalidated whether or not they tile.** A container
|
|
1392
|
+
# that paints nothing of its own can only redraw its area *through* them, so
|
|
1393
|
+
# a tiling container that skipped this would be a dead end in the cascade: an
|
|
1394
|
+
# ancestor's `clear_background` wipes the whole ancestor rect — siblings and
|
|
1395
|
+
# grandchildren included — and re-invalidates only its *direct* children, so
|
|
1396
|
+
# the notice has to keep travelling down or the cleared cells are never
|
|
1397
|
+
# repainted. Cheap by construction: repainting the same glyphs leaves
|
|
1398
|
+
# {Buffer::Cell} unchanged, so nothing extra reaches the wire.
|
|
1312
1399
|
def repaint: () -> void
|
|
1313
1400
|
|
|
1314
1401
|
# Called when a key is pressed; override to act on keys you care about (the
|
|
@@ -1322,6 +1409,27 @@ module Tuile
|
|
|
1322
1409
|
# _@return_ — true if the key was handled, false if not.
|
|
1323
1410
|
def handle_key: (String _key) -> bool
|
|
1324
1411
|
|
|
1412
|
+
# Called when text is pasted while this component is on the focus chain;
|
|
1413
|
+
# override to accept it (the default reports every paste unhandled, and
|
|
1414
|
+
# unhandled text is dropped). Arrives whole and `\n`-normalized, so
|
|
1415
|
+
# `text.lines.size` is the paste's line count and a single mutation can
|
|
1416
|
+
# absorb it:
|
|
1417
|
+
#
|
|
1418
|
+
# def handle_paste(text)
|
|
1419
|
+
# self.caption = "[Pasted #{text.lines.size} lines]"
|
|
1420
|
+
# true
|
|
1421
|
+
# end
|
|
1422
|
+
#
|
|
1423
|
+
# Reaching here means the terminal said "this came from the clipboard" —
|
|
1424
|
+
# {Component::AbstractStringField} inserts it at the caret, which is why a
|
|
1425
|
+
# subclass that rebinds ENTER to submit needs no paste handling of its own
|
|
1426
|
+
# to stop firing once per pasted line.
|
|
1427
|
+
#
|
|
1428
|
+
# _@param_ `_text` — the pasted text.
|
|
1429
|
+
#
|
|
1430
|
+
# _@return_ — true if the paste was consumed.
|
|
1431
|
+
def handle_paste: (String _text) -> bool
|
|
1432
|
+
|
|
1325
1433
|
# Handles mouse event. Default implementation focuses this component when
|
|
1326
1434
|
# clicked (if {#focusable?}).
|
|
1327
1435
|
#
|
|
@@ -1399,15 +1507,10 @@ module Tuile
|
|
|
1399
1507
|
# _@return_ — absolute screen coordinates, or nil to hide.
|
|
1400
1508
|
def cursor_position: () -> Point?
|
|
1401
1509
|
|
|
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
1510
|
# Adopts `child`: places it in {#children} and wires its parent pointer.
|
|
1408
1511
|
#
|
|
1409
|
-
# add_child(
|
|
1410
|
-
# add_child(
|
|
1512
|
+
# add_child(content, at: 0) # the tiled layer, painted beneath …
|
|
1513
|
+
# add_child(@footer) # … and chrome appended, painted over it
|
|
1411
1514
|
#
|
|
1412
1515
|
# _@param_ `child` — must not already have a parent.
|
|
1413
1516
|
#
|
|
@@ -1696,6 +1799,22 @@ module Tuile
|
|
|
1696
1799
|
# _@return_ — true if a match was found.
|
|
1697
1800
|
def select_prev: (String query, ?include_current: bool) -> bool
|
|
1698
1801
|
|
|
1802
|
+
# Moves the cursor to the item at `index`, scrolling it into view and
|
|
1803
|
+
# firing {#on_cursor_changed} — the positional member of the
|
|
1804
|
+
# {#select_next} / {#select_prev} family, for a caller that already knows
|
|
1805
|
+
# *which* item it wants:
|
|
1806
|
+
#
|
|
1807
|
+
# list.select(items.index(chosen))
|
|
1808
|
+
#
|
|
1809
|
+
# Refuses an index the current {#cursor} can't reach — out of range, or
|
|
1810
|
+
# anything at all under {Cursor::None} — rather than stranding the cursor
|
|
1811
|
+
# off-content.
|
|
1812
|
+
#
|
|
1813
|
+
# _@param_ `index`
|
|
1814
|
+
#
|
|
1815
|
+
# _@return_ — whether the cursor moved there.
|
|
1816
|
+
def select: (Integer index) -> bool
|
|
1817
|
+
|
|
1699
1818
|
# _@param_ `event`
|
|
1700
1819
|
def handle_mouse: (MouseEvent event) -> void
|
|
1701
1820
|
|
|
@@ -2037,6 +2156,319 @@ module Tuile
|
|
|
2037
2156
|
end
|
|
2038
2157
|
end
|
|
2039
2158
|
|
|
2159
|
+
# A one-row strip of captions with exactly one of them selected — the map of
|
|
2160
|
+
# where the user is. Knows nothing about content: pair it with
|
|
2161
|
+
# {Component::TabSheet} to swap panes, or swap views yourself from
|
|
2162
|
+
# {#on_tab_selected}.
|
|
2163
|
+
#
|
|
2164
|
+
# ␣Details␣│␣Payment␣│␣Shipping␣
|
|
2165
|
+
# ^^^^^^^ selected: bold, and highlighted while the strip has focus
|
|
2166
|
+
#
|
|
2167
|
+
# tabs = Component::Tabs.new
|
|
2168
|
+
# tabs.add_tab("Details") # the first tab is selected
|
|
2169
|
+
# payment = tabs.add_tab("Payment")
|
|
2170
|
+
# tabs.on_tab_selected = ->(index, tab) { show(index) }
|
|
2171
|
+
# tabs.selected = payment # fires the listener
|
|
2172
|
+
# payment.caption = "Payment ⚠" # repaints the strip
|
|
2173
|
+
#
|
|
2174
|
+
# LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter
|
|
2175
|
+
# to confirm — clamping at both ends rather than wrapping; a left click
|
|
2176
|
+
# selects the tab under the pointer. Everything else bubbles to an ancestor,
|
|
2177
|
+
# Enter, Space, Up, Down, Home and End included, so a form's default button
|
|
2178
|
+
# and the app's own keys keep working while the strip has focus.
|
|
2179
|
+
#
|
|
2180
|
+
# One tab stop for the whole strip: Tab moves *past* it, never between its
|
|
2181
|
+
# tabs. For a key of your own that switches tabs from elsewhere in the app,
|
|
2182
|
+
# bind it yourself and call {#select_next} / {#select_previous}.
|
|
2183
|
+
#
|
|
2184
|
+
# {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
|
|
2185
|
+
# `items=`: a tab is identity plus its own state, so the set grows and
|
|
2186
|
+
# shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D-tabs`.
|
|
2187
|
+
#
|
|
2188
|
+
# == Sizing
|
|
2189
|
+
# Assign a {#rect} (typically from the surrounding {Layout}). One wider than
|
|
2190
|
+
# {#extent}`.width` leaves a dead tail; a narrower one **scrolls**. The strip
|
|
2191
|
+
# keeps the selected segment whole in view, moving its window by the minimum
|
|
2192
|
+
# needed, so arrowing into an off-screen tab brings that tab on screen — and
|
|
2193
|
+
# a click on a half-visible segment at an edge selects it and pulls it into
|
|
2194
|
+
# view. A `<` or `>` painted over an edge column says there is more strip
|
|
2195
|
+
# that way, as does the cut caption underneath it. The one thing that cannot
|
|
2196
|
+
# be shown whole is a caption wider than the entire rect: it shows its head
|
|
2197
|
+
# and clips its tail. The scroll offset itself is not API — the invariant is.
|
|
2198
|
+
#
|
|
2199
|
+
# == Implementation details
|
|
2200
|
+
# A segment is one space of padding, the caption, one space of padding, and
|
|
2201
|
+
# segments are joined by a single {DEFAULT_SEPARATOR} column. The padding
|
|
2202
|
+
# belongs to the segment: the highlight covers it and a click on it selects
|
|
2203
|
+
# the tab, while the separator column is chrome and selects nothing, like
|
|
2204
|
+
# the blank tail past {#extent}. One private `segments` method is the sole
|
|
2205
|
+
# source of that arithmetic — both the paint and the hit test read it, and
|
|
2206
|
+
# both offset it by the same scroll column, so a click cannot land on a tab
|
|
2207
|
+
# other than the one drawn under it — and it is
|
|
2208
|
+
# derived from the captions on each call rather than recorded during the
|
|
2209
|
+
# last paint, so a hit test is correct before the first paint.
|
|
2210
|
+
#
|
|
2211
|
+
# The selected caption is bold *always*, so the strip still says where you
|
|
2212
|
+
# are once focus has moved on, and additionally sits on
|
|
2213
|
+
# {Theme#active_bg_color} while the strip is on the focus chain. Bold is the
|
|
2214
|
+
# selection channel and not strip chrome: bolding every caption would leave
|
|
2215
|
+
# selection to the focus-gated background alone, and an unfocused strip
|
|
2216
|
+
# would then show no selection at all.
|
|
2217
|
+
class Tabs < Component
|
|
2218
|
+
DEFAULT_SEPARATOR: String
|
|
2219
|
+
|
|
2220
|
+
# _@param_ `separator` — see {#separator=}.
|
|
2221
|
+
def initialize: (?separator: (String | StyledString)) -> void
|
|
2222
|
+
|
|
2223
|
+
# _@return_ — `true` — the strip takes focus, so its arrows work.
|
|
2224
|
+
def focusable?: () -> bool
|
|
2225
|
+
|
|
2226
|
+
# _@return_ — `true` — one stop for the whole strip.
|
|
2227
|
+
def tab_stop?: () -> bool
|
|
2228
|
+
|
|
2229
|
+
# _@return_ — the selected tab; `nil` only while there are no tabs.
|
|
2230
|
+
def selected: () -> Tab?
|
|
2231
|
+
|
|
2232
|
+
# _@param_ `tab` — one of this strip's tabs.
|
|
2233
|
+
def selected=: (Tab tab) -> void
|
|
2234
|
+
|
|
2235
|
+
# Appends a tab and returns its handle. The first tab added becomes the
|
|
2236
|
+
# selection; later ones don't disturb it.
|
|
2237
|
+
#
|
|
2238
|
+
# _@param_ `caption` — parsed as {Tab#caption=} parses it.
|
|
2239
|
+
def add_tab: (?(String | StyledString)? caption) -> Tab
|
|
2240
|
+
|
|
2241
|
+
# Removes `tab` and detaches its handle permanently.
|
|
2242
|
+
#
|
|
2243
|
+
# The selection is never left dangling: removing the selected tab selects
|
|
2244
|
+
# whichever tab slid into its place (the new last tab, if it was the last),
|
|
2245
|
+
# and removing the final tab leaves {#selected} `nil`. Either way
|
|
2246
|
+
# {#on_tab_selected} fires — the empty case with `(nil, nil)`, since a
|
|
2247
|
+
# listener rendering from the selection has to be told to render nothing.
|
|
2248
|
+
#
|
|
2249
|
+
# _@param_ `tab` — one of this strip's tabs.
|
|
2250
|
+
def remove_tab: (Tab tab) -> void
|
|
2251
|
+
|
|
2252
|
+
# Selects the next tab, clamping at the last — the strip never wraps.
|
|
2253
|
+
# Public because it is the verb an app's own key binding drives.
|
|
2254
|
+
#
|
|
2255
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2256
|
+
def select_next: () -> bool
|
|
2257
|
+
|
|
2258
|
+
# Selects the previous tab, clamping at the first.
|
|
2259
|
+
#
|
|
2260
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2261
|
+
def select_previous: () -> bool
|
|
2262
|
+
|
|
2263
|
+
# The cells the strip actually paints: one row, as wide as its segments and
|
|
2264
|
+
# separators need, clipped to {#rect}. A layout routinely hands a strip a
|
|
2265
|
+
# window's full width for a 32-column strip — the extent is those 32
|
|
2266
|
+
# columns.
|
|
2267
|
+
#
|
|
2268
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
2269
|
+
# blank tail — or on a lower row, when the rect is taller than one —
|
|
2270
|
+
# selects nothing. It still *focuses*: {Component#handle_mouse}'s
|
|
2271
|
+
# click-to-focus is ungated by geometry.
|
|
2272
|
+
def extent: () -> Rect
|
|
2273
|
+
|
|
2274
|
+
# Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
|
|
2275
|
+
# strip (the selection clamps). Every other key is left unhandled so it
|
|
2276
|
+
# bubbles to an ancestor; an empty strip handles nothing at all.
|
|
2277
|
+
#
|
|
2278
|
+
# _@param_ `key`
|
|
2279
|
+
def handle_key: (String key) -> bool
|
|
2280
|
+
|
|
2281
|
+
# Selects the tab under a left click; `super` runs first, so a click
|
|
2282
|
+
# anywhere in {#rect} still focuses.
|
|
2283
|
+
#
|
|
2284
|
+
# _@param_ `event`
|
|
2285
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2286
|
+
|
|
2287
|
+
def repaint: () -> void
|
|
2288
|
+
|
|
2289
|
+
# Re-syncs the scroll offset and repaints — what every change to the
|
|
2290
|
+
# captions, the separator or the selection ends in.
|
|
2291
|
+
def refresh: () -> void
|
|
2292
|
+
|
|
2293
|
+
# The rect's *width* is the only part of it the offset depends on, so this
|
|
2294
|
+
# hook is the whole geometry story; {Component#rect=} invalidates for us.
|
|
2295
|
+
def on_width_changed: () -> void
|
|
2296
|
+
|
|
2297
|
+
# Scrolls the minimum needed to show the selected segment whole, and is the
|
|
2298
|
+
# sole writer of {#left_column}. Idempotent, so every mutation site can
|
|
2299
|
+
# call it blindly; it returns the offset to `0` on its own once the strip
|
|
2300
|
+
# fits again, which is why no mutator owes a scroll-back branch.
|
|
2301
|
+
#
|
|
2302
|
+
# A segment wider than the whole rect cannot be shown whole: its head wins,
|
|
2303
|
+
# being the half of a caption that identifies it.
|
|
2304
|
+
def adjust_left_column: () -> void
|
|
2305
|
+
|
|
2306
|
+
# {StyledString#slice} *drops* a cluster straddling the window's edge
|
|
2307
|
+
# rather than half-painting it, which would leave the painted row a column
|
|
2308
|
+
# short and shift everything past the hole one column left — paint and hit
|
|
2309
|
+
# test would then disagree, silently and only for wide glyphs. So the
|
|
2310
|
+
# offset only ever lands on a cluster boundary. Snapping *forward* is the
|
|
2311
|
+
# safe direction: it gives up at most one column of the segment to the left
|
|
2312
|
+
# of the window, never of the one being revealed.
|
|
2313
|
+
#
|
|
2314
|
+
# _@param_ `column`
|
|
2315
|
+
#
|
|
2316
|
+
# _@return_ — the smallest cluster-boundary column `>= column`.
|
|
2317
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
2318
|
+
|
|
2319
|
+
# Paints the overflow cues over the windowed row's edge columns: `<` when
|
|
2320
|
+
# segments sit to the left of the window, `>` when more sit to the right.
|
|
2321
|
+
# ASCII by convention rather than by constant, as {Checkbox}'s brackets
|
|
2322
|
+
# are, and *overlaid* rather than given reserved columns — reserving would
|
|
2323
|
+
# make the window width a function of the offset computed from it.
|
|
2324
|
+
#
|
|
2325
|
+
# _@param_ `row` — the windowed row, as painted.
|
|
2326
|
+
def draw_cues: (StyledString row) -> void
|
|
2327
|
+
|
|
2328
|
+
# The cue keeps the style of the cell it covers, so one landing on the
|
|
2329
|
+
# selected segment doesn't punch a default-background hole in its
|
|
2330
|
+
# highlight.
|
|
2331
|
+
#
|
|
2332
|
+
# _@param_ `row` — the windowed row.
|
|
2333
|
+
#
|
|
2334
|
+
# _@param_ `column` — relative to {#rect}`.left`.
|
|
2335
|
+
#
|
|
2336
|
+
# _@param_ `glyph`
|
|
2337
|
+
def draw_cue: (StyledString row, Integer column, String glyph) -> void
|
|
2338
|
+
|
|
2339
|
+
# One `[tab, start_column, width]` triple per tab, in strip order, in
|
|
2340
|
+
# columns relative to {#rect}`.left`. A segment's width is its caption plus
|
|
2341
|
+
# the two padding columns; the separator columns between segments belong to
|
|
2342
|
+
# no segment.
|
|
2343
|
+
def segments: () -> ::Array[[Tab, Integer, Integer]]
|
|
2344
|
+
|
|
2345
|
+
# _@return_ — columns the strip would paint given an unlimited rect.
|
|
2346
|
+
def painted_width: () -> Integer
|
|
2347
|
+
|
|
2348
|
+
# _@param_ `point`
|
|
2349
|
+
#
|
|
2350
|
+
# _@return_ — the tab painted at `point`; `nil` for a separator
|
|
2351
|
+
# column, the blank tail, or a row the strip doesn't paint.
|
|
2352
|
+
def tab_at: (Point point) -> Tab?
|
|
2353
|
+
|
|
2354
|
+
# _@return_ — the whole strip as one row, unclipped: segments
|
|
2355
|
+
# left to right, joined by the separator. {#repaint} windows it to the
|
|
2356
|
+
# rect; nothing else may, since the window's own arithmetic is
|
|
2357
|
+
# {#adjust_left_column}'s.
|
|
2358
|
+
def strip_row: () -> StyledString
|
|
2359
|
+
|
|
2360
|
+
# _@param_ `tab`
|
|
2361
|
+
#
|
|
2362
|
+
# _@param_ `index`
|
|
2363
|
+
#
|
|
2364
|
+
# _@return_ — the caption between its padding columns, styled
|
|
2365
|
+
# for the selection.
|
|
2366
|
+
def segment_text: (Tab tab, Integer index) -> StyledString
|
|
2367
|
+
|
|
2368
|
+
# _@param_ `tab`
|
|
2369
|
+
#
|
|
2370
|
+
# _@return_ — the tab's position on this strip.
|
|
2371
|
+
def index_of!: (Tab tab) -> Integer
|
|
2372
|
+
|
|
2373
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
2374
|
+
#
|
|
2375
|
+
# _@return_ — `false` only when there are no tabs.
|
|
2376
|
+
def step_selection: (Integer delta) -> bool
|
|
2377
|
+
|
|
2378
|
+
# _@param_ `index`
|
|
2379
|
+
def select_at: (Integer? index) -> void
|
|
2380
|
+
|
|
2381
|
+
# Stores the selection, repaints, and fires {#on_tab_selected} when the
|
|
2382
|
+
# selected *tab* changed.
|
|
2383
|
+
#
|
|
2384
|
+
# `previous` is passed in rather than read here because a removal can leave
|
|
2385
|
+
# the index numerically unchanged while a different tab sits under it —
|
|
2386
|
+
# remove the selected middle tab of three and index 1 now holds what used
|
|
2387
|
+
# to be index 2. Comparing indices would swallow that notification.
|
|
2388
|
+
#
|
|
2389
|
+
# _@param_ `index` — the new selection.
|
|
2390
|
+
#
|
|
2391
|
+
# _@param_ `previous` — the tab selected before the caller's change.
|
|
2392
|
+
def apply_selection: (Integer? index, Tab? previous) -> void
|
|
2393
|
+
|
|
2394
|
+
# Called with `@tabs` already shortened and `@selected_index` still holding
|
|
2395
|
+
# the pre-removal position.
|
|
2396
|
+
#
|
|
2397
|
+
# _@param_ `removed_index` — the position the removed tab held.
|
|
2398
|
+
#
|
|
2399
|
+
# _@return_ — where the selection lands.
|
|
2400
|
+
def selection_after_removing: (Integer removed_index) -> Integer?
|
|
2401
|
+
|
|
2402
|
+
# Called on every change of {#selected} with the new selection —
|
|
2403
|
+
# `(index, tab)`, or `(nil, nil)` once the last tab has been removed.
|
|
2404
|
+
#
|
|
2405
|
+
# It reports that the selection *changed*, not that the user pressed
|
|
2406
|
+
# something: arrows, a click, {#selected=} / {#selected_index=}, the
|
|
2407
|
+
# autoselect of the first {#add_tab} and the re-selection that follows
|
|
2408
|
+
# removing the selected tab all fire it. Re-selecting the tab already
|
|
2409
|
+
# selected fires nothing.
|
|
2410
|
+
attr_accessor on_tab_selected: Proc?
|
|
2411
|
+
|
|
2412
|
+
# _@return_ — the tabs, in strip order. Read-only by convention
|
|
2413
|
+
# (like {Component#children}) — grow and shrink it through {#add_tab} /
|
|
2414
|
+
# {#remove_tab}, which keep the selection consistent. Enumerate it to
|
|
2415
|
+
# find a tab: `tabs.find { |t| t.caption.to_s == "Payment" }`.
|
|
2416
|
+
attr_reader tabs: ::Array[Tab]
|
|
2417
|
+
|
|
2418
|
+
# _@return_ — the column painted between two segments.
|
|
2419
|
+
attr_accessor separator: (StyledString | String)
|
|
2420
|
+
|
|
2421
|
+
# _@return_ — the selected tab's position; `nil` only while
|
|
2422
|
+
# there are no tabs.
|
|
2423
|
+
attr_accessor selected_index: Integer?
|
|
2424
|
+
|
|
2425
|
+
# _@return_ — the strip column painted in {#rect}'s leftmost cell —
|
|
2426
|
+
# the horizontal scroll offset. `0` unless the strip overflows its rect;
|
|
2427
|
+
# {#adjust_left_column} is its sole writer.
|
|
2428
|
+
attr_reader left_column: Integer
|
|
2429
|
+
|
|
2430
|
+
# A single tab: a caption, plus its identity on the strip.
|
|
2431
|
+
#
|
|
2432
|
+
# tab = tabs.add_tab("Payment")
|
|
2433
|
+
# tab.caption = "Payment ⚠" # repaints the strip
|
|
2434
|
+
# tab.remove # `tab` now raises on every mutator
|
|
2435
|
+
#
|
|
2436
|
+
# Apps don't construct tabs; {Tabs#add_tab} mints them. A removed handle
|
|
2437
|
+
# raises {RuntimeError} on every mutator and on every reader that consults
|
|
2438
|
+
# the strip — answering confidently about a tab the strip no longer holds
|
|
2439
|
+
# would hide the bug. {#caption} and {#attached?} stay readable (the
|
|
2440
|
+
# caption lives here, so an error message can still name it), and
|
|
2441
|
+
# {#remove} is a silent no-op so a cleanup path can call it blindly.
|
|
2442
|
+
class Tab
|
|
2443
|
+
# _@param_ `strip` — the owning strip.
|
|
2444
|
+
#
|
|
2445
|
+
# _@param_ `caption` — already coerced by the caller.
|
|
2446
|
+
def initialize: (Tabs strip, StyledString caption) -> void
|
|
2447
|
+
|
|
2448
|
+
# _@return_ — `true` while the tab is owned by its {Tabs}; `false`
|
|
2449
|
+
# permanently once removed.
|
|
2450
|
+
def attached?: () -> bool
|
|
2451
|
+
|
|
2452
|
+
# _@return_ — whether this is the strip's selected tab.
|
|
2453
|
+
def selected?: () -> bool
|
|
2454
|
+
|
|
2455
|
+
# Removes this tab from its strip and detaches the handle permanently —
|
|
2456
|
+
# {Tabs#remove_tab} has what that does to the selection. Idempotent on an
|
|
2457
|
+
# already-removed tab, unlike the mutators.
|
|
2458
|
+
def remove: () -> void
|
|
2459
|
+
|
|
2460
|
+
def inspect: () -> String
|
|
2461
|
+
|
|
2462
|
+
def detach: () -> void
|
|
2463
|
+
|
|
2464
|
+
def check_attached: () -> void
|
|
2465
|
+
|
|
2466
|
+
# _@return_ — the label painted on the strip. Safe to read on
|
|
2467
|
+
# a removed tab.
|
|
2468
|
+
attr_accessor caption: (StyledString | String)?
|
|
2469
|
+
end
|
|
2470
|
+
end
|
|
2471
|
+
|
|
2040
2472
|
# A label which shows static text. No word-wrapping; long lines are
|
|
2041
2473
|
# truncated with an ellipsis. Text is modeled as a {StyledString};
|
|
2042
2474
|
# {#text=} accepts a {String} (parsed via {StyledString.parse}, so
|
|
@@ -2123,6 +2555,10 @@ module Tuile
|
|
|
2123
2555
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
2124
2556
|
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
2125
2557
|
# consumes it first.
|
|
2558
|
+
#
|
|
2559
|
+
# A left click *outside* the popup closes it too, modal or not — see
|
|
2560
|
+
# {#close_on_outside_click?} for the exact contract and {#on_close=} for
|
|
2561
|
+
# the notice a driver hears when it happens.
|
|
2126
2562
|
class Popup < Component
|
|
2127
2563
|
include Tuile::Component::HasContent
|
|
2128
2564
|
|
|
@@ -2131,11 +2567,41 @@ module Tuile
|
|
|
2131
2567
|
# _@param_ `modal` — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
|
|
2132
2568
|
#
|
|
2133
2569
|
# _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
2134
|
-
|
|
2570
|
+
#
|
|
2571
|
+
# _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this popup. See {#close_on_outside_click?}.
|
|
2572
|
+
def initialize: (
|
|
2573
|
+
?content: Component?,
|
|
2574
|
+
?modal: bool,
|
|
2575
|
+
?size: (Size | Fraction),
|
|
2576
|
+
?close_on_outside_click: bool
|
|
2577
|
+
) -> void
|
|
2135
2578
|
|
|
2136
2579
|
# _@return_ — whether this popup is modal. See {#initialize}.
|
|
2137
2580
|
def modal?: () -> bool
|
|
2138
2581
|
|
|
2582
|
+
# Whether a left click outside this popup closes it (default true, modal or
|
|
2583
|
+
# not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
|
|
2584
|
+
# the open popups *before* routing the click and closes the dismissable
|
|
2585
|
+
# ones *after*, so a widget that toggles its own overlay from a click on
|
|
2586
|
+
# its face (a {Component::Select}, a {Component::MenuBar} title) still
|
|
2587
|
+
# toggles correctly: the delivered click closes the overlay and the
|
|
2588
|
+
# dismissal then no-ops on it, rather than closing and reopening it. Only
|
|
2589
|
+
# `:left` dismisses; scroll and right clicks never do.
|
|
2590
|
+
#
|
|
2591
|
+
# **"Outside" spans the {#owner} chain, not just this rect.** A click
|
|
2592
|
+
# counts as inside this popup when it lands in its rect *or* in any popup
|
|
2593
|
+
# that belongs to it — so a dialog is not dismissed by a click on a
|
|
2594
|
+
# dropdown its own field opened, and a menu cascade is not dismissed by a
|
|
2595
|
+
# click on one of its deeper panels. Popups with no owner relationship are
|
|
2596
|
+
# independent: clicking one dismisses the other, which is what a
|
|
2597
|
+
# window-like overlay should do. A popup that must survive unrelated
|
|
2598
|
+
# clicks entirely ({Component::Notification}) sets this false.
|
|
2599
|
+
#
|
|
2600
|
+
# Every dismissable popup closes, not just the topmost, and stacking order
|
|
2601
|
+
# plays no part: a {Component::MenuBar} cascade must vanish whole on one
|
|
2602
|
+
# click on the background, not peel one panel per click.
|
|
2603
|
+
def close_on_outside_click?: () -> bool
|
|
2604
|
+
|
|
2139
2605
|
def focusable?: () -> bool
|
|
2140
2606
|
|
|
2141
2607
|
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
@@ -2181,9 +2647,6 @@ module Tuile
|
|
|
2181
2647
|
# Recenters the popup on the screen, preserving its current width/height.
|
|
2182
2648
|
def center: () -> void
|
|
2183
2649
|
|
|
2184
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
2185
|
-
def keyboard_hint: () -> String
|
|
2186
|
-
|
|
2187
2650
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
2188
2651
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
2189
2652
|
# focused content after that content declined to handle it.
|
|
@@ -2193,6 +2656,10 @@ module Tuile
|
|
|
2193
2656
|
# _@return_ — true if the key was handled.
|
|
2194
2657
|
def handle_key: (String key) -> bool
|
|
2195
2658
|
|
|
2659
|
+
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
2660
|
+
# the popup's driver never hears that it closed.
|
|
2661
|
+
def on_detached: () -> void
|
|
2662
|
+
|
|
2196
2663
|
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
2197
2664
|
#
|
|
2198
2665
|
# _@param_ `content`
|
|
@@ -2205,6 +2672,37 @@ module Tuile
|
|
|
2205
2672
|
|
|
2206
2673
|
# _@return_ — the popup's declared size. See {#size=}.
|
|
2207
2674
|
attr_accessor size: (Size | Fraction)
|
|
2675
|
+
|
|
2676
|
+
# _@return_ — see {#close_on_outside_click?}.
|
|
2677
|
+
attr_writer close_on_outside_click: bool
|
|
2678
|
+
|
|
2679
|
+
# The component this overlay is *part of*, or `nil` (the default) when it
|
|
2680
|
+
# is an overlay in its own right. It exists for outside-click dismissal:
|
|
2681
|
+
# a click inside this popup also counts as inside whatever popup encloses
|
|
2682
|
+
# its owner, so the host is not dismissed by a click on a panel it put
|
|
2683
|
+
# there. See {#close_on_outside_click?}.
|
|
2684
|
+
#
|
|
2685
|
+
# Set it to the *driver* — {Component::ComboBox} hands its dropdown
|
|
2686
|
+
# `self` — rather than to the enclosing popup: the driver knows what it
|
|
2687
|
+
# is, while the popup above it is a tree relationship the pane resolves
|
|
2688
|
+
# at click time (so it cannot go stale). Any {Component} is accepted, and
|
|
2689
|
+
# a `Popup` resolves to itself, which is how a
|
|
2690
|
+
# {Component::MenuBar::Cascade} chains each panel to the one it dropped
|
|
2691
|
+
# out of.
|
|
2692
|
+
attr_accessor owner: Component?
|
|
2693
|
+
|
|
2694
|
+
# A callback taking no arguments, fired once this popup has left the
|
|
2695
|
+
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
2696
|
+
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
2697
|
+
# is the point, so it hangs off {#on_detached} rather than {#close}; a
|
|
2698
|
+
# driver keeping its own record of open popups reconciles it here and
|
|
2699
|
+
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
2700
|
+
#
|
|
2701
|
+
# It fires *after* the popup is detached, so {#open?} is already false and
|
|
2702
|
+
# the usual {Component#on_detached} caveats apply: release state, don't
|
|
2703
|
+
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
2704
|
+
# through closing a batch of popups, and a raise propagates).
|
|
2705
|
+
attr_accessor on_close: Proc?
|
|
2208
2706
|
end
|
|
2209
2707
|
|
|
2210
2708
|
# A clickable button. Activated by Enter, Space, or a left mouse click;
|
|
@@ -2765,8 +3263,6 @@ module Tuile
|
|
|
2765
3263
|
|
|
2766
3264
|
def tab_stop?: () -> bool
|
|
2767
3265
|
|
|
2768
|
-
def keyboard_hint: () -> String
|
|
2769
|
-
|
|
2770
3266
|
# Re-anchors the (open) dropdown after a move or resize.
|
|
2771
3267
|
#
|
|
2772
3268
|
# _@param_ `new_rect`
|
|
@@ -3097,6 +3593,495 @@ module Tuile
|
|
|
3097
3593
|
def focusable?: () -> bool
|
|
3098
3594
|
end
|
|
3099
3595
|
|
|
3596
|
+
# A one-row strip of menu captions, each dropping open a cascade of submenus
|
|
3597
|
+
# that nests as deep as you build it.
|
|
3598
|
+
#
|
|
3599
|
+
# ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
|
|
3600
|
+
# ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
|
|
3601
|
+
# ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
|
|
3602
|
+
# ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
|
|
3603
|
+
#
|
|
3604
|
+
# bar = Component::MenuBar.new
|
|
3605
|
+
# file = bar.add_item("File", mnemonic: "f")
|
|
3606
|
+
# file.add_item("New", mnemonic: "n") { new_document }
|
|
3607
|
+
# recent = file.add_item("Recent") # no block ⇒ a submenu holder
|
|
3608
|
+
# recent.add_item("notes.txt") { open("notes.txt") }
|
|
3609
|
+
# bar.add_item("Quit") { screen.close } # a top-level leaf: a button
|
|
3610
|
+
#
|
|
3611
|
+
# LEFT / RIGHT move along the strip; Enter, Space or Down opens the
|
|
3612
|
+
# highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
|
|
3613
|
+
# the highlight, Enter or Space activates a row or opens its submenu, RIGHT
|
|
3614
|
+
# opens a submenu, LEFT returns to the previous menu, ESC closes one level.
|
|
3615
|
+
# LEFT at the first level and RIGHT on a row with no submenu step to the
|
|
3616
|
+
# sibling menu, as they do in every menu bar. Book ch7 has the table.
|
|
3617
|
+
#
|
|
3618
|
+
# == Mnemonics
|
|
3619
|
+
# An item given a `mnemonic:` answers to that letter, underlined in its
|
|
3620
|
+
# caption wherever it occurs — on the strip and in the panels, focused or
|
|
3621
|
+
# not (there is no Alt key to reveal them with). Matching is **level-scoped
|
|
3622
|
+
# with no fallback**: the top-level items while the cascade is closed, the
|
|
3623
|
+
# deepest open panel's items while it is open, and nothing else is ever
|
|
3624
|
+
# consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
|
|
3625
|
+
# two items on *different* levels may share a letter with nothing to
|
|
3626
|
+
# arbitrate, and only siblings compete — a duplicate among them raises at
|
|
3627
|
+
# {#add_item}. A letter matching nothing on the live level is swallowed and
|
|
3628
|
+
# rings {Screen#beep}; it never falls out to a shallower level and switches
|
|
3629
|
+
# menus. A mnemonic shadows what the app (or an ancestor, including a
|
|
3630
|
+
# {Popup}'s `q`-to-close) would do with that key while the bar has focus.
|
|
3631
|
+
# A paste can never fire one — pasted text rides its own path off the key
|
|
3632
|
+
# ladder.
|
|
3633
|
+
#
|
|
3634
|
+
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
3635
|
+
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
3636
|
+
# rebuilding: a menu is built once, at construction. See `DECISIONS.md`
|
|
3637
|
+
# `D-menu-bar`.
|
|
3638
|
+
#
|
|
3639
|
+
# == Sizing
|
|
3640
|
+
# Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
|
|
3641
|
+
# {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
|
|
3642
|
+
# narrower one **scrolls** to keep the highlighted segment whole, cueing the
|
|
3643
|
+
# hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
|
|
3644
|
+
# does — so a bar wider than its terminal stays wholly reachable by arrow,
|
|
3645
|
+
# mnemonic and click. Reassigning the rect
|
|
3646
|
+
# **closes** an open cascade: every panel position is derived from a segment
|
|
3647
|
+
# or a parent row, so after a resize they would all sit at stale columns, and
|
|
3648
|
+
# a resize with a menu open is rare enough that closing beats re-anchoring
|
|
3649
|
+
# every level.
|
|
3650
|
+
#
|
|
3651
|
+
# == Implementation details
|
|
3652
|
+
# Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
|
|
3653
|
+
# share: no separator between segments, no bold, and no highlight at all
|
|
3654
|
+
# while unfocused — a menu bar has no persistent selection to show, and a
|
|
3655
|
+
# reader should not have to work out which of the two controls they are
|
|
3656
|
+
# looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
|
|
3657
|
+
# both the paint and the click and both offset it by the same scroll column,
|
|
3658
|
+
# so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
|
|
3659
|
+
# call so a hit test is correct before the first paint.
|
|
3660
|
+
#
|
|
3661
|
+
# The open panels are overlays owned by a private {Cascade}, not children:
|
|
3662
|
+
# focus stays here for the whole interaction, so the strip receives every key
|
|
3663
|
+
# and forwards it. An open cascade swallows keys the cascade doesn't
|
|
3664
|
+
# recognize; a *closed* strip lets every printable bubble, so an app's
|
|
3665
|
+
# `s`-to-save keeps working while the bar has focus.
|
|
3666
|
+
#
|
|
3667
|
+
# A click outside an open cascade is not blocked — non-modal overlays block
|
|
3668
|
+
# nothing — but any click on a focusable component moves focus, and losing
|
|
3669
|
+
# focus closes the cascade.
|
|
3670
|
+
#
|
|
3671
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
3672
|
+
class MenuBar < Component
|
|
3673
|
+
def initialize: () -> void
|
|
3674
|
+
|
|
3675
|
+
# _@return_ — `true` — the strip takes focus, so its keys work.
|
|
3676
|
+
def focusable?: () -> bool
|
|
3677
|
+
|
|
3678
|
+
# _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
|
|
3679
|
+
def tab_stop?: () -> bool
|
|
3680
|
+
|
|
3681
|
+
# _@return_ — the top-level items, in strip order. Read-only by
|
|
3682
|
+
# convention; grow it through {#add_item}.
|
|
3683
|
+
def items: () -> ::Array[Item]
|
|
3684
|
+
|
|
3685
|
+
# Appends a top-level item and returns its handle; nest submenus into it
|
|
3686
|
+
# with {Item#add_item}.
|
|
3687
|
+
#
|
|
3688
|
+
# _@param_ `caption` — parsed as {StyledString.parse} parses it.
|
|
3689
|
+
#
|
|
3690
|
+
# _@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.
|
|
3691
|
+
def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
|
|
3692
|
+
|
|
3693
|
+
# The cells the strip actually paints: one row, as wide as its segments
|
|
3694
|
+
# need, clipped to {#rect}.
|
|
3695
|
+
#
|
|
3696
|
+
# Both the highlight and the click hit test use it, so a click on the blank
|
|
3697
|
+
# tail — or on a lower row, when the rect is taller than one — opens
|
|
3698
|
+
# nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
|
|
3699
|
+
# ungated by geometry.
|
|
3700
|
+
def extent: () -> Rect
|
|
3701
|
+
|
|
3702
|
+
# _@param_ `new_rect`
|
|
3703
|
+
def rect=: (Rect new_rect) -> void
|
|
3704
|
+
|
|
3705
|
+
# Closes the cascade when the strip leaves the focus chain, so tabbing (or
|
|
3706
|
+
# clicking) away doesn't strand an open menu.
|
|
3707
|
+
#
|
|
3708
|
+
# _@param_ `flag`
|
|
3709
|
+
def active=: (bool flag) -> void
|
|
3710
|
+
|
|
3711
|
+
# Closes the cascade, so a bar removed from the tree can't strand its
|
|
3712
|
+
# panels on the pane — they are the {ScreenPane}'s children, not the bar's,
|
|
3713
|
+
# so nothing else would take them down.
|
|
3714
|
+
def on_detached: () -> void
|
|
3715
|
+
|
|
3716
|
+
# Offers the key to the open cascade first, then to the strip's own
|
|
3717
|
+
# LEFT/RIGHT/Enter/Space/Down.
|
|
3718
|
+
#
|
|
3719
|
+
# With a cascade open, the only keys reaching the strip are the two the
|
|
3720
|
+
# cascade declines — LEFT at the first level, RIGHT on a row with no
|
|
3721
|
+
# submenu — and both step to the sibling menu.
|
|
3722
|
+
#
|
|
3723
|
+
# _@param_ `key`
|
|
3724
|
+
def handle_key: (String key) -> bool
|
|
3725
|
+
|
|
3726
|
+
# Opens the menu under a left click, or closes it when it is already the
|
|
3727
|
+
# open one; `super` runs first, so a click anywhere in {#rect} still
|
|
3728
|
+
# focuses.
|
|
3729
|
+
#
|
|
3730
|
+
# _@param_ `event`
|
|
3731
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3732
|
+
|
|
3733
|
+
def repaint: () -> void
|
|
3734
|
+
|
|
3735
|
+
# The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
|
|
3736
|
+
# offset and repaints. Every path that moves the highlight — arrow,
|
|
3737
|
+
# mnemonic, click — goes through it, so the highlighted segment is on
|
|
3738
|
+
# screen *before* {Cascade} anchors a panel to it.
|
|
3739
|
+
#
|
|
3740
|
+
# _@param_ `index`
|
|
3741
|
+
def highlight=: (Integer index) -> void
|
|
3742
|
+
|
|
3743
|
+
# Re-syncs the scroll offset and repaints — what every change to the items
|
|
3744
|
+
# or the highlight ends in.
|
|
3745
|
+
def refresh: () -> void
|
|
3746
|
+
|
|
3747
|
+
# The rect's *width* is the only part of it the offset depends on, so this
|
|
3748
|
+
# hook is the whole geometry story; {Component#rect=} invalidates for us,
|
|
3749
|
+
# and {#rect=} closes the cascade rather than re-anchoring it.
|
|
3750
|
+
def on_width_changed: () -> void
|
|
3751
|
+
|
|
3752
|
+
# Scrolls the minimum needed to show the highlighted segment whole, and is
|
|
3753
|
+
# the sole writer of {#left_column}. Idempotent, so every mutation site can
|
|
3754
|
+
# call it blindly; it returns the offset to `0` on its own once the strip
|
|
3755
|
+
# fits again, which is why no mutator owes a scroll-back branch.
|
|
3756
|
+
#
|
|
3757
|
+
# A segment wider than the whole rect cannot be shown whole: its head wins,
|
|
3758
|
+
# being the half of a caption that identifies it.
|
|
3759
|
+
def adjust_left_column: () -> void
|
|
3760
|
+
|
|
3761
|
+
# {StyledString#slice} *drops* a cluster straddling the window's edge
|
|
3762
|
+
# rather than half-painting it, which would leave the painted row a column
|
|
3763
|
+
# short and shift everything past the hole one column left — paint and hit
|
|
3764
|
+
# test would then disagree, silently and only for wide glyphs. So the
|
|
3765
|
+
# offset only ever lands on a cluster boundary. Snapping *forward* is the
|
|
3766
|
+
# safe direction: it gives up at most one column of the segment to the left
|
|
3767
|
+
# of the window, never of the one being revealed.
|
|
3768
|
+
#
|
|
3769
|
+
# _@param_ `column`
|
|
3770
|
+
#
|
|
3771
|
+
# _@return_ — the smallest cluster-boundary column `>= column`.
|
|
3772
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
3773
|
+
|
|
3774
|
+
# Paints the overflow cues over the windowed row's edge columns: `<` when
|
|
3775
|
+
# segments sit to the left of the window, `>` when more sit to the right.
|
|
3776
|
+
# ASCII by convention rather than by constant, as {Checkbox}'s brackets
|
|
3777
|
+
# are, and *overlaid* rather than given reserved columns — reserving would
|
|
3778
|
+
# make the window width a function of the offset computed from it. Painted
|
|
3779
|
+
# focused or not: overflow is a fact about the captions and the rect, not
|
|
3780
|
+
# about focus.
|
|
3781
|
+
#
|
|
3782
|
+
# _@param_ `row` — the windowed row, as painted.
|
|
3783
|
+
def draw_cues: (StyledString row) -> void
|
|
3784
|
+
|
|
3785
|
+
# The cue keeps the style of the cell it covers, so one landing on the
|
|
3786
|
+
# highlighted segment doesn't punch a default-background hole in its
|
|
3787
|
+
# highlight.
|
|
3788
|
+
#
|
|
3789
|
+
# _@param_ `row` — the windowed row.
|
|
3790
|
+
#
|
|
3791
|
+
# _@param_ `column` — relative to {#rect}`.left`.
|
|
3792
|
+
#
|
|
3793
|
+
# _@param_ `glyph`
|
|
3794
|
+
def draw_cue: (StyledString row, Integer column, String glyph) -> void
|
|
3795
|
+
|
|
3796
|
+
# One `[item, start_column, width]` triple per top-level item, in strip
|
|
3797
|
+
# order, in columns relative to {#rect}`.left`. A segment is its caption
|
|
3798
|
+
# between two padding columns, and neighbours abut — the two blank columns
|
|
3799
|
+
# between captions are the segments' own padding, so a click on either
|
|
3800
|
+
# opens the menu it belongs to.
|
|
3801
|
+
def segments: () -> ::Array[[Item, Integer, Integer]]
|
|
3802
|
+
|
|
3803
|
+
# _@return_ — columns the strip would paint given an unlimited rect.
|
|
3804
|
+
def painted_width: () -> Integer
|
|
3805
|
+
|
|
3806
|
+
# _@param_ `point`
|
|
3807
|
+
#
|
|
3808
|
+
# _@return_ — the index of the item painted at `point`; `nil`
|
|
3809
|
+
# for the blank tail or a row the strip doesn't paint.
|
|
3810
|
+
def index_at: (Point point) -> Integer?
|
|
3811
|
+
|
|
3812
|
+
# _@param_ `index`
|
|
3813
|
+
#
|
|
3814
|
+
# _@return_ — the segment's cells on screen — the cascade's anchor.
|
|
3815
|
+
def segment_rect: (Integer index) -> Rect
|
|
3816
|
+
|
|
3817
|
+
# _@return_ — the whole strip as one row, unclipped. {#repaint}
|
|
3818
|
+
# windows it to the rect; nothing else may, since the window's own
|
|
3819
|
+
# arithmetic is {#adjust_left_column}'s.
|
|
3820
|
+
def strip_row: () -> StyledString
|
|
3821
|
+
|
|
3822
|
+
# _@param_ `item`
|
|
3823
|
+
#
|
|
3824
|
+
# _@param_ `index`
|
|
3825
|
+
#
|
|
3826
|
+
# _@return_ — the caption between its padding columns,
|
|
3827
|
+
# highlighted when it is the one Enter would open *and* the strip has
|
|
3828
|
+
# focus. An unfocused strip shows no highlight at all: there is no
|
|
3829
|
+
# persistent selection to report.
|
|
3830
|
+
def segment_text: (Item item, Integer index) -> StyledString
|
|
3831
|
+
|
|
3832
|
+
# Activates the item bound to `key` on the *live* level — the deepest open
|
|
3833
|
+
# panel while the cascade is open, the top-level strip while it is closed.
|
|
3834
|
+
# No fallback between the two: a letter matching nothing in the live set is
|
|
3835
|
+
# not offered to any other level.
|
|
3836
|
+
#
|
|
3837
|
+
# _@param_ `key`
|
|
3838
|
+
#
|
|
3839
|
+
# _@return_ — whether a mnemonic claimed the key.
|
|
3840
|
+
def handle_mnemonic: (String key) -> bool
|
|
3841
|
+
|
|
3842
|
+
# Moves the highlight along the strip, clamping at both ends. Consumes the
|
|
3843
|
+
# key even at an end, as {Tabs} does.
|
|
3844
|
+
#
|
|
3845
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
3846
|
+
#
|
|
3847
|
+
# _@return_ — `false` only when there are no items.
|
|
3848
|
+
def move_highlight: (Integer delta) -> bool
|
|
3849
|
+
|
|
3850
|
+
# Steps to the neighbouring menu, showing *its* menu instead — or closing
|
|
3851
|
+
# the cascade, when the neighbour is a top-level button with no menu to
|
|
3852
|
+
# show. The cascade is left alone when the highlight is already at an end:
|
|
3853
|
+
# reopening the same menu would throw away the submenu the user is standing
|
|
3854
|
+
# in.
|
|
3855
|
+
#
|
|
3856
|
+
# It deliberately never *activates*. An item arrowed past is highlighted,
|
|
3857
|
+
# not pressed, so a top-level button waits for Enter or Space — otherwise
|
|
3858
|
+
# walking the strip would fire every button on it.
|
|
3859
|
+
#
|
|
3860
|
+
# _@param_ `delta` — `+1` / `-1`.
|
|
3861
|
+
#
|
|
3862
|
+
# _@return_ — always `true`: an open menu swallows the key either way.
|
|
3863
|
+
def step_menu: (Integer delta) -> bool
|
|
3864
|
+
|
|
3865
|
+
# Opens the highlighted item's menu, or fires it when it is a top-level
|
|
3866
|
+
# button — the Enter/Space/Down/click path, and the only one that fires a
|
|
3867
|
+
# listener.
|
|
3868
|
+
#
|
|
3869
|
+
# _@return_ — `false` only when there are no items.
|
|
3870
|
+
def open_highlighted: () -> bool
|
|
3871
|
+
|
|
3872
|
+
# Shows the highlighted item's menu, closing the cascade when it has none.
|
|
3873
|
+
def show_highlighted_menu: () -> void
|
|
3874
|
+
|
|
3875
|
+
# _@return_ — which top-level item the strip highlights while
|
|
3876
|
+
# focused, and which menu Enter opens. `0` until the user moves.
|
|
3877
|
+
attr_reader highlighted_index: Integer
|
|
3878
|
+
|
|
3879
|
+
# _@return_ — the strip column painted in {#rect}'s leftmost cell —
|
|
3880
|
+
# the horizontal scroll offset. `0` unless the strip overflows its rect;
|
|
3881
|
+
# {#adjust_left_column} is its sole writer.
|
|
3882
|
+
attr_reader left_column: Integer
|
|
3883
|
+
|
|
3884
|
+
# One menu item: a caption, an optional click listener, and its children.
|
|
3885
|
+
#
|
|
3886
|
+
# file = bar.add_item("File") # minted by the bar
|
|
3887
|
+
# file.add_item("New") { create } # …and nested by the same method
|
|
3888
|
+
# file.items.size # => 1
|
|
3889
|
+
#
|
|
3890
|
+
# An item with children is a submenu and its own listener is dead
|
|
3891
|
+
# ({#submenu?} decides). An item with **neither** children nor a listener is
|
|
3892
|
+
# legal and inert: it highlights, Enter closes the menu, nothing happens —
|
|
3893
|
+
# an item that looks live but does nothing is the app's error to fix, not
|
|
3894
|
+
# the framework's to raise on.
|
|
3895
|
+
#
|
|
3896
|
+
# Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
|
|
3897
|
+
class Item
|
|
3898
|
+
# _@param_ `caption` — already coerced by the caller.
|
|
3899
|
+
#
|
|
3900
|
+
# _@param_ `mnemonic` — already validated by the caller, in the case it was given in.
|
|
3901
|
+
#
|
|
3902
|
+
# _@param_ `on_click`
|
|
3903
|
+
def initialize: (StyledString caption, String? mnemonic, (Proc | Method)? on_click) -> void
|
|
3904
|
+
|
|
3905
|
+
# _@return_ — whether this item opens a submenu, i.e. has children.
|
|
3906
|
+
def submenu?: () -> bool
|
|
3907
|
+
|
|
3908
|
+
# Appends a child and returns its handle.
|
|
3909
|
+
#
|
|
3910
|
+
# _@param_ `caption` — parsed as {StyledString.parse} parses it.
|
|
3911
|
+
#
|
|
3912
|
+
# _@param_ `mnemonic` — the letter that activates this child while *this* item's children are the live level; see {MenuBar#add_item}.
|
|
3913
|
+
def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
|
|
3914
|
+
|
|
3915
|
+
def inspect: () -> String
|
|
3916
|
+
|
|
3917
|
+
# Rejects a mnemonic that couldn't work, or that would make two siblings
|
|
3918
|
+
# ambiguous — all three at *registration*, since none has a sane answer
|
|
3919
|
+
# at keypress time.
|
|
3920
|
+
#
|
|
3921
|
+
# _@param_ `mnemonic`
|
|
3922
|
+
def validate_mnemonic: (String? mnemonic) -> void
|
|
3923
|
+
|
|
3924
|
+
# {StyledString#slice} counts **columns** while a caption search yields a
|
|
3925
|
+
# **character** index, so the prefix is measured, never counted.
|
|
3926
|
+
#
|
|
3927
|
+
# _@param_ `caption`
|
|
3928
|
+
#
|
|
3929
|
+
# _@param_ `mnemonic` — in the case it was given in.
|
|
3930
|
+
def build_cued_caption: (StyledString caption, String? mnemonic) -> StyledString
|
|
3931
|
+
|
|
3932
|
+
# _@return_ — the label painted on the strip or the row.
|
|
3933
|
+
attr_reader caption: StyledString
|
|
3934
|
+
|
|
3935
|
+
# _@return_ — the downcased letter that activates this item
|
|
3936
|
+
# while its own level is the live one; `nil` when it has none.
|
|
3937
|
+
attr_reader mnemonic: String?
|
|
3938
|
+
|
|
3939
|
+
# _@return_ — {#caption} with the {#mnemonic} underlined —
|
|
3940
|
+
# what both paint sites draw. Equal to {#caption} when there is no
|
|
3941
|
+
# mnemonic or the caption doesn't contain it. Computed once, at
|
|
3942
|
+
# construction: caption and mnemonic are both fixed there, and
|
|
3943
|
+
# underline is a plain attribute with no theme or `bg_color` input, so
|
|
3944
|
+
# this is not a cached theme value.
|
|
3945
|
+
attr_reader cued_caption: StyledString
|
|
3946
|
+
|
|
3947
|
+
# _@return_ — this item's children, in menu order. Read-only by
|
|
3948
|
+
# convention, like {Component#children} — grow it through {#add_item}.
|
|
3949
|
+
attr_reader items: ::Array[Item]
|
|
3950
|
+
|
|
3951
|
+
# _@return_ — no-arg callable fired when the item is
|
|
3952
|
+
# activated (Enter, Space or a left click), exactly as
|
|
3953
|
+
# {Button#on_click}. Never fired on an item with children.
|
|
3954
|
+
attr_accessor on_click: (Proc | Method)?
|
|
3955
|
+
end
|
|
3956
|
+
|
|
3957
|
+
# The stack of open menu panels — one {ListDropdown} per level, the last
|
|
3958
|
+
# deepest — and the drill/pop/activate logic driving them. Private
|
|
3959
|
+
# machinery of {MenuBar}; an app never names it.
|
|
3960
|
+
#
|
|
3961
|
+
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
3962
|
+
# return true if cascade.handle_key(key) # MenuBar#handle_key, first
|
|
3963
|
+
# cascade.close # focus lost, or rect changed
|
|
3964
|
+
#
|
|
3965
|
+
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
3966
|
+
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
3967
|
+
# arrives via {MenuBar#handle_key}, which offers it here first. That is
|
|
3968
|
+
# {Component::Select}'s architecture extended to N levels, and it is why
|
|
3969
|
+
# nothing in the key-dispatch ladder changes.
|
|
3970
|
+
#
|
|
3971
|
+
# Widths are measured here, per level — the panel is as wide as the level's
|
|
3972
|
+
# widest label — because {ListDropdown} deliberately measures nothing
|
|
3973
|
+
# itself (`DECISIONS.md` `D-select`).
|
|
3974
|
+
#
|
|
3975
|
+
# == Implementation details
|
|
3976
|
+
# While open it consumes **everything** except the two keys that mean
|
|
3977
|
+
# "leave this menu sideways", which only the strip can answer: LEFT at
|
|
3978
|
+
# depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
|
|
3979
|
+
# — firing an app's `s`-to-save behind a visible panel is worse than a dead
|
|
3980
|
+
# keystroke.
|
|
3981
|
+
#
|
|
3982
|
+
# UI-thread-confined, like everything in the tree (see {Screen}).
|
|
3983
|
+
class Cascade
|
|
3984
|
+
SUBMENU_ARROW: String
|
|
3985
|
+
ARROW_WIDTH: Integer
|
|
3986
|
+
|
|
3987
|
+
def initialize: () -> void
|
|
3988
|
+
|
|
3989
|
+
# _@return_ — whether any panel is open.
|
|
3990
|
+
def open?: () -> bool
|
|
3991
|
+
|
|
3992
|
+
# _@return_ — how many panels are open; `0` when closed.
|
|
3993
|
+
def depth: () -> Integer
|
|
3994
|
+
|
|
3995
|
+
# Opens `item`'s children directly beneath `anchor`, closing anything
|
|
3996
|
+
# already open first.
|
|
3997
|
+
#
|
|
3998
|
+
# _@param_ `anchor` — the strip segment the menu drops from.
|
|
3999
|
+
#
|
|
4000
|
+
# _@param_ `item` — a childless one opens nothing.
|
|
4001
|
+
def open_below: (Rect anchor, Item item) -> void
|
|
4002
|
+
|
|
4003
|
+
# Closes every open panel, deepest first.
|
|
4004
|
+
def close: () -> void
|
|
4005
|
+
|
|
4006
|
+
# Offers a key to the deepest panel and to the cascade's own verbs.
|
|
4007
|
+
#
|
|
4008
|
+
# _@param_ `key`
|
|
4009
|
+
#
|
|
4010
|
+
# _@return_ — `true` when consumed — almost always, while open.
|
|
4011
|
+
# `false` when closed, and for the two sideways keys {MenuBar} answers
|
|
4012
|
+
# (see the class docs).
|
|
4013
|
+
def handle_key: (String key) -> bool
|
|
4014
|
+
|
|
4015
|
+
# Activates the deepest level's item bound to `key` — the drill-or-fire
|
|
4016
|
+
# the mnemonic shares with Enter. The highlight moves there *first*, so a
|
|
4017
|
+
# submenu anchors beside the row that opened it rather than beside
|
|
4018
|
+
# wherever the cursor happened to be.
|
|
4019
|
+
#
|
|
4020
|
+
# _@param_ `key` — a single printable, already downcased.
|
|
4021
|
+
#
|
|
4022
|
+
# _@return_ — whether an item on the deepest level claimed it. A
|
|
4023
|
+
# miss is never offered to a shallower level.
|
|
4024
|
+
def handle_mnemonic: (String key) -> bool
|
|
4025
|
+
|
|
4026
|
+
# _@return_ — the deepest open panel.
|
|
4027
|
+
def deepest: () -> ListDropdown
|
|
4028
|
+
|
|
4029
|
+
def activate_highlighted: () -> void
|
|
4030
|
+
|
|
4031
|
+
# Drills into `item`, or fires it and closes the cascade.
|
|
4032
|
+
#
|
|
4033
|
+
# _@param_ `level` — the panel the item belongs to.
|
|
4034
|
+
#
|
|
4035
|
+
# _@param_ `item` — `nil` (an off-content cursor) does nothing.
|
|
4036
|
+
def activate: (Integer level, Item? item) -> void
|
|
4037
|
+
|
|
4038
|
+
# Opens `item`'s children beside the row highlighted in `level`.
|
|
4039
|
+
#
|
|
4040
|
+
# _@param_ `level`
|
|
4041
|
+
#
|
|
4042
|
+
# _@param_ `item`
|
|
4043
|
+
def push_beside: (Integer level, Item item) -> void
|
|
4044
|
+
|
|
4045
|
+
# Mounts a panel for `item`'s children and yields it for geometry.
|
|
4046
|
+
#
|
|
4047
|
+
# _@param_ `item`
|
|
4048
|
+
def push: (Item item) ?{ (ListDropdown drop, Integer rows, Integer width) -> void } -> void
|
|
4049
|
+
|
|
4050
|
+
# Closes the deepest panel; at depth 1 that closes the cascade.
|
|
4051
|
+
def pop: () -> void
|
|
4052
|
+
|
|
4053
|
+
# _@param_ `count` — how many panels to keep.
|
|
4054
|
+
def truncate: (Integer count) -> void
|
|
4055
|
+
|
|
4056
|
+
# _@param_ `level`
|
|
4057
|
+
#
|
|
4058
|
+
# _@return_ — the item under `level`'s cursor; `nil` when it sits
|
|
4059
|
+
# off-content. The range guard matters: a cursor at `-1` would
|
|
4060
|
+
# otherwise index the *last* child.
|
|
4061
|
+
def highlighted: (Integer level) -> Item?
|
|
4062
|
+
|
|
4063
|
+
# _@param_ `items`
|
|
4064
|
+
#
|
|
4065
|
+
# _@return_ — item -> row: the label padded to the level's widest, plus
|
|
4066
|
+
# an arrow column when any sibling has a submenu — so every arrow lands
|
|
4067
|
+
# in the same column without asking the {List} how wide it ended up.
|
|
4068
|
+
def renderer_for: (::Array[Item] items) -> Proc
|
|
4069
|
+
|
|
4070
|
+
# _@param_ `items`
|
|
4071
|
+
#
|
|
4072
|
+
# _@return_ — the panel width: the rendered row plus {List}'s two
|
|
4073
|
+
# row gutters, plus a scrollbar column when the rows can't all be shown.
|
|
4074
|
+
# As in {Component::Select}, the scrollbar is predicted from the item
|
|
4075
|
+
# count rather than the final height — a panel the screen clamps
|
|
4076
|
+
# shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
|
|
4077
|
+
# bought that column, and ellipsizes one character early.
|
|
4078
|
+
def width_for: (::Array[Item] items) -> Integer
|
|
4079
|
+
|
|
4080
|
+
# _@param_ `items`
|
|
4081
|
+
def label_width_of: (::Array[Item] items) -> Integer
|
|
4082
|
+
end
|
|
4083
|
+
end
|
|
4084
|
+
|
|
3100
4085
|
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
3101
4086
|
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
3102
4087
|
# the *selected item* — of whatever type the items are — not the display
|
|
@@ -3144,8 +4129,6 @@ module Tuile
|
|
|
3144
4129
|
# hardware cursor to its field).
|
|
3145
4130
|
def cursor_position: () -> Point?
|
|
3146
4131
|
|
|
3147
|
-
def keyboard_hint: () -> String
|
|
3148
|
-
|
|
3149
4132
|
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
3150
4133
|
# field via {#layout}.
|
|
3151
4134
|
#
|
|
@@ -3312,6 +4295,164 @@ module Tuile
|
|
|
3312
4295
|
attr_accessor on_value_change: (Proc | Method)?
|
|
3313
4296
|
end
|
|
3314
4297
|
|
|
4298
|
+
# A {Component::Tabs} strip on its top row plus the pane belonging to the
|
|
4299
|
+
# selected tab underneath it:
|
|
4300
|
+
#
|
|
4301
|
+
# ␣Details␣│␣Payment␣│␣Shipping␣
|
|
4302
|
+
# the selected tab's pane fills the rest of the rect
|
|
4303
|
+
#
|
|
4304
|
+
# sheet = Component::TabSheet.new
|
|
4305
|
+
# sheet.add_tab("Details", details_form) # selected, and shown
|
|
4306
|
+
# sheet.add_tab("Payment", payment_form)
|
|
4307
|
+
# sheet.select_next # shows payment_form
|
|
4308
|
+
# sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
|
|
4309
|
+
#
|
|
4310
|
+
# Tab lands on the strip first and enters the pane on the next press, which
|
|
4311
|
+
# is the browser's order; switching tabs does *not* move focus into the new
|
|
4312
|
+
# pane, but if focus was inside the pane that just went away it lands back
|
|
4313
|
+
# on the strip.
|
|
4314
|
+
#
|
|
4315
|
+
# == Hidden panes are detached
|
|
4316
|
+
# Only the selected tab's pane is in the component tree — the others are
|
|
4317
|
+
# detached, which is how Tuile hides a component (there is no visibility
|
|
4318
|
+
# flag, and an empty rect gates painting only). Consequences worth
|
|
4319
|
+
# designing around:
|
|
4320
|
+
#
|
|
4321
|
+
# - A hidden pane is invisible to *everything*: the Tab cycle, focus
|
|
4322
|
+
# cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
|
|
4323
|
+
# - Its state survives, because state is ivars — scroll position, caret,
|
|
4324
|
+
# list cursor, text are all exactly as the user left them, and mutating a
|
|
4325
|
+
# hidden pane is safe (`invalidate` while detached is a silent no-op).
|
|
4326
|
+
# - {Component#on_detached} / {Component#on_attached} fire on every switch,
|
|
4327
|
+
# so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
|
|
4328
|
+
# ticker — releases it while hidden and re-acquires it on return. A pane
|
|
4329
|
+
# that must keep something alive while hidden can't; that something
|
|
4330
|
+
# belongs in the model the pane renders, not in the pane.
|
|
4331
|
+
#
|
|
4332
|
+
# == Implementation details
|
|
4333
|
+
# `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
|
|
4334
|
+
# traversal gives the strip-then-pane Tab order for free. The swap follows
|
|
4335
|
+
# the slot-swap recipe {Component#detach_child} documents — detach, rewire,
|
|
4336
|
+
# `on_child_removed` last, so the focus repair sees the new occupant.
|
|
4337
|
+
#
|
|
4338
|
+
# Panes live in an identity-keyed `Tab => Component` map here rather than in
|
|
4339
|
+
# a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
|
|
4340
|
+
# authority, and the strip itself stays ignorant of panes. One idempotent
|
|
4341
|
+
# `sync_pane` is the sole writer of the visible pane, deriving it from
|
|
4342
|
+
# `strip.selected` on every call, so registering a pane and selecting a tab
|
|
4343
|
+
# can happen in either order.
|
|
4344
|
+
#
|
|
4345
|
+
# The sheet owns the strip's `on_tab_selected` (that is what drives the
|
|
4346
|
+
# swap); an app's listener goes on {#on_tab_selected} here, which fires
|
|
4347
|
+
# after the pane has been swapped in.
|
|
4348
|
+
class TabSheet < Component
|
|
4349
|
+
# _@param_ `separator` — the strip's separator; see {Tabs#separator=}.
|
|
4350
|
+
def initialize: (?separator: (String | StyledString)) -> void
|
|
4351
|
+
|
|
4352
|
+
# Adds a tab and the pane to show while it is selected. The first tab
|
|
4353
|
+
# added becomes the selection, so its pane is shown immediately.
|
|
4354
|
+
#
|
|
4355
|
+
# _@param_ `caption` — parsed as {Tabs::Tab#caption=} parses it.
|
|
4356
|
+
#
|
|
4357
|
+
# _@param_ `pane` — shown while this tab is selected, detached while it isn't.
|
|
4358
|
+
#
|
|
4359
|
+
# _@return_ — the new tab's handle.
|
|
4360
|
+
def add_tab: ((String | StyledString)? caption, Component pane) -> Tabs::Tab
|
|
4361
|
+
|
|
4362
|
+
# Removes a tab and forgets its pane, detaching it if it was the visible
|
|
4363
|
+
# one. The strip re-selects as {Tabs#remove_tab} describes, and this
|
|
4364
|
+
# sheet shows whatever it lands on.
|
|
4365
|
+
#
|
|
4366
|
+
# _@param_ `tab` — one of this sheet's tabs.
|
|
4367
|
+
#
|
|
4368
|
+
# _@return_ — the pane that tab owned.
|
|
4369
|
+
def remove_tab: (Tabs::Tab tab) -> Component?
|
|
4370
|
+
|
|
4371
|
+
# _@param_ `tab`
|
|
4372
|
+
#
|
|
4373
|
+
# _@return_ — the pane registered for `tab`; `nil` for a
|
|
4374
|
+
# removed tab, a tab of another sheet, or `nil`.
|
|
4375
|
+
def pane_for: (Tabs::Tab? tab) -> Component?
|
|
4376
|
+
|
|
4377
|
+
# _@return_ — the strip's tabs, in order.
|
|
4378
|
+
def tabs: () -> ::Array[Tabs::Tab]
|
|
4379
|
+
|
|
4380
|
+
# _@return_ — the selected tab.
|
|
4381
|
+
def selected: () -> Tabs::Tab?
|
|
4382
|
+
|
|
4383
|
+
# _@param_ `tab` — one of this sheet's tabs.
|
|
4384
|
+
def selected=: (Tabs::Tab tab) -> void
|
|
4385
|
+
|
|
4386
|
+
# _@return_ — the selected tab's position.
|
|
4387
|
+
def selected_index: () -> Integer?
|
|
4388
|
+
|
|
4389
|
+
# _@param_ `index` — a position in `0...tabs.size`.
|
|
4390
|
+
def selected_index=: (Integer index) -> void
|
|
4391
|
+
|
|
4392
|
+
# Selects the next tab, clamping at the last one.
|
|
4393
|
+
#
|
|
4394
|
+
# _@return_ — `false` only when there are no tabs.
|
|
4395
|
+
def select_next: () -> bool
|
|
4396
|
+
|
|
4397
|
+
# Selects the previous tab, clamping at the first one.
|
|
4398
|
+
#
|
|
4399
|
+
# _@return_ — `false` only when there are no tabs.
|
|
4400
|
+
def select_previous: () -> bool
|
|
4401
|
+
|
|
4402
|
+
# _@param_ `new_rect`
|
|
4403
|
+
def rect=: (Rect new_rect) -> void
|
|
4404
|
+
|
|
4405
|
+
# Forwards to whichever child the click landed on — the strip's row, or
|
|
4406
|
+
# the pane below it.
|
|
4407
|
+
#
|
|
4408
|
+
# _@param_ `event`
|
|
4409
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4410
|
+
|
|
4411
|
+
# Sends focus to the strip: a sheet is a container, and the strip is where
|
|
4412
|
+
# a tab switch is driven from. The pane is a Tab press away.
|
|
4413
|
+
def on_focus: () -> void
|
|
4414
|
+
|
|
4415
|
+
# Lands focus on the strip rather than on `self` when the focused pane is
|
|
4416
|
+
# swapped out — a bare container can't use keys, and the user's last
|
|
4417
|
+
# action was a tab switch.
|
|
4418
|
+
#
|
|
4419
|
+
# _@param_ `child`
|
|
4420
|
+
def on_child_removed: (Component child) -> void
|
|
4421
|
+
|
|
4422
|
+
# Makes the visible pane match `strip.selected`, swapping if it doesn't.
|
|
4423
|
+
# Idempotent and the sole writer of `@pane`: it derives everything from
|
|
4424
|
+
# current state, so {#add_tab} can register a pane after the strip has
|
|
4425
|
+
# already selected its tab.
|
|
4426
|
+
def sync_pane: () -> void
|
|
4427
|
+
|
|
4428
|
+
# Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
|
|
4429
|
+
# strip without passing through {#remove_tab}, and a detached tab can never
|
|
4430
|
+
# be selected again, so its entry is dead weight — it pins the pane against
|
|
4431
|
+
# garbage collection and makes {#add_tab} reject that pane as still in use.
|
|
4432
|
+
# Idempotent and the only cleaner, because the rule it enforces is an
|
|
4433
|
+
# invariant ("every key is a live tab of my strip") rather than a step in
|
|
4434
|
+
# one code path.
|
|
4435
|
+
def forget_removed_tabs: () -> void
|
|
4436
|
+
|
|
4437
|
+
def layout_pane: () -> void
|
|
4438
|
+
|
|
4439
|
+
# An app's own selection listener, called after the pane has been swapped
|
|
4440
|
+
# in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
|
|
4441
|
+
# contract as {Tabs#on_tab_selected}: it reports that the selection
|
|
4442
|
+
# changed, whatever changed it.
|
|
4443
|
+
attr_accessor on_tab_selected: Proc?
|
|
4444
|
+
|
|
4445
|
+
# _@return_ — the strip. Reach through it for the rest of its API —
|
|
4446
|
+
# `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
|
|
4447
|
+
# the sheet drives the pane swap through it, and {#on_tab_selected} is
|
|
4448
|
+
# where an app's listener goes.
|
|
4449
|
+
attr_reader strip: Tabs
|
|
4450
|
+
|
|
4451
|
+
# _@return_ — the pane currently in the tree — the selected
|
|
4452
|
+
# tab's, `nil` while the sheet has no tabs.
|
|
4453
|
+
attr_reader pane: Component?
|
|
4454
|
+
end
|
|
4455
|
+
|
|
3315
4456
|
# A multi-line, word-wrapping text input.
|
|
3316
4457
|
#
|
|
3317
4458
|
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
@@ -3327,10 +4468,11 @@ module Tuile
|
|
|
3327
4468
|
# start of the next row in nearly all cases).
|
|
3328
4469
|
#
|
|
3329
4470
|
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
3330
|
-
# {#on_change} is wired.
|
|
3331
|
-
#
|
|
3332
|
-
#
|
|
3333
|
-
#
|
|
4471
|
+
# {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
|
|
4472
|
+
# byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
|
|
4473
|
+
# through {AbstractStringField#handle_paste} instead and never as a key at
|
|
4474
|
+
# all — so a subclass rebinding Enter to submit keeps working under a
|
|
4475
|
+
# multi-line paste, which lands as one draft.
|
|
3334
4476
|
#
|
|
3335
4477
|
# Up/Down move the caret between rows and, at the first/last row, snap to
|
|
3336
4478
|
# the start/end of the text. A subclass can claim the key at that edge
|
|
@@ -3756,8 +4898,10 @@ module Tuile
|
|
|
3756
4898
|
|
|
3757
4899
|
# Scrolls up half a viewport (`rect.height / 2`, at least one row),
|
|
3758
4900
|
# clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
|
|
3759
|
-
# What `Ctrl+U` does, minus the focus:
|
|
3760
|
-
#
|
|
4901
|
+
# What `Ctrl+U` does, minus the focus: dispatch delivers keys only along
|
|
4902
|
+
# the focus chain, so this is what a host with focus elsewhere — a chat
|
|
4903
|
+
# transcript under an input field — calls instead of forwarding a
|
|
4904
|
+
# synthetic keystroke that would lie about where focus is.
|
|
3761
4905
|
def scroll_half_page_up: () -> void
|
|
3762
4906
|
|
|
3763
4907
|
# The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
|
|
@@ -3768,6 +4912,11 @@ module Tuile
|
|
|
3768
4912
|
|
|
3769
4913
|
def tab_stop?: () -> bool
|
|
3770
4914
|
|
|
4915
|
+
# Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
|
|
4916
|
+
# `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
|
|
4917
|
+
# scroll at either edge is still a handled key, and hand-feeding a key to
|
|
4918
|
+
# an unfocused view scrolls it (dispatch gates on focus, this doesn't).
|
|
4919
|
+
#
|
|
3771
4920
|
# _@param_ `key`
|
|
3772
4921
|
def handle_key: (String key) -> bool
|
|
3773
4922
|
|
|
@@ -4188,8 +5337,8 @@ module Tuile
|
|
|
4188
5337
|
#
|
|
4189
5338
|
# - an **index** counts characters into {#text} — {#caret},
|
|
4190
5339
|
# {#max_text_length}, `text[i]`, every edit;
|
|
4191
|
-
# - a **column** counts terminal cells — {#rect}, {#
|
|
4192
|
-
# {
|
|
5340
|
+
# - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
|
|
5341
|
+
# {MouseEvent}, and the private horizontal scroll offset `left_column`.
|
|
4193
5342
|
#
|
|
4194
5343
|
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
4195
5344
|
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
@@ -4223,6 +5372,14 @@ module Tuile
|
|
|
4223
5372
|
# _@param_ `key`
|
|
4224
5373
|
def handle_text_input_key: (String key) -> bool
|
|
4225
5374
|
|
|
5375
|
+
# Flattens the paste onto the field's one row — newlines become spaces —
|
|
5376
|
+
# and trims it to what {#max_text_length} still allows. Trimming rather
|
|
5377
|
+
# than rejecting: a paste that overshoots the cap fills the field, which
|
|
5378
|
+
# is what typing the same characters would have done.
|
|
5379
|
+
#
|
|
5380
|
+
# _@param_ `text`
|
|
5381
|
+
def preprocess_paste: (String text) -> String
|
|
5382
|
+
|
|
4226
5383
|
def on_text_mutated: () -> void
|
|
4227
5384
|
|
|
4228
5385
|
def on_caret_mutated: () -> void
|
|
@@ -4290,10 +5447,6 @@ module Tuile
|
|
|
4290
5447
|
# _@return_ — maximum characters, or nil for unbounded (default).
|
|
4291
5448
|
attr_accessor max_text_length: Integer?
|
|
4292
5449
|
|
|
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
5450
|
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
4298
5451
|
# is consumed by the field; when nil, UP falls through to the parent
|
|
4299
5452
|
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
@@ -4316,6 +5469,15 @@ module Tuile
|
|
|
4316
5469
|
#
|
|
4317
5470
|
# _@return_ — no-arg callable, or nil.
|
|
4318
5471
|
attr_accessor on_enter: (Proc | Method)?
|
|
5472
|
+
|
|
5473
|
+
# Internal — the field's own scroll state, with no caller outside this
|
|
5474
|
+
# class: the paint, the cursor and the hit test all read the ivar, and
|
|
5475
|
+
# nothing above the field has a column to spend it on. Specs assert the
|
|
5476
|
+
# scrolling through `send`.
|
|
5477
|
+
#
|
|
5478
|
+
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
5479
|
+
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
5480
|
+
attr_reader left_column: Integer
|
|
4319
5481
|
end
|
|
4320
5482
|
|
|
4321
5483
|
# A single-line field whose {#value} is a `Float` (or `nil` when empty) —
|
|
@@ -4697,8 +5859,11 @@ module Tuile
|
|
|
4697
5859
|
#
|
|
4698
5860
|
# - **Take focus, or receive keys.** A non-modal popup sits off the
|
|
4699
5861
|
# key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
|
|
4700
|
-
# `q`/ESC arrives here. A left click
|
|
4701
|
-
# wanting a key registers a global shortcut and
|
|
5862
|
+
# `q`/ESC arrives here. A left click *on the box* dismisses
|
|
5863
|
+
# ({#handle_mouse}); an app wanting a key registers a global shortcut and
|
|
5864
|
+
# calls {#close}. A click *elsewhere* does not — this is the one popup
|
|
5865
|
+
# with {Popup#close_on_outside_click?} false, since a toast is timed and
|
|
5866
|
+
# an unrelated click is not about it.
|
|
4702
5867
|
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
4703
5868
|
# the message is added — a toast lives seconds, so there is no
|
|
4704
5869
|
# {Component#on_theme_changed} rebuild.
|
|
@@ -4736,11 +5901,6 @@ module Tuile
|
|
|
4736
5901
|
# _@return_ — false — see {#focusable?}.
|
|
4737
5902
|
def tab_stop?: () -> bool
|
|
4738
5903
|
|
|
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
5904
|
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
4745
5905
|
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
4746
5906
|
# append without repeating {show}'s lookup.
|
|
@@ -5109,9 +6269,10 @@ module Tuile
|
|
|
5109
6269
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
5110
6270
|
#
|
|
5111
6271
|
# It owns only what every such dropdown shares — *placement* included, via
|
|
5112
|
-
# {#anchor_to}
|
|
5113
|
-
#
|
|
5114
|
-
#
|
|
6272
|
+
# {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
|
|
6273
|
+
# a cascading submenu). What stays with the driver: the width **policy**
|
|
6274
|
+
# (neither placement method measures anything itself), filtering, row
|
|
6275
|
+
# rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
|
|
5115
6276
|
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
5116
6277
|
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
5117
6278
|
# from its own branches.
|
|
@@ -5141,12 +6302,24 @@ module Tuile
|
|
|
5141
6302
|
# _@param_ `proc` — commit callback; see {List#on_item_chosen}.
|
|
5142
6303
|
def on_item_chosen=: ((Proc | Method)? proc) -> void
|
|
5143
6304
|
|
|
6305
|
+
# _@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.
|
|
6306
|
+
def on_cursor_changed=: ((Proc | Method)? proc) -> void
|
|
6307
|
+
|
|
5144
6308
|
# _@param_ `cursor` — the highlight; see {List#cursor=}.
|
|
5145
6309
|
def cursor=: (List::Cursor cursor) -> void
|
|
5146
6310
|
|
|
5147
6311
|
# _@return_ — the list's cursor (the current highlight).
|
|
5148
6312
|
def cursor: () -> List::Cursor
|
|
5149
6313
|
|
|
6314
|
+
# Moves the highlight to the item at `index`, scrolling it into view; see
|
|
6315
|
+
# {List#select}. The positional counterpart of {#move}, for a driver that
|
|
6316
|
+
# picked a row by something other than a key — a mnemonic letter, say.
|
|
6317
|
+
#
|
|
6318
|
+
# _@param_ `index`
|
|
6319
|
+
#
|
|
6320
|
+
# _@return_ — whether the highlight moved there.
|
|
6321
|
+
def select: (Integer index) -> bool
|
|
6322
|
+
|
|
5150
6323
|
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
5151
6324
|
# flipped above when `rows` won't fit below, clamped — with the list
|
|
5152
6325
|
# scrolling — when neither side has room. Horizontally the left edges line
|
|
@@ -5172,6 +6345,49 @@ module Tuile
|
|
|
5172
6345
|
?max_rows: Integer
|
|
5173
6346
|
) -> void
|
|
5174
6347
|
|
|
6348
|
+
# Sizes and places the dropdown *beside* `anchor` — the placement a
|
|
6349
|
+
# cascading submenu wants, where {#anchor_to} is the placement a field's
|
|
6350
|
+
# dropdown wants.
|
|
6351
|
+
#
|
|
6352
|
+
# sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
|
|
6353
|
+
#
|
|
6354
|
+
# Horizontally it sits against `anchor`'s right edge, **flipping** to its
|
|
6355
|
+
# left when the right has no room (and clamping to the screen when neither
|
|
6356
|
+
# side does). Vertically it **slides**: the panel's first row lines up with
|
|
6357
|
+
# the anchored row, sliding up only far enough to keep the panel on screen.
|
|
6358
|
+
#
|
|
6359
|
+
# The two axes are the mirror image of {#anchor_to}'s, for the same reason:
|
|
6360
|
+
# never cover the thing being chosen from. A field's dropdown must not
|
|
6361
|
+
# cover the field, so it flips *vertically* and shares its columns; a
|
|
6362
|
+
# submenu must not cover its parent panel, so it flips *horizontally* and
|
|
6363
|
+
# shares its rows.
|
|
6364
|
+
#
|
|
6365
|
+
# _@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.
|
|
6366
|
+
#
|
|
6367
|
+
# _@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).
|
|
6368
|
+
#
|
|
6369
|
+
# _@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).
|
|
6370
|
+
#
|
|
6371
|
+
# _@param_ `max_rows` — rows shown before the list scrolls.
|
|
6372
|
+
def anchor_beside: (
|
|
6373
|
+
Rect anchor,
|
|
6374
|
+
rows: Integer,
|
|
6375
|
+
width: Integer,
|
|
6376
|
+
?max_rows: Integer
|
|
6377
|
+
) -> void
|
|
6378
|
+
|
|
6379
|
+
# The highlighted row's rect on screen — what a cascading submenu anchors
|
|
6380
|
+
# against, via {#anchor_beside}.
|
|
6381
|
+
#
|
|
6382
|
+
# It lives here rather than in the driver because {ListDropdown} owns the
|
|
6383
|
+
# list's geometry: a driver computing `top + position - scroll_top_row`
|
|
6384
|
+
# itself would have to reach through to the private list.
|
|
6385
|
+
#
|
|
6386
|
+
# _@return_ — one row spanning the panel's width, or `nil` when
|
|
6387
|
+
# the cursor is off-content ({List::Cursor::None}, an empty list) or its
|
|
6388
|
+
# row is scrolled out of the viewport.
|
|
6389
|
+
def cursor_row_rect: () -> Rect?
|
|
6390
|
+
|
|
5175
6391
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
5176
6392
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
5177
6393
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -5225,8 +6441,6 @@ module Tuile
|
|
|
5225
6441
|
# _@param_ `key`
|
|
5226
6442
|
def handle_key: (String key) -> bool
|
|
5227
6443
|
|
|
5228
|
-
def keyboard_hint: () -> String
|
|
5229
|
-
|
|
5230
6444
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
5231
6445
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
5232
6446
|
#
|
|
@@ -5637,6 +6851,8 @@ module Tuile
|
|
|
5637
6851
|
#
|
|
5638
6852
|
# - {#preprocess_text} — input filter (e.g. {TextField} truncates to
|
|
5639
6853
|
# fit `rect.width - 1`).
|
|
6854
|
+
# - {#preprocess_paste} — the same for {#handle_paste}, which lands a
|
|
6855
|
+
# whole clipboard at the caret in one mutation.
|
|
5640
6856
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
5641
6857
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
5642
6858
|
# keep the caret visible).
|
|
@@ -5668,6 +6884,34 @@ module Tuile
|
|
|
5668
6884
|
# _@param_ `key`
|
|
5669
6885
|
def handle_key: (String key) -> bool
|
|
5670
6886
|
|
|
6887
|
+
# Inserts pasted text at the caret as **one** mutation, so {#on_change}
|
|
6888
|
+
# fires once for the whole paste rather than once per character.
|
|
6889
|
+
# {#preprocess_paste} filters it first.
|
|
6890
|
+
#
|
|
6891
|
+
# _@param_ `text`
|
|
6892
|
+
#
|
|
6893
|
+
# _@return_ — always true — a field consumes every paste, an empty
|
|
6894
|
+
# one included.
|
|
6895
|
+
def handle_paste: (String text) -> bool
|
|
6896
|
+
|
|
6897
|
+
# Input filter for {#handle_paste}, the paste-side counterpart of
|
|
6898
|
+
# {#preprocess_text}. Strips the C0 control characters a text buffer
|
|
6899
|
+
# cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
|
|
6900
|
+
# terminal cursor mid-frame — keeping `\n`, and turning a tab into a
|
|
6901
|
+
# single space so pasted code keeps its word gaps. {TextField} narrows it
|
|
6902
|
+
# further; an app wanting tab *expansion* overrides {#handle_paste}.
|
|
6903
|
+
#
|
|
6904
|
+
# _@param_ `text`
|
|
6905
|
+
def preprocess_paste: (String text) -> String
|
|
6906
|
+
|
|
6907
|
+
# Inserts `str` at the caret, leaving the caret behind it. The bulk
|
|
6908
|
+
# counterpart of a subclass's per-key insert.
|
|
6909
|
+
#
|
|
6910
|
+
# _@param_ `str`
|
|
6911
|
+
#
|
|
6912
|
+
# _@return_ — true if the text changed.
|
|
6913
|
+
def insert_text: (String str) -> bool
|
|
6914
|
+
|
|
5671
6915
|
# Renders `text` on the field's background well, looked up from the
|
|
5672
6916
|
# current {Screen#theme} at paint time: {Theme#active_bg_color} when this
|
|
5673
6917
|
# input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
|
|
@@ -5974,6 +7218,28 @@ module Tuile
|
|
|
5974
7218
|
attr_reader key: String
|
|
5975
7219
|
end
|
|
5976
7220
|
|
|
7221
|
+
# Text arrived from the clipboard rather than the keyboard: the terminal
|
|
7222
|
+
# bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
|
|
7223
|
+
# {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
|
|
7224
|
+
# so a component sees one mutation instead of a keystroke per character —
|
|
7225
|
+
# and a pasted line break can no longer be mistaken for a typed Enter.
|
|
7226
|
+
#
|
|
7227
|
+
# {Screen#event_loop} routes it to {Component#handle_paste} down the focus
|
|
7228
|
+
# chain. It never enters the key ladder: no Tab traversal, no global
|
|
7229
|
+
# shortcut, no {Component#handle_key}.
|
|
7230
|
+
#
|
|
7231
|
+
# @!attribute [r] text
|
|
7232
|
+
# @return [String] the pasted text, `\n`-normalized by
|
|
7233
|
+
# {Keys.normalize_paste}.
|
|
7234
|
+
class PasteEvent
|
|
7235
|
+
# _@param_ `text`
|
|
7236
|
+
def initialize: (text: String) -> void
|
|
7237
|
+
|
|
7238
|
+
# _@return_ — the pasted text, `\n`-normalized by
|
|
7239
|
+
# {Keys.normalize_paste}.
|
|
7240
|
+
attr_reader text: String
|
|
7241
|
+
end
|
|
7242
|
+
|
|
5977
7243
|
# An error event, causes {EventQueue#run_loop} to throw `StandardError` with
|
|
5978
7244
|
# {#error} as its origin.
|
|
5979
7245
|
#
|
|
@@ -6110,6 +7376,22 @@ module Tuile
|
|
|
6110
7376
|
# _@param_ `str`
|
|
6111
7377
|
def emit: (String str) -> void
|
|
6112
7378
|
|
|
7379
|
+
# Pastes `text` into the focused component, as a real terminal would with
|
|
7380
|
+
# bracketed paste on:
|
|
7381
|
+
#
|
|
7382
|
+
# area.focus
|
|
7383
|
+
# Screen.instance.paste("one\r\ntwo")
|
|
7384
|
+
# area.text # => "one\ntwo" — one mutation, no ENTER anywhere
|
|
7385
|
+
#
|
|
7386
|
+
# Goes through {Keys.normalize_paste} first, so a spec can hand it the
|
|
7387
|
+
# CR-flavored line endings terminals actually deliver and still assert
|
|
7388
|
+
# against `\n`.
|
|
7389
|
+
#
|
|
7390
|
+
# _@param_ `text`
|
|
7391
|
+
#
|
|
7392
|
+
# _@return_ — true if some component consumed it.
|
|
7393
|
+
def paste: (String text) -> bool
|
|
7394
|
+
|
|
6113
7395
|
# _@param_ `component` — the component to check.
|
|
6114
7396
|
def invalidated?: (Component component) -> bool
|
|
6115
7397
|
|
|
@@ -6184,10 +7466,15 @@ module Tuile
|
|
|
6184
7466
|
#
|
|
6185
7467
|
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
6186
7468
|
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
6187
|
-
# {#content}
|
|
6188
|
-
#
|
|
6189
|
-
#
|
|
6190
|
-
#
|
|
7469
|
+
# {#content} and the {#popups} stack. Putting them under a single Component
|
|
7470
|
+
# parent gives focus traversal a real root, makes {Component#attached?} a
|
|
7471
|
+
# one-liner, and lets popup-focus repair fall out of the standard
|
|
7472
|
+
# {Component#on_child_removed} hook.
|
|
7473
|
+
#
|
|
7474
|
+
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
7475
|
+
# {#content} gets the full pane rect, and an app that wants a status line
|
|
7476
|
+
# builds one into its own layout and drives it from
|
|
7477
|
+
# {Screen#on_focus_changed=} (`D-status-bar`).
|
|
6191
7478
|
#
|
|
6192
7479
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
6193
7480
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -6237,9 +7524,9 @@ module Tuile
|
|
|
6237
7524
|
|
|
6238
7525
|
# _@return_ — the topmost *modal* popup, or nil when
|
|
6239
7526
|
# only non-modal overlays (or no popups) are open. This is the "modal
|
|
6240
|
-
# owner": the popup that scopes key dispatch, blocks mouse clicks,
|
|
6241
|
-
#
|
|
6242
|
-
#
|
|
7527
|
+
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
7528
|
+
# confines Tab cycling. Non-modal overlays are excluded — they float above
|
|
7529
|
+
# the content without capturing input.
|
|
6243
7530
|
def modal_popup: () -> Component::Popup?
|
|
6244
7531
|
|
|
6245
7532
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -6247,11 +7534,11 @@ module Tuile
|
|
|
6247
7534
|
# _@param_ `new_rect`
|
|
6248
7535
|
def rect=: (Rect new_rect) -> void
|
|
6249
7536
|
|
|
6250
|
-
#
|
|
6251
|
-
#
|
|
6252
|
-
#
|
|
6253
|
-
#
|
|
6254
|
-
#
|
|
7537
|
+
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
7538
|
+
# itself. Each popup re-resolves its {Component::Popup#size} against the new
|
|
7539
|
+
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
7540
|
+
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
7541
|
+
# keep the top-left their owner assigned).
|
|
6255
7542
|
def layout: () -> void
|
|
6256
7543
|
|
|
6257
7544
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
@@ -6278,6 +7565,15 @@ module Tuile
|
|
|
6278
7565
|
# _@return_ — true if the key was handled.
|
|
6279
7566
|
def handle_key: (String key) -> bool
|
|
6280
7567
|
|
|
7568
|
+
# Delivers pasted text along the same focus chain {#handle_key} bubbles
|
|
7569
|
+
# along, and with the same scoping — first {Component#handle_paste}
|
|
7570
|
+
# returning true wins.
|
|
7571
|
+
#
|
|
7572
|
+
# _@param_ `text`
|
|
7573
|
+
#
|
|
7574
|
+
# _@return_ — true if the text was consumed.
|
|
7575
|
+
def handle_paste: (String text) -> bool
|
|
7576
|
+
|
|
6281
7577
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
6282
7578
|
# fall through to content only when no popup is hit *and* no modal popup is
|
|
6283
7579
|
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
@@ -6285,6 +7581,35 @@ module Tuile
|
|
|
6285
7581
|
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
6286
7582
|
# content beneath.
|
|
6287
7583
|
#
|
|
7584
|
+
# A left click also *dismisses* the open popups it landed outside of that
|
|
7585
|
+
# asked for it ({Component::Popup#close_on_outside_click?}). That is a
|
|
7586
|
+
# second thing happening on a click, but not a second dispatch: the click is
|
|
7587
|
+
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
7588
|
+
# closed rather than told.
|
|
7589
|
+
#
|
|
7590
|
+
# "Outside" is measured against the {Component::Popup#owner} chain, not
|
|
7591
|
+
# against one rect and not against stacking order: the popup the click hit
|
|
7592
|
+
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
7593
|
+
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
7594
|
+
# field opened, and a menu cascade being dismissed by a click on one of its
|
|
7595
|
+
# own deeper panels. Order carries no meaning here — between unrelated
|
|
7596
|
+
# overlays it is merely the order they opened in — so ownership is declared
|
|
7597
|
+
# rather than inferred from the stack.
|
|
7598
|
+
#
|
|
7599
|
+
# Two halves of the ordering are load-bearing, and both are specced:
|
|
7600
|
+
#
|
|
7601
|
+
# - **Snapshot before routing.** A popup the delivered click *opens* must
|
|
7602
|
+
# not be in the set (it would immediately dismiss itself — every
|
|
7603
|
+
# {Component::Select} would be unopenable by mouse).
|
|
7604
|
+
# - **Close after routing.** A widget toggling its own overlay from a click
|
|
7605
|
+
# on its face closes it during delivery, and {Component::Popup#close} is
|
|
7606
|
+
# idempotent, so the dismissal no-ops. Close *first* and the widget sees
|
|
7607
|
+
# a shut overlay and reopens it — a Select's dropdown could then never be
|
|
7608
|
+
# dismissed by clicking the Select.
|
|
7609
|
+
#
|
|
7610
|
+
# The snapshot is a fresh array for a third reason: a handler may close
|
|
7611
|
+
# further popups, and `@popups` must not be mutated mid-iteration.
|
|
7612
|
+
#
|
|
6288
7613
|
# _@param_ `event`
|
|
6289
7614
|
def handle_mouse: (MouseEvent event) -> void
|
|
6290
7615
|
|
|
@@ -6303,6 +7628,22 @@ module Tuile
|
|
|
6303
7628
|
# _@param_ `child`
|
|
6304
7629
|
def on_child_removed: (Component child) -> void
|
|
6305
7630
|
|
|
7631
|
+
# The popups a click counts as landing *inside*: the one it hit, plus every
|
|
7632
|
+
# popup that one belongs to, up the {Component::Popup#owner} chain. An owner
|
|
7633
|
+
# is any component, so it is resolved to the popup enclosing it (a popup
|
|
7634
|
+
# resolves to itself) — which keeps the relationship a live tree question
|
|
7635
|
+
# rather than one frozen when the overlay opened. The `include?` guard makes
|
|
7636
|
+
# a mis-wired cycle terminate instead of hanging the UI thread.
|
|
7637
|
+
#
|
|
7638
|
+
# _@param_ `hit` — the popup the click landed in, if any.
|
|
7639
|
+
def kept_by: (Component::Popup? hit) -> ::Array[Component::Popup]
|
|
7640
|
+
|
|
7641
|
+
# _@param_ `component`
|
|
7642
|
+
#
|
|
7643
|
+
# _@return_ — `component` itself when it is a popup,
|
|
7644
|
+
# else the nearest popup above it, else nil.
|
|
7645
|
+
def enclosing_popup: (Component? component) -> Component::Popup?
|
|
7646
|
+
|
|
6306
7647
|
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
6307
7648
|
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
6308
7649
|
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
@@ -6316,6 +7657,14 @@ module Tuile
|
|
|
6316
7657
|
# _@return_ — true if some component on the chain handled the key.
|
|
6317
7658
|
def bubble_key: (String key, Component scope) -> bool
|
|
6318
7659
|
|
|
7660
|
+
# {Screen#focused} and its ancestors up to and including `scope`.
|
|
7661
|
+
#
|
|
7662
|
+
# _@param_ `scope` — the modal scope root (topmost popup or content).
|
|
7663
|
+
#
|
|
7664
|
+
# _@return_ — the chain, innermost first; nil when
|
|
7665
|
+
# focus is nil or sits outside `scope`.
|
|
7666
|
+
def focus_chain: (Component scope) -> ::Array[Component]?
|
|
7667
|
+
|
|
6319
7668
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
6320
7669
|
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
6321
7670
|
# if `root` is `nil`.
|
|
@@ -6330,9 +7679,6 @@ module Tuile
|
|
|
6330
7679
|
# topmost. Holds both modal popups and non-modal overlays
|
|
6331
7680
|
# ({Component::Popup#modal?}). The array must not be mutated by callers.
|
|
6332
7681
|
attr_reader popups: ::Array[Component]
|
|
6333
|
-
|
|
6334
|
-
# _@return_ — the bottom status bar.
|
|
6335
|
-
attr_reader status_bar: Component::Label
|
|
6336
7682
|
end
|
|
6337
7683
|
|
|
6338
7684
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
@@ -6514,6 +7860,40 @@ module Tuile
|
|
|
6514
7860
|
# _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
|
|
6515
7861
|
def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
|
|
6516
7862
|
|
|
7863
|
+
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
7864
|
+
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
7865
|
+
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
7866
|
+
# {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
|
|
7867
|
+
# possibly multi-span content — a widget marking one caption out of several
|
|
7868
|
+
# as selected, where the caption may already carry its own colors.
|
|
7869
|
+
#
|
|
7870
|
+
# There is deliberately no `under_bold` (the fill-unset counterpart
|
|
7871
|
+
# {#under_bg} provides for backgrounds): a background is inherited down the
|
|
7872
|
+
# component tree, so a span with none has a meaningful "unset" state to
|
|
7873
|
+
# fill, while `bold` is a plain per-span attribute that is either on or
|
|
7874
|
+
# off. Pass `bold: false` to clear it.
|
|
7875
|
+
#
|
|
7876
|
+
# _@param_ `bold` — whether the spans should be bold.
|
|
7877
|
+
def with_bold: (?bold: bool) -> StyledString
|
|
7878
|
+
|
|
7879
|
+
# Returns a new {StyledString} with `underline` applied to every span,
|
|
7880
|
+
# preserving each span's text and other style attributes (`fg`, `bg`,
|
|
7881
|
+
# `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
|
|
7882
|
+
# of a string, which is what a one-character cue needs:
|
|
7883
|
+
#
|
|
7884
|
+
# cap = StyledString.parse("File")
|
|
7885
|
+
# cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
|
|
7886
|
+
# # => "File" with the F underlined — a menu mnemonic
|
|
7887
|
+
#
|
|
7888
|
+
# Note {#slice} counts **columns**, not characters, so a caption with a
|
|
7889
|
+
# wide glyph before the cue needs the prefix measured rather than counted.
|
|
7890
|
+
#
|
|
7891
|
+
# There is deliberately no `under_underline`, for the reason {#with_bold}
|
|
7892
|
+
# spells out. Pass `underline: false` to clear it.
|
|
7893
|
+
#
|
|
7894
|
+
# _@param_ `underline` — whether the spans should be underlined.
|
|
7895
|
+
def with_underline: (?underline: bool) -> StyledString
|
|
7896
|
+
|
|
6517
7897
|
def inspect: () -> String
|
|
6518
7898
|
|
|
6519
7899
|
def build_ansi: () -> String
|