tuile 0.11.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 +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- 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 +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- 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 +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -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.
|
|
@@ -550,7 +592,7 @@ module Tuile
|
|
|
550
592
|
|
|
551
593
|
# An in-memory grid of styled cells mirroring the terminal screen. This is
|
|
552
594
|
# the back buffer behind flicker-free rendering: components paint into it
|
|
553
|
-
# (via {#
|
|
595
|
+
# (via {#set_text} / {#set_char} / {#fill}) instead of writing escape
|
|
554
596
|
# sequences straight to the terminal, and {#flush} emits the minimal escape
|
|
555
597
|
# string needed to bring a terminal — one that already matches the buffer's
|
|
556
598
|
# state as of the previous flush — up to date. Only cells that actually
|
|
@@ -603,7 +645,7 @@ module Tuile
|
|
|
603
645
|
# _@param_ `y` — row.
|
|
604
646
|
#
|
|
605
647
|
# _@return_ — the live cell at `(x, y)` (do not mutate — paint via
|
|
606
|
-
# {#set_char} / {#
|
|
648
|
+
# {#set_char} / {#set_text} so dirty tracking stays correct), or nil when
|
|
607
649
|
# out of bounds.
|
|
608
650
|
def cell: (Integer x, Integer y) -> Cell?
|
|
609
651
|
|
|
@@ -632,14 +674,14 @@ module Tuile
|
|
|
632
674
|
|
|
633
675
|
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
634
676
|
# display width and clipping at the right edge. Newlines are not handled —
|
|
635
|
-
# pass one
|
|
677
|
+
# pass the text of one row.
|
|
636
678
|
#
|
|
637
679
|
# _@param_ `x` — starting column.
|
|
638
680
|
#
|
|
639
681
|
# _@param_ `y` — row.
|
|
640
682
|
#
|
|
641
683
|
# _@param_ `styled`
|
|
642
|
-
def
|
|
684
|
+
def set_text: (Integer x, Integer y, StyledString styled) -> void
|
|
643
685
|
|
|
644
686
|
# Fills the intersection of `rect` and the buffer with blank cells in
|
|
645
687
|
# `style` — the cell-grid equivalent of clearing a background. Only `bg`
|
|
@@ -695,7 +737,7 @@ module Tuile
|
|
|
695
737
|
#
|
|
696
738
|
# _@return_ — row `y` rendered to ANSI across its full width — the
|
|
697
739
|
# minimal-SGR encoding of its cells, equivalent to what a component's
|
|
698
|
-
# `
|
|
740
|
+
# `set_text` of the whole row would have printed. Intended for tests that
|
|
699
741
|
# assert on styled output (see {FakeScreen}); empty for an out-of-range row.
|
|
700
742
|
def row_ansi: (Integer y) -> String
|
|
701
743
|
|
|
@@ -709,13 +751,13 @@ module Tuile
|
|
|
709
751
|
# _@param_ `rect`
|
|
710
752
|
#
|
|
711
753
|
# _@return_ — each row within `rect` rendered to ANSI, top to
|
|
712
|
-
# bottom — byte-identical to what a component's per-row `
|
|
754
|
+
# bottom — byte-identical to what a component's per-row `set_text` over
|
|
713
755
|
# that rect emitted. The region equivalent of {#row_ansi}. Intended for
|
|
714
756
|
# tests asserting styled output.
|
|
715
757
|
def region_ansi: (Rect rect) -> ::Array[String]
|
|
716
758
|
|
|
717
759
|
# Core of {#set_char} with the grapheme's display width already known.
|
|
718
|
-
# {#
|
|
760
|
+
# {#set_text} computes each width once while advancing the column and passes
|
|
719
761
|
# it straight through, so the paint hot path measures every grapheme exactly
|
|
720
762
|
# once (and that once is a {.display_width} memo read). See {#set_char} for
|
|
721
763
|
# the wide-glyph / clipping / out-of-bounds contract.
|
|
@@ -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.
|
|
@@ -1182,7 +1237,7 @@ module Tuile
|
|
|
1182
1237
|
attr_reader color_scheme: Symbol
|
|
1183
1238
|
|
|
1184
1239
|
# _@return_ — the back buffer components paint into
|
|
1185
|
-
# ({Buffer#
|
|
1240
|
+
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
1186
1241
|
attr_reader buffer: Buffer
|
|
1187
1242
|
|
|
1188
1243
|
# Handler invoked when a {StandardError} escapes an event handler inside
|
|
@@ -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
|
#
|
|
@@ -1517,7 +1620,7 @@ module Tuile
|
|
|
1517
1620
|
# _@param_ `area` — the region to blank; defaults to the whole {#rect}.
|
|
1518
1621
|
def clear_background: (?Rect area) -> void
|
|
1519
1622
|
|
|
1520
|
-
# {Buffer#
|
|
1623
|
+
# {Buffer#set_text} wrapper that fills {#effective_bg_color} behind any span
|
|
1521
1624
|
# with no bg of its own (via {StyledString#under_bg}), so an inherited
|
|
1522
1625
|
# {#bg_color} shows through the content a component paints. A no-op layer
|
|
1523
1626
|
# when nothing is inherited. Self-painters (those skipping the {#repaint}
|
|
@@ -1528,9 +1631,9 @@ module Tuile
|
|
|
1528
1631
|
# _@param_ `y` — row.
|
|
1529
1632
|
#
|
|
1530
1633
|
# _@param_ `styled`
|
|
1531
|
-
def
|
|
1634
|
+
def draw_text: (Integer x, Integer y, StyledString styled) -> void
|
|
1532
1635
|
|
|
1533
|
-
# {#
|
|
1636
|
+
# {#draw_text}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
|
|
1534
1637
|
# filling {#effective_bg_color} when `style` carries no bg of its own.
|
|
1535
1638
|
#
|
|
1536
1639
|
# _@param_ `x` — column.
|
|
@@ -1576,7 +1679,7 @@ module Tuile
|
|
|
1576
1679
|
# Called on every attached component (pre-order, popups included) when
|
|
1577
1680
|
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
1578
1681
|
# OS appearance flips. The hook exists for app *content* whose colors were
|
|
1579
|
-
# baked in from the old theme (a {Label#text} / {List#lines} {StyledString}
|
|
1682
|
+
# baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
|
|
1580
1683
|
# styled with `theme[:accent]`); rebuild it here by re-running the code that
|
|
1581
1684
|
# rendered it. See book ch6 for why built-in accents need no such handling.
|
|
1582
1685
|
#
|
|
@@ -1586,62 +1689,84 @@ module Tuile
|
|
|
1586
1689
|
# {#on_theme_changed=} listener keeps firing.
|
|
1587
1690
|
attr_accessor on_theme_changed: Proc?
|
|
1588
1691
|
|
|
1589
|
-
# A scrollable list of items with cursor support.
|
|
1692
|
+
# A scrollable list of typed items, one row each, with cursor support.
|
|
1693
|
+
#
|
|
1694
|
+
# list = Component::List.new
|
|
1695
|
+
# list.items = people
|
|
1696
|
+
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
|
|
1697
|
+
# list.cursor = List::Cursor.new # a bare list has none
|
|
1698
|
+
# list.on_item_chosen = ->(index, person) { open(person) }
|
|
1590
1699
|
#
|
|
1591
|
-
#
|
|
1592
|
-
#
|
|
1593
|
-
# {
|
|
1594
|
-
#
|
|
1595
|
-
# bottom in view.
|
|
1700
|
+
# The {#renderer} turns an item into one row; the default renders an item
|
|
1701
|
+
# as itself, so a list of `String`s or {StyledString}s needs none — which
|
|
1702
|
+
# is what {#lines=} and {#build_lines} are, items that are their own
|
|
1703
|
+
# rendering (split on `\n`, one row per line).
|
|
1596
1704
|
#
|
|
1597
|
-
#
|
|
1598
|
-
#
|
|
1599
|
-
#
|
|
1600
|
-
#
|
|
1705
|
+
# There are no appenders. The items are always assigned whole — an app that
|
|
1706
|
+
# grows a list keeps its own array and re-assigns it (`list.items = mine`) —
|
|
1707
|
+
# so a `List` stays a *snapshot of a collection*, which is the only shape a
|
|
1708
|
+
# lazily-sourced provider could ever fill.
|
|
1709
|
+
#
|
|
1710
|
+
# Rows wider than the viewport are ellipsized via {StyledString#ellipsize}
|
|
1711
|
+
# with span styles preserved across the cut. Vertical scrolling is via
|
|
1712
|
+
# {#scroll_top_row}; enable {#auto_scroll} to keep the bottom in view. The cursor
|
|
1713
|
+
# responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
|
|
1714
|
+
# automatically; its highlight overlays {Theme#active_bg_color} while
|
|
1715
|
+
# preserving each span's foreground color.
|
|
1716
|
+
#
|
|
1717
|
+
# == Implementation details
|
|
1718
|
+
# Rendering is lazy: only the rows in the viewport are rendered, each
|
|
1719
|
+
# memoized until {#items=}, {#renderer=} or a width change drops the cache.
|
|
1720
|
+
# So a renderer runs *at paint time*, on any frame — keep it pure and
|
|
1721
|
+
# cheap; work that reaches a service belongs in the item, not in the
|
|
1722
|
+
# renderer. {#select_next} deliberately renders without memoizing: one
|
|
1723
|
+
# failed scan would otherwise cache a row per item.
|
|
1601
1724
|
class List < Component
|
|
1725
|
+
DEFAULT_RENDERER: Proc
|
|
1726
|
+
|
|
1602
1727
|
def initialize: () -> void
|
|
1603
1728
|
|
|
1604
1729
|
# _@return_ — whether {#auto_scroll} is currently tailing. True
|
|
1605
|
-
# while the viewport sits at the last
|
|
1730
|
+
# while the viewport sits at the last row; flips to false the moment
|
|
1606
1731
|
# the user scrolls up, and back to true once they scroll to the bottom
|
|
1607
1732
|
# again. Only consulted when {#auto_scroll} is enabled.
|
|
1608
1733
|
def following?: () -> bool
|
|
1609
1734
|
|
|
1610
|
-
#
|
|
1611
|
-
#
|
|
1612
|
-
#
|
|
1613
|
-
#
|
|
1614
|
-
#
|
|
1615
|
-
#
|
|
1616
|
-
#
|
|
1735
|
+
# Re-renders every row — for a {#renderer} whose *inputs* changed while
|
|
1736
|
+
# it and {#items} stayed the same, e.g. one prefixing a marker read from
|
|
1737
|
+
# a selection it closes over:
|
|
1738
|
+
#
|
|
1739
|
+
# def value=(new_value) # RadioGroup: the marked row moved
|
|
1740
|
+
# super
|
|
1741
|
+
# content.refresh_rows
|
|
1742
|
+
# end
|
|
1743
|
+
def refresh_rows: () -> void
|
|
1744
|
+
|
|
1745
|
+
# Sets the items from line-flavored input: each entry is coerced into a
|
|
1746
|
+
# {StyledString} (a `String` is parsed via {StyledString.parse}, so
|
|
1747
|
+
# embedded ANSI is honored; a {StyledString} is used as-is; anything else
|
|
1748
|
+
# is stringified via `#to_s` first), then split on `\n` into separate
|
|
1749
|
+
# lines via {StyledString#lines}, with trailing empty pieces dropped and
|
|
1750
|
+
# trailing ASCII whitespace stripped. The resulting {StyledString}s
|
|
1751
|
+
# *are* the {#items}, so under the
|
|
1752
|
+
# {DEFAULT_RENDERER} each is its own row.
|
|
1617
1753
|
#
|
|
1618
1754
|
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
1619
1755
|
def lines=: (::Array[untyped] lines) -> void
|
|
1620
1756
|
|
|
1621
|
-
#
|
|
1622
|
-
#
|
|
1757
|
+
# Fully re-populates the list, line-flavored: yields a fresh buffer and
|
|
1758
|
+
# assigns it through {#lines=}, so each entry is coerced, split and
|
|
1759
|
+
# rstripped exactly as there. The buffer is a plain `Array`, which a
|
|
1760
|
+
# builder can read mid-build — recording the row a {Cursor::Limited} may
|
|
1761
|
+
# land on is the case this exists for:
|
|
1623
1762
|
# ```ruby
|
|
1624
|
-
# list.
|
|
1625
|
-
#
|
|
1763
|
+
# list.build_lines do |lines|
|
|
1764
|
+
# cursor_positions << lines.size
|
|
1765
|
+
# lines << format_overview(vm)
|
|
1766
|
+
# lines << format_detail(vm)
|
|
1626
1767
|
# end
|
|
1627
1768
|
# ```
|
|
1628
|
-
|
|
1629
|
-
# _@return_ — current lines (when called without a
|
|
1630
|
-
# block).
|
|
1631
|
-
def lines: () ?{ (::Array[untyped] buffer) -> void } -> ::Array[StyledString]
|
|
1632
|
-
|
|
1633
|
-
# sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
|
|
1634
|
-
# Adds a line.
|
|
1635
|
-
#
|
|
1636
|
-
# _@param_ `line`
|
|
1637
|
-
def add_line: ((String | StyledString | _ToS) line) -> void
|
|
1638
|
-
|
|
1639
|
-
# Appends given lines. Each entry is parsed the same way as in
|
|
1640
|
-
# {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
|
|
1641
|
-
# empty pieces dropped and trailing ASCII whitespace stripped.
|
|
1642
|
-
#
|
|
1643
|
-
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
1644
|
-
def add_lines: (::Array[untyped] lines) -> void
|
|
1769
|
+
def build_lines: () ?{ (::Array[untyped] buffer) -> void } -> void
|
|
1645
1770
|
|
|
1646
1771
|
def focusable?: () -> bool
|
|
1647
1772
|
|
|
@@ -1652,15 +1777,15 @@ module Tuile
|
|
|
1652
1777
|
# _@return_ — true if the key was handled.
|
|
1653
1778
|
def handle_key: (String key) -> bool
|
|
1654
1779
|
|
|
1655
|
-
# Moves the cursor to the next
|
|
1780
|
+
# Moves the cursor to the next item whose text contains `query`
|
|
1656
1781
|
# (case-insensitive substring match). Search wraps around the end of the
|
|
1657
|
-
# list. Only
|
|
1658
|
-
# Matching uses the
|
|
1782
|
+
# list. Only items reachable by the current {#cursor} are considered.
|
|
1783
|
+
# Matching uses the rendered row's plain text — span styles do not affect the
|
|
1659
1784
|
# match.
|
|
1660
1785
|
#
|
|
1661
1786
|
# _@param_ `query` — substring to match. Empty query never matches.
|
|
1662
1787
|
#
|
|
1663
|
-
# _@param_ `include_current` — when true, the current cursor position is eligible (useful when the query has just changed and the current
|
|
1788
|
+
# _@param_ `include_current` — when true, the current cursor position is eligible (useful when the query has just changed and the current item may still match); when false, the search starts after the current position (useful for "find next" key bindings that should advance past the current).
|
|
1664
1789
|
#
|
|
1665
1790
|
# _@return_ — true if a match was found.
|
|
1666
1791
|
def select_next: (String query, ?include_current: bool) -> bool
|
|
@@ -1674,25 +1799,42 @@ module Tuile
|
|
|
1674
1799
|
# _@return_ — true if a match was found.
|
|
1675
1800
|
def select_prev: (String query, ?include_current: bool) -> bool
|
|
1676
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
|
+
|
|
1677
1818
|
# _@param_ `event`
|
|
1678
1819
|
def handle_mouse: (MouseEvent event) -> void
|
|
1679
1820
|
|
|
1680
|
-
# Paints the
|
|
1821
|
+
# Paints the visible items into {#rect}, rendering the ones not already
|
|
1822
|
+
# cached.
|
|
1681
1823
|
#
|
|
1682
1824
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
1683
1825
|
# {#rect} is painted below (with blank padding past the last item),
|
|
1684
1826
|
# so the parent contract — "fully draw over your rect" — is met
|
|
1685
|
-
# without an upfront wipe. Rows go through {Component#
|
|
1827
|
+
# without an upfront wipe. Rows go through {Component#draw_text}, so
|
|
1686
1828
|
# content *and* blank filler inherit {Component#effective_bg_color}
|
|
1687
1829
|
# (a {#bg_color} set here or on an ancestor); the cursor row's
|
|
1688
1830
|
# {Theme#active_bg_color} highlight composes on top of it.
|
|
1689
1831
|
def repaint: () -> void
|
|
1690
1832
|
|
|
1691
|
-
#
|
|
1692
|
-
# depends on {#rect}`.width` and the scrollbar gutter, both of
|
|
1693
|
-
# trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
1694
|
-
#
|
|
1695
|
-
#
|
|
1833
|
+
# Drops the rendered-row cache when the wrap width changes. The wrap
|
|
1834
|
+
# width depends on {#rect}`.width` and the scrollbar gutter, both of
|
|
1835
|
+
# which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
1836
|
+
# assigned while the rect was empty (e.g. a {Popup}-wrapped list was
|
|
1837
|
+
# populated before the popup was opened), the auto-scroll update
|
|
1696
1838
|
# was skipped because there was no viewport — re-run it now that there
|
|
1697
1839
|
# is one, so the list snaps to the bottom on first paint.
|
|
1698
1840
|
def on_width_changed: () -> void
|
|
@@ -1702,7 +1844,7 @@ module Tuile
|
|
|
1702
1844
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
1703
1845
|
# via `#to_s`), then split on `\n` via {StyledString#lines} — with
|
|
1704
1846
|
# trailing empty pieces dropped (matching `String#split("\n")`'s
|
|
1705
|
-
# default behavior, so `
|
|
1847
|
+
# default behavior, so a lone `""` entry adds no row) — and trailing ASCII
|
|
1706
1848
|
# whitespace stripped on each resulting line.
|
|
1707
1849
|
#
|
|
1708
1850
|
# _@param_ `entries`
|
|
@@ -1719,16 +1861,16 @@ module Tuile
|
|
|
1719
1861
|
# _@param_ `line`
|
|
1720
1862
|
def rstrip_styled: (StyledString line) -> StyledString
|
|
1721
1863
|
|
|
1722
|
-
# _@return_ — true if the cursor sits on a real
|
|
1864
|
+
# _@return_ — true if the cursor sits on a real item.
|
|
1723
1865
|
def cursor_on_item?: () -> bool
|
|
1724
1866
|
|
|
1725
|
-
# Calls {#on_item_chosen} with the cursor's current `(index,
|
|
1867
|
+
# Calls {#on_item_chosen} with the cursor's current `(index, item)`.
|
|
1726
1868
|
# Caller must ensure {#cursor_on_item?}.
|
|
1727
1869
|
def fire_item_chosen: () -> void
|
|
1728
1870
|
|
|
1729
|
-
# _@return_ — `[position,
|
|
1871
|
+
# _@return_ — `[position, item_at_position]`, with the item nil when the cursor is
|
|
1730
1872
|
# off-content.
|
|
1731
|
-
def cursor_state: () -> [[Integer,
|
|
1873
|
+
def cursor_state: () -> [[Integer, Object, NilClass]]
|
|
1732
1874
|
|
|
1733
1875
|
# Fires {#on_cursor_changed} if {#cursor_state} differs from the last
|
|
1734
1876
|
# fired state. Idempotent — safe to call after any mutation.
|
|
@@ -1762,97 +1904,115 @@ module Tuile
|
|
|
1762
1904
|
# Scrolls the viewport so the cursor is visible.
|
|
1763
1905
|
def move_viewport_to_cursor: () -> void
|
|
1764
1906
|
|
|
1765
|
-
# _@return_ — the max value of {#
|
|
1766
|
-
def
|
|
1907
|
+
# _@return_ — the max value of {#scroll_top_row}.
|
|
1908
|
+
def scroll_top_row_max: () -> Integer
|
|
1767
1909
|
|
|
1768
|
-
# _@return_ — whether the viewport is pinned to the last
|
|
1769
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
1910
|
+
# _@return_ — whether the viewport is pinned to the last row.
|
|
1911
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
1770
1912
|
def at_bottom?: () -> bool
|
|
1771
1913
|
|
|
1772
|
-
# _@return_ — the number of visible
|
|
1773
|
-
def
|
|
1914
|
+
# _@return_ — the number of visible rows.
|
|
1915
|
+
def viewport_rows: () -> Integer
|
|
1774
1916
|
|
|
1775
1917
|
# Scrolls the list.
|
|
1776
1918
|
#
|
|
1777
1919
|
# _@param_ `delta` — negative scrolls up, positive scrolls down.
|
|
1778
|
-
def
|
|
1920
|
+
def move_scroll_top_row_by: (Integer delta) -> void
|
|
1779
1921
|
|
|
1780
|
-
# If auto-scrolling, recalculate the top
|
|
1922
|
+
# If auto-scrolling, recalculate the top row and snap the cursor to the
|
|
1781
1923
|
# last reachable position. Without the cursor snap the viewport gets
|
|
1782
1924
|
# yanked back to wherever the cursor sat on the next arrow press,
|
|
1783
1925
|
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
1784
|
-
# viewport the "
|
|
1785
|
-
# which would leave `
|
|
1926
|
+
# viewport the "items minus viewport" formula yields `@items.size`,
|
|
1927
|
+
# which would leave `scroll_top_row` past the last item once a real rect
|
|
1786
1928
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
1787
1929
|
# the snap-to-bottom intent is preserved.
|
|
1788
1930
|
#
|
|
1789
1931
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
1790
1932
|
# cursor snap and viewport pin are both skipped, so reading older
|
|
1791
|
-
# content is not interrupted by incoming
|
|
1933
|
+
# content is not interrupted by incoming items. {#scroll_top_row=} re-arms
|
|
1792
1934
|
# `@follow` when the viewport returns to the bottom.
|
|
1793
|
-
def
|
|
1935
|
+
def update_scroll_top_row_if_auto_scroll: () -> void
|
|
1794
1936
|
|
|
1795
1937
|
# _@return_ — whether the scrollbar should be drawn right now.
|
|
1796
1938
|
def scrollbar_visible?: () -> bool
|
|
1797
1939
|
|
|
1798
|
-
# _@return_ — column width available for
|
|
1940
|
+
# _@return_ — column width available for row content (rect width
|
|
1799
1941
|
# minus the scrollbar gutter, when visible). `0` when {#rect}'s width
|
|
1800
1942
|
# is non-positive.
|
|
1801
1943
|
def content_width: () -> Integer
|
|
1802
1944
|
|
|
1803
|
-
#
|
|
1804
|
-
#
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1945
|
+
# Discards every rendered row, so the next paint re-renders the viewport
|
|
1946
|
+
# against the current items, renderer and width.
|
|
1947
|
+
def drop_row_cache: () -> void
|
|
1948
|
+
|
|
1949
|
+
# _@param_ `index` — 0-based index into {#items}.
|
|
1950
|
+
#
|
|
1951
|
+
# _@return_ — the item's padded row, rendered on first use and
|
|
1952
|
+
# memoized until {#drop_row_cache}.
|
|
1953
|
+
def padded_row: (Integer index) -> StyledString
|
|
1954
|
+
|
|
1955
|
+
# _@return_ — the blank row painted past the last item.
|
|
1956
|
+
def blank_row: () -> StyledString
|
|
1957
|
+
|
|
1958
|
+
# Renders one item, *without* populating the row cache — {#search_and_go}
|
|
1959
|
+
# scans with this, and caching a failed scan would grow the cache to one
|
|
1960
|
+
# row per item.
|
|
1961
|
+
#
|
|
1962
|
+
# _@param_ `item`
|
|
1963
|
+
#
|
|
1964
|
+
# _@return_ — one row: the {#renderer}'s output coerced to a
|
|
1965
|
+
# {StyledString}, cut to its first line since a `\n` reaching the buffer
|
|
1966
|
+
# would corrupt the frame.
|
|
1967
|
+
def render: (Object item) -> StyledString
|
|
1808
1968
|
|
|
1809
|
-
# Pads `
|
|
1810
|
-
# excluded).
|
|
1969
|
+
# Pads `row` to one full row of the viewport (scrollbar gutter
|
|
1970
|
+
# excluded). Rows wider than the content area are ellipsized via
|
|
1811
1971
|
# {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
1812
|
-
#
|
|
1972
|
+
# ones are padded with default-styled spaces.
|
|
1813
1973
|
#
|
|
1814
|
-
# _@param_ `
|
|
1974
|
+
# _@param_ `row`
|
|
1815
1975
|
#
|
|
1816
1976
|
# _@return_ — exactly {#content_width} display columns wide
|
|
1817
1977
|
# (or {StyledString::EMPTY} when content_width is non-positive).
|
|
1818
|
-
def pad_to_row: (StyledString
|
|
1978
|
+
def pad_to_row: (StyledString row) -> StyledString
|
|
1819
1979
|
|
|
1820
|
-
# _@param_ `index` — 0-based index into {#
|
|
1980
|
+
# _@param_ `index` — 0-based index into {#items}.
|
|
1821
1981
|
#
|
|
1822
1982
|
# _@param_ `row_in_viewport` — 0-based row within the viewport.
|
|
1823
1983
|
#
|
|
1824
1984
|
# _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
|
|
1825
1985
|
#
|
|
1826
|
-
# _@return_ — paintable
|
|
1986
|
+
# _@return_ — paintable row exactly `rect.width` columns wide;
|
|
1827
1987
|
# highlighted if cursor is here.
|
|
1828
|
-
def
|
|
1988
|
+
def paintable_row: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
|
|
1829
1989
|
|
|
1830
1990
|
# _@return_ — callback fired when an item is chosen — by pressing
|
|
1831
|
-
# Enter on the cursor's item, or by left-clicking
|
|
1832
|
-
# `proc.call(index,
|
|
1833
|
-
#
|
|
1834
|
-
#
|
|
1991
|
+
# Enter on the cursor's item, or by left-clicking it. Called as
|
|
1992
|
+
# `proc.call(index, item)` with the chosen 0-based index and the item
|
|
1993
|
+
# itself. Never fires when the cursor's position is outside the content
|
|
1994
|
+
# (e.g. {Cursor::None}, or empty content).
|
|
1835
1995
|
attr_accessor on_item_chosen: Proc?
|
|
1836
1996
|
|
|
1837
|
-
# _@return_ — callback fired when the `(index,
|
|
1838
|
-
# the cursor changes
|
|
1839
|
-
#
|
|
1997
|
+
# _@return_ — callback fired when the `(index, item)` tuple under
|
|
1998
|
+
# the cursor changes (items compared with `==`). Called as
|
|
1999
|
+
# `proc.call(index, item)`, with `item` `nil` when the cursor is
|
|
1840
2000
|
# off-content ({Cursor::None}, empty list, or `index` past the last
|
|
1841
|
-
#
|
|
1842
|
-
# and on {#
|
|
2001
|
+
# item). Fires on cursor moves (key, mouse, search), on {#cursor=},
|
|
2002
|
+
# and on {#items=} when the item at the cursor's index
|
|
1843
2003
|
# changes (or its in-range/out-of-range status flips). Useful for
|
|
1844
2004
|
# keeping a details pane in sync with the highlighted row.
|
|
1845
2005
|
attr_accessor on_cursor_changed: Proc?
|
|
1846
2006
|
|
|
1847
|
-
# _@return_ — if true and
|
|
1848
|
-
#
|
|
1849
|
-
#
|
|
1850
|
-
#
|
|
1851
|
-
#
|
|
2007
|
+
# _@return_ — if true and new content is set, auto-scrolls to the
|
|
2008
|
+
# bottom — but only while the viewport is already pinned to the last
|
|
2009
|
+
# row (see {#following?}). Scroll up to read older content and
|
|
2010
|
+
# incoming rows stop yanking you back down; scroll back to the bottom
|
|
2011
|
+
# and tailing resumes.
|
|
1852
2012
|
attr_accessor auto_scroll: bool
|
|
1853
2013
|
|
|
1854
|
-
# _@return_ — top
|
|
1855
|
-
attr_accessor
|
|
2014
|
+
# _@return_ — top row of the viewport. 0 or positive.
|
|
2015
|
+
attr_accessor scroll_top_row: Integer
|
|
1856
2016
|
|
|
1857
2017
|
# _@return_ — the list's cursor.
|
|
1858
2018
|
attr_accessor cursor: Cursor
|
|
@@ -1865,34 +2025,42 @@ module Tuile
|
|
|
1865
2025
|
# Defaults to false.
|
|
1866
2026
|
attr_accessor show_cursor_when_inactive: bool
|
|
1867
2027
|
|
|
2028
|
+
# _@return_ — the items, one row each.
|
|
2029
|
+
attr_accessor items: ::Array[untyped]
|
|
2030
|
+
|
|
2031
|
+
# _@return_ — item -> row: a {StyledString}, a `String` (parsed,
|
|
2032
|
+
# so embedded ANSI is honored), or anything with `#to_s`. Only the first
|
|
2033
|
+
# line of a multi-line rendering is kept — one item is one row.
|
|
2034
|
+
attr_accessor renderer: (Proc | Method)
|
|
2035
|
+
|
|
1868
2036
|
# Tracks cursor position within the list.
|
|
1869
2037
|
class Cursor
|
|
1870
2038
|
# _@param_ `position` — the initial cursor position.
|
|
1871
2039
|
def initialize: (?position: Integer) -> void
|
|
1872
2040
|
|
|
1873
|
-
# _@param_ `
|
|
2041
|
+
# _@param_ `item_count` — number of items in the list.
|
|
1874
2042
|
#
|
|
1875
2043
|
# _@return_ — positions the cursor can land on, in
|
|
1876
2044
|
# ascending order.
|
|
1877
|
-
def candidate_positions: (Integer
|
|
2045
|
+
def candidate_positions: (Integer item_count) -> ::Array[Integer]
|
|
1878
2046
|
|
|
1879
2047
|
# _@param_ `key` — pressed keyboard key.
|
|
1880
2048
|
#
|
|
1881
|
-
# _@param_ `
|
|
2049
|
+
# _@param_ `item_count` — number of items in the list.
|
|
1882
2050
|
#
|
|
1883
|
-
# _@param_ `
|
|
2051
|
+
# _@param_ `viewport_rows` — number of visible rows.
|
|
1884
2052
|
#
|
|
1885
2053
|
# _@return_ — true if the cursor moved.
|
|
1886
|
-
def handle_key: (String key, Integer
|
|
2054
|
+
def handle_key: (String key, Integer item_count, Integer viewport_rows) -> bool
|
|
1887
2055
|
|
|
1888
|
-
# _@param_ `
|
|
2056
|
+
# _@param_ `item_index` — the item the cursor is hovering over.
|
|
1889
2057
|
#
|
|
1890
2058
|
# _@param_ `event` — the event.
|
|
1891
2059
|
#
|
|
1892
|
-
# _@param_ `
|
|
2060
|
+
# _@param_ `item_count` — number of items in the list.
|
|
1893
2061
|
#
|
|
1894
2062
|
# _@return_ — true if the event was handled.
|
|
1895
|
-
def handle_mouse: (Integer
|
|
2063
|
+
def handle_mouse: (Integer item_index, MouseEvent event, Integer item_count) -> bool
|
|
1896
2064
|
|
|
1897
2065
|
# Moves the cursor to the new position. Public only because of testing.
|
|
1898
2066
|
#
|
|
@@ -1902,25 +2070,25 @@ module Tuile
|
|
|
1902
2070
|
def go: (Integer new_position) -> bool
|
|
1903
2071
|
|
|
1904
2072
|
# Moves the cursor to the last reachable position. For base {Cursor},
|
|
1905
|
-
# the last
|
|
2073
|
+
# the last item; {Limited} clamps to the last allowed position; {None}
|
|
1906
2074
|
# is a no-op.
|
|
1907
2075
|
#
|
|
1908
|
-
# _@param_ `
|
|
2076
|
+
# _@param_ `item_count` — number of items in the list.
|
|
1909
2077
|
#
|
|
1910
2078
|
# _@return_ — true if the position changed.
|
|
1911
|
-
def go_to_last: (Integer
|
|
2079
|
+
def go_to_last: (Integer item_count) -> bool
|
|
1912
2080
|
|
|
1913
|
-
# _@param_ `
|
|
2081
|
+
# _@param_ `count`
|
|
1914
2082
|
#
|
|
1915
|
-
# _@param_ `
|
|
1916
|
-
def go_down_by: (Integer
|
|
2083
|
+
# _@param_ `item_count`
|
|
2084
|
+
def go_down_by: (Integer count, Integer item_count) -> bool
|
|
1917
2085
|
|
|
1918
|
-
# _@param_ `
|
|
1919
|
-
def go_up_by: (Integer
|
|
2086
|
+
# _@param_ `count`
|
|
2087
|
+
def go_up_by: (Integer count) -> bool
|
|
1920
2088
|
|
|
1921
2089
|
def go_to_first: () -> bool
|
|
1922
2090
|
|
|
1923
|
-
# _@return_ — 0-based
|
|
2091
|
+
# _@return_ — 0-based item index of the current cursor position.
|
|
1924
2092
|
attr_reader position: Integer
|
|
1925
2093
|
|
|
1926
2094
|
# No cursor — cursor is disabled.
|
|
@@ -1929,20 +2097,20 @@ module Tuile
|
|
|
1929
2097
|
|
|
1930
2098
|
# _@param_ `_key`
|
|
1931
2099
|
#
|
|
1932
|
-
# _@param_ `
|
|
2100
|
+
# _@param_ `_item_count`
|
|
1933
2101
|
#
|
|
1934
|
-
# _@param_ `
|
|
1935
|
-
def handle_key: (String _key, Integer
|
|
2102
|
+
# _@param_ `_viewport_rows`
|
|
2103
|
+
def handle_key: (String _key, Integer _item_count, Integer _viewport_rows) -> bool
|
|
1936
2104
|
|
|
1937
|
-
# _@param_ `
|
|
2105
|
+
# _@param_ `_item_index`
|
|
1938
2106
|
#
|
|
1939
2107
|
# _@param_ `_event`
|
|
1940
2108
|
#
|
|
1941
|
-
# _@param_ `
|
|
1942
|
-
def handle_mouse: (Integer
|
|
2109
|
+
# _@param_ `_item_count`
|
|
2110
|
+
def handle_mouse: (Integer _item_index, MouseEvent _event, Integer _item_count) -> bool
|
|
1943
2111
|
|
|
1944
|
-
# _@param_ `
|
|
1945
|
-
def candidate_positions: (Integer
|
|
2112
|
+
# _@param_ `_item_count`
|
|
2113
|
+
def candidate_positions: (Integer _item_count) -> ::Array[Integer]
|
|
1946
2114
|
|
|
1947
2115
|
# Overridden so all movement funnels — base {Cursor#go_to_last},
|
|
1948
2116
|
# {Cursor#go_to_first}, etc., which all call {#go} — become safe
|
|
@@ -1955,39 +2123,352 @@ module Tuile
|
|
|
1955
2123
|
def go: (Integer _new_position) -> bool
|
|
1956
2124
|
end
|
|
1957
2125
|
|
|
1958
|
-
# Cursor which can only land on specific allowed
|
|
2126
|
+
# Cursor which can only land on specific allowed items.
|
|
1959
2127
|
class Limited < Cursor
|
|
1960
2128
|
# _@param_ `positions` — allowed positions. Must not be empty.
|
|
1961
2129
|
#
|
|
1962
2130
|
# _@param_ `position` — initial position.
|
|
1963
2131
|
def initialize: (::Array[Integer] positions, ?position: Integer) -> void
|
|
1964
2132
|
|
|
1965
|
-
# _@param_ `
|
|
2133
|
+
# _@param_ `item_index`
|
|
1966
2134
|
#
|
|
1967
2135
|
# _@param_ `event`
|
|
1968
2136
|
#
|
|
1969
|
-
# _@param_ `
|
|
1970
|
-
def handle_mouse: (Integer
|
|
2137
|
+
# _@param_ `_item_count`
|
|
2138
|
+
def handle_mouse: (Integer item_index, MouseEvent event, Integer _item_count) -> bool
|
|
1971
2139
|
|
|
1972
|
-
# _@param_ `
|
|
1973
|
-
def candidate_positions: (Integer
|
|
2140
|
+
# _@param_ `item_count`
|
|
2141
|
+
def candidate_positions: (Integer item_count) -> ::Array[Integer]
|
|
1974
2142
|
|
|
1975
|
-
# _@param_ `
|
|
1976
|
-
def go_to_last: (Integer
|
|
2143
|
+
# _@param_ `_item_count`
|
|
2144
|
+
def go_to_last: (Integer _item_count) -> bool
|
|
1977
2145
|
|
|
1978
|
-
# _@param_ `
|
|
2146
|
+
# _@param_ `count`
|
|
1979
2147
|
#
|
|
1980
|
-
# _@param_ `
|
|
1981
|
-
def go_down_by: (Integer
|
|
2148
|
+
# _@param_ `item_count`
|
|
2149
|
+
def go_down_by: (Integer count, Integer item_count) -> bool
|
|
1982
2150
|
|
|
1983
|
-
# _@param_ `
|
|
1984
|
-
def go_up_by: (Integer
|
|
2151
|
+
# _@param_ `count`
|
|
2152
|
+
def go_up_by: (Integer count) -> bool
|
|
1985
2153
|
|
|
1986
2154
|
def go_to_first: () -> bool
|
|
1987
2155
|
end
|
|
1988
2156
|
end
|
|
1989
2157
|
end
|
|
1990
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
|
+
|
|
1991
2472
|
# A label which shows static text. No word-wrapping; long lines are
|
|
1992
2473
|
# truncated with an ellipsis. Text is modeled as a {StyledString};
|
|
1993
2474
|
# {#text=} accepts a {String} (parsed via {StyledString.parse}, so
|
|
@@ -2002,19 +2483,19 @@ module Tuile
|
|
|
2002
2483
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
2003
2484
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
2004
2485
|
# the "fully draw over your rect" contract is met without an upfront
|
|
2005
|
-
# wipe. Rows go through {Component#
|
|
2486
|
+
# wipe. Rows go through {Component#draw_text}, so the padding and blank
|
|
2006
2487
|
# rows inherit {Component#effective_bg_color} when {#bg} is unset.
|
|
2007
2488
|
def repaint: () -> void
|
|
2008
2489
|
|
|
2009
2490
|
def on_width_changed: () -> void
|
|
2010
2491
|
|
|
2011
|
-
# Recomputes {@
|
|
2492
|
+
# Recomputes {@rows} for the current text and rect width.
|
|
2012
2493
|
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
2013
|
-
# the full width, so {#repaint} is just a lookup + {Buffer#
|
|
2014
|
-
# row. {@
|
|
2494
|
+
# the full width, so {#repaint} is just a lookup + {Buffer#set_text} per
|
|
2495
|
+
# row. {@blank_row} covers rows past the last text line. When {#bg} is
|
|
2015
2496
|
# set, every produced line (and the blank row) has the bg applied
|
|
2016
2497
|
# uniformly.
|
|
2017
|
-
def
|
|
2498
|
+
def update_rows: () -> void
|
|
2018
2499
|
|
|
2019
2500
|
# _@param_ `line`
|
|
2020
2501
|
def apply_bg: (StyledString line) -> StyledString
|
|
@@ -2074,6 +2555,10 @@ module Tuile
|
|
|
2074
2555
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
2075
2556
|
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
2076
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.
|
|
2077
2562
|
class Popup < Component
|
|
2078
2563
|
include Tuile::Component::HasContent
|
|
2079
2564
|
|
|
@@ -2082,11 +2567,41 @@ module Tuile
|
|
|
2082
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).
|
|
2083
2568
|
#
|
|
2084
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}.
|
|
2085
|
-
|
|
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
|
|
2086
2578
|
|
|
2087
2579
|
# _@return_ — whether this popup is modal. See {#initialize}.
|
|
2088
2580
|
def modal?: () -> bool
|
|
2089
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
|
+
|
|
2090
2605
|
def focusable?: () -> bool
|
|
2091
2606
|
|
|
2092
2607
|
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
@@ -2102,18 +2617,13 @@ module Tuile
|
|
|
2102
2617
|
|
|
2103
2618
|
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
2104
2619
|
# current screen first.
|
|
2105
|
-
def open: () -> void
|
|
2106
|
-
|
|
2107
|
-
# Constructs and opens a popup in one call.
|
|
2108
2620
|
#
|
|
2109
|
-
#
|
|
2110
|
-
#
|
|
2111
|
-
# _@param_ `modal` — see {#initialize}.
|
|
2621
|
+
# popup = Component::Popup.new(content: window).open # construct and mount
|
|
2112
2622
|
#
|
|
2113
|
-
#
|
|
2114
|
-
#
|
|
2115
|
-
#
|
|
2116
|
-
def
|
|
2623
|
+
# There is deliberately no class-level `Popup.open` factory — see
|
|
2624
|
+
# `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
|
|
2625
|
+
# one-liner above available without one.
|
|
2626
|
+
def open: () -> self
|
|
2117
2627
|
|
|
2118
2628
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
2119
2629
|
def close: () -> void
|
|
@@ -2137,9 +2647,6 @@ module Tuile
|
|
|
2137
2647
|
# Recenters the popup on the screen, preserving its current width/height.
|
|
2138
2648
|
def center: () -> void
|
|
2139
2649
|
|
|
2140
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
2141
|
-
def keyboard_hint: () -> String
|
|
2142
|
-
|
|
2143
2650
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
2144
2651
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
2145
2652
|
# focused content after that content declined to handle it.
|
|
@@ -2149,6 +2656,10 @@ module Tuile
|
|
|
2149
2656
|
# _@return_ — true if the key was handled.
|
|
2150
2657
|
def handle_key: (String key) -> bool
|
|
2151
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
|
+
|
|
2152
2663
|
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
2153
2664
|
#
|
|
2154
2665
|
# _@param_ `content`
|
|
@@ -2161,6 +2672,37 @@ module Tuile
|
|
|
2161
2672
|
|
|
2162
2673
|
# _@return_ — the popup's declared size. See {#size=}.
|
|
2163
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?
|
|
2164
2706
|
end
|
|
2165
2707
|
|
|
2166
2708
|
# A clickable button. Activated by Enter, Space, or a left mouse click;
|
|
@@ -2721,8 +3263,6 @@ module Tuile
|
|
|
2721
3263
|
|
|
2722
3264
|
def tab_stop?: () -> bool
|
|
2723
3265
|
|
|
2724
|
-
def keyboard_hint: () -> String
|
|
2725
|
-
|
|
2726
3266
|
# Re-anchors the (open) dropdown after a move or resize.
|
|
2727
3267
|
#
|
|
2728
3268
|
# _@param_ `new_rect`
|
|
@@ -2766,10 +3306,10 @@ module Tuile
|
|
|
2766
3306
|
|
|
2767
3307
|
def close_menu: () -> void
|
|
2768
3308
|
|
|
2769
|
-
# Adopts the item
|
|
3309
|
+
# Adopts the chosen item as {#value} and closes the dropdown.
|
|
2770
3310
|
#
|
|
2771
|
-
# _@param_ `
|
|
2772
|
-
def commit: (
|
|
3311
|
+
# _@param_ `item`
|
|
3312
|
+
def commit: (Object item) -> void
|
|
2773
3313
|
|
|
2774
3314
|
def anchor: () -> void
|
|
2775
3315
|
|
|
@@ -2867,15 +3407,15 @@ module Tuile
|
|
|
2867
3407
|
# _@param_ `content`
|
|
2868
3408
|
def layout: (Component content) -> void
|
|
2869
3409
|
|
|
2870
|
-
# Paints the window border via {Component#
|
|
3410
|
+
# Paints the window border via {Component#draw_text}/{Component#draw_char},
|
|
2871
3411
|
# so the border cells inherit {Component#effective_bg_color} — a
|
|
2872
3412
|
# {Component#bg_color} on the window tints border and content alike. Both
|
|
2873
|
-
# border
|
|
3413
|
+
# border rows are clipped by *display* width, so no caption overflows the
|
|
2874
3414
|
# box; when the window is active the whole border — the caption's own
|
|
2875
3415
|
# colors included — is drawn in {Theme#active_border_color}.
|
|
2876
3416
|
def repaint_border: () -> void
|
|
2877
3417
|
|
|
2878
|
-
# Builds the top border
|
|
3418
|
+
# Builds the top border row: corners, {#caption} embedded at its own
|
|
2879
3419
|
# width, dashes filling the remainder. The caption keeps its own styling
|
|
2880
3420
|
# unless `fg` is set — an active window's border claims it.
|
|
2881
3421
|
#
|
|
@@ -2884,7 +3424,7 @@ module Tuile
|
|
|
2884
3424
|
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2885
3425
|
def top_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2886
3426
|
|
|
2887
|
-
# Builds the bottom border
|
|
3427
|
+
# Builds the bottom border row. The corners take the border color; the
|
|
2888
3428
|
# interior is plain dashes when a {#footer} component occupies the row
|
|
2889
3429
|
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
2890
3430
|
# {#footer_text} embedded at its own width — keeping the text's own
|
|
@@ -3053,86 +3593,573 @@ module Tuile
|
|
|
3053
3593
|
def focusable?: () -> bool
|
|
3054
3594
|
end
|
|
3055
3595
|
|
|
3056
|
-
# A
|
|
3057
|
-
#
|
|
3058
|
-
#
|
|
3059
|
-
#
|
|
3060
|
-
#
|
|
3061
|
-
#
|
|
3062
|
-
#
|
|
3063
|
-
#
|
|
3064
|
-
#
|
|
3065
|
-
#
|
|
3066
|
-
#
|
|
3067
|
-
#
|
|
3068
|
-
#
|
|
3069
|
-
#
|
|
3070
|
-
#
|
|
3071
|
-
#
|
|
3072
|
-
#
|
|
3073
|
-
#
|
|
3074
|
-
#
|
|
3075
|
-
#
|
|
3076
|
-
#
|
|
3077
|
-
#
|
|
3078
|
-
#
|
|
3079
|
-
#
|
|
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.
|
|
3080
3650
|
#
|
|
3081
|
-
#
|
|
3082
|
-
#
|
|
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.
|
|
3083
3670
|
#
|
|
3084
3671
|
# UI-thread-confined, like every component (see {Screen}).
|
|
3085
|
-
class
|
|
3086
|
-
|
|
3087
|
-
include Tuile::Component::HasValue
|
|
3672
|
+
class MenuBar < Component
|
|
3673
|
+
def initialize: () -> void
|
|
3088
3674
|
|
|
3089
|
-
# _@
|
|
3090
|
-
def
|
|
3675
|
+
# _@return_ — `true` — the strip takes focus, so its keys work.
|
|
3676
|
+
def focusable?: () -> bool
|
|
3091
3677
|
|
|
3092
|
-
#
|
|
3093
|
-
|
|
3094
|
-
# clears the selection (blank field). The value need not be in {#items}.
|
|
3095
|
-
#
|
|
3096
|
-
# _@param_ `new_value`
|
|
3097
|
-
def value=: (Object new_value) -> void
|
|
3678
|
+
# _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
|
|
3679
|
+
def tab_stop?: () -> bool
|
|
3098
3680
|
|
|
3099
|
-
# _@return_ — the
|
|
3100
|
-
#
|
|
3101
|
-
def
|
|
3681
|
+
# _@return_ — the top-level items, in strip order. Read-only by
|
|
3682
|
+
# convention; grow it through {#add_item}.
|
|
3683
|
+
def items: () -> ::Array[Item]
|
|
3102
3684
|
|
|
3103
|
-
|
|
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
|
|
3104
3692
|
|
|
3105
|
-
#
|
|
3106
|
-
#
|
|
3693
|
+
# The cells the strip actually paints: one row, as wide as its segments
|
|
3694
|
+
# need, clipped to {#rect}.
|
|
3107
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
|
+
|
|
3108
3702
|
# _@param_ `new_rect`
|
|
3109
3703
|
def rect=: (Rect new_rect) -> void
|
|
3110
3704
|
|
|
3111
|
-
# Closes the
|
|
3112
|
-
#
|
|
3113
|
-
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
3114
|
-
# the (non-focusable) {ListDropdown}, so closing the overlay repairs no
|
|
3115
|
-
# focus.
|
|
3705
|
+
# Closes the cascade when the strip leaves the focus chain, so tabbing (or
|
|
3706
|
+
# clicking) away doesn't strand an open menu.
|
|
3116
3707
|
#
|
|
3117
3708
|
# _@param_ `flag`
|
|
3118
3709
|
def active=: (bool flag) -> void
|
|
3119
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
|
+
#
|
|
3120
3730
|
# _@param_ `event`
|
|
3121
3731
|
def handle_mouse: (MouseEvent event) -> void
|
|
3122
3732
|
|
|
3123
3733
|
def repaint: () -> void
|
|
3124
3734
|
|
|
3125
|
-
#
|
|
3126
|
-
#
|
|
3127
|
-
#
|
|
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.
|
|
3128
3739
|
#
|
|
3129
|
-
# _@param_ `
|
|
3130
|
-
def
|
|
3740
|
+
# _@param_ `index`
|
|
3741
|
+
def highlight=: (Integer index) -> void
|
|
3131
3742
|
|
|
3132
|
-
#
|
|
3133
|
-
#
|
|
3134
|
-
|
|
3135
|
-
|
|
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
|
+
|
|
4085
|
+
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
4086
|
+
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
4087
|
+
# the *selected item* — of whatever type the items are — not the display
|
|
4088
|
+
# string, so a combo over domain objects hands back the object:
|
|
4089
|
+
#
|
|
4090
|
+
# combo = Component::ComboBox.new
|
|
4091
|
+
# combo.items = User.all # Array of any type
|
|
4092
|
+
# combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
|
|
4093
|
+
# combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
|
|
4094
|
+
# combo.value = some_user # selects it; field shows its label
|
|
4095
|
+
#
|
|
4096
|
+
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
4097
|
+
# non-modal {Popup} over a {List} — promoted to one component. Give it a
|
|
4098
|
+
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
4099
|
+
# last column and floats the dropdown above or below.
|
|
4100
|
+
#
|
|
4101
|
+
# == The two values
|
|
4102
|
+
# {#value} (the committed selection) and the field's typed text (a transient
|
|
4103
|
+
# *query*) are deliberately distinct. Keystrokes move the query and refilter
|
|
4104
|
+
# the list; only Enter/click commits, and only a commit changes {#value} and
|
|
4105
|
+
# fires {#on_value_change}. An uncommitted query reverts to the current
|
|
4106
|
+
# value's label when the dropdown is dismissed (ESC) or the combo loses
|
|
4107
|
+
# focus. Selecting by list index (not by matching the label back) is what
|
|
4108
|
+
# lets two items share a label and still resolve to the right object.
|
|
4109
|
+
#
|
|
4110
|
+
# The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
|
|
4111
|
+
# it for the theming knob.
|
|
4112
|
+
#
|
|
4113
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4114
|
+
class ComboBox < Component
|
|
4115
|
+
include Tuile::Component::HasContent
|
|
4116
|
+
include Tuile::Component::HasValue
|
|
4117
|
+
|
|
4118
|
+
# _@param_ `items` — the candidate items (any type); also settable via {#items=}.
|
|
4119
|
+
def initialize: (?items: ::Array[untyped]) -> void
|
|
4120
|
+
|
|
4121
|
+
# Selects `new_value` programmatically: updates the field to its label
|
|
4122
|
+
# *without* opening the dropdown, then fires {#on_value_change}. `nil`
|
|
4123
|
+
# clears the selection (blank field). The value need not be in {#items}.
|
|
4124
|
+
#
|
|
4125
|
+
# _@param_ `new_value`
|
|
4126
|
+
def value=: (Object new_value) -> void
|
|
4127
|
+
|
|
4128
|
+
# _@return_ — the field's caret position (the combo delegates the
|
|
4129
|
+
# hardware cursor to its field).
|
|
4130
|
+
def cursor_position: () -> Point?
|
|
4131
|
+
|
|
4132
|
+
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
4133
|
+
# field via {#layout}.
|
|
4134
|
+
#
|
|
4135
|
+
# _@param_ `new_rect`
|
|
4136
|
+
def rect=: (Rect new_rect) -> void
|
|
4137
|
+
|
|
4138
|
+
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
4139
|
+
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
4140
|
+
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
4141
|
+
# the (non-focusable) {ListDropdown}, so closing the overlay repairs no
|
|
4142
|
+
# focus.
|
|
4143
|
+
#
|
|
4144
|
+
# _@param_ `flag`
|
|
4145
|
+
def active=: (bool flag) -> void
|
|
4146
|
+
|
|
4147
|
+
# _@param_ `event`
|
|
4148
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4149
|
+
|
|
4150
|
+
def repaint: () -> void
|
|
4151
|
+
|
|
4152
|
+
# Field spans the row bar the last column, which the `▾` occupies
|
|
4153
|
+
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
4154
|
+
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
4155
|
+
#
|
|
4156
|
+
# _@param_ `field`
|
|
4157
|
+
def layout: (Component field) -> void
|
|
4158
|
+
|
|
4159
|
+
# The field's key interceptor: while the dropdown is open forwards movement
|
|
4160
|
+
# to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
|
|
4161
|
+
# and dismisses on ESC (reverting the query); opens it on Down or Enter
|
|
4162
|
+
# when closed. Everything else (printable keys, editing) falls through to
|
|
3136
4163
|
# the field, whose {TextField#on_change} refilters.
|
|
3137
4164
|
#
|
|
3138
4165
|
# _@param_ `key`
|
|
@@ -3152,11 +4179,11 @@ module Tuile
|
|
|
3152
4179
|
# _@param_ `query`
|
|
3153
4180
|
def matching: (String query) -> ::Array[untyped]
|
|
3154
4181
|
|
|
3155
|
-
# Commits the item
|
|
3156
|
-
#
|
|
4182
|
+
# Commits the item chosen from the menu: closes the dropdown and adopts it
|
|
4183
|
+
# as {#value} (which repaints the field with its label).
|
|
3157
4184
|
#
|
|
3158
|
-
# _@param_ `
|
|
3159
|
-
def commit: (
|
|
4185
|
+
# _@param_ `item`
|
|
4186
|
+
def commit: (Object item) -> void
|
|
3160
4187
|
|
|
3161
4188
|
def open_menu: () -> void
|
|
3162
4189
|
|
|
@@ -3166,6 +4193,9 @@ module Tuile
|
|
|
3166
4193
|
|
|
3167
4194
|
# Sets the field's text without triggering a refilter — for programmatic
|
|
3168
4195
|
# value changes and query reverts, which must not spring the dropdown.
|
|
4196
|
+
# Every programmatic write to the field goes through here; a direct
|
|
4197
|
+
# `content.text =` reaches the field's `on_change` and pops the dropdown
|
|
4198
|
+
# open on a {#value=} the user never asked to browse.
|
|
3169
4199
|
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
3170
4200
|
# shorter query replaced by a longer label would otherwise strand it
|
|
3171
4201
|
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
@@ -3265,11 +4295,169 @@ module Tuile
|
|
|
3265
4295
|
attr_accessor on_value_change: (Proc | Method)?
|
|
3266
4296
|
end
|
|
3267
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
|
+
|
|
3268
4456
|
# A multi-line, word-wrapping text input.
|
|
3269
4457
|
#
|
|
3270
4458
|
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
3271
4459
|
# content. Text is wrapped to {Rect#width} columns and any text that
|
|
3272
|
-
# doesn't fit vertically is reached by scrolling: {#
|
|
4460
|
+
# doesn't fit vertically is reached by scrolling: {#scroll_top_row}
|
|
3273
4461
|
# follows the caret so the line being edited stays visible. There is no
|
|
3274
4462
|
# horizontal scrolling.
|
|
3275
4463
|
#
|
|
@@ -3280,28 +4468,54 @@ module Tuile
|
|
|
3280
4468
|
# start of the next row in nearly all cases).
|
|
3281
4469
|
#
|
|
3282
4470
|
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
3283
|
-
# {#on_change} is wired.
|
|
3284
|
-
#
|
|
3285
|
-
#
|
|
3286
|
-
#
|
|
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.
|
|
4476
|
+
#
|
|
4477
|
+
# Up/Down move the caret between rows and, at the first/last row, snap to
|
|
4478
|
+
# the start/end of the text. A subclass can claim the key at that edge
|
|
4479
|
+
# instead — shell-style history recall is the motivating case — by asking
|
|
4480
|
+
# {#caret_row} and {#row_count} before delegating:
|
|
4481
|
+
#
|
|
4482
|
+
# class PromptArea < Component::TextArea
|
|
4483
|
+
# protected
|
|
4484
|
+
#
|
|
4485
|
+
# def handle_text_input_key(key)
|
|
4486
|
+
# return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
|
|
4487
|
+
# return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
|
|
4488
|
+
#
|
|
4489
|
+
# super # anywhere else: the caret moves, and the edge still snaps
|
|
4490
|
+
# end
|
|
4491
|
+
# end
|
|
4492
|
+
#
|
|
4493
|
+
# Both recalls return `true` to consume the key; app code that would rather
|
|
4494
|
+
# not subclass claims the same keys through {AbstractStringField#on_key}.
|
|
3287
4495
|
#
|
|
3288
4496
|
# == Implementation details
|
|
3289
4497
|
#
|
|
3290
|
-
# The
|
|
3291
|
-
# **index**
|
|
3292
|
-
# `
|
|
3293
|
-
#
|
|
3294
|
-
#
|
|
3295
|
-
#
|
|
3296
|
-
#
|
|
3297
|
-
#
|
|
3298
|
-
# The wrap walks **grapheme clusters**, not characters — a combining mark must
|
|
3299
|
-
# add no columns and must not be split from its base across a row break. Note
|
|
3300
|
-
# `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
|
|
3301
|
-
# rather than equality.
|
|
4498
|
+
# The wrap itself — and with it every conversion between a character
|
|
4499
|
+
# **index** and a **row/column** — lives in {WrappedText}, a
|
|
4500
|
+
# snapshot of `(text, rect.width)` this class caches and drops whenever
|
|
4501
|
+
# either changes. What stays here is the widget: keys, mouse, painting, and
|
|
4502
|
+
# the {#scroll_top_row} viewport, which {WrappedText} deliberately knows
|
|
4503
|
+
# nothing about (it is a pure function of text and width; the viewport is
|
|
4504
|
+
# stateful and needs {Rect#height}).
|
|
3302
4505
|
class TextArea < Tuile::Component::AbstractStringField
|
|
3303
4506
|
def initialize: () -> void
|
|
3304
4507
|
|
|
4508
|
+
# The caret's row, counted from the text's first row — *not* from the top
|
|
4509
|
+
# of the viewport (subtract {#scroll_top_row} for that).
|
|
4510
|
+
#
|
|
4511
|
+
# _@return_ — a row index in `0...row_count`.
|
|
4512
|
+
def caret_row: () -> Integer
|
|
4513
|
+
|
|
4514
|
+
# _@return_ — rows the wrapped text occupies at the current
|
|
4515
|
+
# {Rect#width}; always `>= 1`, since empty text still wraps to one
|
|
4516
|
+
# (empty) row.
|
|
4517
|
+
def row_count: () -> Integer
|
|
4518
|
+
|
|
3305
4519
|
def cursor_position: () -> Point?
|
|
3306
4520
|
|
|
3307
4521
|
# _@param_ `event`
|
|
@@ -3318,119 +4532,236 @@ module Tuile
|
|
|
3318
4532
|
|
|
3319
4533
|
def on_width_changed: () -> void
|
|
3320
4534
|
|
|
3321
|
-
# _@return_ —
|
|
3322
|
-
|
|
3323
|
-
def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
4535
|
+
# _@return_ — the current wrap of {#text} at {Rect#width}.
|
|
4536
|
+
def wrap: () -> WrappedText
|
|
3324
4537
|
|
|
3325
|
-
# _@
|
|
3326
|
-
|
|
3327
|
-
# Rebuilt per wrap and discarded — the wrap is what's cached.
|
|
3328
|
-
def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
|
|
4538
|
+
# _@param_ `delta` — `+1` for down, `-1` for up.
|
|
4539
|
+
def move_caret_vertical: (Integer delta) -> void
|
|
3329
4540
|
|
|
3330
|
-
|
|
3331
|
-
#
|
|
3332
|
-
# _@return_ — true for a space or tab (each exactly one column).
|
|
3333
|
-
def blank?: (::Hash[Symbol, Object] cluster) -> bool
|
|
4541
|
+
def move_caret_to_row_start: () -> void
|
|
3334
4542
|
|
|
3335
|
-
|
|
4543
|
+
def move_caret_to_row_end: () -> void
|
|
4544
|
+
|
|
4545
|
+
# _@param_ `char`
|
|
3336
4546
|
#
|
|
3337
|
-
# _@return_ — true
|
|
3338
|
-
|
|
3339
|
-
def newline?: (::Hash[Symbol, Object] cluster) -> bool
|
|
4547
|
+
# _@return_ — always true.
|
|
4548
|
+
def insert_char: (String char) -> bool
|
|
3340
4549
|
|
|
3341
|
-
#
|
|
3342
|
-
|
|
3343
|
-
# point is absorbed (not rendered on either row). A token wider than
|
|
3344
|
-
# {Rect#width} hard-wraps inside the token. Newlines force a hard break and
|
|
3345
|
-
# the wrap restarts on the next cluster.
|
|
3346
|
-
def compute_display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
4550
|
+
# Keeps the caret visible by scrolling vertically.
|
|
4551
|
+
def adjust_scroll_top_row: () -> void
|
|
3347
4552
|
|
|
3348
|
-
# _@
|
|
3349
|
-
|
|
3350
|
-
# _@param_ `index` — cluster index of the word's first glyph.
|
|
3351
|
-
#
|
|
3352
|
-
# _@return_ — `[chars, columns, next_index]`
|
|
3353
|
-
# for the run of non-whitespace starting at `index`.
|
|
3354
|
-
def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
|
|
4553
|
+
# _@return_ — index of the topmost row currently visible.
|
|
4554
|
+
attr_reader scroll_top_row: Integer
|
|
3355
4555
|
|
|
3356
|
-
#
|
|
3357
|
-
#
|
|
3358
|
-
# the row — otherwise the wrap would not terminate (the row would stay empty
|
|
3359
|
-
# and the same token be reconsidered forever). Such a row reports more
|
|
3360
|
-
# columns than the rect holds and {#padded_row} drops the glyph; a
|
|
3361
|
-
# 2-column glyph in a 1-column area is unpaintable either way.
|
|
4556
|
+
# The word-wrapped layout of a {TextArea}'s text: which row each
|
|
4557
|
+
# character lands on, and which glyphs each row paints.
|
|
3362
4558
|
#
|
|
3363
|
-
#
|
|
4559
|
+
# wrap = WrappedText.new("hello world", 6)
|
|
4560
|
+
# wrap.row_count # => 2
|
|
4561
|
+
# wrap.position_at(8) # => [1, 2] index 8 ("r") sits at row 1, column 2
|
|
4562
|
+
# wrap.index_at(1, 2) # => 8 and back again
|
|
4563
|
+
# wrap.row_text(0) # => "hello " padded out to the width
|
|
3364
4564
|
#
|
|
3365
|
-
#
|
|
4565
|
+
# A snapshot of `(text, width)` — rebuild it whenever either changes.
|
|
3366
4566
|
#
|
|
3367
|
-
#
|
|
4567
|
+
# == Implementation details
|
|
3368
4568
|
#
|
|
3369
|
-
#
|
|
3370
|
-
|
|
4569
|
+
# Two axes meet here, and every method name says which one it speaks: an
|
|
4570
|
+
# **index** counts characters into {#text}, a **column** counts terminal
|
|
4571
|
+
# cells. They agree only for one-column glyphs. A {Row} therefore carries
|
|
4572
|
+
# *both* counts — the wrap fills each row to a column budget while
|
|
4573
|
+
# recording the character span that produced it.
|
|
4574
|
+
#
|
|
4575
|
+
# The wrap walks **grapheme clusters**, not characters: a combining mark
|
|
4576
|
+
# must add no columns and must not be split from its base across a row
|
|
4577
|
+
# break. Note `"\r\n"` is a *single* cluster, so a hard break tests
|
|
4578
|
+
# `end_with?("\n")` rather than equality. Every branch of the wrap
|
|
4579
|
+
# consumes at least one cluster — `"\v"` and `"\f"` match `/\s/` but are
|
|
4580
|
+
# neither blank nor a newline here, and a loop that measured them as zero
|
|
4581
|
+
# and did not advance would hang the UI thread on
|
|
4582
|
+
# `area.text = File.read(...)`.
|
|
4583
|
+
class WrappedText
|
|
4584
|
+
# _@param_ `text` — the full buffer, unwrapped.
|
|
4585
|
+
#
|
|
4586
|
+
# _@param_ `width` — column budget per row; `0` or less yields a single empty row.
|
|
4587
|
+
def initialize: (String text, Integer width) -> void
|
|
3371
4588
|
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
3376
|
-
# end-of-row caret position would coincide with row 1's start.
|
|
3377
|
-
#
|
|
3378
|
-
# Both counts drop by one per trimmed character: a space and a tab each
|
|
3379
|
-
# measure exactly one column.
|
|
3380
|
-
#
|
|
3381
|
-
# _@param_ `row_start`
|
|
3382
|
-
#
|
|
3383
|
-
# _@param_ `row_chars`
|
|
3384
|
-
#
|
|
3385
|
-
# _@param_ `row_cols`
|
|
3386
|
-
#
|
|
3387
|
-
# _@return_ — `[row_chars, row_cols]`
|
|
3388
|
-
def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
|
|
4589
|
+
# _@return_ — rows the text occupies; always `>= 1`, since
|
|
4590
|
+
# empty text still wraps to one (empty) row.
|
|
4591
|
+
def row_count: () -> Integer
|
|
3389
4592
|
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
4593
|
+
# Display row holding `index`. An index inside a whitespace run absorbed
|
|
4594
|
+
# by a soft wrap belongs to the row *before* the break.
|
|
4595
|
+
#
|
|
4596
|
+
# _@param_ `index` — a character index into {#text}.
|
|
4597
|
+
#
|
|
4598
|
+
# _@return_ — a row index in `0...row_count`.
|
|
4599
|
+
def row_at: (Integer index) -> Integer
|
|
3394
4600
|
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
# _@return_ — `caret`'s column offset within `row`.
|
|
3400
|
-
def caret_column_in: (::Hash[Symbol, Integer] row, Integer caret) -> Integer
|
|
4601
|
+
# _@param_ `index` — a character index into {#text}.
|
|
4602
|
+
#
|
|
4603
|
+
# _@return_ — `[row, column]` for `index`.
|
|
4604
|
+
def position_at: (Integer index) -> [Integer, Integer]
|
|
3401
4605
|
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
4606
|
+
# Inverse of {#position_at}. A column landing in a wide glyph's right
|
|
4607
|
+
# half resolves *past* it, as a click does in {TextField}.
|
|
4608
|
+
#
|
|
4609
|
+
# _@param_ `row` — a row index in `0...row_count`.
|
|
4610
|
+
#
|
|
4611
|
+
# _@param_ `column` — a column offset within that row.
|
|
4612
|
+
#
|
|
4613
|
+
# _@return_ — a character index into {#text}.
|
|
4614
|
+
def index_at: (Integer row, Integer column) -> Integer
|
|
3410
4615
|
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
def padded_row: (::Hash[Symbol, Integer] row) -> String
|
|
4616
|
+
# _@param_ `row` — a row index in `0...row_count`.
|
|
4617
|
+
#
|
|
4618
|
+
# _@return_ — character index where the row begins.
|
|
4619
|
+
def row_start: (Integer row) -> Integer
|
|
3416
4620
|
|
|
3417
|
-
|
|
3418
|
-
|
|
4621
|
+
# _@param_ `row` — a row index in `0...row_count`.
|
|
4622
|
+
#
|
|
4623
|
+
# _@return_ — character index one past the row's last *visible*
|
|
4624
|
+
# character — whitespace absorbed by a soft wrap is excluded.
|
|
4625
|
+
def row_end: (Integer row) -> Integer
|
|
4626
|
+
|
|
4627
|
+
# The row's glyphs, padded with spaces out to {#width}. A trailing glyph
|
|
4628
|
+
# with no room left is dropped rather than half-painted. A row past the
|
|
4629
|
+
# end of the text is all spaces, so a caller can paint a viewport taller
|
|
4630
|
+
# than the text without a bounds check.
|
|
4631
|
+
#
|
|
4632
|
+
# _@param_ `row`
|
|
4633
|
+
def row_text: (Integer row) -> String
|
|
3419
4634
|
|
|
3420
|
-
|
|
4635
|
+
# A plain Hash rather than a {Row}-style Data: this table is one entry
|
|
4636
|
+
# per grapheme cluster, built and discarded inside a single {#compute_rows}
|
|
4637
|
+
# call and never handed to another method as a documented type.
|
|
4638
|
+
#
|
|
4639
|
+
# _@return_ — one entry per grapheme cluster of
|
|
4640
|
+
# {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
|
|
4641
|
+
def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
|
|
3421
4642
|
|
|
3422
|
-
|
|
4643
|
+
# _@param_ `cluster`
|
|
4644
|
+
#
|
|
4645
|
+
# _@return_ — true for a space or tab (each exactly one column).
|
|
4646
|
+
def blank?: (::Hash[Symbol, Object] cluster) -> bool
|
|
3423
4647
|
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
4648
|
+
# _@param_ `cluster`
|
|
4649
|
+
#
|
|
4650
|
+
# _@return_ — true for a hard line break. Tests the suffix rather
|
|
4651
|
+
# than equality because `"\r\n"` is one grapheme cluster.
|
|
4652
|
+
def newline?: (::Hash[Symbol, Object] cluster) -> bool
|
|
4653
|
+
|
|
4654
|
+
# Greedy word-wrap, filling each row to a **column** budget while recording
|
|
4655
|
+
# the **character** span that produced it. Whitespace at a soft-wrap break
|
|
4656
|
+
# point is absorbed (not rendered on either row). A token wider than
|
|
4657
|
+
# {#width} hard-wraps inside the token. Newlines force a hard break and
|
|
4658
|
+
# the wrap restarts on the next cluster.
|
|
4659
|
+
#
|
|
4660
|
+
# _@return_ — one entry per row.
|
|
4661
|
+
def compute_rows: () -> ::Array[Row]
|
|
3428
4662
|
|
|
3429
|
-
|
|
3430
|
-
|
|
4663
|
+
# _@param_ `clusters`
|
|
4664
|
+
#
|
|
4665
|
+
# _@param_ `index` — cluster index of the word's first glyph.
|
|
4666
|
+
#
|
|
4667
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
4668
|
+
# for the run of non-whitespace starting at `index`.
|
|
4669
|
+
def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
|
|
4670
|
+
|
|
4671
|
+
# Splits a token too wide for a whole row, taking entire glyphs while they
|
|
4672
|
+
# fit. Consumes at least one glyph even when that single glyph is wider than
|
|
4673
|
+
# the row — otherwise the wrap would not terminate (the row would stay empty
|
|
4674
|
+
# and the same token be reconsidered forever). Such a row reports more
|
|
4675
|
+
# columns than {#width} holds and {#row_text} drops the glyph; a 2-column
|
|
4676
|
+
# glyph in a 1-column area is unpaintable either way.
|
|
4677
|
+
#
|
|
4678
|
+
# _@param_ `clusters`
|
|
4679
|
+
#
|
|
4680
|
+
# _@param_ `index`
|
|
4681
|
+
#
|
|
4682
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
4683
|
+
def hard_wrap: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
|
|
4684
|
+
|
|
4685
|
+
# Trims trailing space/tab characters off a row's visible length so the
|
|
4686
|
+
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
4687
|
+
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
4688
|
+
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
4689
|
+
# end-of-row caret position would coincide with row 1's start.
|
|
4690
|
+
#
|
|
4691
|
+
# Both counts drop by one per trimmed character: a space and a tab each
|
|
4692
|
+
# measure exactly one column.
|
|
4693
|
+
#
|
|
4694
|
+
# _@param_ `row_start`
|
|
4695
|
+
#
|
|
4696
|
+
# _@param_ `row_chars`
|
|
4697
|
+
#
|
|
4698
|
+
# _@param_ `row_cols`
|
|
4699
|
+
#
|
|
4700
|
+
# _@return_ — `[row_chars, row_cols]`
|
|
4701
|
+
def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
|
|
4702
|
+
|
|
4703
|
+
# _@param_ `row`
|
|
4704
|
+
#
|
|
4705
|
+
# _@param_ `index`
|
|
4706
|
+
#
|
|
4707
|
+
# _@return_ — `index`'s column offset within `row`.
|
|
4708
|
+
def column_in: (Row row, Integer index) -> Integer
|
|
4709
|
+
|
|
4710
|
+
# _@param_ `row`
|
|
4711
|
+
#
|
|
4712
|
+
# _@param_ `column`
|
|
4713
|
+
#
|
|
4714
|
+
# _@return_ — characters from the row's start.
|
|
4715
|
+
def chars_for_column: (Row row, Integer column) -> Integer
|
|
4716
|
+
|
|
4717
|
+
# _@param_ `row`
|
|
4718
|
+
#
|
|
4719
|
+
# _@return_ — the row's visible characters.
|
|
4720
|
+
def glyphs_of: (Row row) -> String
|
|
4721
|
+
|
|
4722
|
+
# Mirrors {AbstractStringField}'s measurement primitive, which this class
|
|
4723
|
+
# can't inherit. Per-cluster rather than whole-string so a multi-codepoint
|
|
4724
|
+
# emoji measures as the one glyph a terminal draws.
|
|
4725
|
+
#
|
|
4726
|
+
# _@param_ `str`
|
|
4727
|
+
#
|
|
4728
|
+
# _@return_ — columns.
|
|
4729
|
+
def columns_of: (String str) -> Integer
|
|
3431
4730
|
|
|
3432
|
-
|
|
3433
|
-
|
|
4731
|
+
attr_reader text: String
|
|
4732
|
+
|
|
4733
|
+
attr_reader width: Integer
|
|
4734
|
+
|
|
4735
|
+
# One row's span, measured on both axes.
|
|
4736
|
+
#
|
|
4737
|
+
# Both counts cover only the row's *visible* content: whitespace absorbed
|
|
4738
|
+
# at a soft wrap, and the newline ending a hard one, belong to no row. So
|
|
4739
|
+
# the next row's `start` may sit past this row's `start + length`, and an
|
|
4740
|
+
# index in that gap resolves to the earlier row (see {WrappedText#row_at}).
|
|
4741
|
+
#
|
|
4742
|
+
# @!attribute [r] start
|
|
4743
|
+
# @return [Integer] character index into {WrappedText#text} where the
|
|
4744
|
+
# row begins.
|
|
4745
|
+
# @!attribute [r] length
|
|
4746
|
+
# @return [Integer] visible characters from `start`.
|
|
4747
|
+
# @!attribute [r] columns
|
|
4748
|
+
# @return [Integer] terminal cells those characters occupy. Exceeds
|
|
4749
|
+
# {WrappedText#width} only for a single glyph too wide for a row.
|
|
4750
|
+
class Row
|
|
4751
|
+
EMPTY: Row
|
|
4752
|
+
|
|
4753
|
+
# _@return_ — character index into {WrappedText#text} where the
|
|
4754
|
+
# row begins.
|
|
4755
|
+
attr_reader start: Integer
|
|
4756
|
+
|
|
4757
|
+
# _@return_ — visible characters from `start`.
|
|
4758
|
+
attr_reader length: Integer
|
|
4759
|
+
|
|
4760
|
+
# _@return_ — terminal cells those characters occupy. Exceeds
|
|
4761
|
+
# {WrappedText#width} only for a single glyph too wide for a row.
|
|
4762
|
+
attr_reader columns: Integer
|
|
4763
|
+
end
|
|
4764
|
+
end
|
|
3434
4765
|
end
|
|
3435
4766
|
|
|
3436
4767
|
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
@@ -3524,7 +4855,7 @@ module Tuile
|
|
|
3524
4855
|
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
3525
4856
|
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
3526
4857
|
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
3527
|
-
# (the `\n`-delimited entries), not wrapped
|
|
4858
|
+
# (the `\n`-delimited entries), not wrapped rows. `n == 0` and the
|
|
3528
4859
|
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
3529
4860
|
#
|
|
3530
4861
|
# _@param_ `n` — number of hard lines to drop; must be >= 0.
|
|
@@ -3541,7 +4872,7 @@ module Tuile
|
|
|
3541
4872
|
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
3542
4873
|
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
3543
4874
|
#
|
|
3544
|
-
# Splices in place — only the affected slice of the
|
|
4875
|
+
# Splices in place — only the affected slice of the row buffer is
|
|
3545
4876
|
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
3546
4877
|
# content)). A no-op when the replacement equals the covered range, so
|
|
3547
4878
|
# `replace(n...n, "")` is cheap.
|
|
@@ -3565,10 +4896,27 @@ module Tuile
|
|
|
3565
4896
|
# Clears the text. Equivalent to `text = ""`.
|
|
3566
4897
|
def clear: () -> void
|
|
3567
4898
|
|
|
4899
|
+
# Scrolls up half a viewport (`rect.height / 2`, at least one row),
|
|
4900
|
+
# clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
|
|
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.
|
|
4905
|
+
def scroll_half_page_up: () -> void
|
|
4906
|
+
|
|
4907
|
+
# The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
|
|
4908
|
+
# arriving there re-arms {#following?}.
|
|
4909
|
+
def scroll_half_page_down: () -> void
|
|
4910
|
+
|
|
3568
4911
|
def focusable?: () -> bool
|
|
3569
4912
|
|
|
3570
4913
|
def tab_stop?: () -> bool
|
|
3571
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
|
+
#
|
|
3572
4920
|
# _@param_ `key`
|
|
3573
4921
|
def handle_key: (String key) -> bool
|
|
3574
4922
|
|
|
@@ -3580,7 +4928,7 @@ module Tuile
|
|
|
3580
4928
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
3581
4929
|
# painted explicitly (with padded blanks past the last line), so the
|
|
3582
4930
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
3583
|
-
# Rows go through {Component#
|
|
4931
|
+
# Rows go through {Component#draw_text}, so content and blank rows inherit
|
|
3584
4932
|
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
3585
4933
|
def repaint: () -> void
|
|
3586
4934
|
|
|
@@ -3606,7 +4954,7 @@ module Tuile
|
|
|
3606
4954
|
# _@param_ `what`
|
|
3607
4955
|
def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
|
|
3608
4956
|
|
|
3609
|
-
# Hard-line index where `region` begins in {@
|
|
4957
|
+
# Hard-line index where `region` begins in {@lines} — derived
|
|
3610
4958
|
# by summing the line counts of all regions that precede it.
|
|
3611
4959
|
#
|
|
3612
4960
|
# _@param_ `region`
|
|
@@ -3647,7 +4995,7 @@ module Tuile
|
|
|
3647
4995
|
def append_to_region: (Region region, (String | StyledString)? str) -> void
|
|
3648
4996
|
|
|
3649
4997
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
3650
|
-
# {#
|
|
4998
|
+
# {#splice_lines}. `n` is clamped to the region's current
|
|
3651
4999
|
# line count; callers guarantee `n > 0` and the region is
|
|
3652
5000
|
# non-empty (the {Region#remove_last_n_lines} guard handles the
|
|
3653
5001
|
# no-op cases).
|
|
@@ -3658,7 +5006,7 @@ module Tuile
|
|
|
3658
5006
|
def remove_last_n_from_region: (Region region, Integer n) -> void
|
|
3659
5007
|
|
|
3660
5008
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
3661
|
-
# {#
|
|
5009
|
+
# {#splice_lines}, the handle is detached, and the always-one
|
|
3662
5010
|
# default is restored if the removal would have left zero regions.
|
|
3663
5011
|
# Skips the rewrap / invalidate work when the region was empty
|
|
3664
5012
|
# (the buffer didn't change), but always detaches.
|
|
@@ -3666,7 +5014,7 @@ module Tuile
|
|
|
3666
5014
|
# _@param_ `region`
|
|
3667
5015
|
def remove_region: (Region region) -> void
|
|
3668
5016
|
|
|
3669
|
-
# Adjusts region line counts after a {@
|
|
5017
|
+
# Adjusts region line counts after a {@lines} splice that removed
|
|
3670
5018
|
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
3671
5019
|
# each region's overlap with the removed range, then credits the added
|
|
3672
5020
|
# lines to the first region that lost lines. Pure insertions have no such
|
|
@@ -3681,68 +5029,71 @@ module Tuile
|
|
|
3681
5029
|
def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
|
|
3682
5030
|
|
|
3683
5031
|
# _@return_ — number of visible lines.
|
|
3684
|
-
def
|
|
5032
|
+
def viewport_rows: () -> Integer
|
|
3685
5033
|
|
|
3686
|
-
# _@return_ — the max value of {#
|
|
3687
|
-
def
|
|
5034
|
+
# _@return_ — the max value of {#scroll_top_row} for scroll-key clamping.
|
|
5035
|
+
def scroll_top_row_max: () -> Integer
|
|
3688
5036
|
|
|
3689
|
-
#
|
|
3690
|
-
#
|
|
5037
|
+
# _@return_ — half a viewport, at least one row, for the half-page
|
|
5038
|
+
# scroll verbs and their keys.
|
|
5039
|
+
def half_page_rows: () -> Integer
|
|
5040
|
+
|
|
5041
|
+
# Full rebuild of {@rows} and {@line_wrap_counts}
|
|
5042
|
+
# from {@lines}. Called when wrap width changes (which
|
|
3691
5043
|
# invalidates every cached row count) and from {#text=} (which
|
|
3692
5044
|
# replaces the whole logical model). Mid-buffer mutators splice
|
|
3693
|
-
# incrementally via {#
|
|
3694
|
-
# here. Clamps {@
|
|
5045
|
+
# incrementally via {#splice_lines} and do *not* go through
|
|
5046
|
+
# here. Clamps {@scroll_top_row} if the new line count puts it out of
|
|
3695
5047
|
# range.
|
|
3696
5048
|
def rewrap: () -> void
|
|
3697
5049
|
|
|
3698
|
-
# Wraps `
|
|
3699
|
-
#
|
|
3700
|
-
# run) and degenerate `width <= 0` both emit a single {@
|
|
5050
|
+
# Wraps `line` at `width` and returns the padded rows alongside the
|
|
5051
|
+
# row count. Empty lines (e.g. from a `"\n\n"`
|
|
5052
|
+
# run) and degenerate `width <= 0` both emit a single {@blank_row}
|
|
3701
5053
|
# row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
3702
5054
|
# would have produced.
|
|
3703
5055
|
#
|
|
3704
|
-
# _@param_ `
|
|
5056
|
+
# _@param_ `line`
|
|
3705
5057
|
#
|
|
3706
5058
|
# _@param_ `width`
|
|
3707
|
-
def
|
|
5059
|
+
def wrap_line: (StyledString line, Integer width) -> [::Array[StyledString], Integer]
|
|
3708
5060
|
|
|
3709
|
-
# Appends `
|
|
3710
|
-
# wrap-count cache and {@
|
|
5061
|
+
# Appends `line` to the tail of {@lines}, updating the
|
|
5062
|
+
# wrap-count cache and {@rows} in lockstep.
|
|
3711
5063
|
#
|
|
3712
|
-
# _@param_ `
|
|
5064
|
+
# _@param_ `line`
|
|
3713
5065
|
#
|
|
3714
5066
|
# _@param_ `width`
|
|
3715
|
-
def
|
|
3716
|
-
|
|
3717
|
-
# Pops the last
|
|
3718
|
-
#
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
#
|
|
3723
|
-
#
|
|
3724
|
-
#
|
|
3725
|
-
#
|
|
3726
|
-
# adds via the cache — no wraps of preceding hard lines. Wraps are
|
|
5067
|
+
def push_line: (StyledString line, Integer width) -> void
|
|
5068
|
+
|
|
5069
|
+
# Pops the last line, the corresponding cache entry, and the rows
|
|
5070
|
+
# that line contributed. Returns the popped line.
|
|
5071
|
+
def pop_line: () -> StyledString
|
|
5072
|
+
|
|
5073
|
+
# Splices `new_lines` into the buffer in place of the `count` lines
|
|
5074
|
+
# starting at index `from`. Updates {@lines},
|
|
5075
|
+
# {@line_wrap_counts}, and {@rows} consistently.
|
|
5076
|
+
# The starting row offset is computed in O(`from`) integer
|
|
5077
|
+
# adds via the cache — no wraps of preceding lines. Wraps are
|
|
3727
5078
|
# done only for the new content, so total cost is
|
|
3728
|
-
# `O(from + count +
|
|
5079
|
+
# `O(from + count + new_lines.sum(&:display_width))`.
|
|
3729
5080
|
#
|
|
3730
5081
|
# _@param_ `from`
|
|
3731
5082
|
#
|
|
3732
|
-
# _@param_ `count` — number of existing
|
|
5083
|
+
# _@param_ `count` — number of existing lines to remove.
|
|
3733
5084
|
#
|
|
3734
|
-
# _@param_ `
|
|
3735
|
-
def
|
|
5085
|
+
# _@param_ `new_lines`
|
|
5086
|
+
def splice_lines: (Integer from, Integer count, ::Array[StyledString] new_lines) -> void
|
|
3736
5087
|
|
|
3737
5088
|
# _@param_ `idx`
|
|
3738
5089
|
#
|
|
3739
|
-
# _@return_ — the {@
|
|
3740
|
-
# at {@
|
|
5090
|
+
# _@return_ — the {@rows} index where the line
|
|
5091
|
+
# at {@lines}`[idx]` starts. O(`idx`) integer adds via the
|
|
3741
5092
|
# wrap-count cache.
|
|
3742
|
-
def
|
|
5093
|
+
def row_offset_at: (Integer idx) -> Integer
|
|
3743
5094
|
|
|
3744
|
-
# Rebuilds the joined {StyledString} from {@
|
|
3745
|
-
# default-styled `"\n"` between
|
|
5095
|
+
# Rebuilds the joined {StyledString} from {@lines}, inserting a
|
|
5096
|
+
# default-styled `"\n"` between lines. Called from the {#text}
|
|
3746
5097
|
# reader when the cache is cold. Cost is O(total spans).
|
|
3747
5098
|
def build_text: () -> StyledString
|
|
3748
5099
|
|
|
@@ -3752,19 +5103,19 @@ module Tuile
|
|
|
3752
5103
|
def wrap_width: () -> Integer
|
|
3753
5104
|
|
|
3754
5105
|
# _@param_ `delta` — negative scrolls up, positive scrolls down.
|
|
3755
|
-
def
|
|
5106
|
+
def move_scroll_top_row_by: (Integer delta) -> void
|
|
3756
5107
|
|
|
3757
|
-
# _@param_ `target` — desired top line; clamped to `[0,
|
|
3758
|
-
def
|
|
5108
|
+
# _@param_ `target` — desired top line; clamped to `[0, scroll_top_row_max]`.
|
|
5109
|
+
def move_scroll_top_row_to: (Integer target) -> void
|
|
3759
5110
|
|
|
3760
5111
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
3761
5112
|
# viewport pin is skipped, so reading older content is not interrupted
|
|
3762
|
-
# by incoming lines. {#
|
|
5113
|
+
# by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
|
|
3763
5114
|
# returns to the bottom.
|
|
3764
|
-
def
|
|
5115
|
+
def update_scroll_top_row_if_auto_scroll: () -> void
|
|
3765
5116
|
|
|
3766
5117
|
# _@return_ — whether the viewport is pinned to the last line.
|
|
3767
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
5118
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
3768
5119
|
def at_bottom?: () -> bool
|
|
3769
5120
|
|
|
3770
5121
|
def scrollbar_visible?: () -> bool
|
|
@@ -3775,24 +5126,24 @@ module Tuile
|
|
|
3775
5126
|
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
3776
5127
|
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
3777
5128
|
#
|
|
3778
|
-
# _@param_ `
|
|
5129
|
+
# _@param_ `row`
|
|
3779
5130
|
#
|
|
3780
5131
|
# _@param_ `width`
|
|
3781
|
-
def pad_to: (StyledString
|
|
5132
|
+
def pad_to: (StyledString row, Integer width) -> StyledString
|
|
3782
5133
|
|
|
3783
|
-
# _@param_ `index` — 0-based index into `@
|
|
5134
|
+
# _@param_ `index` — 0-based index into `@rows`.
|
|
3784
5135
|
#
|
|
3785
5136
|
# _@param_ `row_in_viewport` — 0-based row within the viewport.
|
|
3786
5137
|
#
|
|
3787
5138
|
# _@param_ `scrollbar`
|
|
3788
5139
|
#
|
|
3789
|
-
# _@return_ — paintable
|
|
3790
|
-
# Body
|
|
5140
|
+
# _@return_ — paintable row exactly `rect.width` columns wide.
|
|
5141
|
+
# Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
3791
5142
|
# plus a concat of the scrollbar glyph when one is present.
|
|
3792
|
-
def
|
|
5143
|
+
def paintable_row: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
|
|
3793
5144
|
|
|
3794
|
-
# _@return_ — index of the first visible
|
|
3795
|
-
attr_accessor
|
|
5145
|
+
# _@return_ — index of the first visible row.
|
|
5146
|
+
attr_accessor scroll_top_row: Integer
|
|
3796
5147
|
|
|
3797
5148
|
# _@return_ — `:gone` or `:visible`.
|
|
3798
5149
|
attr_accessor scrollbar_visibility: Symbol
|
|
@@ -3986,8 +5337,8 @@ module Tuile
|
|
|
3986
5337
|
#
|
|
3987
5338
|
# - an **index** counts characters into {#text} — {#caret},
|
|
3988
5339
|
# {#max_text_length}, `text[i]`, every edit;
|
|
3989
|
-
# - a **column** counts terminal cells — {#rect}, {#
|
|
3990
|
-
# {
|
|
5340
|
+
# - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
|
|
5341
|
+
# {MouseEvent}, and the private horizontal scroll offset `left_column`.
|
|
3991
5342
|
#
|
|
3992
5343
|
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
3993
5344
|
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
@@ -4021,6 +5372,14 @@ module Tuile
|
|
|
4021
5372
|
# _@param_ `key`
|
|
4022
5373
|
def handle_text_input_key: (String key) -> bool
|
|
4023
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
|
+
|
|
4024
5383
|
def on_text_mutated: () -> void
|
|
4025
5384
|
|
|
4026
5385
|
def on_caret_mutated: () -> void
|
|
@@ -4088,10 +5447,6 @@ module Tuile
|
|
|
4088
5447
|
# _@return_ — maximum characters, or nil for unbounded (default).
|
|
4089
5448
|
attr_accessor max_text_length: Integer?
|
|
4090
5449
|
|
|
4091
|
-
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
4092
|
-
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
4093
|
-
attr_reader left_column: Integer
|
|
4094
|
-
|
|
4095
5450
|
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
4096
5451
|
# is consumed by the field; when nil, UP falls through to the parent
|
|
4097
5452
|
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
@@ -4114,6 +5469,15 @@ module Tuile
|
|
|
4114
5469
|
#
|
|
4115
5470
|
# _@return_ — no-arg callable, or nil.
|
|
4116
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
|
|
4117
5481
|
end
|
|
4118
5482
|
|
|
4119
5483
|
# A single-line field whose {#value} is a `Float` (or `nil` when empty) —
|
|
@@ -4328,12 +5692,13 @@ module Tuile
|
|
|
4328
5692
|
# initial state, and assigning it is the only way back, since Space on the
|
|
4329
5693
|
# already-selected row is a no-op rather than a deselect.
|
|
4330
5694
|
#
|
|
4331
|
-
# Composes rather than subclasses, like {ComboBox}: a {List}
|
|
4332
|
-
# {HasContent} child, which is where the cursor, scrolling,
|
|
4333
|
-
# and per-row mouse hit-testing come from
|
|
4334
|
-
#
|
|
4335
|
-
#
|
|
4336
|
-
#
|
|
5695
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} of the items
|
|
5696
|
+
# is its single {HasContent} child, which is where the cursor, scrolling,
|
|
5697
|
+
# the scrollbar and per-row mouse hit-testing come from — the group only
|
|
5698
|
+
# supplies the {List#renderer} that puts the marker in front of the label.
|
|
5699
|
+
# `content` is that list, so an app can tune it (`scrollbar_visibility`,
|
|
5700
|
+
# `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
|
|
5701
|
+
# inner list is the tab stop, not the group.
|
|
4337
5702
|
#
|
|
4338
5703
|
# == The cursor is chrome
|
|
4339
5704
|
# The cursor and the selection are two independent things, as in
|
|
@@ -4357,7 +5722,7 @@ module Tuile
|
|
|
4357
5722
|
# == Implementation details
|
|
4358
5723
|
# Two `==`-equal items share one selection, so selecting either marks both
|
|
4359
5724
|
# rows; two *distinct* items that merely render the same label stay
|
|
4360
|
-
# independent, because a row resolves to
|
|
5725
|
+
# independent, because a row resolves to its own item, never to its label.
|
|
4361
5726
|
#
|
|
4362
5727
|
# Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
|
|
4363
5728
|
# than importing constants from it. ASCII deliberately: `(•)` would measure
|
|
@@ -4374,6 +5739,15 @@ module Tuile
|
|
|
4374
5739
|
# _@param_ `value` — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
|
|
4375
5740
|
def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
|
|
4376
5741
|
|
|
5742
|
+
# _@return_ — the presented items.
|
|
5743
|
+
def items: () -> ::Array[untyped]
|
|
5744
|
+
|
|
5745
|
+
# Replaces the presented rows, leaving {#value} untouched and clamping the
|
|
5746
|
+
# cursor back into range.
|
|
5747
|
+
#
|
|
5748
|
+
# _@param_ `new_items`
|
|
5749
|
+
def items=: (::Array[untyped] new_items) -> void
|
|
5750
|
+
|
|
4377
5751
|
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
4378
5752
|
# changed. The cursor stays where it is.
|
|
4379
5753
|
#
|
|
@@ -4393,19 +5767,26 @@ module Tuile
|
|
|
4393
5767
|
# _@param_ `list`
|
|
4394
5768
|
def layout: (Component list) -> void
|
|
4395
5769
|
|
|
4396
|
-
# Selects the item on row `index`; an index outside {#items} is ignored
|
|
5770
|
+
# Selects the item on row `index`; an index outside {#items} is ignored —
|
|
5771
|
+
# {List::Cursor::None}'s `-1` would otherwise select the *last* item.
|
|
4397
5772
|
#
|
|
4398
5773
|
# _@param_ `index`
|
|
4399
5774
|
def select_at: (Integer index) -> void
|
|
4400
5775
|
|
|
4401
|
-
#
|
|
4402
|
-
|
|
5776
|
+
# _@param_ `item`
|
|
5777
|
+
#
|
|
5778
|
+
# _@return_ — the item's row: its label behind a selection
|
|
5779
|
+
# marker. The {List} calls this at paint time, so the marker tracks
|
|
5780
|
+
# {#value} without re-rendering anything but the visible rows.
|
|
5781
|
+
def render_row: (Object item) -> StyledString
|
|
4403
5782
|
|
|
4404
5783
|
# Pulls an over-range cursor back onto the last row (row 0 when there are
|
|
4405
|
-
# none). {List#
|
|
5784
|
+
# none). {List#items=} leaves a stale cursor alone, which would strand it
|
|
4406
5785
|
# off-content: no highlight, a dead Enter, and a Space that resolves to
|
|
4407
5786
|
# `nil` and silently clears the selection.
|
|
4408
|
-
|
|
5787
|
+
#
|
|
5788
|
+
# _@param_ `item_count` — size of the incoming item list.
|
|
5789
|
+
def clamp_cursor: (Integer item_count) -> void
|
|
4409
5790
|
|
|
4410
5791
|
# _@param_ `item`
|
|
4411
5792
|
#
|
|
@@ -4441,14 +5822,182 @@ module Tuile
|
|
|
4441
5822
|
|
|
4442
5823
|
def on_focus: () -> void
|
|
4443
5824
|
|
|
4444
|
-
# _@return_ — the presented items.
|
|
4445
|
-
attr_accessor items: ::Array[untyped]
|
|
4446
|
-
|
|
4447
5825
|
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
4448
5826
|
# anything with `#to_s`); `:to_s` by default.
|
|
4449
5827
|
attr_accessor item_label: (Proc | Method)
|
|
4450
5828
|
end
|
|
4451
5829
|
|
|
5830
|
+
# A transient message in the screen's top-right corner — the TTY toast:
|
|
5831
|
+
#
|
|
5832
|
+
# Component::Notification.show("Saved")
|
|
5833
|
+
# Component::Notification.show("Disk full", color: Theme.ref(:error))
|
|
5834
|
+
#
|
|
5835
|
+
# ┌─────────┐ ← flush: row 0, right edge at the last column
|
|
5836
|
+
# │Saved │ ← oldest on top, retires in 3 s
|
|
5837
|
+
# │Disk full│ ← then this one, 3 s after that
|
|
5838
|
+
# └─────────┘
|
|
5839
|
+
#
|
|
5840
|
+
# {show} is the only entry point ({new} is private): it finds the live
|
|
5841
|
+
# notification and appends to it, so a burst stacks as entries in one box
|
|
5842
|
+
# instead of opening five overlapping ones.
|
|
5843
|
+
#
|
|
5844
|
+
# One repeating ticker retires the **oldest** entry every {DISPLAY_SECONDS}
|
|
5845
|
+
# and closes the box when the last one goes — five messages raised together
|
|
5846
|
+
# appear at once and drain over fifteen seconds. A message arriving mid-cycle
|
|
5847
|
+
# waits its turn and does *not* restart the clock, so the bottom entry of a
|
|
5848
|
+
# full box is visible for about `N × DISPLAY_SECONDS`. Past {MAX_MESSAGES} a
|
|
5849
|
+
# message is dropped and reported to {Tuile.logger}; an app notifying faster
|
|
5850
|
+
# than that wants a {Component::LogWindow}.
|
|
5851
|
+
#
|
|
5852
|
+
# The box is flush to the corner, at most {WIDTH_FRACTION} of the screen wide
|
|
5853
|
+
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
5854
|
+
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
5855
|
+
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
5856
|
+
# `DECISIONS.md` `D-notification` has why each of those is what it is.
|
|
5857
|
+
#
|
|
5858
|
+
# Three things it deliberately doesn't do:
|
|
5859
|
+
#
|
|
5860
|
+
# - **Take focus, or receive keys.** A non-modal popup sits off the
|
|
5861
|
+
# key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
|
|
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.
|
|
5867
|
+
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
5868
|
+
# the message is added — a toast lives seconds, so there is no
|
|
5869
|
+
# {Component#on_theme_changed} rebuild.
|
|
5870
|
+
# - **Take a size.** {#size=} raises; the messages decide.
|
|
5871
|
+
class Notification < Tuile::Component::Popup
|
|
5872
|
+
MAX_MESSAGES: Integer
|
|
5873
|
+
MAX_ROWS_PER_MESSAGE: Integer
|
|
5874
|
+
DISPLAY_SECONDS: Float
|
|
5875
|
+
WIDTH_FRACTION: Float
|
|
5876
|
+
HEIGHT_FRACTION: Float
|
|
5877
|
+
MIN_CAP_WIDTH: Integer
|
|
5878
|
+
SPACE: StyledString
|
|
5879
|
+
ROW_BREAK: StyledString
|
|
5880
|
+
|
|
5881
|
+
# Shows `text` in the corner, creating the box if none is open and
|
|
5882
|
+
# appending to it if one is.
|
|
5883
|
+
#
|
|
5884
|
+
# _@param_ `text` — the message. A `String` is parsed via {StyledString.parse}, so embedded ANSI is honored. `nil` and the empty string are no-ops (nothing is shown, nothing is created).
|
|
5885
|
+
#
|
|
5886
|
+
# _@param_ `color` — applied to every span of the message via {StyledString#with_fg}. A {Theme::Ref} is resolved against the current theme *now* — see the class docs on theme following. `nil` leaves the message's own colors alone.
|
|
5887
|
+
#
|
|
5888
|
+
# _@return_ — the live notification, or `nil` when `text`
|
|
5889
|
+
# was empty.
|
|
5890
|
+
def self.show: ((String | StyledString)? text, ?color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?) -> Notification?
|
|
5891
|
+
|
|
5892
|
+
def initialize: () -> void
|
|
5893
|
+
|
|
5894
|
+
# Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
|
|
5895
|
+
# outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
|
|
5896
|
+
# nobody — every keystroke would go dead until the user pressed Tab.
|
|
5897
|
+
#
|
|
5898
|
+
# _@return_ — false.
|
|
5899
|
+
def focusable?: () -> bool
|
|
5900
|
+
|
|
5901
|
+
# _@return_ — false — see {#focusable?}.
|
|
5902
|
+
def tab_stop?: () -> bool
|
|
5903
|
+
|
|
5904
|
+
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
5905
|
+
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
5906
|
+
# append without repeating {show}'s lookup.
|
|
5907
|
+
#
|
|
5908
|
+
# _@param_ `text` — see {show}. Empty is a no-op.
|
|
5909
|
+
#
|
|
5910
|
+
# _@param_ `color` — see {show}.
|
|
5911
|
+
def add_message: ((String | StyledString)? text, ?color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?) -> void
|
|
5912
|
+
|
|
5913
|
+
# Recomputes the box from its messages and re-anchors it to the screen's
|
|
5914
|
+
# top-right corner — so a SIGWINCH re-wraps and re-anchors, where
|
|
5915
|
+
# {Popup#reposition} would have kept the stale left column of a *derived*
|
|
5916
|
+
# position (off-screen entirely if the terminal narrowed).
|
|
5917
|
+
#
|
|
5918
|
+
# Rebuilds the {TextView}'s text too, and every mutation routes through
|
|
5919
|
+
# here, because the four are one computation: the wrap width *is* the box
|
|
5920
|
+
# width, the height *is* the wrapped row count, the left edge *is* derived
|
|
5921
|
+
# from the width.
|
|
5922
|
+
def reposition: () -> void
|
|
5923
|
+
|
|
5924
|
+
# A notification is sized by its messages, so this always raises. Failing
|
|
5925
|
+
# loudly beats accepting a size the next {#reposition} would discard.
|
|
5926
|
+
#
|
|
5927
|
+
# _@param_ `_new_size`
|
|
5928
|
+
def size=: ((Size | Fraction) _new_size) -> void
|
|
5929
|
+
|
|
5930
|
+
# A left click dismisses the whole box, every message with it. Other buttons
|
|
5931
|
+
# are consumed and inert — including the scroll wheel, which would otherwise
|
|
5932
|
+
# nuke the box on a stray spin.
|
|
5933
|
+
#
|
|
5934
|
+
# Deliberately *replaces* rather than augments: neither `super` nor
|
|
5935
|
+
# {HasContent#handle_mouse} may run, since both end at a
|
|
5936
|
+
# `screen.focused = …` inside this subtree (see {#focusable?}).
|
|
5937
|
+
#
|
|
5938
|
+
# _@param_ `event`
|
|
5939
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
5940
|
+
|
|
5941
|
+
def on_attached: () -> void
|
|
5942
|
+
|
|
5943
|
+
def on_detached: () -> void
|
|
5944
|
+
|
|
5945
|
+
# Retires the oldest message, closing the box when it was the last. Runs on
|
|
5946
|
+
# the event-loop thread, from the ticker.
|
|
5947
|
+
def retire_oldest: () -> void
|
|
5948
|
+
|
|
5949
|
+
# Syncs the retirement clock from the invariant "something to retire, and on
|
|
5950
|
+
# screen" — the sole writer of `@ticker`. Four sites change whether it is
|
|
5951
|
+
# wanted (append, a retirement that empties the queue, {#close}, detach),
|
|
5952
|
+
# which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
|
|
5953
|
+
# gets half wrong. The early return is also what keeps an append from
|
|
5954
|
+
# *restarting* the clock and extending the oldest message's life.
|
|
5955
|
+
def sync_ticker: () -> void
|
|
5956
|
+
|
|
5957
|
+
# _@param_ `text`
|
|
5958
|
+
#
|
|
5959
|
+
# _@param_ `color`
|
|
5960
|
+
def build_message: ((String | StyledString)? text, (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])? color) -> StyledString
|
|
5961
|
+
|
|
5962
|
+
# Columns the message would like, ignoring wrapping — the *widest* of its
|
|
5963
|
+
# hard lines, not the sum of its spans (which would add every line
|
|
5964
|
+
# together for a message carrying `\n`).
|
|
5965
|
+
#
|
|
5966
|
+
# _@param_ `message`
|
|
5967
|
+
def natural_width: (StyledString message) -> Integer
|
|
5968
|
+
|
|
5969
|
+
# Grow-only: the high-water mark is kept in *desired* columns and the cap
|
|
5970
|
+
# is applied here, last. Storing the clamped value instead would let a
|
|
5971
|
+
# SIGWINCH that narrows the terminal ratchet the box permanently down to
|
|
5972
|
+
# the narrow cap, with nothing to restore it when the terminal widens.
|
|
5973
|
+
def box_width: () -> Integer
|
|
5974
|
+
|
|
5975
|
+
def cap_width: () -> Integer
|
|
5976
|
+
|
|
5977
|
+
# _@return_ — at least 3: two border rows plus one row of message.
|
|
5978
|
+
def cap_height: () -> Integer
|
|
5979
|
+
|
|
5980
|
+
# Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
|
|
5981
|
+
# rows.
|
|
5982
|
+
#
|
|
5983
|
+
# The overflow is ellipsized from the *joined remainder*, not by
|
|
5984
|
+
# ellipsizing the last kept row: that row usually already fits `width`, so
|
|
5985
|
+
# {StyledString#ellipsize} would be a no-op and the message would be
|
|
5986
|
+
# truncated with no `…` to say so.
|
|
5987
|
+
#
|
|
5988
|
+
# _@param_ `message`
|
|
5989
|
+
#
|
|
5990
|
+
# _@param_ `width`
|
|
5991
|
+
def wrap_message: (StyledString message, Integer width) -> ::Array[StyledString]
|
|
5992
|
+
|
|
5993
|
+
# Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
|
|
5994
|
+
# {TextView} takes them as hard lines and its own wrap is a no-op over
|
|
5995
|
+
# them (each row already fits the width it will be painted at).
|
|
5996
|
+
#
|
|
5997
|
+
# _@param_ `rows`
|
|
5998
|
+
def join_rows: (::Array[StyledString] rows) -> StyledString
|
|
5999
|
+
end
|
|
6000
|
+
|
|
4452
6001
|
# A one-row progress bar: a run of `█` growing left to right across {#rect},
|
|
4453
6002
|
# over a `░` track.
|
|
4454
6003
|
#
|
|
@@ -4710,18 +6259,20 @@ module Tuile
|
|
|
4710
6259
|
# highlight, and reads the pick.
|
|
4711
6260
|
#
|
|
4712
6261
|
# drop = Component::ListDropdown.new
|
|
4713
|
-
# drop.
|
|
6262
|
+
# drop.renderer = method(:label_for) # caller renders
|
|
6263
|
+
# drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
|
|
4714
6264
|
# # …then, from the driver's key handler:
|
|
4715
|
-
# drop.
|
|
6265
|
+
# drop.items = matches # caller filters
|
|
4716
6266
|
# drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
|
|
4717
6267
|
# drop.open
|
|
4718
6268
|
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
4719
6269
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
4720
6270
|
#
|
|
4721
6271
|
# It owns only what every such dropdown shares — *placement* included, via
|
|
4722
|
-
# {#anchor_to}
|
|
4723
|
-
#
|
|
4724
|
-
#
|
|
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
|
|
4725
6276
|
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
4726
6277
|
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
4727
6278
|
# from its own branches.
|
|
@@ -4739,21 +6290,36 @@ module Tuile
|
|
|
4739
6290
|
|
|
4740
6291
|
def initialize: () -> void
|
|
4741
6292
|
|
|
4742
|
-
# _@param_ `
|
|
4743
|
-
def
|
|
6293
|
+
# _@param_ `items` — the items to show, one row each; see {List#items=}.
|
|
6294
|
+
def items=: (::Array[untyped] items) -> void
|
|
4744
6295
|
|
|
4745
|
-
# _@return_ — the
|
|
4746
|
-
def
|
|
6296
|
+
# _@return_ — the items currently shown.
|
|
6297
|
+
def items: () -> ::Array[untyped]
|
|
6298
|
+
|
|
6299
|
+
# _@param_ `proc` — item -> row; see {List#renderer}.
|
|
6300
|
+
def renderer=: ((Proc | Method) proc) -> void
|
|
4747
6301
|
|
|
4748
6302
|
# _@param_ `proc` — commit callback; see {List#on_item_chosen}.
|
|
4749
6303
|
def on_item_chosen=: ((Proc | Method)? proc) -> void
|
|
4750
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
|
+
|
|
4751
6308
|
# _@param_ `cursor` — the highlight; see {List#cursor=}.
|
|
4752
6309
|
def cursor=: (List::Cursor cursor) -> void
|
|
4753
6310
|
|
|
4754
6311
|
# _@return_ — the list's cursor (the current highlight).
|
|
4755
6312
|
def cursor: () -> List::Cursor
|
|
4756
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
|
+
|
|
4757
6323
|
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
4758
6324
|
# flipped above when `rows` won't fit below, clamped — with the list
|
|
4759
6325
|
# scrolling — when neither side has room. Horizontally the left edges line
|
|
@@ -4779,6 +6345,49 @@ module Tuile
|
|
|
4779
6345
|
?max_rows: Integer
|
|
4780
6346
|
) -> void
|
|
4781
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
|
+
|
|
4782
6391
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
4783
6392
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
4784
6393
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -4802,6 +6411,8 @@ module Tuile
|
|
|
4802
6411
|
# stealing focus — so a driving text input never loses its caret
|
|
4803
6412
|
# mid-interaction.
|
|
4804
6413
|
class Menu < Tuile::Component::List
|
|
6414
|
+
DEFAULT_RENDERER: Proc
|
|
6415
|
+
|
|
4805
6416
|
def focusable?: () -> bool
|
|
4806
6417
|
|
|
4807
6418
|
def tab_stop?: () -> bool
|
|
@@ -4830,8 +6441,6 @@ module Tuile
|
|
|
4830
6441
|
# _@param_ `key`
|
|
4831
6442
|
def handle_key: (String key) -> bool
|
|
4832
6443
|
|
|
4833
|
-
def keyboard_hint: () -> String
|
|
4834
|
-
|
|
4835
6444
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
4836
6445
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
4837
6446
|
#
|
|
@@ -4885,12 +6494,13 @@ module Tuile
|
|
|
4885
6494
|
# Treat it as *unordered*: it iterates in toggle order, so use
|
|
4886
6495
|
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
4887
6496
|
#
|
|
4888
|
-
# Composes rather than subclasses, like {ComboBox}: a {List}
|
|
4889
|
-
# {HasContent} child, which is where the cursor, scrolling, the
|
|
4890
|
-
# per-row mouse hit-testing come from
|
|
4891
|
-
#
|
|
4892
|
-
#
|
|
4893
|
-
#
|
|
6497
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} of the items is
|
|
6498
|
+
# its single {HasContent} child, which is where the cursor, scrolling, the
|
|
6499
|
+
# scrollbar and per-row mouse hit-testing come from — the group only supplies
|
|
6500
|
+
# the {List#renderer} that puts the box in front of the label. `content` is
|
|
6501
|
+
# that list, so an app can tune it (`scrollbar_visibility`,
|
|
6502
|
+
# `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
|
|
6503
|
+
# inner list is the tab stop, not the group.
|
|
4894
6504
|
#
|
|
4895
6505
|
# == +items+ is chrome; +value+ is authoritative
|
|
4896
6506
|
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
@@ -4908,7 +6518,7 @@ module Tuile
|
|
|
4908
6518
|
# mutated after being selected becomes unfindable. Two `==`-equal items also
|
|
4909
6519
|
# share one selection — their rows check and uncheck together — whereas two
|
|
4910
6520
|
# *distinct* items that merely render the same label toggle independently,
|
|
4911
|
-
# because a row resolves to
|
|
6521
|
+
# because a row resolves to its own item, never to its label.
|
|
4912
6522
|
#
|
|
4913
6523
|
# Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
|
|
4914
6524
|
# importing a constant from it.
|
|
@@ -4924,6 +6534,14 @@ module Tuile
|
|
|
4924
6534
|
# _@param_ `value` — the initial selection. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
|
|
4925
6535
|
def initialize: (?items: ::Array[untyped], ?value: ::Enumerable[untyped]?) -> void
|
|
4926
6536
|
|
|
6537
|
+
# _@return_ — the presented items.
|
|
6538
|
+
def items: () -> ::Array[untyped]
|
|
6539
|
+
|
|
6540
|
+
# Replaces the presented rows, leaving {#value} untouched.
|
|
6541
|
+
#
|
|
6542
|
+
# _@param_ `new_items`
|
|
6543
|
+
def items=: (::Array[untyped] new_items) -> void
|
|
6544
|
+
|
|
4927
6545
|
# _@return_ — the frozen empty set — {HasValue#empty?} means nothing is
|
|
4928
6546
|
# selected.
|
|
4929
6547
|
def empty_value: () -> ::Set[untyped]
|
|
@@ -4949,13 +6567,23 @@ module Tuile
|
|
|
4949
6567
|
def layout: (Component list) -> void
|
|
4950
6568
|
|
|
4951
6569
|
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
4952
|
-
# ignored
|
|
6570
|
+
# ignored — {List::Cursor::None}'s `-1` would otherwise toggle the *last*
|
|
6571
|
+
# item.
|
|
4953
6572
|
#
|
|
4954
6573
|
# _@param_ `index`
|
|
4955
6574
|
def toggle_at: (Integer index) -> void
|
|
4956
6575
|
|
|
4957
|
-
#
|
|
4958
|
-
|
|
6576
|
+
# Flips `item`'s membership of {#value}.
|
|
6577
|
+
#
|
|
6578
|
+
# _@param_ `item`
|
|
6579
|
+
def toggle: (Object item) -> void
|
|
6580
|
+
|
|
6581
|
+
# _@param_ `item`
|
|
6582
|
+
#
|
|
6583
|
+
# _@return_ — the item's row: its label behind a checkmark box.
|
|
6584
|
+
# The {List} calls this at paint time, so the boxes track {#value}
|
|
6585
|
+
# without re-rendering anything but the visible rows.
|
|
6586
|
+
def render_row: (Object item) -> StyledString
|
|
4959
6587
|
|
|
4960
6588
|
# _@param_ `new_value`
|
|
4961
6589
|
#
|
|
@@ -4992,9 +6620,6 @@ module Tuile
|
|
|
4992
6620
|
|
|
4993
6621
|
def on_focus: () -> void
|
|
4994
6622
|
|
|
4995
|
-
# _@return_ — the presented items.
|
|
4996
|
-
attr_accessor items: ::Array[untyped]
|
|
4997
|
-
|
|
4998
6623
|
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
4999
6624
|
# anything with `#to_s`); `:to_s` by default.
|
|
5000
6625
|
attr_accessor item_label: (Proc | Method)
|
|
@@ -5226,6 +6851,8 @@ module Tuile
|
|
|
5226
6851
|
#
|
|
5227
6852
|
# - {#preprocess_text} — input filter (e.g. {TextField} truncates to
|
|
5228
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.
|
|
5229
6856
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
5230
6857
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
5231
6858
|
# keep the caret visible).
|
|
@@ -5257,6 +6884,34 @@ module Tuile
|
|
|
5257
6884
|
# _@param_ `key`
|
|
5258
6885
|
def handle_key: (String key) -> bool
|
|
5259
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
|
+
|
|
5260
6915
|
# Renders `text` on the field's background well, looked up from the
|
|
5261
6916
|
# current {Screen#theme} at paint time: {Theme#active_bg_color} when this
|
|
5262
6917
|
# input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
|
|
@@ -5563,6 +7218,28 @@ module Tuile
|
|
|
5563
7218
|
attr_reader key: String
|
|
5564
7219
|
end
|
|
5565
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
|
+
|
|
5566
7243
|
# An error event, causes {EventQueue#run_loop} to throw `StandardError` with
|
|
5567
7244
|
# {#error} as its origin.
|
|
5568
7245
|
#
|
|
@@ -5699,6 +7376,22 @@ module Tuile
|
|
|
5699
7376
|
# _@param_ `str`
|
|
5700
7377
|
def emit: (String str) -> void
|
|
5701
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
|
+
|
|
5702
7395
|
# _@param_ `component` — the component to check.
|
|
5703
7396
|
def invalidated?: (Component component) -> bool
|
|
5704
7397
|
|
|
@@ -5773,10 +7466,15 @@ module Tuile
|
|
|
5773
7466
|
#
|
|
5774
7467
|
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
5775
7468
|
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
5776
|
-
# {#content}
|
|
5777
|
-
#
|
|
5778
|
-
#
|
|
5779
|
-
#
|
|
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`).
|
|
5780
7478
|
#
|
|
5781
7479
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
5782
7480
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -5826,9 +7524,9 @@ module Tuile
|
|
|
5826
7524
|
|
|
5827
7525
|
# _@return_ — the topmost *modal* popup, or nil when
|
|
5828
7526
|
# only non-modal overlays (or no popups) are open. This is the "modal
|
|
5829
|
-
# owner": the popup that scopes key dispatch, blocks mouse clicks,
|
|
5830
|
-
#
|
|
5831
|
-
#
|
|
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.
|
|
5832
7530
|
def modal_popup: () -> Component::Popup?
|
|
5833
7531
|
|
|
5834
7532
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -5836,11 +7534,11 @@ module Tuile
|
|
|
5836
7534
|
# _@param_ `new_rect`
|
|
5837
7535
|
def rect=: (Rect new_rect) -> void
|
|
5838
7536
|
|
|
5839
|
-
#
|
|
5840
|
-
#
|
|
5841
|
-
#
|
|
5842
|
-
#
|
|
5843
|
-
#
|
|
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).
|
|
5844
7542
|
def layout: () -> void
|
|
5845
7543
|
|
|
5846
7544
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
@@ -5867,6 +7565,15 @@ module Tuile
|
|
|
5867
7565
|
# _@return_ — true if the key was handled.
|
|
5868
7566
|
def handle_key: (String key) -> bool
|
|
5869
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
|
+
|
|
5870
7577
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
5871
7578
|
# fall through to content only when no popup is hit *and* no modal popup is
|
|
5872
7579
|
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
@@ -5874,6 +7581,35 @@ module Tuile
|
|
|
5874
7581
|
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
5875
7582
|
# content beneath.
|
|
5876
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
|
+
#
|
|
5877
7613
|
# _@param_ `event`
|
|
5878
7614
|
def handle_mouse: (MouseEvent event) -> void
|
|
5879
7615
|
|
|
@@ -5892,6 +7628,22 @@ module Tuile
|
|
|
5892
7628
|
# _@param_ `child`
|
|
5893
7629
|
def on_child_removed: (Component child) -> void
|
|
5894
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
|
+
|
|
5895
7647
|
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
5896
7648
|
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
5897
7649
|
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
@@ -5905,6 +7657,14 @@ module Tuile
|
|
|
5905
7657
|
# _@return_ — true if some component on the chain handled the key.
|
|
5906
7658
|
def bubble_key: (String key, Component scope) -> bool
|
|
5907
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
|
+
|
|
5908
7668
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
5909
7669
|
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
5910
7670
|
# if `root` is `nil`.
|
|
@@ -5919,9 +7679,6 @@ module Tuile
|
|
|
5919
7679
|
# topmost. Holds both modal popups and non-modal overlays
|
|
5920
7680
|
# ({Component::Popup#modal?}). The array must not be mutated by callers.
|
|
5921
7681
|
attr_reader popups: ::Array[Component]
|
|
5922
|
-
|
|
5923
|
-
# _@return_ — the bottom status bar.
|
|
5924
|
-
attr_reader status_bar: Component::Label
|
|
5925
7682
|
end
|
|
5926
7683
|
|
|
5927
7684
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
@@ -6047,7 +7804,7 @@ module Tuile
|
|
|
6047
7804
|
# An empty {StyledString} returns a single empty entry, like `"".split`.
|
|
6048
7805
|
def lines: () -> ::Array[StyledString]
|
|
6049
7806
|
|
|
6050
|
-
# Word-wraps to
|
|
7807
|
+
# Word-wraps to rows that each fit within `width` display
|
|
6051
7808
|
# columns, preserving spans and styles across breaks. Greedy word-wrap,
|
|
6052
7809
|
# hard-break for words wider than `width`, leading whitespace dropped on
|
|
6053
7810
|
# wrapped continuations, hard `"\n"` breaks preserved as separate output
|
|
@@ -6070,7 +7827,7 @@ module Tuile
|
|
|
6070
7827
|
#
|
|
6071
7828
|
# _@param_ `width` — target column width. `nil` or `<= 0` skips wrapping and returns each hard-line as-is, so callers can pass a stale viewport width without crashing.
|
|
6072
7829
|
#
|
|
6073
|
-
# _@return_ — one entry per
|
|
7830
|
+
# _@return_ — one entry per output row.
|
|
6074
7831
|
# An empty receiver returns `[]`.
|
|
6075
7832
|
def wrap: (Integer? width) -> ::Array[StyledString]
|
|
6076
7833
|
|
|
@@ -6103,6 +7860,40 @@ module Tuile
|
|
|
6103
7860
|
# _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
|
|
6104
7861
|
def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
|
|
6105
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
|
+
|
|
6106
7897
|
def inspect: () -> String
|
|
6107
7898
|
|
|
6108
7899
|
def build_ansi: () -> String
|
|
@@ -6124,23 +7915,23 @@ module Tuile
|
|
|
6124
7915
|
# _@param_ `len`
|
|
6125
7916
|
def slice_spans: (Integer start, Integer len) -> StyledString
|
|
6126
7917
|
|
|
6127
|
-
# _@param_ `
|
|
7918
|
+
# _@param_ `line` — one line — no embedded `"\n"`.
|
|
6128
7919
|
#
|
|
6129
7920
|
# _@param_ `width`
|
|
6130
|
-
def wrap_one: (StyledString
|
|
7921
|
+
def wrap_one: (StyledString line, Integer width) -> ::Array[StyledString]
|
|
6131
7922
|
|
|
6132
7923
|
# Splits into whitespace/word tokens by **grapheme cluster**, not character:
|
|
6133
7924
|
# a cluster is the unit a terminal draws, so measuring its parts separately
|
|
6134
7925
|
# would both mis-total an emoji sequence and let a wrap break a letter away
|
|
6135
7926
|
# from its combining mark.
|
|
6136
7927
|
#
|
|
6137
|
-
# _@param_ `
|
|
7928
|
+
# _@param_ `line`
|
|
6138
7929
|
#
|
|
6139
7930
|
# _@return_ — tokens shaped `[type, glyphs, w]` where `type` is
|
|
6140
7931
|
# `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
|
|
6141
7932
|
# (grapheme cluster, style, display width), and `w` is the token's total
|
|
6142
7933
|
# width.
|
|
6143
|
-
def tokenize_for_wrap: (StyledString
|
|
7934
|
+
def tokenize_for_wrap: (StyledString line) -> ::Array[::Array[untyped]]
|
|
6144
7935
|
|
|
6145
7936
|
# Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
|
|
6146
7937
|
# style boundary takes the style of its first span — pathological input, and
|
|
@@ -6506,10 +8297,10 @@ module Tuile
|
|
|
6506
8297
|
class VerticalScrollBar
|
|
6507
8298
|
# _@param_ `height` — number of rows in the scrollbar (== viewport height).
|
|
6508
8299
|
#
|
|
6509
|
-
# _@param_ `
|
|
8300
|
+
# _@param_ `row_count` — total number of content rows.
|
|
6510
8301
|
#
|
|
6511
|
-
# _@param_ `
|
|
6512
|
-
def initialize: (Integer height,
|
|
8302
|
+
# _@param_ `scroll_top_row` — index of the first visible content row.
|
|
8303
|
+
def initialize: (Integer height, row_count: Integer, scroll_top_row: Integer) -> void
|
|
6513
8304
|
|
|
6514
8305
|
# Returns the scrollbar character for the given viewport row.
|
|
6515
8306
|
#
|