tuile 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- data/mise.toml +0 -2
data/lib/tuile/component.rb
CHANGED
|
@@ -94,20 +94,29 @@ module Tuile
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
# Repaints the component. The default does the bookkeeping most components
|
|
97
|
-
# need: it clears the background
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
# children
|
|
97
|
+
# need: it clears the background — unless the direct children already tile
|
|
98
|
+
# {#rect}, in which case there is no gap to wipe and blanking cells they are
|
|
99
|
+
# about to repaint would only make them dirty — and then re-invalidates
|
|
100
|
+
# those children so they paint over the cleared area. That is what makes
|
|
101
|
+
# mixed-width form layouts safe.
|
|
101
102
|
#
|
|
102
103
|
# Call `super` from your own `repaint` to inherit this. Skip it only if you
|
|
103
104
|
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
104
105
|
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
106
|
+
#
|
|
107
|
+
# **The children are re-invalidated whether or not they tile.** A container
|
|
108
|
+
# that paints nothing of its own can only redraw its area *through* them, so
|
|
109
|
+
# a tiling container that skipped this would be a dead end in the cascade: an
|
|
110
|
+
# ancestor's `clear_background` wipes the whole ancestor rect — siblings and
|
|
111
|
+
# grandchildren included — and re-invalidates only its *direct* children, so
|
|
112
|
+
# the notice has to keep travelling down or the cleared cells are never
|
|
113
|
+
# repainted. Cheap by construction: repainting the same glyphs leaves
|
|
114
|
+
# {Buffer::Cell} unchanged, so nothing extra reaches the wire.
|
|
105
115
|
# @return [void]
|
|
106
116
|
def repaint
|
|
107
117
|
return if rect.empty?
|
|
108
|
-
return if children.any? && children_tile_rect?
|
|
109
118
|
|
|
110
|
-
clear_background
|
|
119
|
+
clear_background unless children.any? && children_tile_rect?
|
|
111
120
|
children.each { |c| screen.invalidate(c) }
|
|
112
121
|
end
|
|
113
122
|
|
|
@@ -122,6 +131,27 @@ module Tuile
|
|
|
122
131
|
false
|
|
123
132
|
end
|
|
124
133
|
|
|
134
|
+
# Called when text is pasted while this component is on the focus chain;
|
|
135
|
+
# override to accept it (the default reports every paste unhandled, and
|
|
136
|
+
# unhandled text is dropped). Arrives whole and `\n`-normalized, so
|
|
137
|
+
# `text.lines.size` is the paste's line count and a single mutation can
|
|
138
|
+
# absorb it:
|
|
139
|
+
#
|
|
140
|
+
# def handle_paste(text)
|
|
141
|
+
# self.caption = "[Pasted #{text.lines.size} lines]"
|
|
142
|
+
# true
|
|
143
|
+
# end
|
|
144
|
+
#
|
|
145
|
+
# Reaching here means the terminal said "this came from the clipboard" —
|
|
146
|
+
# {Component::AbstractStringField} inserts it at the caret, which is why a
|
|
147
|
+
# subclass that rebinds ENTER to submit needs no paste handling of its own
|
|
148
|
+
# to stop firing once per pasted line.
|
|
149
|
+
# @param _text [String] the pasted text.
|
|
150
|
+
# @return [Boolean] true if the paste was consumed.
|
|
151
|
+
def handle_paste(_text)
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
125
155
|
# Handles mouse event. Default implementation focuses this component when
|
|
126
156
|
# clicked (if {#focusable?}).
|
|
127
157
|
# @param event [MouseEvent]
|
|
@@ -268,17 +298,12 @@ module Tuile
|
|
|
268
298
|
# @return [Point, nil] absolute screen coordinates, or nil to hide.
|
|
269
299
|
def cursor_position = nil
|
|
270
300
|
|
|
271
|
-
# @return [String] formatted keyboard hint surfaced in the status bar by
|
|
272
|
-
# {Screen} when this component is the active tiled window or the
|
|
273
|
-
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
274
|
-
def keyboard_hint = ""
|
|
275
|
-
|
|
276
301
|
protected
|
|
277
302
|
|
|
278
303
|
# Adopts `child`: places it in {#children} and wires its parent pointer.
|
|
279
304
|
#
|
|
280
|
-
# add_child(
|
|
281
|
-
# add_child(
|
|
305
|
+
# add_child(content, at: 0) # the tiled layer, painted beneath …
|
|
306
|
+
# add_child(@footer) # … and chrome appended, painted over it
|
|
282
307
|
#
|
|
283
308
|
# @param child [Component] must not already have a parent.
|
|
284
309
|
# @param at [Integer, nil] index to insert at; appends when nil.
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -167,6 +167,26 @@ module Tuile
|
|
|
167
167
|
class KeyEvent < Data.define(:key)
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# Text arrived from the clipboard rather than the keyboard: the terminal
|
|
171
|
+
# bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
|
|
172
|
+
# {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
|
|
173
|
+
# so a component sees one mutation instead of a keystroke per character —
|
|
174
|
+
# and a pasted line break can no longer be mistaken for a typed Enter.
|
|
175
|
+
#
|
|
176
|
+
# {Screen#event_loop} routes it to {Component#handle_paste} down the focus
|
|
177
|
+
# chain. It never enters the key ladder: no Tab traversal, no global
|
|
178
|
+
# shortcut, no {Component#handle_key}.
|
|
179
|
+
#
|
|
180
|
+
# @!attribute [r] text
|
|
181
|
+
# @return [String] the pasted text, `\n`-normalized by
|
|
182
|
+
# {Keys.normalize_paste}.
|
|
183
|
+
class PasteEvent < Data.define(:text)
|
|
184
|
+
# @param text [String]
|
|
185
|
+
def initialize(text:)
|
|
186
|
+
super(text: text.freeze)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
170
190
|
# An error event, causes {EventQueue#run_loop} to throw `StandardError` with
|
|
171
191
|
# {#error} as its origin.
|
|
172
192
|
#
|
|
@@ -321,7 +341,11 @@ module Tuile
|
|
|
321
341
|
@key_thread = Thread.new do
|
|
322
342
|
loop do
|
|
323
343
|
key = Keys.getkey
|
|
324
|
-
event =
|
|
344
|
+
event = if key == Keys::PASTE_START
|
|
345
|
+
PasteEvent.new(Keys.read_paste)
|
|
346
|
+
else
|
|
347
|
+
MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
|
|
348
|
+
end
|
|
325
349
|
post event
|
|
326
350
|
end
|
|
327
351
|
rescue StandardError => e
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -58,6 +58,20 @@ module Tuile
|
|
|
58
58
|
@prints << str
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Pastes `text` into the focused component, as a real terminal would with
|
|
62
|
+
# bracketed paste on:
|
|
63
|
+
#
|
|
64
|
+
# area.focus
|
|
65
|
+
# Screen.instance.paste("one\r\ntwo")
|
|
66
|
+
# area.text # => "one\ntwo" — one mutation, no ENTER anywhere
|
|
67
|
+
#
|
|
68
|
+
# Goes through {Keys.normalize_paste} first, so a spec can hand it the
|
|
69
|
+
# CR-flavored line endings terminals actually deliver and still assert
|
|
70
|
+
# against `\n`.
|
|
71
|
+
# @param text [String]
|
|
72
|
+
# @return [Boolean] true if some component consumed it.
|
|
73
|
+
def paste(text) = handle_paste(Keys.normalize_paste(text))
|
|
74
|
+
|
|
61
75
|
# @param component [Component] the component to check.
|
|
62
76
|
# @return [Boolean]
|
|
63
77
|
def invalidated?(component) = @invalidated.include?(component)
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -114,6 +114,24 @@ module Tuile
|
|
|
114
114
|
# @return [String]
|
|
115
115
|
SHIFT_TAB = "\e[Z"
|
|
116
116
|
|
|
117
|
+
# Enables **bracketed paste** (DEC private mode 2004): the terminal wraps
|
|
118
|
+
# pasted text in {PASTE_START} … {PASTE_END} instead of feeding it as
|
|
119
|
+
# keystrokes. Terminals that don't know the mode ignore it, so
|
|
120
|
+
# {Screen#run_event_loop} prints it unconditionally.
|
|
121
|
+
# @return [String]
|
|
122
|
+
BRACKETED_PASTE_ON = "\e[?2004h"
|
|
123
|
+
# Disables bracketed paste. See {BRACKETED_PASTE_ON}.
|
|
124
|
+
# @return [String]
|
|
125
|
+
BRACKETED_PASTE_OFF = "\e[?2004l"
|
|
126
|
+
# Opens a bracketed paste. {.getkey} returns it like any other key; the
|
|
127
|
+
# payload behind it is read by {.read_paste}.
|
|
128
|
+
# @return [String]
|
|
129
|
+
PASTE_START = "\e[200~"
|
|
130
|
+
# Closes a bracketed paste. Never surfaces as a key — {.read_paste}
|
|
131
|
+
# consumes it as the payload terminator.
|
|
132
|
+
# @return [String]
|
|
133
|
+
PASTE_END = "\e[201~"
|
|
134
|
+
|
|
117
135
|
# True iff `key` is a single printable character — a one-character string
|
|
118
136
|
# whose codepoint is not in Unicode's C (Other) category. Rejects multi-
|
|
119
137
|
# character escape sequences ({UP_ARROW}, mouse events, …), control bytes
|
|
@@ -168,5 +186,52 @@ module Tuile
|
|
|
168
186
|
|
|
169
187
|
char
|
|
170
188
|
end
|
|
189
|
+
|
|
190
|
+
# Reads the body of a bracketed paste, having just read {PASTE_START}, and
|
|
191
|
+
# returns it {.normalize_paste}d:
|
|
192
|
+
#
|
|
193
|
+
# Keys.getkey # => "\e[200~"
|
|
194
|
+
# Keys.read_paste # => "one\ntwo" (terminator consumed)
|
|
195
|
+
#
|
|
196
|
+
# Reads **raw**, one byte at a time, rather than looping on {.getkey}: a
|
|
197
|
+
# pasted `\e` would send `getkey` gulping five bytes of the *payload* as an
|
|
198
|
+
# escape tail, surfacing them as phantom keypresses. One byte at a time is
|
|
199
|
+
# also what keeps the terminator from being over-read — nothing past
|
|
200
|
+
# {PASTE_END} is consumed, so typing that lands behind a paste survives.
|
|
201
|
+
#
|
|
202
|
+
# Blocks until the terminator arrives; returns what it has at EOF.
|
|
203
|
+
# @return [String] the pasted text, UTF-8, without the brackets.
|
|
204
|
+
def self.read_paste
|
|
205
|
+
buffer = +"".b
|
|
206
|
+
terminator = PASTE_END.b
|
|
207
|
+
until buffer.end_with?(terminator)
|
|
208
|
+
byte = $stdin.read(1)
|
|
209
|
+
break if byte.nil?
|
|
210
|
+
|
|
211
|
+
buffer << byte
|
|
212
|
+
end
|
|
213
|
+
buffer.delete_suffix!(terminator)
|
|
214
|
+
normalize_paste(buffer.force_encoding(Encoding::UTF_8))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Rewrites a raw paste payload into the one convention callers see: `\n`
|
|
218
|
+
# line endings, valid UTF-8.
|
|
219
|
+
#
|
|
220
|
+
# Keys.normalize_paste("a\r\nb\rc") # => "a\nb\nc"
|
|
221
|
+
#
|
|
222
|
+
# Both are terminal-layer artifacts, not text: a terminal with bracketed
|
|
223
|
+
# paste *off* rewrites the clipboard's `\n` to `\r` so a paste looks like
|
|
224
|
+
# typing, and several keep doing it inside the brackets — so the byte a
|
|
225
|
+
# line break arrives as is not something a component should have to know.
|
|
226
|
+
# Invalid bytes are scrubbed to `U+FFFD`, which is what lets
|
|
227
|
+
# {Component#handle_paste} take any clipboard, a binary file included,
|
|
228
|
+
# without the grapheme-cluster walk raising downstream.
|
|
229
|
+
#
|
|
230
|
+
# Control characters other than `\n` are left alone: they are content, and
|
|
231
|
+
# what a *text buffer* may hold is {Component::AbstractStringField}'s call,
|
|
232
|
+
# not this layer's.
|
|
233
|
+
# @param text [String] raw payload.
|
|
234
|
+
# @return [String]
|
|
235
|
+
def self.normalize_paste(text) = text.scrub.gsub(/\r\n?/, "\n")
|
|
171
236
|
end
|
|
172
237
|
end
|
data/lib/tuile/screen.rb
CHANGED
|
@@ -14,10 +14,15 @@ module Tuile
|
|
|
14
14
|
# Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
|
|
15
15
|
# {#content} (set via {#content=}, filling the whole terminal and laying
|
|
16
16
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
17
|
-
# {Component::Popup#open}, drawn on top of the content)
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
17
|
+
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
18
|
+
# sized from their content — each carries its own top-down
|
|
19
|
+
# {Component::Popup#size} — and they deliberately overdraw the content
|
|
20
|
+
# without clipping.
|
|
21
|
+
#
|
|
22
|
+
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
23
|
+
# row, so {#content} gets the whole terminal. An app that wants a status line
|
|
24
|
+
# builds one into its own layout and drives it from {#on_focus_changed=}
|
|
25
|
+
# (`D-status-bar`).
|
|
21
26
|
#
|
|
22
27
|
# ## Repaint model
|
|
23
28
|
#
|
|
@@ -69,8 +74,8 @@ module Tuile
|
|
|
69
74
|
@color_scheme = detect_scheme
|
|
70
75
|
@theme_def = ThemeDef.default
|
|
71
76
|
@theme = @theme_def.for(@color_scheme)
|
|
72
|
-
# Structural root of the component tree: holds tiled content
|
|
73
|
-
# stack
|
|
77
|
+
# Structural root of the component tree: holds tiled content and the
|
|
78
|
+
# popup stack.
|
|
74
79
|
@pane = ScreenPane.new
|
|
75
80
|
@on_error = ->(e) { raise e }
|
|
76
81
|
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
@@ -82,10 +87,10 @@ module Tuile
|
|
|
82
87
|
@buffer = Buffer.new(@size)
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
# Entry in the global shortcut registry: the block to run, whether it
|
|
86
|
-
# pre-empts open popups
|
|
90
|
+
# Entry in the global shortcut registry: the block to run, and whether it
|
|
91
|
+
# pre-empts open popups.
|
|
87
92
|
# @api private
|
|
88
|
-
Shortcut = Data.define(:block, :over_popups
|
|
93
|
+
Shortcut = Data.define(:block, :over_popups)
|
|
89
94
|
private_constant :Shortcut
|
|
90
95
|
|
|
91
96
|
# Keys {#register_global_shortcut} refuses because every editable widget
|
|
@@ -193,12 +198,10 @@ module Tuile
|
|
|
193
198
|
end
|
|
194
199
|
|
|
195
200
|
# Replaces the theme and restyles the whole UI: fires
|
|
196
|
-
# {Component#on_theme_changed} across the attached tree
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
# for durable theming. Preformatted status-bar hints (see
|
|
201
|
-
# {#register_global_shortcut}) have their colors baked in and aren't restyled.
|
|
201
|
+
# {Component#on_theme_changed} across the attached tree and invalidates
|
|
202
|
+
# every attached component. No-op when `new_theme` equals the current theme.
|
|
203
|
+
# This is a *transient* override — the next OS appearance flip re-picks from
|
|
204
|
+
# {#theme_def}; assign {#theme_def=} for durable theming.
|
|
202
205
|
# @param new_theme [Theme]
|
|
203
206
|
# @return [void]
|
|
204
207
|
def theme=(new_theme)
|
|
@@ -209,7 +212,6 @@ module Tuile
|
|
|
209
212
|
|
|
210
213
|
@theme = new_theme
|
|
211
214
|
@pane&.on_tree(&:on_theme_changed)
|
|
212
|
-
refresh_status_bar
|
|
213
215
|
needs_full_repaint
|
|
214
216
|
end
|
|
215
217
|
|
|
@@ -287,6 +289,7 @@ module Tuile
|
|
|
287
289
|
end
|
|
288
290
|
|
|
289
291
|
check_locked
|
|
292
|
+
previous = @focused
|
|
290
293
|
if focused.nil?
|
|
291
294
|
@focused = nil
|
|
292
295
|
@pane.on_tree { _1.active = false }
|
|
@@ -303,45 +306,34 @@ module Tuile
|
|
|
303
306
|
@pane.on_tree { _1.active = active.include?(_1) }
|
|
304
307
|
@focused.on_focus
|
|
305
308
|
end
|
|
306
|
-
|
|
309
|
+
@on_focus_changed&.call unless @focused.equal?(previous)
|
|
307
310
|
end
|
|
308
311
|
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
# @param popup_open [Boolean]
|
|
335
|
-
# @return [Array<String>]
|
|
336
|
-
def global_shortcut_hints(popup_open:)
|
|
337
|
-
@global_shortcuts.each_value.filter_map do |s|
|
|
338
|
-
next if s.hint.nil? || s.hint.empty?
|
|
339
|
-
next if popup_open && !s.over_popups
|
|
340
|
-
|
|
341
|
-
s.hint
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
private :global_shortcut_hints
|
|
312
|
+
# Called after the focused component *changes* — including to and from
|
|
313
|
+
# `nil`, and including the focus repair that runs when a popup closes.
|
|
314
|
+
# Takes no arguments; read {#focused} (and walk its `parent` chain) for the
|
|
315
|
+
# new state.
|
|
316
|
+
#
|
|
317
|
+
# This is the hook an app drives its own status line from. Tuile owns no
|
|
318
|
+
# status bar and reserves no row: build a {Component::Label} into your own
|
|
319
|
+
# layout and fill it here (`D-status-bar`).
|
|
320
|
+
#
|
|
321
|
+
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
322
|
+
#
|
|
323
|
+
# **Edge-triggered**, like {Component#on_attached}: re-assigning the
|
|
324
|
+
# component that already has focus fires nothing, so a callback can be as
|
|
325
|
+
# expensive as rebuilding a hint string without a `did it really change?`
|
|
326
|
+
# guard of its own. That matters more than it looks — `ScreenPane#content=`
|
|
327
|
+
# clears focus on every content swap, which on a level-triggered hook would
|
|
328
|
+
# fire a nil→nil notification during assembly.
|
|
329
|
+
#
|
|
330
|
+
# It runs *after* the active-flag cascade and `on_focus`, so the tree is
|
|
331
|
+
# settled. Two things a callback must tolerate: {#focused} being `nil`, and
|
|
332
|
+
# firing during {#close} — teardown clears focus, exactly as it fires
|
|
333
|
+
# {Component#on_detached}. A raising callback propagates out of {#focused=}
|
|
334
|
+
# and leaves focus assigned; keep it trivial, as with the attach hooks.
|
|
335
|
+
# @return [Proc, nil]
|
|
336
|
+
attr_accessor :on_focus_changed
|
|
345
337
|
|
|
346
338
|
# Internal — use {Component::Popup#open} instead. Adds the popup to
|
|
347
339
|
# {#pane}, centers and focuses it.
|
|
@@ -370,9 +362,16 @@ module Tuile
|
|
|
370
362
|
# you want if the app benefits more from select-to-copy than from
|
|
371
363
|
# click-to-focus. Components' `handle_mouse` is simply never invoked
|
|
372
364
|
# from the loop in that mode (the terminal stops sending the bytes).
|
|
365
|
+
# @param bracketed_paste [Boolean] when true (default), enables DEC private
|
|
366
|
+
# mode 2004 so pasted text arrives whole, as {Component#handle_paste},
|
|
367
|
+
# instead of as one keystroke per character — which is the only way a
|
|
368
|
+
# pasted line break can be told from a typed Enter. When false, a paste
|
|
369
|
+
# streams in as keys again and a component that gives ENTER a meaning
|
|
370
|
+
# fires it once per pasted line. Turn it off only for a terminal that
|
|
371
|
+
# mishandles the mode.
|
|
373
372
|
# @raise [Tuile::Error] if the screen is already {#close}d.
|
|
374
373
|
# @return [void]
|
|
375
|
-
def run_event_loop(capture_mouse: true)
|
|
374
|
+
def run_event_loop(capture_mouse: true, bracketed_paste: true)
|
|
376
375
|
raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
|
|
377
376
|
|
|
378
377
|
# The guard above stays outside the begin: teardown for a setup that never
|
|
@@ -381,6 +380,7 @@ module Tuile
|
|
|
381
380
|
begin
|
|
382
381
|
$stdin.echo = false
|
|
383
382
|
print MouseEvent.start_tracking if capture_mouse
|
|
383
|
+
print Keys::BRACKETED_PASTE_ON if bracketed_paste
|
|
384
384
|
# Follow OS light/dark flips live: terminals supporting mode 2031
|
|
385
385
|
# push color-scheme reports that the key thread turns into
|
|
386
386
|
# {EventQueue::ColorSchemeEvent}s.
|
|
@@ -390,6 +390,7 @@ module Tuile
|
|
|
390
390
|
end
|
|
391
391
|
ensure
|
|
392
392
|
print TerminalBackground::NOTIFY_OFF
|
|
393
|
+
print Keys::BRACKETED_PASTE_OFF if bracketed_paste
|
|
393
394
|
print MouseEvent.stop_tracking if capture_mouse
|
|
394
395
|
print TTY::Cursor.show
|
|
395
396
|
$stdin.echo = true
|
|
@@ -425,9 +426,7 @@ module Tuile
|
|
|
425
426
|
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
426
427
|
# which every editable widget needs.
|
|
427
428
|
#
|
|
428
|
-
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
429
|
-
# over_popups: true,
|
|
430
|
-
# hint: "^L #{screen.theme.hint("log")}") do
|
|
429
|
+
# screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
|
|
431
430
|
# log_popup.open
|
|
432
431
|
# end
|
|
433
432
|
#
|
|
@@ -435,11 +434,9 @@ module Tuile
|
|
|
435
434
|
# @param over_popups [Boolean] when true, fires even while a modal popup is
|
|
436
435
|
# open (pre-empting the popup); when false (default), suppressed while any
|
|
437
436
|
# popup is open so the popup gets the key.
|
|
438
|
-
# @param hint [String, nil] preformatted status-bar hint; nil (default) is
|
|
439
|
-
# silent. Colors are baked in — re-register after a {#theme=} to recolor.
|
|
440
437
|
# @yield invoked with no arguments when `key` is pressed.
|
|
441
438
|
# @return [void]
|
|
442
|
-
def register_global_shortcut(key, over_popups: false,
|
|
439
|
+
def register_global_shortcut(key, over_popups: false, &block)
|
|
443
440
|
raise ArgumentError, "block required" if block.nil?
|
|
444
441
|
raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
|
|
445
442
|
raise ArgumentError, "key cannot be empty" if key.empty?
|
|
@@ -461,10 +458,7 @@ module Tuile
|
|
|
461
458
|
"button, handle ENTER in the form's own handle_key instead — a focused " \
|
|
462
459
|
"TextArea/TextField gets first refusal there."
|
|
463
460
|
end
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
|
|
467
|
-
refresh_status_bar
|
|
461
|
+
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
|
|
468
462
|
end
|
|
469
463
|
|
|
470
464
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -473,15 +467,6 @@ module Tuile
|
|
|
473
467
|
# @return [void]
|
|
474
468
|
def unregister_global_shortcut(key)
|
|
475
469
|
@global_shortcuts.delete(key)
|
|
476
|
-
refresh_status_bar
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# @return [Component, nil] current active tiled component.
|
|
480
|
-
def active_window
|
|
481
|
-
check_locked
|
|
482
|
-
result = nil
|
|
483
|
-
@pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
|
|
484
|
-
result
|
|
485
470
|
end
|
|
486
471
|
|
|
487
472
|
# Internal — use {Component::Popup#close} instead. Removes the popup
|
|
@@ -572,6 +557,26 @@ module Tuile
|
|
|
572
557
|
Kernel.print(*args)
|
|
573
558
|
end
|
|
574
559
|
|
|
560
|
+
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
561
|
+
# went nowhere, e.g. a letter matching no menu mnemonic while a menu is
|
|
562
|
+
# open.
|
|
563
|
+
#
|
|
564
|
+
# return true if activate_mnemonic(key)
|
|
565
|
+
#
|
|
566
|
+
# screen.beep # no match: the key is swallowed, say so
|
|
567
|
+
# true
|
|
568
|
+
#
|
|
569
|
+
# Writes **immediately** rather than riding the next frame: a beep is not
|
|
570
|
+
# part of a frame, and the keystrokes worth beeping at are precisely the
|
|
571
|
+
# ones that invalidate nothing, so {#repaint} may never emit at all. Whether
|
|
572
|
+
# the user hears anything is the terminal's setting to make, so there is no
|
|
573
|
+
# Tuile-level enable/disable knob.
|
|
574
|
+
# @return [void]
|
|
575
|
+
def beep
|
|
576
|
+
check_locked
|
|
577
|
+
print(Ansi::BEL)
|
|
578
|
+
end
|
|
579
|
+
|
|
575
580
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
576
581
|
# considering invalidated components and flushing just the changed cells
|
|
577
582
|
# of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
|
|
@@ -602,10 +607,9 @@ module Tuile
|
|
|
602
607
|
|
|
603
608
|
# Build the repaint list in z-order, leaning on the tree itself rather
|
|
604
609
|
# than a depth sort. The pane's pre-order traversal already orders the
|
|
605
|
-
# tiled layer (content subtree
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
# walk won't do — we collect the tiled layer first, then append popups.
|
|
610
|
+
# tiled layer (the content subtree) parent-before-child; the popups are
|
|
611
|
+
# the top layer and must paint last, so we collect the tiled layer first
|
|
612
|
+
# and append popups rather than taking a single pane.on_tree walk.
|
|
609
613
|
popup_members = Set.new
|
|
610
614
|
popups.each { |p| p.on_tree { popup_members << _1 } }
|
|
611
615
|
|
|
@@ -770,6 +774,17 @@ module Tuile
|
|
|
770
774
|
# @return [void]
|
|
771
775
|
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
772
776
|
|
|
777
|
+
# Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
|
|
778
|
+
#
|
|
779
|
+
# Deliberately *not* the key ladder: a paste is not a keystroke, so it
|
|
780
|
+
# skips Tab traversal and the global-shortcut registry entirely and goes
|
|
781
|
+
# straight to delivery. Unhandled text is dropped — there is no fallback
|
|
782
|
+
# that replays it as keys, which would put back the very ambiguity mode
|
|
783
|
+
# 2004 exists to remove.
|
|
784
|
+
# @param text [String]
|
|
785
|
+
# @return [Boolean] true if some component consumed it.
|
|
786
|
+
def handle_paste(text) = @pane.handle_paste(text)
|
|
787
|
+
|
|
773
788
|
# @return [void]
|
|
774
789
|
def event_loop
|
|
775
790
|
@event_queue.run_loop do |event|
|
|
@@ -778,6 +793,8 @@ module Tuile
|
|
|
778
793
|
key = event.key
|
|
779
794
|
handled = handle_key(key)
|
|
780
795
|
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
796
|
+
when EventQueue::PasteEvent
|
|
797
|
+
handle_paste(event.text)
|
|
781
798
|
when MouseEvent
|
|
782
799
|
handle_mouse(event)
|
|
783
800
|
when EventQueue::TTYSizeEvent
|