tuile 0.2.0 → 0.4.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 +32 -0
- data/README.md +141 -6
- data/examples/sampler.rb +33 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/list.rb +197 -82
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +40 -137
- data/lib/tuile/component/text_field.rb +31 -151
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +456 -0
- data/lib/tuile/component/window.rb +7 -12
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +154 -12
- data/lib/tuile/styled_string.rb +774 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1026 -174
- metadata +5 -2
- data/lib/tuile/truncate.rb +0 -83
data/lib/tuile/mouse_event.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Tuile
|
|
|
5
5
|
#
|
|
6
6
|
# @!attribute [r] button
|
|
7
7
|
# @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
|
|
8
|
-
# `:scroll_down`; `nil` if not known.
|
|
8
|
+
# `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
|
|
9
9
|
# @!attribute [r] x
|
|
10
10
|
# @return [Integer] x coordinate, 0-based.
|
|
11
11
|
# @!attribute [r] y
|
|
@@ -14,17 +14,34 @@ module Tuile
|
|
|
14
14
|
# @return [Point] the event's position.
|
|
15
15
|
def point = Point.new(x, y)
|
|
16
16
|
|
|
17
|
-
# Checks whether given key is a mouse event key
|
|
17
|
+
# Checks whether given key is a mouse event key. Returns true on the X10
|
|
18
|
+
# `\e[M` prefix regardless of length — {.parse} is the place that
|
|
19
|
+
# validates the full 6-byte shape and raises on malformed input.
|
|
18
20
|
# @param key [String] key read via {Keys.getkey}
|
|
19
21
|
# @return [Boolean] true if it is a mouse event
|
|
20
22
|
def self.mouse_event?(key)
|
|
21
|
-
key.start_with?("\e[M")
|
|
23
|
+
key.start_with?("\e[M")
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
|
|
27
|
+
#
|
|
28
|
+
# Raises {Tuile::Error} when `key` starts with the mouse prefix but is
|
|
29
|
+
# not exactly 6 bytes long. Both shorter and longer inputs are bugs in
|
|
30
|
+
# the upstream key-reader: a shorter prefix means the tail was lost on
|
|
31
|
+
# the way in, and a longer one means we over-consumed into the next
|
|
32
|
+
# escape sequence. We refuse to silently truncate either case because
|
|
33
|
+
# the trailing `\e` of an over-read corrupts the *next* getkey, and the
|
|
34
|
+
# corruption then surfaces as garbled keystrokes in focused inputs
|
|
35
|
+
# rather than as a parser failure pointing at the actual cause.
|
|
24
36
|
# @param key [String] key read via {Keys.getkey}
|
|
25
|
-
# @return [MouseEvent, nil]
|
|
37
|
+
# @return [MouseEvent, nil] `nil` if `key` is not a mouse event
|
|
38
|
+
# @raise [Tuile::Error] if `key` is a malformed mouse event
|
|
26
39
|
def self.parse(key)
|
|
27
40
|
return nil unless mouse_event?(key)
|
|
41
|
+
unless key.bytesize == 6
|
|
42
|
+
raise Tuile::Error,
|
|
43
|
+
"malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
|
|
44
|
+
end
|
|
28
45
|
|
|
29
46
|
button = key[3].ord - 32
|
|
30
47
|
# XTerm reports coordinates 1-based (column N is encoded as N + 32);
|
|
@@ -37,6 +54,8 @@ module Tuile
|
|
|
37
54
|
when 1 then :middle
|
|
38
55
|
when 64 then :scroll_up
|
|
39
56
|
when 65 then :scroll_down
|
|
57
|
+
when 66 then :scroll_left
|
|
58
|
+
when 67 then :scroll_right
|
|
40
59
|
end
|
|
41
60
|
MouseEvent.new(button, x, y)
|
|
42
61
|
end
|
data/lib/tuile/screen.rb
CHANGED
|
@@ -39,8 +39,17 @@ module Tuile
|
|
|
39
39
|
# stack and status bar.
|
|
40
40
|
@pane = ScreenPane.new
|
|
41
41
|
@on_error = ->(e) { raise e }
|
|
42
|
+
# App-level keyboard shortcuts dispatched by {#handle_key} before keys
|
|
43
|
+
# reach the pane. See {#register_global_shortcut}.
|
|
44
|
+
@global_shortcuts = {}
|
|
42
45
|
end
|
|
43
46
|
|
|
47
|
+
# Entry in the global shortcut registry: the block to run, whether it
|
|
48
|
+
# pre-empts open popups, and an optional preformatted status-bar hint.
|
|
49
|
+
# @api private
|
|
50
|
+
Shortcut = Data.define(:block, :over_popups, :hint)
|
|
51
|
+
private_constant :Shortcut
|
|
52
|
+
|
|
44
53
|
# @return [ScreenPane] the structural root of the component tree.
|
|
45
54
|
attr_reader :pane
|
|
46
55
|
|
|
@@ -148,15 +157,45 @@ module Tuile
|
|
|
148
157
|
@pane.on_tree { it.active = active.include?(it) }
|
|
149
158
|
@focused.on_focus
|
|
150
159
|
end
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
refresh_status_bar
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Rebuild the status-bar text from the current focus and global-shortcut
|
|
164
|
+
# registry. Called from {#focused=} and whenever the global registry
|
|
165
|
+
# changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
|
|
166
|
+
# for the tiled case Screen tacks on the global "q quit" instead.
|
|
167
|
+
# Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
|
|
168
|
+
# for the over_popups filter rule.
|
|
169
|
+
# @api private
|
|
170
|
+
# @return [void]
|
|
171
|
+
def refresh_status_bar
|
|
153
172
|
top_popup = @pane.popups.last
|
|
173
|
+
globals = global_shortcut_hints(popup_open: !top_popup.nil?)
|
|
154
174
|
@pane.status_bar.text = if top_popup.nil?
|
|
155
|
-
"q #{Rainbow("quit").cadetblue}
|
|
175
|
+
["q #{Rainbow("quit").cadetblue}", *globals,
|
|
176
|
+
active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
|
|
156
177
|
else
|
|
157
|
-
top_popup.keyboard_hint
|
|
178
|
+
[*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
|
|
158
179
|
end
|
|
159
180
|
end
|
|
181
|
+
private :refresh_status_bar
|
|
182
|
+
|
|
183
|
+
# Status-bar hints from currently-registered global shortcuts.
|
|
184
|
+
# When a popup is open, only `over_popups: true` shortcuts contribute —
|
|
185
|
+
# the rest don't fire in that context, so showing them would be a lie.
|
|
186
|
+
# Insertion order is preserved (Hash iteration order).
|
|
187
|
+
# @api private
|
|
188
|
+
# @param popup_open [Boolean]
|
|
189
|
+
# @return [Array<String>]
|
|
190
|
+
def global_shortcut_hints(popup_open:)
|
|
191
|
+
@global_shortcuts.each_value.filter_map do |s|
|
|
192
|
+
next if s.hint.nil? || s.hint.empty?
|
|
193
|
+
next if popup_open && !s.over_popups
|
|
194
|
+
|
|
195
|
+
s.hint
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
private :global_shortcut_hints
|
|
160
199
|
|
|
161
200
|
# Internal — use {Component::Popup#open} instead. Adds the popup to
|
|
162
201
|
# {#pane}, centers and focuses it.
|
|
@@ -172,16 +211,24 @@ module Tuile
|
|
|
172
211
|
|
|
173
212
|
# Runs event loop – waits for keys and sends them to active window. The
|
|
174
213
|
# function exits when the 'ESC' or 'q' key is pressed.
|
|
214
|
+
#
|
|
215
|
+
# @param capture_mouse [Boolean] when true (default), enables xterm mouse
|
|
216
|
+
# tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
|
|
217
|
+
# {Component#handle_mouse}. When false, no tracking escape sequence is
|
|
218
|
+
# written: the terminal keeps its native click handling, which is what
|
|
219
|
+
# you want if the app benefits more from select-to-copy than from
|
|
220
|
+
# click-to-focus. Components' `handle_mouse` is simply never invoked
|
|
221
|
+
# from the loop in that mode (the terminal stops sending the bytes).
|
|
175
222
|
# @return [void]
|
|
176
|
-
def run_event_loop
|
|
223
|
+
def run_event_loop(capture_mouse: true)
|
|
177
224
|
@pretend_ui_lock = false
|
|
178
225
|
$stdin.echo = false
|
|
179
|
-
print MouseEvent.start_tracking
|
|
226
|
+
print MouseEvent.start_tracking if capture_mouse
|
|
180
227
|
$stdin.raw do
|
|
181
228
|
event_loop
|
|
182
229
|
end
|
|
183
230
|
ensure
|
|
184
|
-
print MouseEvent.stop_tracking
|
|
231
|
+
print MouseEvent.stop_tracking if capture_mouse
|
|
185
232
|
print TTY::Cursor.show
|
|
186
233
|
$stdin.echo = true
|
|
187
234
|
end
|
|
@@ -197,6 +244,80 @@ module Tuile
|
|
|
197
244
|
# @return [Boolean] true if focus moved.
|
|
198
245
|
def focus_previous = cycle_focus(forward: false)
|
|
199
246
|
|
|
247
|
+
# Registers an app-level keyboard shortcut. When `key` arrives, the block
|
|
248
|
+
# is invoked on the event-loop thread (so it may freely mutate UI) before
|
|
249
|
+
# the key reaches any component. Re-registering the same key replaces the
|
|
250
|
+
# previous binding; use {#unregister_global_shortcut} to remove one.
|
|
251
|
+
#
|
|
252
|
+
# Only unprintable keys are accepted — control characters (Ctrl+letter,
|
|
253
|
+
# ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
|
|
254
|
+
# F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
|
|
255
|
+
# into a {Component::TextField} and should be expressed as
|
|
256
|
+
# {Component#key_shortcut} instead, which the dispatcher suppresses while
|
|
257
|
+
# a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
|
|
258
|
+
# rejected because {#handle_key} intercepts them for focus navigation
|
|
259
|
+
# before the global registry is consulted, so a binding on them would
|
|
260
|
+
# silently never fire.
|
|
261
|
+
#
|
|
262
|
+
# Pass `hint:` to surface the shortcut in the status bar. It's a
|
|
263
|
+
# preformatted string the caller fully owns (so colors and the key label
|
|
264
|
+
# style stay consistent with whatever the host app uses elsewhere). The
|
|
265
|
+
# framework splices it in like any other status hint: in the tiled case,
|
|
266
|
+
# right after `q quit` and before the active window's own hint; while a
|
|
267
|
+
# popup is open, only hints from `over_popups: true` shortcuts are
|
|
268
|
+
# shown, and they're prepended before the popup's `q Close`.
|
|
269
|
+
#
|
|
270
|
+
# Example — open a log popup with Ctrl+L from anywhere, even while a
|
|
271
|
+
# popup is already on screen:
|
|
272
|
+
#
|
|
273
|
+
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
274
|
+
# over_popups: true,
|
|
275
|
+
# hint: "^L #{Rainbow("log").cadetblue}") do
|
|
276
|
+
# log_popup.open
|
|
277
|
+
# end
|
|
278
|
+
#
|
|
279
|
+
# @param key [String] unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC},
|
|
280
|
+
# {Keys::PAGE_UP}).
|
|
281
|
+
# @param over_popups [Boolean] when true, fires even while a modal popup
|
|
282
|
+
# is open (pre-empting the popup's own key handling). When false
|
|
283
|
+
# (default), the shortcut is suppressed while any popup is open and
|
|
284
|
+
# the popup gets the key instead.
|
|
285
|
+
# @param hint [String, nil] preformatted status-bar hint (e.g.
|
|
286
|
+
# `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut
|
|
287
|
+
# is silent in the status bar.
|
|
288
|
+
# @yield invoked with no arguments when `key` is pressed.
|
|
289
|
+
# @return [void]
|
|
290
|
+
def register_global_shortcut(key, over_popups: false, hint: nil, &block)
|
|
291
|
+
raise ArgumentError, "block required" if block.nil?
|
|
292
|
+
raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
|
|
293
|
+
raise ArgumentError, "key cannot be empty" if key.empty?
|
|
294
|
+
if Keys.printable?(key)
|
|
295
|
+
raise ArgumentError,
|
|
296
|
+
"global shortcut key must be unprintable; got #{key.inspect}. " \
|
|
297
|
+
"Use Component#key_shortcut for printable keys (it's suppressed " \
|
|
298
|
+
"while a text widget owns the cursor, so it won't hijack typing)."
|
|
299
|
+
end
|
|
300
|
+
if [Keys::TAB, Keys::SHIFT_TAB].include?(key)
|
|
301
|
+
raise ArgumentError,
|
|
302
|
+
"#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
|
|
303
|
+
end
|
|
304
|
+
unless hint.nil? || hint.is_a?(String)
|
|
305
|
+
raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
|
|
309
|
+
refresh_status_bar
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
313
|
+
# No-op if `key` was not registered.
|
|
314
|
+
# @param key [String]
|
|
315
|
+
# @return [void]
|
|
316
|
+
def unregister_global_shortcut(key)
|
|
317
|
+
@global_shortcuts.delete(key)
|
|
318
|
+
refresh_status_bar
|
|
319
|
+
end
|
|
320
|
+
|
|
200
321
|
# @return [Component, nil] current active tiled component.
|
|
201
322
|
def active_window
|
|
202
323
|
check_locked
|
|
@@ -289,6 +410,14 @@ module Tuile
|
|
|
289
410
|
@frame_buffer = +""
|
|
290
411
|
begin
|
|
291
412
|
until @invalidated.empty?
|
|
413
|
+
# Defensive filter: a component can become detached between enqueue
|
|
414
|
+
# and drain (popup close, sibling removed mid-event-handling, focus
|
|
415
|
+
# repair). Detached components have no place on the screen and must
|
|
416
|
+
# never paint, even though Component#invalidate already gates them
|
|
417
|
+
# out — this catches the case where attachment changed since.
|
|
418
|
+
@invalidated.delete_if { |c| !c.attached? }
|
|
419
|
+
break if @invalidated.empty?
|
|
420
|
+
|
|
292
421
|
did_paint = true
|
|
293
422
|
popups = @pane.popups
|
|
294
423
|
|
|
@@ -417,10 +546,17 @@ module Tuile
|
|
|
417
546
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
418
547
|
# window.
|
|
419
548
|
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
#
|
|
549
|
+
# Dispatch order:
|
|
550
|
+
# 1. Tab / Shift+Tab — reserved focus navigation, intercepted before
|
|
551
|
+
# anything else so a focused {Component::TextField} (which would
|
|
552
|
+
# otherwise swallow printable keys via cursor-owner suppression)
|
|
553
|
+
# doesn't trap them.
|
|
554
|
+
# 2. App-level shortcuts from {#register_global_shortcut}. An entry
|
|
555
|
+
# registered with `over_popups: true` always fires; one with the
|
|
556
|
+
# default `over_popups: false` fires only when no popup is open
|
|
557
|
+
# (otherwise the popup receives the key normally).
|
|
558
|
+
# 3. {ScreenPane#handle_key}, which routes to the topmost popup or
|
|
559
|
+
# tiled content.
|
|
424
560
|
# @param key [String]
|
|
425
561
|
# @return [Boolean] true if the key was handled by some window.
|
|
426
562
|
def handle_key(key)
|
|
@@ -432,7 +568,13 @@ module Tuile
|
|
|
432
568
|
focus_previous
|
|
433
569
|
true
|
|
434
570
|
else
|
|
435
|
-
@
|
|
571
|
+
shortcut = @global_shortcuts[key]
|
|
572
|
+
if !shortcut.nil? && (shortcut.over_popups || @pane.popups.empty?)
|
|
573
|
+
shortcut.block.call
|
|
574
|
+
true
|
|
575
|
+
else
|
|
576
|
+
@pane.handle_key(key)
|
|
577
|
+
end
|
|
436
578
|
end
|
|
437
579
|
end
|
|
438
580
|
|