tuile 0.14.0 → 0.16.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 +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
module Mouse
|
|
5
|
+
# Delivers {Mouse::Event}s to the component tree. {Screen} owns one and hands
|
|
6
|
+
# it every event the terminal reports; a component only answers the handlers
|
|
7
|
+
# it calls, and walks nothing itself.
|
|
8
|
+
#
|
|
9
|
+
# screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2)) # what the loop does
|
|
10
|
+
#
|
|
11
|
+
# Every event resolves against one **path**: the topmost popup containing the
|
|
12
|
+
# point, else the tiled content unless a modal popup is open
|
|
13
|
+
# ({ScreenPane#mouse_root_at}), then down through the shown children whose
|
|
14
|
+
# {Component#rect} contains it.
|
|
15
|
+
#
|
|
16
|
+
# - **{DownEvent}** — focuses the innermost {Component#focusable?} on that
|
|
17
|
+
# path, then offers {Component#handle_mouse_down?} innermost-first until one
|
|
18
|
+
# component answers `true`. That claimant becomes {#grabbed}.
|
|
19
|
+
# - **{ScrollEvent}**, **{MoveEvent}** — bubble the same way through
|
|
20
|
+
# {Component#handle_mouse_scroll?} / {Component#handle_mouse_move?}, and grab
|
|
21
|
+
# nothing. A move fires the enter/exit hooks first, and arrives only at
|
|
22
|
+
# `:hover`.
|
|
23
|
+
# - **{DragEvent}**, **{UpEvent}** — skip the path entirely: they go to
|
|
24
|
+
# {#grabbed}, wherever the pointer is, and an up ends the grab.
|
|
25
|
+
#
|
|
26
|
+
# The two geometries are deliberately different. Focus follows `rect`, so a
|
|
27
|
+
# press on the dead tail a widget does not paint still focuses it; the
|
|
28
|
+
# handlers bubble only along the prefix whose {Component#extent_rect} contains
|
|
29
|
+
# the point, so that same press activates nothing (`D_extent`).
|
|
30
|
+
#
|
|
31
|
+
# UI-thread-confined.
|
|
32
|
+
#
|
|
33
|
+
# == Implementation details
|
|
34
|
+
#
|
|
35
|
+
# **The grab has three releases and no relinquishing**: the up, the next
|
|
36
|
+
# down (the up was lost — ssh and tmux do lose them), and any key
|
|
37
|
+
# ({#release_grab}). None of the last two tells the grabbed component. Hiding
|
|
38
|
+
# or detaching it does not end the grab either; the router just stops
|
|
39
|
+
# delivering to it until the release (`D_mouse_dispatch`).
|
|
40
|
+
#
|
|
41
|
+
# **The hovered chain is synced, not toggled**: a move re-resolves it and
|
|
42
|
+
# diffs, and {#sync_hover} — run by {Screen#repaint} — drops members that
|
|
43
|
+
# were detached, hidden or reparented since, firing their exits.
|
|
44
|
+
class Router
|
|
45
|
+
# @param screen [Screen]
|
|
46
|
+
def initialize(screen)
|
|
47
|
+
@screen = screen
|
|
48
|
+
@level = :hover
|
|
49
|
+
@hovered = []
|
|
50
|
+
@grabbed = nil
|
|
51
|
+
@grab_button = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The tracking level {Screen#run_event_loop} asked the terminal for, one of
|
|
55
|
+
# {Mouse::LEVELS}; `:hover` while no loop is running, so a spec may post
|
|
56
|
+
# anything. An ungrabbed move is dropped below `:hover` — under `:drag` the
|
|
57
|
+
# terminal reports one only while a button nobody claimed is held, and
|
|
58
|
+
# enter/exit that fire *sometimes* are worse than none.
|
|
59
|
+
# @return [Symbol, nil]
|
|
60
|
+
attr_accessor :level
|
|
61
|
+
|
|
62
|
+
# @return [Component, nil] the component whose {Component#handle_mouse_down?}
|
|
63
|
+
# claimed the press currently held.
|
|
64
|
+
attr_reader :grabbed
|
|
65
|
+
|
|
66
|
+
# @return [Component, nil] the innermost component under the pointer, as of
|
|
67
|
+
# the last move under `:hover`.
|
|
68
|
+
def hovered = @hovered.last
|
|
69
|
+
|
|
70
|
+
# @param event [Mouse::Event]
|
|
71
|
+
# @return [void]
|
|
72
|
+
def dispatch(event)
|
|
73
|
+
case event
|
|
74
|
+
when DownEvent then press(event)
|
|
75
|
+
when UpEvent then release(event)
|
|
76
|
+
when ScrollEvent then bubble(extent_path(event.point), :handle_mouse_scroll?, event)
|
|
77
|
+
when MoveEvent then move(event)
|
|
78
|
+
when DragEvent
|
|
79
|
+
# No route of its own: it is manufactured here from a move while
|
|
80
|
+
# something holds the grab, so a posted one can only be a mistake.
|
|
81
|
+
raise Tuile::Error,
|
|
82
|
+
"a Mouse::DragEvent is the router's own; post " \
|
|
83
|
+
"Mouse::MoveEvent.new(:#{event.button}, #{event.x}, #{event.y}) " \
|
|
84
|
+
"— or FakeScreen#drag — to drive a grabbed component"
|
|
85
|
+
else raise TypeError, "not a routable Mouse::Event: #{event.inspect}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Ends the grab without telling the grabbed component.
|
|
90
|
+
# @return [void]
|
|
91
|
+
def release_grab
|
|
92
|
+
@grabbed = nil
|
|
93
|
+
@grab_button = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Drops the hovered-chain members that are no longer attached, shown, or
|
|
97
|
+
# children of the member before them, firing
|
|
98
|
+
# {Component#handle_mouse_exit} innermost first. Idempotent.
|
|
99
|
+
# @return [void]
|
|
100
|
+
def sync_hover
|
|
101
|
+
valid = @hovered.each_with_index.take_while do |c, i|
|
|
102
|
+
c.attached? && c.visible? && (i.zero? || c.parent.equal?(@hovered[i - 1]))
|
|
103
|
+
end.size
|
|
104
|
+
return if valid == @hovered.size
|
|
105
|
+
|
|
106
|
+
rehover(@hovered.take(valid))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# @param event [DownEvent]
|
|
112
|
+
# @return [void]
|
|
113
|
+
def press(event)
|
|
114
|
+
release_grab
|
|
115
|
+
point = event.point
|
|
116
|
+
pane = @screen.pane
|
|
117
|
+
root = pane.mouse_root_at(point)
|
|
118
|
+
path = rect_path(root, point)
|
|
119
|
+
pane.dismissing_popups_outside(point, left: event.button == :left) do
|
|
120
|
+
focus_innermost(root, path) if event.button == :left
|
|
121
|
+
claimant = bubble(path.take_while { _1.extent_rect.contains?(point) }, :handle_mouse_down?, event)
|
|
122
|
+
unless claimant.nil?
|
|
123
|
+
@grabbed = claimant
|
|
124
|
+
@grab_button = event.button
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param event [UpEvent]
|
|
130
|
+
# @return [void]
|
|
131
|
+
def release(event)
|
|
132
|
+
grabbed = @grabbed
|
|
133
|
+
release_grab
|
|
134
|
+
grabbed.__send__(:handle_mouse_up, event) if reachable?(grabbed)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param event [MoveEvent]
|
|
138
|
+
# @return [void]
|
|
139
|
+
def move(event)
|
|
140
|
+
unless @grabbed.nil?
|
|
141
|
+
drag = DragEvent.new(@grab_button, event.x, event.y)
|
|
142
|
+
@grabbed.__send__(:handle_mouse_drag, drag) if reachable?(@grabbed)
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
return unless @level == :hover
|
|
146
|
+
|
|
147
|
+
path = extent_path(event.point)
|
|
148
|
+
rehover(path)
|
|
149
|
+
bubble(path, :handle_mouse_move?, event)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A non-modal overlay is never focused into: it sits outside the key scope,
|
|
153
|
+
# so focus there would make every keystroke go dead (`D_overlay`).
|
|
154
|
+
# @param root [Component, nil]
|
|
155
|
+
# @param path [Array<Component>]
|
|
156
|
+
# @return [void]
|
|
157
|
+
def focus_innermost(root, path)
|
|
158
|
+
return if root.is_a?(Component::Overlay) && !root.modal?
|
|
159
|
+
|
|
160
|
+
target = path.reverse_each.find(&:focusable?)
|
|
161
|
+
@screen.focused = target unless target.nil? || target.active?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @param path [Array<Component>] root first.
|
|
165
|
+
# @param handler [Symbol] a routed `handle_mouse_…?`.
|
|
166
|
+
# @param event [Mouse::Event]
|
|
167
|
+
# @return [Component, nil] the component that answered true.
|
|
168
|
+
def bubble(path, handler, event)
|
|
169
|
+
# A handler may detach what is below it on the path (a click that swaps
|
|
170
|
+
# a slot's occupant), so re-check before each delivery.
|
|
171
|
+
path.reverse_each.find { |c| c.attached? && c.__send__(handler, event) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param point [Point]
|
|
175
|
+
# @return [Array<Component>] the shown components under `point` whose
|
|
176
|
+
# extent contains it, root first.
|
|
177
|
+
def extent_path(point)
|
|
178
|
+
rect_path(@screen.pane.mouse_root_at(point), point).take_while { _1.extent_rect.contains?(point) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param root [Component, nil]
|
|
182
|
+
# @param point [Point]
|
|
183
|
+
# @return [Array<Component>] the shown components whose rect contains
|
|
184
|
+
# `point`, root first. Tiled siblings never overlap, so at most one child
|
|
185
|
+
# qualifies at each level.
|
|
186
|
+
def rect_path(root, point)
|
|
187
|
+
path = []
|
|
188
|
+
component = root
|
|
189
|
+
while component&.visible? && component.rect.contains?(point)
|
|
190
|
+
path << component
|
|
191
|
+
component = component.children.find { _1.visible? && _1.rect.contains?(point) }
|
|
192
|
+
end
|
|
193
|
+
path
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @param chain [Array<Component>] the new hovered chain, root first.
|
|
197
|
+
# @return [void]
|
|
198
|
+
def rehover(chain)
|
|
199
|
+
old = @hovered
|
|
200
|
+
@hovered = chain
|
|
201
|
+
(old - chain).reverse_each { _1.__send__(:handle_mouse_exit) }
|
|
202
|
+
(chain - old).each { _1.__send__(:handle_mouse_enter) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @param component [Component, nil]
|
|
206
|
+
# @return [Boolean] whether `component` is attached and it and every
|
|
207
|
+
# ancestor are shown.
|
|
208
|
+
def reachable?(component)
|
|
209
|
+
return false if component.nil? || !component.attached?
|
|
210
|
+
|
|
211
|
+
cursor = component
|
|
212
|
+
cursor = cursor.parent while cursor&.visible?
|
|
213
|
+
cursor.nil?
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
data/lib/tuile/mouse.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The mouse: the events, their parser, and the tracking levels
|
|
5
|
+
# {Screen#run_event_loop} asks the terminal for. Who gets one is
|
|
6
|
+
# {Mouse::Router}'s story.
|
|
7
|
+
#
|
|
8
|
+
# Four of the five classes are what {.parse} reads off the wire; {DragEvent} is
|
|
9
|
+
# the router's own, a move while something holds the grab. Each is a
|
|
10
|
+
# `Data.define` including the {Event} marker — no inheritance, so a `case`
|
|
11
|
+
# matches either one class or `Mouse::Event` for all of them. Enter and exit
|
|
12
|
+
# have no class at all: nothing is parsed, they are the difference between two
|
|
13
|
+
# hovered chains.
|
|
14
|
+
#
|
|
15
|
+
# Coordinates are screen-absolute and 0-based everywhere.
|
|
16
|
+
module Mouse
|
|
17
|
+
# Included by every mouse event class: a marker for `case`/`is_a?`, plus the
|
|
18
|
+
# {#point} they share.
|
|
19
|
+
module Event
|
|
20
|
+
# @return [Point] the event's position.
|
|
21
|
+
def point = Point.new(x, y)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A button went down.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] button
|
|
27
|
+
# @return [Symbol] `:left`, `:middle` or `:right`.
|
|
28
|
+
# @!attribute [r] x
|
|
29
|
+
# @return [Integer] 0-based column.
|
|
30
|
+
# @!attribute [r] y
|
|
31
|
+
# @return [Integer] 0-based row.
|
|
32
|
+
DownEvent = Data.define(:button, :x, :y) { include Event }
|
|
33
|
+
|
|
34
|
+
# A button came up. **Deliberately no `button`**: an up reaches only the
|
|
35
|
+
# component that claimed the press, and that grab already knows which button
|
|
36
|
+
# it holds (`D_mouse_dispatch`).
|
|
37
|
+
#
|
|
38
|
+
# @!attribute [r] x
|
|
39
|
+
# @return [Integer] 0-based column.
|
|
40
|
+
# @!attribute [r] y
|
|
41
|
+
# @return [Integer] 0-based row.
|
|
42
|
+
UpEvent = Data.define(:x, :y) { include Event }
|
|
43
|
+
|
|
44
|
+
# One wheel notch. A notch is not a button, which is why this is not a
|
|
45
|
+
# {DownEvent}.
|
|
46
|
+
#
|
|
47
|
+
# @!attribute [r] direction
|
|
48
|
+
# @return [Symbol] `:up`, `:down`, `:left` or `:right`.
|
|
49
|
+
# @!attribute [r] x
|
|
50
|
+
# @return [Integer] 0-based column.
|
|
51
|
+
# @!attribute [r] y
|
|
52
|
+
# @return [Integer] 0-based row.
|
|
53
|
+
ScrollEvent = Data.define(:direction, :x, :y) { include Event }
|
|
54
|
+
|
|
55
|
+
# The pointer moved and nothing holds the grab.
|
|
56
|
+
#
|
|
57
|
+
# @!attribute [r] button
|
|
58
|
+
# @return [Symbol, nil] the button held while moving, or nil.
|
|
59
|
+
# @!attribute [r] x
|
|
60
|
+
# @return [Integer] 0-based column.
|
|
61
|
+
# @!attribute [r] y
|
|
62
|
+
# @return [Integer] 0-based row.
|
|
63
|
+
MoveEvent = Data.define(:button, :x, :y) { include Event }
|
|
64
|
+
|
|
65
|
+
# The pointer moved while a component holds the grab.
|
|
66
|
+
#
|
|
67
|
+
# The one event {Router} *makes* rather than routes, so posting one to
|
|
68
|
+
# {Screen#handle_mouse} raises: drive a grabbed component with a
|
|
69
|
+
# {MoveEvent} carrying the held button, or with {FakeScreen#drag}.
|
|
70
|
+
#
|
|
71
|
+
# @!attribute [r] button
|
|
72
|
+
# @return [Symbol] the button whose press took the grab.
|
|
73
|
+
# @!attribute [r] x
|
|
74
|
+
# @return [Integer] 0-based column — may lie outside the grabbed
|
|
75
|
+
# component's rect, and even outside the screen's last known size.
|
|
76
|
+
# @!attribute [r] y
|
|
77
|
+
# @return [Integer] 0-based row.
|
|
78
|
+
DragEvent = Data.define(:button, :x, :y) { include Event }
|
|
79
|
+
|
|
80
|
+
# The `capture_mouse:` levels, each a strict superset of the one before, and
|
|
81
|
+
# each unlocking one tier of events: `:clicks` (mode 1000) gives
|
|
82
|
+
# down, up and scroll; `:drag` (1002) adds drag; `:hover` (1003) adds move,
|
|
83
|
+
# enter and exit (`R_mouse_reporting`).
|
|
84
|
+
# @return [Array<Symbol>]
|
|
85
|
+
LEVELS = %i[clicks drag hover].freeze
|
|
86
|
+
|
|
87
|
+
# @return [Hash{Symbol => Integer}] the DEC private mode each level sets.
|
|
88
|
+
MODES = { clicks: 1000, drag: 1002, hover: 1003 }.freeze
|
|
89
|
+
private_constant :MODES
|
|
90
|
+
|
|
91
|
+
# X10 button code layout: `button | 4 shift | 8 meta | 16 ctrl | 32 motion |
|
|
92
|
+
# 64 wheel`, button 3 meaning "released" (`R_mouse_reporting`).
|
|
93
|
+
# @return [Integer]
|
|
94
|
+
MODIFIER_BITS = 4 | 8 | 16
|
|
95
|
+
private_constant :MODIFIER_BITS
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
# Normalizes a `capture_mouse:` argument to a level.
|
|
99
|
+
# @param capture_mouse [Boolean, Symbol] `false`, `true` (== `:clicks`),
|
|
100
|
+
# or one of {LEVELS}.
|
|
101
|
+
# @return [Symbol, nil] the level; nil when tracking is off.
|
|
102
|
+
# @raise [ArgumentError] on anything else.
|
|
103
|
+
def level(capture_mouse)
|
|
104
|
+
case capture_mouse
|
|
105
|
+
when false, nil then nil
|
|
106
|
+
when true then :clicks
|
|
107
|
+
when *LEVELS then capture_mouse
|
|
108
|
+
else
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"capture_mouse: expected true, false or one of #{LEVELS}, got #{capture_mouse.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param level [Symbol] one of {LEVELS}.
|
|
115
|
+
# @return [String] the escape enabling that level.
|
|
116
|
+
def start_tracking(level) = "\e[?#{MODES.fetch(level)}h"
|
|
117
|
+
|
|
118
|
+
# @param level [Symbol] one of {LEVELS}.
|
|
119
|
+
# @return [String] the escape disabling that level.
|
|
120
|
+
def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l"
|
|
121
|
+
|
|
122
|
+
# Whether `key` is a mouse report. True on the X10 `\e[M` prefix
|
|
123
|
+
# regardless of length — {.parse} is the place that validates the full
|
|
124
|
+
# 6-byte shape and raises on malformed input.
|
|
125
|
+
# @param key [String] key read via {Keys.getkey}
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def report?(key) = key.start_with?("\e[M")
|
|
128
|
+
|
|
129
|
+
# Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y) into one of
|
|
130
|
+
# {DownEvent}, {UpEvent}, {ScrollEvent} or {MoveEvent}. Modifier bits are
|
|
131
|
+
# ignored.
|
|
132
|
+
#
|
|
133
|
+
# Raises {Tuile::Error} when `key` starts with the mouse prefix but is
|
|
134
|
+
# not exactly 6 bytes long. Both shorter and longer inputs are bugs in
|
|
135
|
+
# the upstream key-reader: a shorter prefix means the tail was lost on
|
|
136
|
+
# the way in, and a longer one means we over-consumed into the next
|
|
137
|
+
# escape sequence. We refuse to silently truncate either case because
|
|
138
|
+
# the trailing `\e` of an over-read corrupts the *next* getkey, and the
|
|
139
|
+
# corruption then surfaces as garbled keystrokes in focused inputs
|
|
140
|
+
# rather than as a parser failure pointing at the actual cause.
|
|
141
|
+
# @param key [String] key read via {Keys.getkey}
|
|
142
|
+
# @return [Event, nil] `nil` if `key` is not a mouse report, or reports
|
|
143
|
+
# a wheel button beyond the four directions.
|
|
144
|
+
# @raise [Tuile::Error] if `key` is a malformed mouse report
|
|
145
|
+
def parse(key)
|
|
146
|
+
return nil unless report?(key)
|
|
147
|
+
unless key.bytesize == 6
|
|
148
|
+
raise Tuile::Error,
|
|
149
|
+
"malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
code = (key[3].ord - 32) & ~MODIFIER_BITS
|
|
153
|
+
# XTerm reports coordinates 1-based (column N is encoded as N + 32);
|
|
154
|
+
# subtract 33 so that `x` and `y` are 0-based.
|
|
155
|
+
x = key[4].ord - 33
|
|
156
|
+
y = key[5].ord - 33
|
|
157
|
+
low = code & 3
|
|
158
|
+
if code.anybits?(64)
|
|
159
|
+
direction = %i[up down left right][low]
|
|
160
|
+
direction && ScrollEvent.new(direction, x, y)
|
|
161
|
+
elsif code.anybits?(32)
|
|
162
|
+
MoveEvent.new(button(low), x, y)
|
|
163
|
+
elsif low == 3
|
|
164
|
+
UpEvent.new(x, y)
|
|
165
|
+
else
|
|
166
|
+
DownEvent.new(button(low), x, y)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# @param low [Integer] the code's two low bits.
|
|
173
|
+
# @return [Symbol, nil]
|
|
174
|
+
def button(low) = %i[left middle right][low]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|