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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +7 -4
  4. data/docs/accessibility.md +38 -12
  5. data/docs/adr/010-text-overlay-layout.md +24 -0
  6. data/docs/adr/011-stable-accessibility-identity.md +26 -0
  7. data/docs/components.md +11 -3
  8. data/docs/drag_drop.md +46 -0
  9. data/docs/layout.md +20 -0
  10. data/docs/text.md +37 -0
  11. data/docs/tui.md +1 -1
  12. data/lib/zaniah/accessibility/linux/service.rb +23 -8
  13. data/lib/zaniah/accessibility/linux.rb +1 -1
  14. data/lib/zaniah/accessibility/mac.rb +20 -5
  15. data/lib/zaniah/accessibility/native_tree.rb +13 -3
  16. data/lib/zaniah/accessibility/node.rb +5 -2
  17. data/lib/zaniah/accessibility/tree.rb +112 -6
  18. data/lib/zaniah/accessibility/windows/provider.rb +56 -14
  19. data/lib/zaniah/accessibility/windows.rb +4 -0
  20. data/lib/zaniah/accessibility.rb +7 -0
  21. data/lib/zaniah/drag_drop.rb +246 -0
  22. data/lib/zaniah/input/keymap.rb +3 -0
  23. data/lib/zaniah/layout/engine.rb +10 -5
  24. data/lib/zaniah/text.rb +252 -15
  25. data/lib/zaniah/text_system/line_breaker.rb +5 -1
  26. data/lib/zaniah/text_system/low_resolution_text_cache.rb +133 -0
  27. data/lib/zaniah/text_system/paragraph.rb +161 -14
  28. data/lib/zaniah/text_system.rb +1 -0
  29. data/lib/zaniah/ui/component.rb +2 -2
  30. data/lib/zaniah/ui/data.rb +0 -131
  31. data/lib/zaniah/ui/pane_grid.rb +327 -0
  32. data/lib/zaniah/ui/tree_view.rb +333 -0
  33. data/lib/zaniah/ui.rb +2 -0
  34. data/lib/zaniah/version.rb +1 -1
  35. data/lib/zaniah.rb +1 -0
  36. data/sig/accessibility.rbs +31 -2
  37. data/sig/drag_drop.rbs +41 -0
  38. data/sig/elements.rbs +20 -0
  39. data/sig/text.rbs +69 -3
  40. data/sig/ui.rbs +29 -0
  41. 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
- return false if @changes.empty?
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
- return node if node.is_a?(Node)
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
- [before.children.length, after.children.length].max.times do |index|
50
- changes.concat(diff(before.children[index], after.children[index], path + [index]))
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
- (@providers[entry.path] ||= Element.new(self, entry.path)).entry = entry
48
+ provider = @providers[entry.runtime_id] ||= Element.new(self)
49
+ provider.entry = entry
48
50
  end
49
- @root_provider = @providers[@tree.root.path]
51
+ @root_provider = @providers[@tree.root.runtime_id]
50
52
  end
51
53
 
52
- def provider(entry) = entry && @providers[entry.path]
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
- @vtables = {simple: vtable(simple), fragment: vtable(fragment), root: vtable(root), invoke: vtable(invoke)}.freeze
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, path)
177
+ def initialize(bridge)
158
178
  self.class.install
159
- @bridge, @path, @references, @interfaces = bridge, path, 1, {}
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?) && (requested != :invoke || invokable?)
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
- return self.class.write_pointer(output, 0) unless pattern == 10_000 && invokable?
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(:invoke))
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
- action = %i[press toggle select sort expand collapse dismiss].find { |candidate| @entry.node.actions.include?(candidate) }
258
- Accessibility.perform(@bridge.window, @entry.node, action, bounds: @bridge.tree.bounds(@entry))
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
@@ -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
@@ -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")
@@ -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