zaniah 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 +12 -0
- data/README.md +7 -4
- data/docs/accessibility.md +38 -12
- data/docs/adr/010-text-overlay-layout.md +24 -0
- data/docs/adr/011-stable-accessibility-identity.md +26 -0
- data/docs/components.md +11 -3
- data/docs/drag_drop.md +46 -0
- data/docs/layout.md +20 -0
- data/docs/text.md +37 -0
- data/docs/tui.md +1 -1
- data/lib/zaniah/accessibility/linux/service.rb +23 -8
- data/lib/zaniah/accessibility/linux.rb +1 -1
- data/lib/zaniah/accessibility/mac.rb +20 -5
- data/lib/zaniah/accessibility/native_tree.rb +13 -3
- data/lib/zaniah/accessibility/node.rb +5 -2
- data/lib/zaniah/accessibility/tree.rb +112 -6
- data/lib/zaniah/accessibility/windows/provider.rb +56 -14
- data/lib/zaniah/accessibility/windows.rb +4 -0
- data/lib/zaniah/accessibility.rb +7 -0
- data/lib/zaniah/drag_drop.rb +246 -0
- data/lib/zaniah/input/keymap.rb +3 -0
- data/lib/zaniah/layout/engine.rb +10 -5
- data/lib/zaniah/text.rb +252 -15
- data/lib/zaniah/text_system/line_breaker.rb +5 -1
- data/lib/zaniah/text_system/low_resolution_text_cache.rb +133 -0
- data/lib/zaniah/text_system/paragraph.rb +161 -14
- data/lib/zaniah/text_system.rb +1 -0
- data/lib/zaniah/ui/component.rb +2 -2
- data/lib/zaniah/ui/data.rb +0 -131
- data/lib/zaniah/ui/pane_grid.rb +327 -0
- data/lib/zaniah/ui/tree_view.rb +333 -0
- data/lib/zaniah/ui.rb +2 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/accessibility.rbs +31 -2
- data/sig/drag_drop.rbs +41 -0
- data/sig/elements.rbs +20 -0
- data/sig/text.rbs +69 -3
- data/sig/ui.rbs +29 -0
- metadata +13 -5
|
@@ -3,20 +3,28 @@
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
module Accessibility
|
|
5
5
|
Change = Data.define(:kind, :path, :before, :after)
|
|
6
|
+
Event = Data.define(:kind, :id, :path, :node)
|
|
6
7
|
|
|
7
8
|
class Tree
|
|
8
|
-
attr_reader :root, :changes, :revision
|
|
9
|
+
attr_reader :root, :changes, :events, :revision
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
|
-
@root, @changes, @revision = nil, [], 0
|
|
12
|
+
@root, @changes, @events, @revision = nil, [], [], 0
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def update(renderable, context, overlays: [])
|
|
16
|
+
@next_action_owners = {}
|
|
15
17
|
nodes = [renderable, *overlays].filter_map { |item| semantic(item, context) }
|
|
16
18
|
next_root = nodes.length == 1 ? nodes.first : nodes.empty? ? nil : Accessibility.node(role: :group, label: "Application", children: nodes)
|
|
19
|
+
validate_ids(next_root) if next_root
|
|
17
20
|
@changes = diff(@root, next_root).freeze
|
|
18
|
-
|
|
21
|
+
@events = events_for(@changes).freeze
|
|
22
|
+
if @changes.empty?
|
|
23
|
+
@action_owners = previous_action_owners(@root, next_root)
|
|
24
|
+
return false
|
|
25
|
+
end
|
|
19
26
|
@root = next_root
|
|
27
|
+
@action_owners = @next_action_owners
|
|
20
28
|
@revision += 1
|
|
21
29
|
true
|
|
22
30
|
end
|
|
@@ -28,12 +36,25 @@ module Zaniah
|
|
|
28
36
|
|
|
29
37
|
def find(&predicate) = each.find { |node, _path| predicate.call(node) }&.first
|
|
30
38
|
|
|
39
|
+
def perform(node, action)
|
|
40
|
+
owner = @action_owners&.[](node.object_id)
|
|
41
|
+
owner&.accessibility_action(node, action)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def assign(node, value)
|
|
45
|
+
owner = @action_owners&.[](node.object_id)
|
|
46
|
+
owner&.accessibility_value(node, value) if owner&.respond_to?(:accessibility_value)
|
|
47
|
+
end
|
|
48
|
+
|
|
31
49
|
private
|
|
32
50
|
|
|
33
51
|
def semantic(renderable, context)
|
|
34
52
|
return unless renderable
|
|
35
53
|
node = renderable.accessibility_node(context) if renderable.respond_to?(:accessibility_node)
|
|
36
|
-
|
|
54
|
+
if node.is_a?(Node)
|
|
55
|
+
register_actions(node, renderable) if renderable.respond_to?(:accessibility_action)
|
|
56
|
+
return node
|
|
57
|
+
end
|
|
37
58
|
children = renderable.respond_to?(:children) ? renderable.children : []
|
|
38
59
|
nodes = children.filter_map { |child| semantic(child, context) }
|
|
39
60
|
Accessibility.node(role: :group, children: nodes) unless nodes.empty?
|
|
@@ -46,12 +67,97 @@ module Zaniah
|
|
|
46
67
|
if before.with(children: []) != after.with(children: [])
|
|
47
68
|
changes << Change.new(kind: :updated, path: path.freeze, before: before, after: after)
|
|
48
69
|
end
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
|
|
71
|
+
before_ids = indexed_ids(before.children)
|
|
72
|
+
matched = {}
|
|
73
|
+
pairs = after.children.map.with_index do |child, after_index|
|
|
74
|
+
before_index = child.id.nil? ? after_index : before_ids[child.id]
|
|
75
|
+
before_child = before.children[before_index] if before_index && !matched[before_index]
|
|
76
|
+
before_child = nil if child.id.nil? && before_child && !before_child.id.nil?
|
|
77
|
+
matched[before_index] = true if before_child
|
|
78
|
+
[child, after_index, before_child, before_index]
|
|
79
|
+
end
|
|
80
|
+
before.children.each_with_index do |child, index|
|
|
81
|
+
changes.concat(diff(child, nil, path + [index])) unless matched[index]
|
|
82
|
+
end
|
|
83
|
+
pairs.each do |child, after_index, before_child, before_index|
|
|
84
|
+
unless before_child
|
|
85
|
+
changes.concat(diff(nil, child, path + [after_index]))
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
if !child.id.nil? && before_index != after_index
|
|
89
|
+
changes << Change.new(kind: :moved, path: (path + [after_index]).freeze,
|
|
90
|
+
before: before_child, after: child)
|
|
91
|
+
end
|
|
92
|
+
changes.concat(diff(before_child, child, path + [after_index]))
|
|
51
93
|
end
|
|
52
94
|
changes
|
|
53
95
|
end
|
|
54
96
|
|
|
97
|
+
def indexed_ids(children)
|
|
98
|
+
children.each_with_index.each_with_object({}) do |(child, index), result|
|
|
99
|
+
result[child.id] = index unless child.id.nil?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def events_for(changes)
|
|
104
|
+
changes.flat_map do |change|
|
|
105
|
+
node = change.after || change.before
|
|
106
|
+
path = change.path
|
|
107
|
+
id = node.id.nil? ? path : node.id
|
|
108
|
+
events = [Event.new(kind: {added: :structure, removed: :structure, updated: :property, moved: :layout}.fetch(change.kind),
|
|
109
|
+
id: id, path: path, node: node)]
|
|
110
|
+
special_nodes(change).each do |after, before, special_path|
|
|
111
|
+
special_id = after.id.nil? ? special_path : after.id
|
|
112
|
+
if after.states[:focused] && !before&.states&.[](:focused)
|
|
113
|
+
events << Event.new(kind: :focus, id: special_id, path: special_path, node: after)
|
|
114
|
+
end
|
|
115
|
+
if after.states[:live] && live_changed?(change.with(path: special_path, before: before, after: after))
|
|
116
|
+
events << Event.new(kind: :announcement, id: special_id, path: special_path, node: after)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
events
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def special_nodes(change)
|
|
124
|
+
return [] unless change.after
|
|
125
|
+
return [] if change.kind == :moved
|
|
126
|
+
return [[change.after, change.before, change.path]] unless change.kind == :added && change.after
|
|
127
|
+
nodes = []
|
|
128
|
+
walk(change.after, change.path) { |node, path| nodes << [node, nil, path] }
|
|
129
|
+
nodes
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def live_changed?(change)
|
|
133
|
+
!change.before || change.before.label != change.after.label || change.before.value != change.after.value ||
|
|
134
|
+
change.before.states[:revision] != change.after.states[:revision]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def register_actions(node, owner)
|
|
138
|
+
@next_action_owners[node.object_id] = owner unless node.actions.empty?
|
|
139
|
+
node.children.each { |child| register_actions(child, owner) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_ids(node)
|
|
143
|
+
ids = node.children.map(&:id).reject(&:nil?)
|
|
144
|
+
raise ArgumentError, "accessibility ids must be unique among siblings" unless ids.uniq.length == ids.length
|
|
145
|
+
node.children.each { |child| validate_ids(child) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def previous_action_owners(before, after)
|
|
149
|
+
owners = {}
|
|
150
|
+
return owners unless before && after
|
|
151
|
+
pending = [[before, after]]
|
|
152
|
+
until pending.empty?
|
|
153
|
+
previous, current = pending.pop
|
|
154
|
+
owner = @next_action_owners[current.object_id]
|
|
155
|
+
owners[previous.object_id] = owner if owner
|
|
156
|
+
previous.children.zip(current.children) { |pair| pending << pair }
|
|
157
|
+
end
|
|
158
|
+
owners
|
|
159
|
+
end
|
|
160
|
+
|
|
55
161
|
def walk(node, path = [], &block)
|
|
56
162
|
yield node, path.freeze
|
|
57
163
|
node.children.each_with_index { |child, index| walk(child, path + [index], &block) }
|
|
@@ -20,7 +20,8 @@ module Zaniah
|
|
|
20
20
|
simple: "d6dd68d1-86fd-4332-8666-9abedea2d24c",
|
|
21
21
|
fragment: "f7063da8-8359-439c-9297-bbc5299a7d87",
|
|
22
22
|
root: "620ce2a5-ab8f-40a9-86cb-de3c75599b58",
|
|
23
|
-
invoke: "54fcb24b-e18e-47a2-b4d3-eccbe77599a2"
|
|
23
|
+
invoke: "54fcb24b-e18e-47a2-b4d3-eccbe77599a2",
|
|
24
|
+
range: "36dc7aef-33e6-4691-afe1-2be7274b3d33"
|
|
24
25
|
}.transform_values { |value| first, second, third, fourth, fifth = value.split("-"); [first.hex, second.hex, third.hex].pack("Vvv") + [fourth + fifth].pack("H*") }.freeze
|
|
25
26
|
|
|
26
27
|
CONTROL_TYPES = {
|
|
@@ -42,16 +43,27 @@ module Zaniah
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def update(root)
|
|
45
|
-
@tree = NativeTree.new(root)
|
|
46
|
+
@tree = NativeTree.new(root, previous: @tree)
|
|
46
47
|
@tree.each do |entry|
|
|
47
|
-
|
|
48
|
+
provider = @providers[entry.runtime_id] ||= Element.new(self)
|
|
49
|
+
provider.entry = entry
|
|
48
50
|
end
|
|
49
|
-
@root_provider = @providers[@tree.root.
|
|
51
|
+
@root_provider = @providers[@tree.root.runtime_id]
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
def provider(entry) = entry && @providers[entry.
|
|
54
|
+
def provider(entry) = entry && @providers[entry.runtime_id]
|
|
53
55
|
def entry(path) = @tree[path]
|
|
54
56
|
|
|
57
|
+
def raise_events(events)
|
|
58
|
+
events.each do |event|
|
|
59
|
+
provider = %i[structure layout].include?(event.kind) ? @root_provider : provider(@tree[event.path]) || @root_provider
|
|
60
|
+
id = Windows::AUTOMATION_EVENTS[event.kind]
|
|
61
|
+
core.fn(:UiaRaiseAutomationEvent, [P, I], I).call(provider.pointer(:simple), id) if id
|
|
62
|
+
end
|
|
63
|
+
rescue Fiddle::DLError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
def screen_bounds(entry)
|
|
56
68
|
bounds = @tree.bounds(entry)
|
|
57
69
|
point = [(bounds.x * scale).round, (bounds.y * scale).round].pack("l2")
|
|
@@ -127,7 +139,15 @@ module Zaniah
|
|
|
127
139
|
root = unknown + [closure(I, [P, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE, P]) { |this, x, y, output| interface(this).from_point(x, y, output) },
|
|
128
140
|
closure(I, [P, P]) { |this, output| interface(this).focus(output) }]
|
|
129
141
|
invoke = unknown + [closure(I, [P]) { |this| interface(this).invoke }]
|
|
130
|
-
|
|
142
|
+
range = unknown + [closure(I, [P, Fiddle::TYPE_DOUBLE]) { |this, value| interface(this).set_range_value(value) },
|
|
143
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:value, output) },
|
|
144
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:readonly, output) },
|
|
145
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:maximum, output) },
|
|
146
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:minimum, output) },
|
|
147
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:large_change, output) },
|
|
148
|
+
closure(I, [P, P]) { |this, output| interface(this).range_property(:small_change, output) }]
|
|
149
|
+
@vtables = {simple: vtable(simple), fragment: vtable(fragment), root: vtable(root),
|
|
150
|
+
invoke: vtable(invoke), range: vtable(range)}.freeze
|
|
131
151
|
end
|
|
132
152
|
|
|
133
153
|
def closure(result, arguments, &block)
|
|
@@ -154,10 +174,10 @@ module Zaniah
|
|
|
154
174
|
attr_accessor :entry
|
|
155
175
|
attr_reader :bridge
|
|
156
176
|
|
|
157
|
-
def initialize(bridge
|
|
177
|
+
def initialize(bridge)
|
|
158
178
|
self.class.install
|
|
159
|
-
@bridge, @
|
|
160
|
-
%i[simple fragment root invoke].each do |kind|
|
|
179
|
+
@bridge, @references, @interfaces = bridge, 1, {}
|
|
180
|
+
%i[simple fragment root invoke range].each do |kind|
|
|
161
181
|
pointer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
|
|
162
182
|
pointer[0, Fiddle::SIZEOF_VOIDP] = [self.class.vtables.fetch(kind).to_i].pack("J")
|
|
163
183
|
@interfaces[kind] = pointer
|
|
@@ -170,7 +190,8 @@ module Zaniah
|
|
|
170
190
|
def query(iid, output)
|
|
171
191
|
requested = GUIDS.key(Fiddle::Pointer.new(iid)[0, 16])
|
|
172
192
|
requested = :simple if requested == :unknown
|
|
173
|
-
supported = requested && (requested != :root || @entry.parent.nil?) &&
|
|
193
|
+
supported = requested && (requested != :root || @entry.parent.nil?) &&
|
|
194
|
+
(requested != :invoke || invokable?) && (requested != :range || range?)
|
|
174
195
|
return self.class.write_pointer(output, 0) && E_NOINTERFACE unless supported
|
|
175
196
|
add_ref
|
|
176
197
|
self.class.write_pointer(output, pointer(requested))
|
|
@@ -180,9 +201,10 @@ module Zaniah
|
|
|
180
201
|
def release = (@references = [@references - 1, 1].max)
|
|
181
202
|
|
|
182
203
|
def pattern(pattern, output)
|
|
183
|
-
|
|
204
|
+
kind = pattern == 10_000 && invokable? ? :invoke : pattern == 10_003 && range? ? :range : nil
|
|
205
|
+
return self.class.write_pointer(output, 0) unless kind
|
|
184
206
|
add_ref
|
|
185
|
-
self.class.write_pointer(output, pointer(
|
|
207
|
+
self.class.write_pointer(output, pointer(kind))
|
|
186
208
|
end
|
|
187
209
|
|
|
188
210
|
def property(property, output)
|
|
@@ -194,6 +216,9 @@ module Zaniah
|
|
|
194
216
|
when 30_008 then variant_bool(variant, @bridge.focused_provider.equal?(self))
|
|
195
217
|
when 30_009 then variant_bool(variant, focusable?)
|
|
196
218
|
when 30_010 then variant_bool(variant, !@entry.node.states[:disabled])
|
|
219
|
+
when 30_011
|
|
220
|
+
id = @entry.node.id
|
|
221
|
+
variant_bstr(variant, id.nil? ? @entry.runtime_id : id)
|
|
197
222
|
when 30_012 then variant_bstr(variant, "Zaniah")
|
|
198
223
|
when 30_016, 30_017 then variant_bool(variant, true)
|
|
199
224
|
when 30_022 then variant_bool(variant, @bridge.tree.bounds(@entry).empty?)
|
|
@@ -254,14 +279,31 @@ module Zaniah
|
|
|
254
279
|
end
|
|
255
280
|
|
|
256
281
|
def invoke
|
|
257
|
-
|
|
258
|
-
|
|
282
|
+
actions = @entry.node.role == :treeitem ? %i[select collapse expand] : %i[press toggle select sort expand collapse dismiss]
|
|
283
|
+
action = actions.find { |candidate| @entry.node.actions.include?(candidate) }
|
|
284
|
+
Accessibility.perform(@bridge.window, @entry.node, action, bounds: @bridge.tree.bounds(@entry)) if action
|
|
285
|
+
S_OK
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def set_range_value(value)
|
|
289
|
+
Accessibility.assign(@bridge.window, @entry.node, value) ? S_OK : E_FAIL
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def range_property(name, output)
|
|
293
|
+
values = {value: @entry.node.value, readonly: 0, maximum: @entry.node.states.fetch(:maximum, 1.0),
|
|
294
|
+
minimum: @entry.node.states.fetch(:minimum, 0.0), large_change: @entry.node.states.fetch(:large_change, 0.1),
|
|
295
|
+
small_change: @entry.node.states.fetch(:small_change, 0.01)}
|
|
296
|
+
pointer = Fiddle::Pointer.new(output)
|
|
297
|
+
name == :readonly ? pointer[0, 4] = [values[name]].pack("l") : pointer[0, 8] = [Float(values[name])].pack("d")
|
|
259
298
|
S_OK
|
|
260
299
|
end
|
|
261
300
|
|
|
262
301
|
private
|
|
263
302
|
|
|
264
303
|
def invokable? = @entry.node.actions.any? { |action| %i[press toggle select sort expand collapse dismiss].include?(action) }
|
|
304
|
+
def range? = @entry.node.value.is_a?(Numeric) &&
|
|
305
|
+
@entry.node.states.key?(:minimum) && @entry.node.states.key?(:maximum) &&
|
|
306
|
+
(@entry.node.actions & %i[increment decrement]).any?
|
|
265
307
|
def focusable? = !@entry.node.states[:disabled] && (!@entry.node.actions.empty? || %i[textbox searchbox slider combobox].include?(@entry.node.role))
|
|
266
308
|
def variant_i4(pointer, value) = (pointer[0, 2] = [3].pack("v"); pointer[8, 4] = [value].pack("l"))
|
|
267
309
|
def variant_bool(pointer, value) = (pointer[0, 2] = [11].pack("v"); pointer[8, 2] = [value ? -1 : 0].pack("s"))
|
|
@@ -6,6 +6,8 @@ module Zaniah
|
|
|
6
6
|
module Accessibility
|
|
7
7
|
module Windows
|
|
8
8
|
EVENT_OBJECT_REORDER = 0x8004
|
|
9
|
+
AUTOMATION_EVENTS = {structure: 20_002, property: 20_004, focus: 20_005,
|
|
10
|
+
layout: 20_008, announcement: 20_024}.freeze
|
|
9
11
|
OBJID_CLIENT = -4
|
|
10
12
|
CHILDID_SELF = 0
|
|
11
13
|
|
|
@@ -15,6 +17,7 @@ module Zaniah
|
|
|
15
17
|
return unless root && window.respond_to?(:handle)
|
|
16
18
|
bridge = bridges[window.handle.to_i] ||= Provider::Bridge.new(window)
|
|
17
19
|
bridge.update(root)
|
|
20
|
+
bridge.raise_events(window.accessibility_tree.events)
|
|
18
21
|
user = window.instance_variable_get(:@user)
|
|
19
22
|
return unless user
|
|
20
23
|
user.fn(:NotifyWinEvent,
|
|
@@ -32,6 +35,7 @@ module Zaniah
|
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def close(window) = bridges.delete(window.handle.to_i)
|
|
38
|
+
def event_ids(events) = events.filter_map { |event| AUTOMATION_EVENTS[event.kind] }
|
|
35
39
|
def bridges = (@bridges ||= {})
|
|
36
40
|
end
|
|
37
41
|
end
|
data/lib/zaniah/accessibility.rb
CHANGED
|
@@ -35,6 +35,8 @@ module Zaniah
|
|
|
35
35
|
|
|
36
36
|
def perform(window, node, action, bounds: node&.bounds)
|
|
37
37
|
return false unless node && node.actions.include?(action)
|
|
38
|
+
direct = window.accessibility_tree.perform(node, action) if window.respond_to?(:accessibility_tree)
|
|
39
|
+
return !!direct unless direct.nil?
|
|
38
40
|
if action == :dismiss
|
|
39
41
|
window.input(Input::KeyDown.new("esc", false))
|
|
40
42
|
return true
|
|
@@ -51,6 +53,11 @@ module Zaniah
|
|
|
51
53
|
true
|
|
52
54
|
end
|
|
53
55
|
|
|
56
|
+
def assign(window, node, value)
|
|
57
|
+
return false unless node && window.respond_to?(:accessibility_tree)
|
|
58
|
+
!!window.accessibility_tree.assign(node, value)
|
|
59
|
+
end
|
|
60
|
+
|
|
54
61
|
def focus_at(window, position)
|
|
55
62
|
hit = window.dispatcher.hits.reverse.find { |candidate| candidate.bounds&.contains?(position) }
|
|
56
63
|
owner = hit&.owner
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module DragDrop
|
|
5
|
+
Target = Data.define(:id, :position)
|
|
6
|
+
Event = Data.define(:phase, :source_id, :target, :point, :reason)
|
|
7
|
+
|
|
8
|
+
class Reorder
|
|
9
|
+
POSITIONS = %i[before after inside].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :source_id, :target, :announcement
|
|
12
|
+
|
|
13
|
+
def initialize(threshold: 4, locate:, keyboard: nil, label: nil)
|
|
14
|
+
@threshold = Float(threshold)
|
|
15
|
+
raise ArgumentError, "drag threshold must be nonnegative" unless @threshold.finite? && @threshold >= 0
|
|
16
|
+
raise ArgumentError, "a pointer target locator is required" unless locate.respond_to?(:call)
|
|
17
|
+
raise TypeError, "keyboard target locator must respond to call" if keyboard && !keyboard.respond_to?(:call)
|
|
18
|
+
raise TypeError, "item labeler must respond to call" if label && !label.respond_to?(:call)
|
|
19
|
+
|
|
20
|
+
@locate, @keyboard, @label = locate, keyboard, label || ->(id) { id.to_s }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def on_event(&block) = (guard_reentry; @on_event = block; self)
|
|
24
|
+
def on_drop(&block) = (guard_reentry; @on_drop = block; self)
|
|
25
|
+
def on_announce(&block) = (guard_reentry; @on_announce = block; self)
|
|
26
|
+
def dragging? = !!@dragging
|
|
27
|
+
|
|
28
|
+
def press(id, point)
|
|
29
|
+
guard_reentry
|
|
30
|
+
validate_id(id)
|
|
31
|
+
validate_point(point)
|
|
32
|
+
begin
|
|
33
|
+
cancel(:replaced) if @active
|
|
34
|
+
@active = true
|
|
35
|
+
@source_id, @pressed_at, @last_point = id, point, point
|
|
36
|
+
emit(:press, point: point)
|
|
37
|
+
true
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
recover(error)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def move(point)
|
|
44
|
+
guard_reentry
|
|
45
|
+
return false unless @active
|
|
46
|
+
validate_point(point)
|
|
47
|
+
begin
|
|
48
|
+
@last_point = point
|
|
49
|
+
unless @dragging
|
|
50
|
+
return false if distance(@pressed_at, point) < @threshold
|
|
51
|
+
@dragging = true
|
|
52
|
+
emit(:start, point: point)
|
|
53
|
+
end
|
|
54
|
+
transition(normalize_target(callback(@locate, point, @source_id)), point)
|
|
55
|
+
true
|
|
56
|
+
rescue StandardError => error
|
|
57
|
+
recover(error)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def release(point = @last_point)
|
|
62
|
+
guard_reentry
|
|
63
|
+
return false unless @active
|
|
64
|
+
validate_point(point) if point
|
|
65
|
+
begin
|
|
66
|
+
move(point) if point && (!@last_point.equal?(point) || !@dragging)
|
|
67
|
+
return reset(false) unless @dragging
|
|
68
|
+
|
|
69
|
+
dropped = @target
|
|
70
|
+
unless dropped
|
|
71
|
+
cancel(:no_target)
|
|
72
|
+
return false
|
|
73
|
+
end
|
|
74
|
+
source = @source_id
|
|
75
|
+
message = move_announcement(source, dropped)
|
|
76
|
+
event = Event.new(phase: :drop, source_id: source, target: dropped, point: point, reason: nil)
|
|
77
|
+
reset
|
|
78
|
+
publish(event)
|
|
79
|
+
callback(@on_drop, event)
|
|
80
|
+
announce(message)
|
|
81
|
+
true
|
|
82
|
+
rescue StandardError => error
|
|
83
|
+
recover(error)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def keyboard(id, direction)
|
|
88
|
+
guard_reentry
|
|
89
|
+
validate_id(id)
|
|
90
|
+
raise ArgumentError, "keyboard reordering is not configured" unless @keyboard
|
|
91
|
+
raise ArgumentError, "keyboard direction must be previous, next, or inside" unless %i[previous next inside].include?(direction)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
cancel(:replaced) if @active
|
|
95
|
+
@active, @source_id, @dragging = true, id, true
|
|
96
|
+
emit(:start, reason: :keyboard)
|
|
97
|
+
destination = normalize_target(callback(@keyboard, id, direction))
|
|
98
|
+
unless destination
|
|
99
|
+
cancel(:no_target)
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
transition(destination, nil)
|
|
103
|
+
message = move_announcement(id, destination)
|
|
104
|
+
event = Event.new(phase: :drop, source_id: id, target: destination, point: nil, reason: :keyboard)
|
|
105
|
+
reset
|
|
106
|
+
publish(event)
|
|
107
|
+
callback(@on_drop, event)
|
|
108
|
+
announce(message)
|
|
109
|
+
true
|
|
110
|
+
rescue StandardError => error
|
|
111
|
+
recover(error)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def action(id, action)
|
|
116
|
+
case action
|
|
117
|
+
when :reorder_before then keyboard(id, :previous)
|
|
118
|
+
when :reorder_after then keyboard(id, :next)
|
|
119
|
+
when :reorder_inside then keyboard(id, :inside)
|
|
120
|
+
when :cancel_reorder then cancel
|
|
121
|
+
else false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def accessibility_actions(id)
|
|
126
|
+
validate_id(id)
|
|
127
|
+
@keyboard ? %i[reorder_before reorder_after reorder_inside cancel_reorder] : [].freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def remove(id)
|
|
131
|
+
guard_reentry
|
|
132
|
+
return cancel(:removed) if @source_id == id
|
|
133
|
+
return false unless @target&.id == id
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
emit(:leave, target: @target, point: @last_point, reason: :removed)
|
|
137
|
+
@target = nil
|
|
138
|
+
true
|
|
139
|
+
rescue StandardError => error
|
|
140
|
+
recover(error)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cancel(reason = :cancelled)
|
|
145
|
+
guard_reentry
|
|
146
|
+
return false unless @active
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
source, destination, point = @source_id, @target, @last_point
|
|
150
|
+
message = "Cancelled moving #{label(source)}" if @dragging
|
|
151
|
+
events = []
|
|
152
|
+
events << Event.new(phase: :leave, source_id: source, target: destination, point: point, reason: reason) if destination
|
|
153
|
+
events << Event.new(phase: :cancel, source_id: source, target: destination, point: point, reason: reason)
|
|
154
|
+
reset
|
|
155
|
+
events.each { |event| publish(event) }
|
|
156
|
+
announce(message) if message
|
|
157
|
+
true
|
|
158
|
+
rescue StandardError => error
|
|
159
|
+
recover(error)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def accessibility_node(_context = nil)
|
|
164
|
+
return unless @announcement
|
|
165
|
+
Accessibility.node(role: :status, id: [:reorder_status, object_id].freeze, label: @announcement,
|
|
166
|
+
states: {live: :polite, atomic: true, revision: @announcement_revision})
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def transition(destination, point)
|
|
172
|
+
if destination == @target
|
|
173
|
+
emit(:over, target: destination, point: point) if destination
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
emit(:leave, target: @target, point: point) if @target
|
|
177
|
+
@target = destination
|
|
178
|
+
emit(:enter, target: destination, point: point) if destination
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def normalize_target(destination)
|
|
182
|
+
return unless destination
|
|
183
|
+
raise TypeError, "target locator must return a DragDrop::Target" unless destination.is_a?(Target)
|
|
184
|
+
validate_id(destination.id)
|
|
185
|
+
raise ArgumentError, "drop position must be before, after, or inside" unless POSITIONS.include?(destination.position)
|
|
186
|
+
return if destination.id == @source_id
|
|
187
|
+
|
|
188
|
+
destination
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def emit(phase, target: @target, point: @last_point, reason: nil)
|
|
192
|
+
Event.new(phase: phase, source_id: @source_id, target: target, point: point, reason: reason).tap { |event| publish(event) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def publish(event) = (callback(@on_event, event); event)
|
|
196
|
+
def move_announcement(source, destination) = "Moved #{label(source)} #{destination.position} #{label(destination.id)}"
|
|
197
|
+
|
|
198
|
+
def announce(message)
|
|
199
|
+
return false if @announcement == message
|
|
200
|
+
@announcement = message.freeze
|
|
201
|
+
@announcement_revision = (@announcement_revision || 0) + 1
|
|
202
|
+
callback(@on_announce, @announcement)
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def label(id) = callback(@label, id).to_s
|
|
207
|
+
def distance(first, second) = Math.hypot(second.x - first.x, second.y - first.y)
|
|
208
|
+
def guard_reentry
|
|
209
|
+
raise Error, "drag-and-drop callbacks must not re-enter their controller" if @notifying
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def callback(callable, *arguments)
|
|
213
|
+
return unless callable
|
|
214
|
+
@notifying = true
|
|
215
|
+
callable.call(*arguments)
|
|
216
|
+
ensure
|
|
217
|
+
@notifying = false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_id(id)
|
|
221
|
+
raise ArgumentError, "drag item id must not be nil" if id.nil?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_point(point)
|
|
225
|
+
raise TypeError, "drag position must be a Point" unless point.is_a?(Point)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def reset(result = nil)
|
|
229
|
+
@active = @source_id = @pressed_at = @last_point = @target = @dragging = nil
|
|
230
|
+
result
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def recover(error)
|
|
234
|
+
active, source, target, point = @active, @source_id, @target, @last_point
|
|
235
|
+
reset
|
|
236
|
+
begin
|
|
237
|
+
publish(Event.new(phase: :cancel, source_id: source, target: target,
|
|
238
|
+
point: point, reason: :error)) if active
|
|
239
|
+
rescue StandardError
|
|
240
|
+
# Preserve the first callback failure.
|
|
241
|
+
end
|
|
242
|
+
raise error
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/zaniah/input/keymap.rb
CHANGED
|
@@ -62,6 +62,9 @@ module Zaniah
|
|
|
62
62
|
.bind("end", :last, context: "in_tree")
|
|
63
63
|
.bind("left", :collapse, context: "in_tree")
|
|
64
64
|
.bind("right", :expand, context: "in_tree")
|
|
65
|
+
.bind("alt-up", :reorder_before, context: "reorderable")
|
|
66
|
+
.bind("alt-down", :reorder_after, context: "reorderable")
|
|
67
|
+
.bind("esc", :cancel_reorder, context: "reorderable")
|
|
65
68
|
.bind("up", :previous_option, context: "in_table")
|
|
66
69
|
.bind("down", :next_option, context: "in_table")
|
|
67
70
|
.bind("shift-up", :extend_previous, context: "in_table")
|
data/lib/zaniah/layout/engine.rb
CHANGED
|
@@ -125,6 +125,16 @@ module Zaniah
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def layout(node, x, y, width, height, available_w, available_h, clamp = true)
|
|
128
|
+
if node.children.empty?
|
|
129
|
+
if node.style[:display] == :none
|
|
130
|
+
node.bounds = Bounds.new(x, y, 0, 0)
|
|
131
|
+
else
|
|
132
|
+
width = clamp_size(node, :width, width, available_w) if clamp
|
|
133
|
+
height = clamp_size(node, :height, height, available_h) if clamp
|
|
134
|
+
node.bounds = Bounds.new(x, y, width, height)
|
|
135
|
+
end
|
|
136
|
+
return
|
|
137
|
+
end
|
|
128
138
|
key = [node.generation, x, y, width, height, available_w, available_h]
|
|
129
139
|
return if node.cache[key]
|
|
130
140
|
if node.style[:display] == :none
|
|
@@ -137,11 +147,6 @@ module Zaniah
|
|
|
137
147
|
height = clamp_size(node, :height, height, available_h)
|
|
138
148
|
end
|
|
139
149
|
node.bounds = Bounds.new(x, y, width, height)
|
|
140
|
-
if node.children.empty?
|
|
141
|
-
node.cache.shift if node.cache.length >= 5
|
|
142
|
-
node.cache[key] = true
|
|
143
|
-
return
|
|
144
|
-
end
|
|
145
150
|
content = content_bounds(node)
|
|
146
151
|
flow, absolute = node.children.partition { |child| child.style[:position] != :absolute }
|
|
147
152
|
s = node.style
|