tuile 0.3.0 → 0.5.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 +34 -0
- data/README.md +137 -5
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +34 -4
- 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 +792 -48
- data/lib/tuile/component/window.rb +5 -10
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +156 -12
- data/lib/tuile/styled_string.rb +38 -58
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +932 -154
- metadata +3 -1
|
@@ -8,8 +8,9 @@ module Tuile
|
|
|
8
8
|
#
|
|
9
9
|
# The window's `content` is unset by default; assign one via {#content=}.
|
|
10
10
|
#
|
|
11
|
-
# Window is considered invisible if {#rect} is empty
|
|
12
|
-
#
|
|
11
|
+
# Window is considered invisible if {#rect} is empty. The window won't
|
|
12
|
+
# draw when invisible. (Repaint of detached windows is short-circuited
|
|
13
|
+
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
13
14
|
class Window < Component
|
|
14
15
|
include Component::HasContent
|
|
15
16
|
|
|
@@ -125,12 +126,6 @@ module Tuile
|
|
|
125
126
|
Size.new(inner_w + 2, inner_h + 2)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
|
-
# @return [Boolean] true if {#rect} is off screen and the window won't
|
|
129
|
-
# paint.
|
|
130
|
-
def visible?
|
|
131
|
-
!@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
|
|
132
|
-
end
|
|
133
|
-
|
|
134
129
|
# Fully repaints the window: both frame and contents.
|
|
135
130
|
#
|
|
136
131
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -143,7 +138,7 @@ module Tuile
|
|
|
143
138
|
# cycle.
|
|
144
139
|
# @return [void]
|
|
145
140
|
def repaint
|
|
146
|
-
return
|
|
141
|
+
return if rect.empty?
|
|
147
142
|
|
|
148
143
|
super
|
|
149
144
|
repaint_border
|
|
@@ -168,7 +163,7 @@ module Tuile
|
|
|
168
163
|
# Paints the window border.
|
|
169
164
|
# @return [void]
|
|
170
165
|
def repaint_border
|
|
171
|
-
return
|
|
166
|
+
return if rect.empty?
|
|
172
167
|
|
|
173
168
|
frame = build_frame(frame_caption)
|
|
174
169
|
frame = Rainbow(frame).green if active?
|
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/event_queue.rb
CHANGED
|
@@ -47,17 +47,56 @@ module Tuile
|
|
|
47
47
|
latch.wait
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Schedules `block` to fire on the event-loop thread roughly `fps` times
|
|
51
|
+
# per second, passing a 0-based monotonically increasing tick counter. Use
|
|
52
|
+
# it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
|
|
53
|
+
# periodic UI refresh from a background task.
|
|
54
|
+
#
|
|
55
|
+
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
56
|
+
# stop it.
|
|
57
|
+
#
|
|
58
|
+
# **Errors:** if `block` raises, the {Ticker} cancels itself and the
|
|
59
|
+
# exception flows through the normal event-loop error path — i.e.
|
|
60
|
+
# {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
|
|
61
|
+
# broken block from spamming `on_error` at the tick rate.
|
|
62
|
+
#
|
|
63
|
+
# Tickers reuse `concurrent-ruby`'s shared timer thread
|
|
64
|
+
# ({Concurrent}.global_timer_set) — adding more tickers does not add more
|
|
65
|
+
# threads, just more work on the shared scheduler.
|
|
66
|
+
#
|
|
67
|
+
# @param fps [Numeric] firings per second, must be positive. Fractional
|
|
68
|
+
# values are fine (`fps: 0.5` ⇒ one tick every two seconds).
|
|
69
|
+
# @yield [tick] called on the event-loop thread each firing.
|
|
70
|
+
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
71
|
+
# @yieldreturn [void]
|
|
72
|
+
# @return [Ticker]
|
|
73
|
+
def tick(fps, &block)
|
|
74
|
+
raise ArgumentError, "block required" unless block
|
|
75
|
+
unless fps.is_a?(Numeric) && fps.positive?
|
|
76
|
+
raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Ticker.new(self, fps, block)
|
|
80
|
+
end
|
|
81
|
+
|
|
50
82
|
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
51
83
|
# same time. Blocks until some thread calls {#stop}. Calls block for all
|
|
52
|
-
# events
|
|
53
|
-
# running this function.
|
|
84
|
+
# events; the block is always called from the thread running this function.
|
|
54
85
|
#
|
|
55
|
-
# Any exception raised by block is re-thrown, causing this function to
|
|
56
|
-
# terminate.
|
|
57
|
-
#
|
|
86
|
+
# Any exception raised by the block is re-thrown, causing this function to
|
|
87
|
+
# terminate. Wrap the block body in `rescue` if you want to handle errors
|
|
88
|
+
# without tearing down the loop — see {Screen#event_loop} for an example.
|
|
89
|
+
#
|
|
90
|
+
# **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
|
|
91
|
+
# the consumer is responsible for invoking it (typically `event.call`).
|
|
92
|
+
# Yielding rather than dispatching inline means a raise inside the
|
|
93
|
+
# submitted block flows through the consumer's `rescue` like any other
|
|
94
|
+
# event-handler error, instead of bypassing it.
|
|
95
|
+
# @yield [event] called for each posted event.
|
|
58
96
|
# @yieldparam event [Object] a posted event — typically a {KeyEvent},
|
|
59
|
-
# {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent},
|
|
60
|
-
# via {#post}.
|
|
97
|
+
# {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
|
|
98
|
+
# or any object pushed via {#post}. {ErrorEvent}s are not yielded — they
|
|
99
|
+
# terminate the loop directly.
|
|
61
100
|
# @yieldreturn [void]
|
|
62
101
|
# @return [void]
|
|
63
102
|
def run_loop(&)
|
|
@@ -152,6 +191,64 @@ module Tuile
|
|
|
152
191
|
include Singleton
|
|
153
192
|
end
|
|
154
193
|
|
|
194
|
+
# Handle returned by {EventQueue#tick}. Cancel a running ticker via
|
|
195
|
+
# {#cancel}.
|
|
196
|
+
#
|
|
197
|
+
# Internally wraps a `Concurrent::TimerTask` whose firing posts a single
|
|
198
|
+
# submit-block to the owning {EventQueue}; the user's block therefore
|
|
199
|
+
# always runs on the event-loop thread and may freely mutate UI. If the
|
|
200
|
+
# user block raises, the Ticker auto-cancels and the exception is
|
|
201
|
+
# re-raised so it flows through the loop's normal error handling
|
|
202
|
+
# ({Screen#on_error} for the default Tuile setup).
|
|
203
|
+
class Ticker
|
|
204
|
+
# @param event_queue [EventQueue] queue to dispatch tick calls onto.
|
|
205
|
+
# @param fps [Numeric] firings per second (positive).
|
|
206
|
+
# @param block [Proc] called as `block.call(tick_count)` on each fire.
|
|
207
|
+
def initialize(event_queue, fps, block)
|
|
208
|
+
@event_queue = event_queue
|
|
209
|
+
@block = block
|
|
210
|
+
@tick = 0
|
|
211
|
+
# AtomicBoolean rather than a plain ivar: cancel may run on any
|
|
212
|
+
# thread (caller code, the event-loop thread from inside the block,
|
|
213
|
+
# or the IO executor on an error path), and we want both a CAS-style
|
|
214
|
+
# one-shot guard against double-shutdown and well-defined visibility
|
|
215
|
+
# on non-MRI Rubies.
|
|
216
|
+
@cancelled = Concurrent::AtomicBoolean.new(false)
|
|
217
|
+
@timer = Concurrent::TimerTask.new(execution_interval: 1.0 / fps) do
|
|
218
|
+
@event_queue.submit { fire }
|
|
219
|
+
end
|
|
220
|
+
@timer.execute
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [Boolean] true once {#cancel} has been called.
|
|
224
|
+
def cancelled? = @cancelled.true?
|
|
225
|
+
|
|
226
|
+
# Stops the ticker. Idempotent and safe to call from any thread,
|
|
227
|
+
# including from inside the tick block. Any tick already queued on the
|
|
228
|
+
# event loop at the moment of cancellation is dropped before the user
|
|
229
|
+
# block runs.
|
|
230
|
+
# @return [void]
|
|
231
|
+
def cancel
|
|
232
|
+
return unless @cancelled.make_true # CAS: only the winner shuts down
|
|
233
|
+
|
|
234
|
+
@timer.shutdown
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
# Runs on the event-loop thread.
|
|
240
|
+
# @return [void]
|
|
241
|
+
def fire
|
|
242
|
+
return if @cancelled.true?
|
|
243
|
+
|
|
244
|
+
@block.call(@tick)
|
|
245
|
+
@tick += 1
|
|
246
|
+
rescue StandardError
|
|
247
|
+
cancel
|
|
248
|
+
raise
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
155
252
|
private
|
|
156
253
|
|
|
157
254
|
# @return [void]
|
|
@@ -169,8 +266,6 @@ module Tuile
|
|
|
169
266
|
# while the loop's own backtrace shows up in the wrapper.
|
|
170
267
|
raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
|
|
171
268
|
end
|
|
172
|
-
elsif event.is_a? Proc
|
|
173
|
-
event.call
|
|
174
269
|
else
|
|
175
270
|
yield event
|
|
176
271
|
end
|
|
@@ -4,6 +4,10 @@ module Tuile
|
|
|
4
4
|
# A "synchronous" event queue – no loop is run, submitted blocks are run right
|
|
5
5
|
# away and submitted events are thrown away. Intended for testing only.
|
|
6
6
|
class FakeEventQueue
|
|
7
|
+
def initialize
|
|
8
|
+
@tickers = []
|
|
9
|
+
end
|
|
10
|
+
|
|
7
11
|
# @return [Boolean]
|
|
8
12
|
def locked? = true
|
|
9
13
|
# @return [void]
|
|
@@ -27,5 +31,70 @@ module Tuile
|
|
|
27
31
|
# @param event [Object]
|
|
28
32
|
# @return [void]
|
|
29
33
|
def post(event); end
|
|
34
|
+
|
|
35
|
+
# Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
|
|
36
|
+
# only fires when a test calls {#tick_once}. The `fps` argument is
|
|
37
|
+
# validated the same way the real queue validates it, then discarded —
|
|
38
|
+
# the fake has no clock, so frame cadence is up to the test.
|
|
39
|
+
#
|
|
40
|
+
# @param fps [Numeric] firings per second, must be positive. Validated
|
|
41
|
+
# for parity with {EventQueue#tick}; otherwise unused.
|
|
42
|
+
# @yield [tick] called on each {#tick_once}.
|
|
43
|
+
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
44
|
+
# @yieldreturn [void]
|
|
45
|
+
# @return [FakeTicker]
|
|
46
|
+
def tick(fps, &block)
|
|
47
|
+
raise ArgumentError, "block required" unless block
|
|
48
|
+
unless fps.is_a?(Numeric) && fps.positive?
|
|
49
|
+
raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
FakeTicker.new(block).tap { |t| @tickers << t }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Test helper: fires every live ticker's user block once and prunes
|
|
56
|
+
# cancelled tickers. No-op when no tickers are registered. Pumps once
|
|
57
|
+
# per call regardless of any ticker's fps — the fake has no clock, so
|
|
58
|
+
# tests pump N frames by calling this N times.
|
|
59
|
+
# @return [void]
|
|
60
|
+
def tick_once
|
|
61
|
+
@tickers.reject!(&:cancelled?)
|
|
62
|
+
@tickers.each(&:fire)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
|
|
66
|
+
# {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
|
|
67
|
+
# the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
|
|
68
|
+
class FakeTicker
|
|
69
|
+
# @param block [Proc] called as `block.call(tick_count)` on each {#fire}.
|
|
70
|
+
def initialize(block)
|
|
71
|
+
@block = block
|
|
72
|
+
@tick = 0
|
|
73
|
+
@cancelled = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] true once {#cancel} has been called.
|
|
77
|
+
def cancelled? = @cancelled
|
|
78
|
+
|
|
79
|
+
# Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
|
|
80
|
+
# no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
|
|
81
|
+
# pass.
|
|
82
|
+
# @return [void]
|
|
83
|
+
def cancel
|
|
84
|
+
@cancelled = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Invokes the user block with the current tick counter, then advances.
|
|
88
|
+
# No-op when {#cancelled?}. Typically driven by
|
|
89
|
+
# {FakeEventQueue#tick_once}; safe to call directly from a test that
|
|
90
|
+
# wants to drive a single ticker.
|
|
91
|
+
# @return [void]
|
|
92
|
+
def fire
|
|
93
|
+
return if @cancelled
|
|
94
|
+
|
|
95
|
+
@block.call(@tick)
|
|
96
|
+
@tick += 1
|
|
97
|
+
end
|
|
98
|
+
end
|
|
30
99
|
end
|
|
31
100
|
end
|
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
|