tuile 0.3.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 +21 -0
- data/README.md +137 -5
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/list.rb +43 -14
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +39 -134
- data/lib/tuile/component/text_field.rb +31 -148
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +127 -22
- data/lib/tuile/component/window.rb +5 -10
- 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 +13 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +407 -110
- metadata +2 -1
data/lib/tuile/component.rb
CHANGED
|
@@ -4,8 +4,10 @@ module Tuile
|
|
|
4
4
|
# A UI component which is positioned on the screen and draws characters into
|
|
5
5
|
# its bounding rectangle (in {#repaint}).
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Painting is gated by attachment: a detached component (one whose {#root}
|
|
8
|
+
# isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
|
|
9
|
+
# any stale invalidation entries are filtered out at drain time. Subclasses
|
|
10
|
+
# can paint freely in {#repaint} without re-asserting attachment.
|
|
9
11
|
class Component
|
|
10
12
|
def initialize
|
|
11
13
|
@rect = Rect.new(0, 0, 0, 0)
|
|
@@ -66,9 +68,11 @@ module Tuile
|
|
|
66
68
|
# responsibility for {#rect}. Everything else should call super.
|
|
67
69
|
#
|
|
68
70
|
# A component must not draw outside of {#rect}.
|
|
71
|
+
#
|
|
72
|
+
# Only called when the component is attached.
|
|
69
73
|
# @return [void]
|
|
70
74
|
def repaint
|
|
71
|
-
return if rect.empty?
|
|
75
|
+
return if rect.empty?
|
|
72
76
|
return if children.any? && children_tile_rect?
|
|
73
77
|
|
|
74
78
|
clear_background
|
|
@@ -251,8 +255,16 @@ module Tuile
|
|
|
251
255
|
|
|
252
256
|
# Invalidates the component: {Screen} records this component as
|
|
253
257
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
258
|
+
#
|
|
259
|
+
# No-op when the component is not {#attached?} — a detached component has
|
|
260
|
+
# no place on the screen to paint to, so {Screen} must never end up
|
|
261
|
+
# repainting it. Callers don't need to guard their own `invalidate` calls;
|
|
262
|
+
# mutating a detached component (e.g. setting `lines=` on a {List} sitting
|
|
263
|
+
# inside a closed {Component::Popup}) is silent.
|
|
254
264
|
# @return [void]
|
|
255
265
|
def invalidate
|
|
266
|
+
return unless attached?
|
|
267
|
+
|
|
256
268
|
screen.invalidate(self)
|
|
257
269
|
end
|
|
258
270
|
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -42,19 +42,71 @@ module Tuile
|
|
|
42
42
|
# @return [String]
|
|
43
43
|
PAGE_DOWN = "\e[6~"
|
|
44
44
|
# @return [String]
|
|
45
|
-
BACKSPACE = "
|
|
45
|
+
BACKSPACE = "\x7f"
|
|
46
46
|
# @return [String]
|
|
47
47
|
DELETE = "\e[3~"
|
|
48
|
+
|
|
49
|
+
# Ctrl+letter sends bytes 0x01..0x1a. Note that {CTRL_H} == `"\b"`,
|
|
50
|
+
# {CTRL_I} == {TAB}, {CTRL_J} == `"\n"`, and {CTRL_M} == {ENTER} —
|
|
51
|
+
# terminals deliver these key combinations indistinguishably from the
|
|
52
|
+
# corresponding named keys.
|
|
53
|
+
# @return [String]
|
|
54
|
+
CTRL_A = "\x01"
|
|
55
|
+
# @return [String]
|
|
56
|
+
CTRL_B = "\x02"
|
|
57
|
+
# @return [String]
|
|
58
|
+
CTRL_C = "\x03"
|
|
59
|
+
# @return [String]
|
|
60
|
+
CTRL_D = "\x04"
|
|
61
|
+
# @return [String]
|
|
62
|
+
CTRL_E = "\x05"
|
|
63
|
+
# @return [String]
|
|
64
|
+
CTRL_F = "\x06"
|
|
65
|
+
# @return [String]
|
|
66
|
+
CTRL_G = "\x07"
|
|
48
67
|
# @return [String]
|
|
49
68
|
CTRL_H = "\b"
|
|
50
|
-
# @return [Array<String>]
|
|
51
|
-
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
52
69
|
# @return [String]
|
|
53
|
-
|
|
70
|
+
CTRL_I = "\t"
|
|
71
|
+
# @return [String]
|
|
72
|
+
CTRL_J = "\n"
|
|
73
|
+
# @return [String]
|
|
74
|
+
CTRL_K = "\x0b"
|
|
75
|
+
# @return [String]
|
|
76
|
+
CTRL_L = "\x0c"
|
|
77
|
+
# @return [String]
|
|
78
|
+
CTRL_M = "\r"
|
|
79
|
+
# @return [String]
|
|
80
|
+
CTRL_N = "\x0e"
|
|
81
|
+
# @return [String]
|
|
82
|
+
CTRL_O = "\x0f"
|
|
83
|
+
# @return [String]
|
|
84
|
+
CTRL_P = "\x10"
|
|
54
85
|
# @return [String]
|
|
55
|
-
|
|
86
|
+
CTRL_Q = "\x11"
|
|
56
87
|
# @return [String]
|
|
57
|
-
|
|
88
|
+
CTRL_R = "\x12"
|
|
89
|
+
# @return [String]
|
|
90
|
+
CTRL_S = "\x13"
|
|
91
|
+
# @return [String]
|
|
92
|
+
CTRL_T = "\x14"
|
|
93
|
+
# @return [String]
|
|
94
|
+
CTRL_U = "\x15"
|
|
95
|
+
# @return [String]
|
|
96
|
+
CTRL_V = "\x16"
|
|
97
|
+
# @return [String]
|
|
98
|
+
CTRL_W = "\x17"
|
|
99
|
+
# @return [String]
|
|
100
|
+
CTRL_X = "\x18"
|
|
101
|
+
# @return [String]
|
|
102
|
+
CTRL_Y = "\x19"
|
|
103
|
+
# @return [String]
|
|
104
|
+
CTRL_Z = "\x1a"
|
|
105
|
+
|
|
106
|
+
# @return [Array<String>]
|
|
107
|
+
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
108
|
+
# @return [String]
|
|
109
|
+
ENTER = "\r"
|
|
58
110
|
# @return [String]
|
|
59
111
|
TAB = "\t"
|
|
60
112
|
# The terminal sequence emitted by Shift+Tab in xterm-style terminals
|
|
@@ -62,6 +114,22 @@ module Tuile
|
|
|
62
114
|
# @return [String]
|
|
63
115
|
SHIFT_TAB = "\e[Z"
|
|
64
116
|
|
|
117
|
+
# True iff `key` is a single printable character — a one-character string
|
|
118
|
+
# whose codepoint is not in Unicode's C (Other) category. Rejects multi-
|
|
119
|
+
# character escape sequences ({UP_ARROW}, mouse events, …), control bytes
|
|
120
|
+
# ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
|
|
121
|
+
# string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
|
|
122
|
+
# printables like "é".
|
|
123
|
+
#
|
|
124
|
+
# Used by {Screen#register_global_shortcut} to reject keys that would
|
|
125
|
+
# collide with typing, and by {Tuile::Component::TextField} to decide
|
|
126
|
+
# whether to insert a key at the caret.
|
|
127
|
+
# @param key [String]
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def self.printable?(key)
|
|
130
|
+
key.length == 1 && !key.match?(/\p{C}/)
|
|
131
|
+
end
|
|
132
|
+
|
|
65
133
|
# Grabs a key from stdin and returns it. Blocks until the key is obtained.
|
|
66
134
|
# Reads a full ESC key sequence; see constants above for some values returned
|
|
67
135
|
# by this function.
|
|
@@ -72,11 +140,26 @@ module Tuile
|
|
|
72
140
|
|
|
73
141
|
# Escape sequence. Try to read more data.
|
|
74
142
|
begin
|
|
75
|
-
# Read
|
|
76
|
-
|
|
143
|
+
# Read up to 5 bytes: that's the maximum tail length of any escape
|
|
144
|
+
# sequence Tuile recognizes after the initial \e (X10 mouse `[Mbxy`,
|
|
145
|
+
# CTRL+arrow `[1;5D`, etc.). Reading 6 here would over-read into the
|
|
146
|
+
# next sequence on tight mouse-event bursts — we'd silently steal
|
|
147
|
+
# the next event's leading \e and the rest of it would surface as
|
|
148
|
+
# individual printable keypresses in focused inputs.
|
|
149
|
+
char += $stdin.read_nonblock(5)
|
|
77
150
|
rescue IO::EAGAINWaitReadable
|
|
78
151
|
# The "ESC" key pressed => only the \e char is emitted.
|
|
152
|
+
return char
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# If `read_nonblock` returned a partial X10 mouse-report prefix (the
|
|
156
|
+
# sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
|
|
157
|
+
# with a blocking read so the parser downstream sees a complete event
|
|
158
|
+
# instead of leaking tail bytes as keypresses.
|
|
159
|
+
if char.start_with?("\e[M") && char.bytesize < 6
|
|
160
|
+
char += $stdin.read(6 - char.bytesize)
|
|
79
161
|
end
|
|
162
|
+
|
|
80
163
|
char
|
|
81
164
|
end
|
|
82
165
|
end
|
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
|
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -495,6 +495,19 @@ module Tuile
|
|
|
495
495
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
|
|
496
496
|
end
|
|
497
497
|
|
|
498
|
+
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
499
|
+
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
500
|
+
# `underline`). The new fg overlays without dropping background colors or
|
|
501
|
+
# text attributes the original styling carried.
|
|
502
|
+
#
|
|
503
|
+
# @param fg [Symbol, Integer, Array<Integer>, nil] foreground color, in
|
|
504
|
+
# any of the forms accepted by {Style.new}. `nil` clears fg back to
|
|
505
|
+
# the terminal default.
|
|
506
|
+
# @return [StyledString]
|
|
507
|
+
def with_fg(fg)
|
|
508
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
|
|
509
|
+
end
|
|
510
|
+
|
|
498
511
|
# @return [String]
|
|
499
512
|
def inspect
|
|
500
513
|
"#<#{self.class.name} #{to_s.inspect}>"
|
data/lib/tuile/version.rb
CHANGED