tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A listener slot: the callables registered on one `on_foo`, fired with one
|
|
5
|
+
# {Event}. The reader *is* the registrar:
|
|
6
|
+
#
|
|
7
|
+
# button.on_click { save } # a block
|
|
8
|
+
# field.on_value_change << method(:preview) # anything callable
|
|
9
|
+
# field.on_value_change.remove(method(:preview)) # …removed, holding nothing
|
|
10
|
+
# field.on_value_change.empty? # => true
|
|
11
|
+
#
|
|
12
|
+
# `Method#==` compares receiver and name, so a widget unsubscribes with the
|
|
13
|
+
# expression it subscribed with and holds nothing. A `Proc` equals only itself,
|
|
14
|
+
# which is why the block form returns the `Proc` it registered rather than the
|
|
15
|
+
# list.
|
|
16
|
+
#
|
|
17
|
+
# Declare one with {Declare}, never by hand. It is not a collection: {#each},
|
|
18
|
+
# {#size} and {#include?} are the whole surface.
|
|
19
|
+
#
|
|
20
|
+
# == There is no setter, and no `clear`
|
|
21
|
+
#
|
|
22
|
+
# The semantics are *append, and remove your own*, and the absent `on_foo=` is
|
|
23
|
+
# the point. A replaceable slot made every claim a contention: wherever the
|
|
24
|
+
# gem wires a listener onto a child it also exposes for tuning
|
|
25
|
+
# (`DateTimeField#date_field`, `RadioGroup#list`, `TabSheet#strip`), an app
|
|
26
|
+
# reaching for that slot silently broke the widget. With no replace operation
|
|
27
|
+
# that failure cannot be written.
|
|
28
|
+
#
|
|
29
|
+
# == An empty list is meaningful, and each slot's rdoc says what its empty means
|
|
30
|
+
#
|
|
31
|
+
# Nothing here reads empty as "nothing to do": while empty, a key-claiming slot
|
|
32
|
+
# declines the key so it keeps bubbling and {Screen#on_error} re-raises.
|
|
33
|
+
# {Declare}'s transition block is for the widget that must *install* something
|
|
34
|
+
# when the slot stops being empty.
|
|
35
|
+
#
|
|
36
|
+
# Duplicates are allowed: two adds fire twice, and one {#remove} balances one
|
|
37
|
+
# {#add}.
|
|
38
|
+
class Listeners
|
|
39
|
+
# A registered callable plus the arity verdict {#fire} needs, settled once
|
|
40
|
+
# at {#add} rather than per fire.
|
|
41
|
+
Entry = Data.define(:callable, :takes_event)
|
|
42
|
+
private_constant :Entry
|
|
43
|
+
|
|
44
|
+
# @param name [Symbol] the slot's name (`:on_click`), used in error messages.
|
|
45
|
+
# @yieldparam claimed [Boolean] `true` when the list just became non-empty,
|
|
46
|
+
# `false` when it just became empty — never called for any other change.
|
|
47
|
+
def initialize(name:, &claim_changed)
|
|
48
|
+
@name = name
|
|
49
|
+
@claim_changed = claim_changed
|
|
50
|
+
@entries = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Appends `callable` and returns it, so a lambda can be held for removal.
|
|
54
|
+
#
|
|
55
|
+
# cb = field.on_value_change.add(->(e) { preview(e.value) })
|
|
56
|
+
# field.on_value_change.remove(cb)
|
|
57
|
+
#
|
|
58
|
+
# Arity is settled here rather than per fire, so a listener that cannot take
|
|
59
|
+
# the event raises at registration instead of later inside a repaint on the
|
|
60
|
+
# loop thread.
|
|
61
|
+
#
|
|
62
|
+
# Deliberately does not call `Screen#check_locked`, alone among the gem's
|
|
63
|
+
# mutations: that would mean holding an owner, hence a `Screen` reach inside
|
|
64
|
+
# {Component::HasValue} and {Component::HasValidation}, plain mixins with
|
|
65
|
+
# none.
|
|
66
|
+
#
|
|
67
|
+
# @param callable [#call] the listener.
|
|
68
|
+
# @return [#call] `callable`.
|
|
69
|
+
# @raise [ArgumentError] if it is not callable, or cannot take zero or one
|
|
70
|
+
# argument.
|
|
71
|
+
def add(callable)
|
|
72
|
+
entry = Entry.new(callable, takes_event?(callable))
|
|
73
|
+
was_empty = @entries.empty?
|
|
74
|
+
@entries << entry
|
|
75
|
+
@claim_changed&.call(true) if was_empty
|
|
76
|
+
callable
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Appends `callable` and returns self, so registrations chain.
|
|
80
|
+
#
|
|
81
|
+
# field.on_value_change << method(:preview) << method(:log)
|
|
82
|
+
#
|
|
83
|
+
# @param callable [#call] the listener.
|
|
84
|
+
# @return [self]
|
|
85
|
+
def <<(callable)
|
|
86
|
+
add(callable)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Removes the **first** occurrence of `callable`.
|
|
91
|
+
#
|
|
92
|
+
# Not `Array#delete`, which drops every occurrence: one `remove` balances
|
|
93
|
+
# one {#add}, the only rule that composes when a widget and an app happen to
|
|
94
|
+
# register the same `method(:x)`.
|
|
95
|
+
#
|
|
96
|
+
# @param callable [#call] the listener to remove.
|
|
97
|
+
# @return [Boolean] whether it was there.
|
|
98
|
+
def remove(callable)
|
|
99
|
+
index = @entries.index { _1.callable == callable }
|
|
100
|
+
return false if index.nil?
|
|
101
|
+
|
|
102
|
+
@entries.delete_at(index)
|
|
103
|
+
@claim_changed&.call(false) if @entries.empty?
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param callable [#call]
|
|
108
|
+
# @return [Boolean] whether `callable` is registered.
|
|
109
|
+
def include?(callable) = @entries.any? { _1.callable == callable }
|
|
110
|
+
|
|
111
|
+
# @return [Boolean] whether nothing is registered — a state each slot gives
|
|
112
|
+
# its own meaning.
|
|
113
|
+
def empty? = @entries.empty?
|
|
114
|
+
|
|
115
|
+
# @return [Integer] how many listeners are registered, duplicates counted.
|
|
116
|
+
def size = @entries.size
|
|
117
|
+
|
|
118
|
+
# Yields each listener in registration order.
|
|
119
|
+
# @yieldparam callable [#call]
|
|
120
|
+
# @return [void]
|
|
121
|
+
def each
|
|
122
|
+
@entries.each { yield _1.callable }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Calls every listener in registration order — so the gem's own listener
|
|
126
|
+
# runs before any app's, a widget having wired itself in its constructor.
|
|
127
|
+
#
|
|
128
|
+
# A listener that raises **aborts the fire**: the ones behind it do not run
|
|
129
|
+
# and the exception propagates, as a single slot did. Isolating each
|
|
130
|
+
# listener would turn a bug into a partial fire that nothing reports.
|
|
131
|
+
#
|
|
132
|
+
# @param event [Event] passed to every listener that declared a parameter.
|
|
133
|
+
# @return [void]
|
|
134
|
+
def fire(event)
|
|
135
|
+
# Snapshot: a listener may add or remove during the fire, and the
|
|
136
|
+
# newcomer is meant to run on the *next* one.
|
|
137
|
+
@entries.dup.each { _1.takes_event ? _1.callable.call(event) : _1.callable.call }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# @param callable [#call]
|
|
143
|
+
# @return [Boolean] whether {#fire} passes it the event.
|
|
144
|
+
# @raise [ArgumentError]
|
|
145
|
+
def takes_event?(callable)
|
|
146
|
+
raise ArgumentError, "#{@name}: expected a callable, got #{callable.inspect}" unless callable.respond_to?(:call)
|
|
147
|
+
|
|
148
|
+
arity = callable.is_a?(Proc) || callable.is_a?(Method) ? callable.arity : callable.method(:call).arity
|
|
149
|
+
required = arity.negative? ? -arity - 1 : arity
|
|
150
|
+
if required > 1
|
|
151
|
+
raise ArgumentError, "#{@name}: a listener takes the event or nothing, but #{callable.inspect} requires " \
|
|
152
|
+
"#{required} arguments"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A negative arity means optional or splat parameters, which can absorb
|
|
156
|
+
# the event; only an exact zero declares it wants none.
|
|
157
|
+
!arity.zero?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Declares listener slots on the class or module that extends it — what
|
|
161
|
+
# `attr_accessor` is to a plain attribute:
|
|
162
|
+
#
|
|
163
|
+
# module HasValue
|
|
164
|
+
# extend Listeners::Declare
|
|
165
|
+
#
|
|
166
|
+
# # @!method on_value_change
|
|
167
|
+
# # Fired whenever the value actually changes — never on a no-op set.
|
|
168
|
+
# # @return [Listeners]
|
|
169
|
+
# listener :on_value_change
|
|
170
|
+
# end
|
|
171
|
+
#
|
|
172
|
+
# The `@!method` directive is not decoration: a `define_method` reader is
|
|
173
|
+
# invisible to sord, so without it the slot vanishes from `sig/tuile.rbs` —
|
|
174
|
+
# and it is the rdoc the slot owes rubydoc.info anyway.
|
|
175
|
+
#
|
|
176
|
+
# Extending {Component} covers every widget, since a subclass inherits the
|
|
177
|
+
# singleton method.
|
|
178
|
+
module Declare
|
|
179
|
+
# Defines the slot's reader, which returns the {Listeners} — or, given a
|
|
180
|
+
# block, registers it and returns the `Proc`.
|
|
181
|
+
#
|
|
182
|
+
# An optional block is the slot's *transition* block, run on the owner
|
|
183
|
+
# whenever the list becomes non-empty or empty again. It is what lets a
|
|
184
|
+
# widget install a bridge only while somebody is listening:
|
|
185
|
+
#
|
|
186
|
+
# listener :on_enter do |claimed|
|
|
187
|
+
# claimed ? editor.on_enter << @bridge : editor.on_enter.remove(@bridge)
|
|
188
|
+
# end
|
|
189
|
+
#
|
|
190
|
+
# The list is built on first read, so no mixin has to remember a
|
|
191
|
+
# constructor line — which means in-class code goes through the reader and
|
|
192
|
+
# never touches `@on_foo`, nil until somebody asks.
|
|
193
|
+
#
|
|
194
|
+
# @param name [Symbol] the slot's **full** name, `on_`-prefixed. Spelling
|
|
195
|
+
# it out is what keeps `on_value_change` greppable from its declaration.
|
|
196
|
+
# @yieldparam claimed [Boolean] whether the list just became non-empty.
|
|
197
|
+
# @return [Symbol] `name`.
|
|
198
|
+
# @raise [Error] unless `name` starts with `on_`.
|
|
199
|
+
def listener(name, &claim_changed)
|
|
200
|
+
unless name.to_s.start_with?("on_")
|
|
201
|
+
raise Error, "listener :#{name} — a listener slot is named on_…, and declared this way the string " \
|
|
202
|
+
"on_#{name} appears nowhere in lib/ for a grep to find. Declare it as :on_#{name}."
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
ivar = :"@#{name}"
|
|
206
|
+
define_method(name) do |&block|
|
|
207
|
+
slot = instance_variable_get(ivar)
|
|
208
|
+
unless slot
|
|
209
|
+
owner = self
|
|
210
|
+
transition = claim_changed && ->(claimed) { owner.instance_exec(claimed, &claim_changed) }
|
|
211
|
+
slot = instance_variable_set(ivar, Listeners.new(name: name, &transition))
|
|
212
|
+
end
|
|
213
|
+
block ? slot.add(block) : slot
|
|
214
|
+
end
|
|
215
|
+
name
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/tuile/mouse/router.rb
CHANGED
|
@@ -11,7 +11,9 @@ module Tuile
|
|
|
11
11
|
# Every event resolves against one **path**: the topmost popup containing the
|
|
12
12
|
# point, else the tiled content unless a modal popup is open
|
|
13
13
|
# ({ScreenPane#mouse_root_at}), then down through the shown children whose
|
|
14
|
-
# {Component#rect} contains it.
|
|
14
|
+
# {Component#rect} contains it. A `rect` is parent-relative, so the walk
|
|
15
|
+
# converts the point as it descends and each component is handed the event
|
|
16
|
+
# in **its own** coordinates — the same ones it paints in (`D_relative_rect`).
|
|
15
17
|
#
|
|
16
18
|
# - **{DownEvent}** — focuses the innermost {Component#focusable?} on that
|
|
17
19
|
# path, then offers {Component#handle_mouse_down?} innermost-first until one
|
|
@@ -25,8 +27,9 @@ module Tuile
|
|
|
25
27
|
#
|
|
26
28
|
# The two geometries are deliberately different. Focus follows `rect`, so a
|
|
27
29
|
# press on the dead tail a widget does not paint still focuses it; the
|
|
28
|
-
# handlers bubble only along the prefix whose
|
|
29
|
-
# the point, so that same press
|
|
30
|
+
# handlers bubble only along the prefix whose
|
|
31
|
+
# {Component#local_extent_rect} contains the point, so that same press
|
|
32
|
+
# activates nothing (`D_extent`).
|
|
30
33
|
#
|
|
31
34
|
# UI-thread-confined.
|
|
32
35
|
#
|
|
@@ -42,6 +45,14 @@ module Tuile
|
|
|
42
45
|
# diffs, and {#sync_hover} — run by {Screen#repaint} — drops members that
|
|
43
46
|
# were detached, hidden or reparented since, firing their exits.
|
|
44
47
|
class Router
|
|
48
|
+
# One step of a resolved path: a component, and the event point in *its*
|
|
49
|
+
# coordinates. The walk down is the only place that conversion is cheap —
|
|
50
|
+
# it already holds the running offset — so it is done there once rather
|
|
51
|
+
# than by each component asking where it is.
|
|
52
|
+
# @api private
|
|
53
|
+
Hit = Data.define(:component, :point)
|
|
54
|
+
private_constant :Hit
|
|
55
|
+
|
|
45
56
|
# @param screen [Screen]
|
|
46
57
|
def initialize(screen)
|
|
47
58
|
@screen = screen
|
|
@@ -118,7 +129,7 @@ module Tuile
|
|
|
118
129
|
path = rect_path(root, point)
|
|
119
130
|
pane.dismissing_popups_outside(point, left: event.button == :left) do
|
|
120
131
|
focus_innermost(root, path) if event.button == :left
|
|
121
|
-
claimant = bubble(path
|
|
132
|
+
claimant = bubble(within_extent(path), :handle_mouse_down?, event)
|
|
122
133
|
unless claimant.nil?
|
|
123
134
|
@grabbed = claimant
|
|
124
135
|
@grab_button = event.button
|
|
@@ -131,63 +142,79 @@ module Tuile
|
|
|
131
142
|
def release(event)
|
|
132
143
|
grabbed = @grabbed
|
|
133
144
|
release_grab
|
|
134
|
-
grabbed.
|
|
145
|
+
return if grabbed.nil? || !ComponentUtil.effectively_visible?(grabbed)
|
|
146
|
+
|
|
147
|
+
local = grabbed.to_local(event.point)
|
|
148
|
+
grabbed.__send__(:handle_mouse_up, event.with(x: local.x, y: local.y))
|
|
135
149
|
end
|
|
136
150
|
|
|
137
151
|
# @param event [MoveEvent]
|
|
138
152
|
# @return [void]
|
|
139
153
|
def move(event)
|
|
140
154
|
unless @grabbed.nil?
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
if ComponentUtil.effectively_visible?(@grabbed)
|
|
156
|
+
local = @grabbed.to_local(event.point)
|
|
157
|
+
@grabbed.__send__(:handle_mouse_drag, DragEvent.new(@grab_button, local.x, local.y))
|
|
158
|
+
end
|
|
143
159
|
return
|
|
144
160
|
end
|
|
145
161
|
return unless @level == :hover
|
|
146
162
|
|
|
147
163
|
path = extent_path(event.point)
|
|
148
|
-
rehover(path)
|
|
164
|
+
rehover(path.map(&:component))
|
|
149
165
|
bubble(path, :handle_mouse_move?, event)
|
|
150
166
|
end
|
|
151
167
|
|
|
152
168
|
# A non-modal overlay is never focused into: it sits outside the key scope,
|
|
153
169
|
# so focus there would make every keystroke go dead (`D_overlay`).
|
|
154
170
|
# @param root [Component, nil]
|
|
155
|
-
# @param path [Array<
|
|
171
|
+
# @param path [Array<Hit>]
|
|
156
172
|
# @return [void]
|
|
157
173
|
def focus_innermost(root, path)
|
|
158
174
|
return if root.is_a?(Component::Overlay) && !root.modal?
|
|
159
175
|
|
|
160
|
-
target = path.reverse_each.find(&:focusable?)
|
|
176
|
+
target = path.reverse_each.map(&:component).find(&:focusable?)
|
|
161
177
|
@screen.focused = target unless target.nil? || target.active?
|
|
162
178
|
end
|
|
163
179
|
|
|
164
|
-
# @param path [Array<
|
|
180
|
+
# @param path [Array<Hit>] root first.
|
|
165
181
|
# @param handler [Symbol] a routed `handle_mouse_…?`.
|
|
166
|
-
# @param event [Mouse::Event]
|
|
182
|
+
# @param event [Mouse::Event] in screen coordinates; each component is
|
|
183
|
+
# handed it converted to its own.
|
|
167
184
|
# @return [Component, nil] the component that answered true.
|
|
168
185
|
def bubble(path, handler, event)
|
|
169
186
|
# A handler may detach what is below it on the path (a click that swaps
|
|
170
187
|
# a slot's occupant), so re-check before each delivery.
|
|
171
|
-
path.reverse_each.find
|
|
188
|
+
hit = path.reverse_each.find do |h|
|
|
189
|
+
h.component.attached? &&
|
|
190
|
+
h.component.__send__(handler, event.with(x: h.point.x, y: h.point.y))
|
|
191
|
+
end
|
|
192
|
+
hit&.component
|
|
172
193
|
end
|
|
173
194
|
|
|
174
|
-
# @param point [Point]
|
|
175
|
-
# @return [Array<
|
|
176
|
-
#
|
|
177
|
-
def extent_path(point)
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
# @param point [Point] in screen coordinates.
|
|
196
|
+
# @return [Array<Hit>] the shown components under `point` whose extent
|
|
197
|
+
# contains it, root first.
|
|
198
|
+
def extent_path(point) = within_extent(rect_path(@screen.pane.mouse_root_at(point), point))
|
|
199
|
+
|
|
200
|
+
# @param path [Array<Hit>]
|
|
201
|
+
# @return [Array<Hit>] the prefix whose extent contains the point — the
|
|
202
|
+
# test each component answers in its own coordinates, so a widget that
|
|
203
|
+
# paints less than its rect has a dead tail (`D_extent`).
|
|
204
|
+
def within_extent(path) = path.take_while { _1.component.local_extent_rect.contains?(_1.point) }
|
|
180
205
|
|
|
181
206
|
# @param root [Component, nil]
|
|
182
|
-
# @param point [Point]
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
207
|
+
# @param point [Point] in `root`'s parent's coordinates — screen
|
|
208
|
+
# coordinates, since every mouse root is a {ScreenPane} child.
|
|
209
|
+
# @return [Array<Hit>] the shown components whose rect contains `point`,
|
|
210
|
+
# root first, each paired with the point in its own coordinates. Tiled
|
|
211
|
+
# siblings never overlap, so at most one child qualifies at each level.
|
|
186
212
|
def rect_path(root, point)
|
|
187
213
|
path = []
|
|
188
214
|
component = root
|
|
189
215
|
while component&.visible? && component.rect.contains?(point)
|
|
190
|
-
|
|
216
|
+
point = Point.new(point.x - component.rect.left, point.y - component.rect.top)
|
|
217
|
+
path << Hit.new(component:, point:)
|
|
191
218
|
component = component.children.find { _1.visible? && _1.rect.contains?(point) }
|
|
192
219
|
end
|
|
193
220
|
path
|
|
@@ -201,17 +228,6 @@ module Tuile
|
|
|
201
228
|
(old - chain).reverse_each { _1.__send__(:handle_mouse_exit) }
|
|
202
229
|
(chain - old).each { _1.__send__(:handle_mouse_enter) }
|
|
203
230
|
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
231
|
end
|
|
216
232
|
end
|
|
217
233
|
end
|
data/lib/tuile/mouse.rb
CHANGED
|
@@ -16,7 +16,13 @@ module Tuile
|
|
|
16
16
|
module Mouse
|
|
17
17
|
# Included by every mouse event class: a marker for `case`/`is_a?`, plus the
|
|
18
18
|
# {#point} they share.
|
|
19
|
+
#
|
|
20
|
+
# The include must stay qualified — a bare `Event` in this namespace
|
|
21
|
+
# resolves right back to here, and the gem-wide marker would be silently
|
|
22
|
+
# lost.
|
|
19
23
|
module Event
|
|
24
|
+
include Tuile::Event
|
|
25
|
+
|
|
20
26
|
# @return [Point] the event's position.
|
|
21
27
|
def point = Point.new(x, y)
|
|
22
28
|
end
|
|
@@ -88,12 +94,34 @@ module Tuile
|
|
|
88
94
|
MODES = { clicks: 1000, drag: 1002, hover: 1003 }.freeze
|
|
89
95
|
private_constant :MODES
|
|
90
96
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
97
|
+
# The SGR encoding mode, requested alongside whichever {MODES} rung the
|
|
98
|
+
# level picked — encoding and reporting are orthogonal modes. A terminal
|
|
99
|
+
# that does not understand it ignores the DECSET and keeps sending X10
|
|
100
|
+
# (`R_mouse_reporting`).
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
SGR_MODE = 1006
|
|
103
|
+
private_constant :SGR_MODE
|
|
104
|
+
|
|
105
|
+
# Button code layout, shared by both encodings: `button | 4 shift | 8 meta
|
|
106
|
+
# | 16 ctrl | 32 motion | 64 wheel` (`R_mouse_reporting`).
|
|
93
107
|
# @return [Integer]
|
|
94
108
|
MODIFIER_BITS = 4 | 8 | 16
|
|
95
109
|
private_constant :MODIFIER_BITS
|
|
96
110
|
|
|
111
|
+
# @return [String] the X10 report prefix, followed by three biased bytes.
|
|
112
|
+
X10_PREFIX = "\e[M"
|
|
113
|
+
private_constant :X10_PREFIX
|
|
114
|
+
|
|
115
|
+
# @return [String] the SGR report prefix, followed by a {SGR_REPORT} body.
|
|
116
|
+
SGR_PREFIX = "\e[<"
|
|
117
|
+
private_constant :SGR_PREFIX
|
|
118
|
+
|
|
119
|
+
# `\e[<Cb;x;y` then `M` for a press and `m` for a release — decimal and
|
|
120
|
+
# uncapped, where X10 packs each coordinate into a byte and dies past 223.
|
|
121
|
+
# @return [Regexp]
|
|
122
|
+
SGR_REPORT = /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
|
|
123
|
+
private_constant :SGR_REPORT
|
|
124
|
+
|
|
97
125
|
class << self
|
|
98
126
|
# Normalizes a `capture_mouse:` argument to a level.
|
|
99
127
|
# @param capture_mouse [Boolean, Symbol] `false`, `true` (== `:clicks`),
|
|
@@ -111,64 +139,103 @@ module Tuile
|
|
|
111
139
|
end
|
|
112
140
|
end
|
|
113
141
|
|
|
142
|
+
# The escape asking for that level, SGR encoding included:
|
|
143
|
+
#
|
|
144
|
+
# Mouse.start_tracking(:clicks) # => "\e[?1006h\e[?1000h"
|
|
145
|
+
#
|
|
114
146
|
# @param level [Symbol] one of {LEVELS}.
|
|
115
147
|
# @return [String] the escape enabling that level.
|
|
116
|
-
def start_tracking(level) = "\e[?#{MODES.fetch(level)}h"
|
|
148
|
+
def start_tracking(level) = "\e[?#{SGR_MODE}h\e[?#{MODES.fetch(level)}h"
|
|
117
149
|
|
|
118
150
|
# @param level [Symbol] one of {LEVELS}.
|
|
119
|
-
# @return [String] the escape disabling that level.
|
|
120
|
-
def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l"
|
|
151
|
+
# @return [String] the escape disabling that level, reporting first.
|
|
152
|
+
def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l\e[?#{SGR_MODE}l"
|
|
121
153
|
|
|
122
|
-
# Whether `key` is a mouse report
|
|
123
|
-
# regardless of length
|
|
124
|
-
#
|
|
154
|
+
# Whether `key` is a mouse report — the X10 `\e[M` prefix or the SGR
|
|
155
|
+
# `\e[<` one, regardless of length. {.parse} is the place that validates
|
|
156
|
+
# the full shape and raises on malformed input.
|
|
125
157
|
# @param key [String] key read via {Keys.getkey}
|
|
126
158
|
# @return [Boolean]
|
|
127
|
-
def report?(key) = key.start_with?(
|
|
159
|
+
def report?(key) = key.start_with?(X10_PREFIX, SGR_PREFIX)
|
|
128
160
|
|
|
129
|
-
# Parses
|
|
130
|
-
# {
|
|
131
|
-
#
|
|
161
|
+
# Parses a mouse report in either wire encoding into one of {DownEvent},
|
|
162
|
+
# {UpEvent}, {ScrollEvent} or {MoveEvent}. Modifier bits are ignored.
|
|
163
|
+
#
|
|
164
|
+
# Mouse.parse("\e[M !\"") # => X10: DownEvent[:left, 0, 0]
|
|
165
|
+
# Mouse.parse("\e[<0;1;1M") # => SGR: DownEvent[:left, 0, 0]
|
|
166
|
+
# Mouse.parse("\e[<0;1;1m") # => SGR: UpEvent[0, 0]
|
|
167
|
+
#
|
|
168
|
+
# Which encoding arrived is invisible in the result, deliberately: SGR
|
|
169
|
+
# names the button on a release and X10 cannot, so the button is dropped
|
|
170
|
+
# (`D_mouse_dispatch`).
|
|
132
171
|
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
# the
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
# the
|
|
139
|
-
# corruption then surfaces as garbled keystrokes in focused inputs
|
|
140
|
-
# rather than as a parser failure pointing at the actual cause.
|
|
172
|
+
# A prefix without a whole well-formed report raises rather than
|
|
173
|
+
# truncates: it is always a bug in the upstream key-reader — a short read
|
|
174
|
+
# lost the tail, a long one over-consumed into the next escape sequence —
|
|
175
|
+
# and swallowing it corrupts the *next* getkey, surfacing as garbled
|
|
176
|
+
# keystrokes in focused inputs rather than as a parser failure pointing at
|
|
177
|
+
# the cause.
|
|
141
178
|
# @param key [String] key read via {Keys.getkey}
|
|
142
|
-
# @return [Event, nil] `nil` if `key` is not a mouse report, or reports
|
|
179
|
+
# @return [Mouse::Event, nil] `nil` if `key` is not a mouse report, or reports
|
|
143
180
|
# a wheel button beyond the four directions.
|
|
144
181
|
# @raise [Tuile::Error] if `key` is a malformed mouse report
|
|
145
182
|
def parse(key)
|
|
146
183
|
return nil unless report?(key)
|
|
184
|
+
|
|
185
|
+
key.start_with?(SGR_PREFIX) ? parse_sgr(key) : parse_x10(key)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# @param key [String] a report known to carry {X10_PREFIX}.
|
|
191
|
+
# @return [Mouse::Event, nil]
|
|
192
|
+
def parse_x10(key)
|
|
147
193
|
unless key.bytesize == 6
|
|
148
194
|
raise Tuile::Error,
|
|
149
195
|
"malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
|
|
150
196
|
end
|
|
151
197
|
|
|
152
198
|
code = (key[3].ord - 32) & ~MODIFIER_BITS
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
199
|
+
# Coordinates are 1-based and biased by 32, so - 33 lands them 0-based.
|
|
200
|
+
# X10's release is the anonymous code 3 exactly: button-less motion is
|
|
201
|
+
# 32 | 3 and the rightwards wheel 64 | 3, neither of them an up.
|
|
202
|
+
event(code, key[4].ord - 33, key[5].ord - 33, released: code == 3)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @param key [String] a report known to carry {SGR_PREFIX}.
|
|
206
|
+
# @return [Mouse::Event, nil]
|
|
207
|
+
def parse_sgr(key)
|
|
208
|
+
match = SGR_REPORT.match(key)
|
|
209
|
+
unless match
|
|
210
|
+
raise Tuile::Error,
|
|
211
|
+
"malformed mouse event: expected \\e[<Cb;x;y and M or m, got #{key.inspect}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
code, x, y = match.values_at(1, 2, 3).map(&:to_i)
|
|
215
|
+
event(code & ~MODIFIER_BITS, x - 1, y - 1, released: match[4] == "m")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The half both encodings share: a button code (unbiased, modifier bits
|
|
219
|
+
# already cleared) and 0-based coordinates become an event.
|
|
220
|
+
# @param code [Integer]
|
|
221
|
+
# @param x [Integer]
|
|
222
|
+
# @param y [Integer]
|
|
223
|
+
# @param released [Boolean] whether the report says a button came up.
|
|
224
|
+
# @return [Mouse::Event, nil]
|
|
225
|
+
def event(code, x, y, released:)
|
|
157
226
|
low = code & 3
|
|
158
227
|
if code.anybits?(64)
|
|
159
228
|
direction = %i[up down left right][low]
|
|
160
229
|
direction && ScrollEvent.new(direction, x, y)
|
|
230
|
+
elsif released
|
|
231
|
+
UpEvent.new(x, y)
|
|
161
232
|
elsif code.anybits?(32)
|
|
162
233
|
MoveEvent.new(button(low), x, y)
|
|
163
|
-
elsif low == 3
|
|
164
|
-
UpEvent.new(x, y)
|
|
165
234
|
else
|
|
166
235
|
DownEvent.new(button(low), x, y)
|
|
167
236
|
end
|
|
168
237
|
end
|
|
169
238
|
|
|
170
|
-
private
|
|
171
|
-
|
|
172
239
|
# @param low [Integer] the code's two low bits.
|
|
173
240
|
# @return [Symbol, nil]
|
|
174
241
|
def button(low) = %i[left middle right][low]
|
data/lib/tuile/point.rb
CHANGED
|
@@ -10,5 +10,11 @@ module Tuile
|
|
|
10
10
|
class Point < Data.define(:x, :y)
|
|
11
11
|
# @return [String]
|
|
12
12
|
def to_s = "#{x},#{y}"
|
|
13
|
+
|
|
14
|
+
# `(0, 0)`, named for the value rather than for a role. Every coordinate
|
|
15
|
+
# space has an origin, and {Canvas#origin} is a *different* point — where a
|
|
16
|
+
# canvas's zero lands on its backend, which is rarely this one.
|
|
17
|
+
# @return [Point]
|
|
18
|
+
ZERO = new(0, 0)
|
|
13
19
|
end
|
|
14
20
|
end
|
data/lib/tuile/rect.rb
CHANGED
|
@@ -26,6 +26,15 @@ module Tuile
|
|
|
26
26
|
Rect.new(point.x, point.y, width, height)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# {#at}'s relative counterpart — the same size, shifted. What moves a
|
|
30
|
+
# rectangle between two coordinate spaces one offset apart, either way;
|
|
31
|
+
# paint and screen, a {Canvas#origin} apart, are the pair Tuile has.
|
|
32
|
+
# @param point [Point] added to {#left} and {#top}.
|
|
33
|
+
# @return [Rect] moved by `point`.
|
|
34
|
+
def moved_by(point)
|
|
35
|
+
Rect.new(left + point.x, top + point.y, width, height)
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
# Centers the rectangle — keeps {#width} and {#height} but modifies
|
|
30
39
|
# {#top} and {#left} so that the rectangle is centered on a screen.
|
|
31
40
|
# @param screen_size [Size] screen size
|
|
@@ -43,6 +52,13 @@ module Tuile
|
|
|
43
52
|
new_width == width && new_height == height ? self : Rect.new(left, top, new_width, new_height)
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
# Half-open: the `left`/`top` edges are inside, `right`/`bottom` are not,
|
|
56
|
+
# so two abutting rectangles never both claim the cell they share.
|
|
57
|
+
#
|
|
58
|
+
# r = Rect.new(0, 0, 2, 2)
|
|
59
|
+
# r.contains?(Point.new(0, 0)) # => true
|
|
60
|
+
# r.contains?(Point.new(2, 0)) # => false — right edge is outside
|
|
61
|
+
#
|
|
46
62
|
# @param point [Point]
|
|
47
63
|
# @return [Boolean]
|
|
48
64
|
def contains?(point)
|
|
@@ -61,6 +77,23 @@ module Tuile
|
|
|
61
77
|
other.top + other.height <= top + height
|
|
62
78
|
end
|
|
63
79
|
|
|
80
|
+
# The region both rectangles cover, in the coordinate space they share.
|
|
81
|
+
# Half-open edges, like {#contains?}.
|
|
82
|
+
#
|
|
83
|
+
# Disjoint rectangles yield an {#empty? empty} rectangle rather than `nil`,
|
|
84
|
+
# so folding a chain of them needs no nil test per level and the caller
|
|
85
|
+
# asks {#empty?} once at the end — which is what a clip resolved up an
|
|
86
|
+
# ancestor chain does.
|
|
87
|
+
# @param other [Rect]
|
|
88
|
+
# @return [Rect]
|
|
89
|
+
def intersect(other)
|
|
90
|
+
new_left = [left, other.left].max
|
|
91
|
+
new_top = [top, other.top].max
|
|
92
|
+
Rect.new(new_left, new_top,
|
|
93
|
+
[[left + width, other.left + other.width].min - new_left, 0].max,
|
|
94
|
+
[[top + height, other.top + other.height].min - new_top, 0].max)
|
|
95
|
+
end
|
|
96
|
+
|
|
64
97
|
# @return [Size]
|
|
65
98
|
def size = Size.new(width, height)
|
|
66
99
|
|