zaniah 0.2.0 → 0.3.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +18 -0
  4. data/README.md +14 -4
  5. data/assets/icons/check.svg +1 -0
  6. data/assets/icons/close.svg +1 -0
  7. data/assets/icons/info.svg +1 -0
  8. data/assets/icons/menu.svg +1 -0
  9. data/assets/icons/search.svg +1 -0
  10. data/assets/icons/warning.svg +1 -0
  11. data/docs/accessibility.md +61 -0
  12. data/docs/adr/004-style-resolution.md +17 -0
  13. data/docs/adr/005-instance-layout.md +43 -0
  14. data/docs/adr/006-component-model.md +17 -0
  15. data/docs/adr/007-text-buffer.md +17 -0
  16. data/docs/adr/008-accessibility-bridge.md +19 -0
  17. data/docs/adr/009-grid-scope.md +17 -0
  18. data/docs/animation.md +46 -0
  19. data/docs/components.md +109 -0
  20. data/docs/gallery/command-palette-dark.png +0 -0
  21. data/docs/gallery/command-palette-high_contrast.png +0 -0
  22. data/docs/gallery/command-palette-light.png +0 -0
  23. data/docs/gallery/components-dark.png +0 -0
  24. data/docs/gallery/components-high_contrast.png +0 -0
  25. data/docs/gallery/components-light.png +0 -0
  26. data/docs/gallery/context-menu-dark.png +0 -0
  27. data/docs/gallery/context-menu-high_contrast.png +0 -0
  28. data/docs/gallery/context-menu-light.png +0 -0
  29. data/docs/gallery/dialog-dark.png +0 -0
  30. data/docs/gallery/dialog-high_contrast.png +0 -0
  31. data/docs/gallery/dialog-light.png +0 -0
  32. data/docs/gallery/drawer-left-dark.png +0 -0
  33. data/docs/gallery/drawer-left-high_contrast.png +0 -0
  34. data/docs/gallery/drawer-left-light.png +0 -0
  35. data/docs/gallery/drawer-right-dark.png +0 -0
  36. data/docs/gallery/drawer-right-high_contrast.png +0 -0
  37. data/docs/gallery/drawer-right-light.png +0 -0
  38. data/docs/gallery/menu-dark.png +0 -0
  39. data/docs/gallery/menu-high_contrast.png +0 -0
  40. data/docs/gallery/menu-light.png +0 -0
  41. data/docs/gallery/modal-dark.png +0 -0
  42. data/docs/gallery/modal-high_contrast.png +0 -0
  43. data/docs/gallery/modal-light.png +0 -0
  44. data/docs/gallery/popover-bottom-dark.png +0 -0
  45. data/docs/gallery/popover-bottom-high_contrast.png +0 -0
  46. data/docs/gallery/popover-bottom-light.png +0 -0
  47. data/docs/gallery/popover-left-dark.png +0 -0
  48. data/docs/gallery/popover-left-high_contrast.png +0 -0
  49. data/docs/gallery/popover-left-light.png +0 -0
  50. data/docs/gallery/popover-right-dark.png +0 -0
  51. data/docs/gallery/popover-right-high_contrast.png +0 -0
  52. data/docs/gallery/popover-right-light.png +0 -0
  53. data/docs/gallery/popover-top-dark.png +0 -0
  54. data/docs/gallery/popover-top-high_contrast.png +0 -0
  55. data/docs/gallery/popover-top-light.png +0 -0
  56. data/docs/gallery/toast-danger-dark.png +0 -0
  57. data/docs/gallery/toast-danger-high_contrast.png +0 -0
  58. data/docs/gallery/toast-danger-light.png +0 -0
  59. data/docs/gallery/toast-info-dark.png +0 -0
  60. data/docs/gallery/toast-info-high_contrast.png +0 -0
  61. data/docs/gallery/toast-info-light.png +0 -0
  62. data/docs/gallery/toast-success-dark.png +0 -0
  63. data/docs/gallery/toast-success-high_contrast.png +0 -0
  64. data/docs/gallery/toast-success-light.png +0 -0
  65. data/docs/gallery/toast-warning-dark.png +0 -0
  66. data/docs/gallery/toast-warning-high_contrast.png +0 -0
  67. data/docs/gallery/toast-warning-light.png +0 -0
  68. data/docs/gallery/tooltip-bottom-dark.png +0 -0
  69. data/docs/gallery/tooltip-bottom-high_contrast.png +0 -0
  70. data/docs/gallery/tooltip-bottom-light.png +0 -0
  71. data/docs/gallery/tooltip-left-dark.png +0 -0
  72. data/docs/gallery/tooltip-left-high_contrast.png +0 -0
  73. data/docs/gallery/tooltip-left-light.png +0 -0
  74. data/docs/gallery/tooltip-right-dark.png +0 -0
  75. data/docs/gallery/tooltip-right-high_contrast.png +0 -0
  76. data/docs/gallery/tooltip-right-light.png +0 -0
  77. data/docs/gallery/tooltip-top-dark.png +0 -0
  78. data/docs/gallery/tooltip-top-high_contrast.png +0 -0
  79. data/docs/gallery/tooltip-top-light.png +0 -0
  80. data/docs/index.html +74 -0
  81. data/docs/layout.md +31 -0
  82. data/docs/native.md +7 -2
  83. data/docs/site.css +32 -0
  84. data/docs/text.md +35 -0
  85. data/docs/theme.md +50 -0
  86. data/docs/tui.md +36 -0
  87. data/examples/gallery.rb +155 -0
  88. data/examples/native_ime.rb +13 -6
  89. data/lib/zaniah/accessibility/linux/service.rb +361 -0
  90. data/lib/zaniah/accessibility/linux.rb +23 -0
  91. data/lib/zaniah/accessibility/mac.rb +136 -0
  92. data/lib/zaniah/accessibility/native_tree.rb +53 -0
  93. data/lib/zaniah/accessibility/node.rb +12 -0
  94. data/lib/zaniah/accessibility/tree.rb +61 -0
  95. data/lib/zaniah/accessibility/windows/provider.rb +273 -0
  96. data/lib/zaniah/accessibility/windows.rb +38 -0
  97. data/lib/zaniah/accessibility.rb +63 -0
  98. data/lib/zaniah/animation/animator.rb +95 -0
  99. data/lib/zaniah/animation/easing.rb +45 -0
  100. data/lib/zaniah/animation/spring.rb +36 -0
  101. data/lib/zaniah/animation/transition.rb +5 -0
  102. data/lib/zaniah/animation.rb +6 -0
  103. data/lib/zaniah/app.rb +22 -2
  104. data/lib/zaniah/color.rb +16 -0
  105. data/lib/zaniah/devtools/hot_reload.rb +40 -0
  106. data/lib/zaniah/devtools/inspector.rb +111 -0
  107. data/lib/zaniah/devtools/stats_overlay.rb +16 -0
  108. data/lib/zaniah/devtools.rb +13 -0
  109. data/lib/zaniah/element.rb +210 -15
  110. data/lib/zaniah/frame_context.rb +4 -1
  111. data/lib/zaniah/gpu/frame_encoder.rb +10 -1
  112. data/lib/zaniah/gpu/instance_packing.rb +33 -5
  113. data/lib/zaniah/gpu/metal.rb +26 -10
  114. data/lib/zaniah/gpu/open_gl.rb +35 -14
  115. data/lib/zaniah/gpu/software.rb +116 -14
  116. data/lib/zaniah/gpu/vulkan/scene.frag +51 -0
  117. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  118. data/lib/zaniah/gpu/vulkan/scene.vert +50 -0
  119. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  120. data/lib/zaniah/gpu/vulkan.rb +282 -36
  121. data/lib/zaniah/gpu.rb +8 -3
  122. data/lib/zaniah/input/dispatcher.rb +96 -12
  123. data/lib/zaniah/input/focus_handle.rb +13 -4
  124. data/lib/zaniah/input/focus_tree.rb +77 -0
  125. data/lib/zaniah/input/keymap.rb +77 -0
  126. data/lib/zaniah/input.rb +1 -0
  127. data/lib/zaniah/interactivity.rb +30 -0
  128. data/lib/zaniah/layout/engine.rb +173 -36
  129. data/lib/zaniah/layout/grid.rb +134 -0
  130. data/lib/zaniah/layout/style.rb +16 -1
  131. data/lib/zaniah/layout.rb +1 -0
  132. data/lib/zaniah/length.rb +3 -0
  133. data/lib/zaniah/length_units.rb +7 -0
  134. data/lib/zaniah/list.rb +27 -15
  135. data/lib/zaniah/platform/appearance.rb +11 -0
  136. data/lib/zaniah/platform/headless/window.rb +101 -9
  137. data/lib/zaniah/platform/linux/appearance_aware.rb +8 -0
  138. data/lib/zaniah/platform/linux/wayland_window.rb +3 -1
  139. data/lib/zaniah/platform/linux/window.rb +4 -2
  140. data/lib/zaniah/platform/mac/app.rb +1 -1
  141. data/lib/zaniah/platform/mac/core_text.rb +3 -0
  142. data/lib/zaniah/platform/mac/window.rb +9 -1
  143. data/lib/zaniah/platform/tui/window.rb +1 -1
  144. data/lib/zaniah/platform/windows/window.rb +12 -2
  145. data/lib/zaniah/platform.rb +1 -0
  146. data/lib/zaniah/scene.rb +137 -28
  147. data/lib/zaniah/scroll_state.rb +117 -0
  148. data/lib/zaniah/scroll_view.rb +94 -0
  149. data/lib/zaniah/style/gradient.rb +28 -0
  150. data/lib/zaniah/style/ring.rb +13 -0
  151. data/lib/zaniah/style/shadow.rb +13 -0
  152. data/lib/zaniah/style/style_set.rb +43 -0
  153. data/lib/zaniah/style/transform.rb +40 -0
  154. data/lib/zaniah/svg.rb +19 -0
  155. data/lib/zaniah/text.rb +261 -6
  156. data/lib/zaniah/text_buffer.rb +119 -0
  157. data/lib/zaniah/text_selection.rb +18 -0
  158. data/lib/zaniah/text_system/atlas_cache.rb +1 -1
  159. data/lib/zaniah/text_system/fallback.rb +24 -0
  160. data/lib/zaniah/text_system/font_db.rb +4 -1
  161. data/lib/zaniah/text_system/kinsoku.rb +28 -0
  162. data/lib/zaniah/text_system/line_breaker.rb +54 -0
  163. data/lib/zaniah/text_system/paragraph.rb +99 -0
  164. data/lib/zaniah/text_system/renderer.rb +13 -2
  165. data/lib/zaniah/text_system/typesetter.rb +4 -0
  166. data/lib/zaniah/text_system.rb +3 -0
  167. data/lib/zaniah/theme.rb +39 -0
  168. data/lib/zaniah/ui/charts.rb +165 -0
  169. data/lib/zaniah/ui/choice_fields.rb +134 -0
  170. data/lib/zaniah/ui/component.rb +73 -0
  171. data/lib/zaniah/ui/controls.rb +439 -0
  172. data/lib/zaniah/ui/data.rb +364 -0
  173. data/lib/zaniah/ui/editors.rb +67 -0
  174. data/lib/zaniah/ui/forms.rb +131 -0
  175. data/lib/zaniah/ui/layout_components.rb +205 -0
  176. data/lib/zaniah/ui/modal.rb +129 -0
  177. data/lib/zaniah/ui/overlays.rb +282 -0
  178. data/lib/zaniah/ui/pickers.rb +169 -0
  179. data/lib/zaniah/ui/primitives.rb +199 -0
  180. data/lib/zaniah/ui/scrollbar.rb +104 -0
  181. data/lib/zaniah/ui/structure.rb +224 -0
  182. data/lib/zaniah/ui/text_inputs.rb +169 -0
  183. data/lib/zaniah/ui/variants.rb +23 -0
  184. data/lib/zaniah/ui.rb +18 -0
  185. data/lib/zaniah/unicode.rb +42 -0
  186. data/lib/zaniah/uniform_list.rb +27 -6
  187. data/lib/zaniah/version.rb +1 -1
  188. data/lib/zaniah.rb +14 -0
  189. data/sig/accessibility.rbs +34 -0
  190. data/sig/animation.rbs +54 -0
  191. data/sig/app.rbs +2 -1
  192. data/sig/devtools.rbs +37 -0
  193. data/sig/elements.rbs +88 -6
  194. data/sig/gpu.rbs +9 -2
  195. data/sig/input.rbs +31 -5
  196. data/sig/layout.rbs +2 -1
  197. data/sig/platform.rbs +24 -4
  198. data/sig/scene.rbs +15 -2
  199. data/sig/style.rbs +82 -0
  200. data/sig/text.rbs +35 -0
  201. data/sig/theme.rbs +64 -0
  202. data/sig/ui.rbs +390 -0
  203. data/sig/zaniah.rbs +56 -3
  204. data/tools/generate_component_gallery.rb +48 -0
  205. metadata +147 -2
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "open3"
5
+ require_relative "../../ffi/library"
6
+
7
+ module Zaniah
8
+ module Accessibility
9
+ module Linux
10
+ class Service
11
+ attr_reader :unique_name
12
+ P = Fiddle::TYPE_VOIDP
13
+ I = Fiddle::TYPE_INT
14
+ U = Fiddle::TYPE_UINT
15
+ ROOT_PATH = "/org/a11y/atspi/accessible/root"
16
+ NULL_PATH = "/org/a11y/atspi/null"
17
+ ACCESSIBLE = "org.a11y.atspi.Accessible"
18
+ COMPONENT = "org.a11y.atspi.Component"
19
+ ACTION = "org.a11y.atspi.Action"
20
+ APPLICATION = "org.a11y.atspi.Application"
21
+
22
+ ROLES = {
23
+ application: 75, button: 43, checkbox: 7, radio: 44, switch: 62, textbox: 79,
24
+ searchbox: 79, combobox: 11, listbox: 98, dialog: 16, table: 55, row: 90,
25
+ cell: 56, columnheader: 57, tree: 65, treeitem: 91, list: 31, listitem: 32,
26
+ slider: 51, progressbar: 42, meter: 103, link: 88, image: 27, heading: 83,
27
+ text: 61, separator: 50, toolbar: 63, menubar: 34, menu: 33, menuitem: 35,
28
+ tab: 37, tabpanel: 38, tooltip: 64, status: 54, navigation: 110,
29
+ radiogroup: 99, form: 87, group: 99
30
+ }.freeze
31
+
32
+ XML = <<~XML.freeze
33
+ <node>
34
+ <interface name="#{ACCESSIBLE}">
35
+ <property name="Name" type="s" access="read"/><property name="Description" type="s" access="read"/>
36
+ <property name="Parent" type="(so)" access="read"/><property name="ChildCount" type="i" access="read"/>
37
+ <property name="Locale" type="s" access="read"/><property name="AccessibleId" type="s" access="read"/>
38
+ <property name="HelpText" type="s" access="read"/>
39
+ <method name="GetChildAtIndex"><arg type="i" direction="in"/><arg type="(so)" direction="out"/></method>
40
+ <method name="GetChildren"><arg type="a(so)" direction="out"/></method>
41
+ <method name="GetIndexInParent"><arg type="i" direction="out"/></method>
42
+ <method name="GetRelationSet"><arg type="a(ua(so))" direction="out"/></method>
43
+ <method name="GetRole"><arg type="u" direction="out"/></method>
44
+ <method name="GetRoleName"><arg type="s" direction="out"/></method>
45
+ <method name="GetLocalizedRoleName"><arg type="s" direction="out"/></method>
46
+ <method name="GetState"><arg type="au" direction="out"/></method>
47
+ <method name="GetAttributes"><arg type="a{ss}" direction="out"/></method>
48
+ <method name="GetApplication"><arg type="(so)" direction="out"/></method>
49
+ <method name="GetInterfaces"><arg type="as" direction="out"/></method>
50
+ </interface>
51
+ <interface name="#{COMPONENT}">
52
+ <method name="Contains"><arg type="i" direction="in"/><arg type="i" direction="in"/><arg type="u" direction="in"/><arg type="b" direction="out"/></method>
53
+ <method name="GetAccessibleAtPoint"><arg type="i" direction="in"/><arg type="i" direction="in"/><arg type="u" direction="in"/><arg type="(so)" direction="out"/></method>
54
+ <method name="GetExtents"><arg type="u" direction="in"/><arg type="(iiii)" direction="out"/></method>
55
+ <method name="GetPosition"><arg type="u" direction="in"/><arg type="i" direction="out"/><arg type="i" direction="out"/></method>
56
+ <method name="GetSize"><arg type="i" direction="out"/><arg type="i" direction="out"/></method>
57
+ <method name="GetLayer"><arg type="u" direction="out"/></method><method name="GetMDIZOrder"><arg type="n" direction="out"/></method>
58
+ <method name="GrabFocus"><arg type="b" direction="out"/></method><method name="GetAlpha"><arg type="d" direction="out"/></method>
59
+ </interface>
60
+ <interface name="#{ACTION}">
61
+ <property name="NActions" type="i" access="read"/>
62
+ <method name="GetDescription"><arg type="i" direction="in"/><arg type="s" direction="out"/></method>
63
+ <method name="GetName"><arg type="i" direction="in"/><arg type="s" direction="out"/></method>
64
+ <method name="GetLocalizedName"><arg type="i" direction="in"/><arg type="s" direction="out"/></method>
65
+ <method name="GetKeyBinding"><arg type="i" direction="in"/><arg type="s" direction="out"/></method>
66
+ <method name="DoAction"><arg type="i" direction="in"/><arg type="b" direction="out"/></method>
67
+ </interface>
68
+ <interface name="#{APPLICATION}">
69
+ <property name="ToolkitName" type="s" access="read"/><property name="Version" type="s" access="read"/>
70
+ <property name="ToolkitVersion" type="s" access="read"/><property name="AtspiVersion" type="s" access="read"/>
71
+ <property name="InterfaceVersion" type="u" access="read"/><property name="Id" type="i" access="readwrite"/>
72
+ <method name="GetLocale"><arg type="u" direction="in"/><arg type="s" direction="out"/></method>
73
+ <method name="GetApplicationBusAddress"><arg type="s" direction="out"/></method>
74
+ </interface>
75
+ </node>
76
+ XML
77
+
78
+ INSTANCES = {}
79
+
80
+ def initialize(window)
81
+ @window, @registrations, @application_id = window, {}, -1
82
+ connect
83
+ install_callbacks
84
+ parse_interfaces
85
+ end
86
+
87
+ def update(root)
88
+ application = Accessibility.node(role: :application, label: window_title,
89
+ bounds: root.bounds, children: [root])
90
+ @tree = NativeTree.new(application)
91
+ @entries = @tree.to_h { |entry| [path(entry), entry] }
92
+ (@registrations.keys - @entries.keys).each { |object_path| unregister(object_path) }
93
+ (@entries.keys - @registrations.keys).each { |object_path| register(object_path) }
94
+ embed unless @embedded
95
+ emit_change
96
+ true
97
+ end
98
+
99
+ def poll = glib.fn(:g_main_context_iteration, [P, I], I).call(0, 0)
100
+
101
+ def close
102
+ @registrations.keys.each { |object_path| unregister(object_path) }
103
+ object.fn(:g_object_unref, [P], Fiddle::TYPE_VOID).call(@connection) if @connection&.to_i&.positive?
104
+ gio.fn(:g_dbus_node_info_unref, [P], Fiddle::TYPE_VOID).call(@node_info) if @node_info&.to_i&.positive?
105
+ INSTANCES.delete(object_id)
106
+ true
107
+ end
108
+
109
+ private
110
+
111
+ def connect
112
+ address = ENV["AT_SPI_BUS_ADDRESS"] || accessibility_bus_address
113
+ raise Error, "AT-SPI accessibility bus is unavailable" if address.to_s.empty?
114
+ error = [0].pack("J")
115
+ @connection = gio.fn(:g_dbus_connection_new_for_address_sync, [P, I, P, P, P], P)
116
+ .call(address, 9, 0, 0, error)
117
+ raise Error, "cannot connect to AT-SPI accessibility bus" if @connection.to_i.zero?
118
+ gio.fn(:g_dbus_connection_set_exit_on_close, [P, I], Fiddle::TYPE_VOID).call(@connection, 0)
119
+ @unique_name = pointer_text(gio.fn(:g_dbus_connection_get_unique_name, [P], P).call(@connection))
120
+ end
121
+
122
+ def accessibility_bus_address
123
+ output, _error, status = Open3.capture3("gdbus", "call", "--session", "--dest", "org.a11y.Bus",
124
+ "--object-path", "/org/a11y/bus", "--method", "org.a11y.Bus.GetAddress")
125
+ status.success? ? output[/\('([^']+)'/, 1] : nil
126
+ end
127
+
128
+ def install_callbacks
129
+ INSTANCES[object_id] = self
130
+ @method_callback = Fiddle::Closure::BlockCaller.new(Fiddle::TYPE_VOID, [P] * 8) do |_connection, _sender, object_path, interface, method, parameters, invocation, data|
131
+ INSTANCES[data.to_i]&.__send__(:method_call, pointer_text(object_path), pointer_text(interface), pointer_text(method), parameters, invocation)
132
+ rescue StandardError => error
133
+ INSTANCES[data.to_i]&.__send__(:return_error, invocation, error)
134
+ end
135
+ @get_callback = Fiddle::Closure::BlockCaller.new(P, [P] * 7) do |_connection, _sender, object_path, interface, property, _error, data|
136
+ INSTANCES[data.to_i]&.__send__(:property, pointer_text(object_path), pointer_text(interface), pointer_text(property)) || 0
137
+ rescue StandardError
138
+ 0
139
+ end
140
+ @set_callback = Fiddle::Closure::BlockCaller.new(I, [P] * 8) do |_connection, _sender, object_path, interface, property, value, _error, data|
141
+ INSTANCES[data.to_i]&.__send__(:set_property, pointer_text(object_path), pointer_text(interface), pointer_text(property), value) ? 1 : 0
142
+ rescue StandardError
143
+ 0
144
+ end
145
+ @vtable = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP * 11)
146
+ @vtable[0, Fiddle::SIZEOF_VOIDP * 11] = [@method_callback.to_i, @get_callback.to_i, @set_callback.to_i, *([0] * 8)].pack("J*")
147
+ end
148
+
149
+ def parse_interfaces
150
+ error = [0].pack("J")
151
+ @node_info = gio.fn(:g_dbus_node_info_new_for_xml, [P, P], P).call(XML, error)
152
+ raise Error, "invalid AT-SPI introspection data" if @node_info.to_i.zero?
153
+ @interfaces = [ACCESSIBLE, COMPONENT, ACTION, APPLICATION].to_h do |name|
154
+ [name, gio.fn(:g_dbus_node_info_lookup_interface, [P, P], P).call(@node_info, name)]
155
+ end
156
+ end
157
+
158
+ def register(object_path)
159
+ names = [ACCESSIBLE, COMPONENT, ACTION]
160
+ names << APPLICATION if object_path == ROOT_PATH
161
+ ids = names.map do |name|
162
+ gio.fn(:g_dbus_connection_register_object, [P, P, P, P, P, P, P], U)
163
+ .call(@connection, object_path, @interfaces.fetch(name), @vtable, object_id, 0, 0)
164
+ end
165
+ raise Error, "cannot register AT-SPI object #{object_path}" if ids.any?(&:zero?)
166
+ @registrations[object_path] = ids
167
+ end
168
+
169
+ def unregister(object_path)
170
+ @registrations.delete(object_path)&.each do |id|
171
+ gio.fn(:g_dbus_connection_unregister_object, [P, U], I).call(@connection, id)
172
+ end
173
+ end
174
+
175
+ def embed
176
+ gio.fn(:g_dbus_connection_call, [P, P, P, P, P, P, P, I, I, P, P, P], Fiddle::TYPE_VOID)
177
+ .call(@connection, "org.a11y.atspi.Registry", ROOT_PATH, "org.a11y.atspi.Socket", "Embed",
178
+ tuple(reference(@tree.root)), 0, 0, 3000, 0, 0, 0)
179
+ @embedded = true
180
+ end
181
+
182
+ def emit_change
183
+ parameters = tuple(string(""), int32(0), int32(0), variant(string("")), array("{sv}", []))
184
+ gio.fn(:g_dbus_connection_emit_signal, [P, P, P, P, P, P, P], I)
185
+ .call(@connection, 0, ROOT_PATH, "org.a11y.atspi.Event.Object", "VisibleDataChanged", parameters, 0)
186
+ end
187
+
188
+ def method_call(object_path, interface, method, parameters, invocation)
189
+ entry = @entries[object_path]
190
+ raise Error, "unknown accessible object" unless entry
191
+ values = case interface
192
+ when ACCESSIBLE then accessible_method(entry, method, parameters)
193
+ when COMPONENT then component_method(entry, method, parameters)
194
+ when ACTION then action_method(entry, method, parameters)
195
+ when APPLICATION then application_method(method)
196
+ else raise Error, "unknown accessibility interface"
197
+ end
198
+ gio.fn(:g_dbus_method_invocation_return_value, [P, P], Fiddle::TYPE_VOID).call(invocation, tuple(*Array(values)))
199
+ end
200
+
201
+ def accessible_method(entry, method, parameters)
202
+ case method
203
+ when "GetChildAtIndex" then reference(entry.children[parameter(parameters, 0, :int32)])
204
+ when "GetChildren" then array("(so)", entry.children.map { |child| reference(child) })
205
+ when "GetIndexInParent" then int32(entry.parent ? entry.parent.children.index(entry) : -1)
206
+ when "GetRelationSet" then array("(ua(so))", [])
207
+ when "GetRole" then uint32(ROLES.fetch(entry.node.role, 67))
208
+ when "GetRoleName", "GetLocalizedRoleName" then string(entry.node.role.to_s.tr("_", " "))
209
+ when "GetState" then array("u", states(entry).map { |state| uint32(state) })
210
+ when "GetAttributes" then array("{ss}", entry.node.states.map { |key, value| dict(string(key), string(value)) })
211
+ when "GetApplication" then reference(@tree.root)
212
+ when "GetInterfaces"
213
+ names = [ACCESSIBLE, COMPONENT]
214
+ names << ACTION unless entry.node.actions.empty?
215
+ names << APPLICATION if entry.equal?(@tree.root)
216
+ array("s", names.map { |name| string(name) })
217
+ else raise Error, "unsupported Accessible method #{method}"
218
+ end
219
+ end
220
+
221
+ def component_method(entry, method, parameters)
222
+ bounds = @tree.bounds(entry)
223
+ case method
224
+ when "Contains"
225
+ point = Point.new(parameter(parameters, 0, :int32), parameter(parameters, 1, :int32))
226
+ boolean(bounds.contains?(point))
227
+ when "GetAccessibleAtPoint"
228
+ point = Point.new(parameter(parameters, 0, :int32), parameter(parameters, 1, :int32))
229
+ reference(@tree.hit(point))
230
+ when "GetExtents" then tuple(*[bounds.x, bounds.y, bounds.width, bounds.height].map { |value| int32(value.round) })
231
+ when "GetPosition" then [int32(bounds.x.round), int32(bounds.y.round)]
232
+ when "GetSize" then [int32(bounds.width.round), int32(bounds.height.round)]
233
+ when "GetLayer" then uint32(3)
234
+ when "GetMDIZOrder" then int16(-1)
235
+ when "GrabFocus"
236
+ point = Point.new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0)
237
+ boolean(Accessibility.focus_at(@window, point))
238
+ when "GetAlpha" then double(1.0)
239
+ else raise Error, "unsupported Component method #{method}"
240
+ end
241
+ end
242
+
243
+ def action_method(entry, method, parameters)
244
+ index = parameter(parameters, 0, :int32)
245
+ action = entry.node.actions[index]
246
+ case method
247
+ when "GetDescription" then string(action ? "Perform #{action.to_s.tr("_", " ")}" : "")
248
+ when "GetName", "GetLocalizedName" then string(action&.to_s || "")
249
+ when "GetKeyBinding" then string("")
250
+ when "DoAction" then boolean(action ? Accessibility.perform(@window, entry.node, action, bounds: @tree.bounds(entry)) : false)
251
+ else raise Error, "unsupported Action method #{method}"
252
+ end
253
+ end
254
+
255
+ def application_method(method)
256
+ case method
257
+ when "GetLocale" then string(ENV["LANG"] || "C")
258
+ when "GetApplicationBusAddress" then string(ENV["AT_SPI_BUS_ADDRESS"] || "")
259
+ else raise Error, "unsupported Application method #{method}"
260
+ end
261
+ end
262
+
263
+ def property(object_path, interface, name)
264
+ entry = @entries[object_path]
265
+ return 0 unless entry
266
+ case [interface, name]
267
+ when [ACCESSIBLE, "Name"] then string(entry.node.label || entry.node.value&.to_s || entry.node.role.to_s)
268
+ when [ACCESSIBLE, "Description"], [ACCESSIBLE, "HelpText"] then string("")
269
+ when [ACCESSIBLE, "Parent"] then reference(entry.parent)
270
+ when [ACCESSIBLE, "ChildCount"] then int32(entry.children.length)
271
+ when [ACCESSIBLE, "Locale"] then string(ENV["LANG"] || "C")
272
+ when [ACCESSIBLE, "AccessibleId"] then string(entry.runtime_id.to_s)
273
+ when [ACTION, "NActions"] then int32(entry.node.actions.length)
274
+ when [APPLICATION, "ToolkitName"] then string("Zaniah")
275
+ when [APPLICATION, "Version"], [APPLICATION, "ToolkitVersion"] then string(Zaniah::VERSION)
276
+ when [APPLICATION, "AtspiVersion"] then string("2.1")
277
+ when [APPLICATION, "InterfaceVersion"] then uint32(1)
278
+ when [APPLICATION, "Id"] then int32(@application_id)
279
+ else 0
280
+ end
281
+ end
282
+
283
+ def set_property(object_path, interface, name, value)
284
+ return false unless object_path == ROOT_PATH && interface == APPLICATION && name == "Id"
285
+ @application_id = glib.fn(:g_variant_get_int32, [P], I).call(value)
286
+ true
287
+ end
288
+
289
+ def states(entry)
290
+ node = entry.node
291
+ values = [25, 30]
292
+ values.concat([8, 24]) unless node.states[:disabled]
293
+ values << 3 if node.states[:busy]
294
+ values << 4 if node.states[:checked]
295
+ values << 41 if node.states.key?(:checked) || node.states.key?(:mixed)
296
+ values.concat([9, node.states[:expanded] ? 10 : 5]) unless node.states[:expanded].nil?
297
+ focusable = !node.states[:disabled] && (!node.actions.empty? || %i[textbox searchbox slider combobox].include?(node.role))
298
+ values << 11 if focusable
299
+ values << 12 if @tree.focused(@window).equal?(entry)
300
+ values << 7 if %i[textbox searchbox].include?(node.role) && !node.states[:readonly] && !node.states[:disabled]
301
+ values << 16 if node.states[:modal]
302
+ values << 17 if node.states[:multiline]
303
+ values << 18 if node.states[:multiselectable]
304
+ values << 20 if node.states[:pressed]
305
+ values << 21 if node.states[:resizable]
306
+ values << 22 if node.states.key?(:selected)
307
+ values << 23 if node.states[:selected]
308
+ values << 43 if node.states[:readonly]
309
+ values.uniq
310
+ end
311
+
312
+ def reference(entry)
313
+ entry ? tuple(string(@unique_name), object_path(path(entry))) : tuple(string(""), object_path(NULL_PATH))
314
+ end
315
+
316
+ def path(entry) = entry.path.empty? ? ROOT_PATH : "/org/a11y/atspi/accessible/#{entry.runtime_id}"
317
+ def window_title = @window.respond_to?(:title) ? @window.title.to_s : "Zaniah application"
318
+
319
+ def parameter(parameters, index, type)
320
+ child = glib.fn(:g_variant_get_child_value, [P, Fiddle::TYPE_SIZE_T], P).call(parameters, index)
321
+ result = glib.fn(:"g_variant_get_#{type}", [P], type == :uint32 ? U : I).call(child)
322
+ glib.fn(:g_variant_unref, [P], Fiddle::TYPE_VOID).call(child)
323
+ result
324
+ end
325
+
326
+ def tuple(*values) = container(:g_variant_new_tuple, values)
327
+ def array(type, values)
328
+ variant_type = glib.fn(:g_variant_type_new, [P], P).call(type)
329
+ result = container(:g_variant_new_array, values, variant_type)
330
+ glib.fn(:g_variant_type_free, [P], Fiddle::TYPE_VOID).call(variant_type)
331
+ result
332
+ end
333
+ def container(function, values, prefix = nil)
334
+ pointers = values.empty? ? 0 : Fiddle::Pointer[values.map(&:to_i).pack("J*")]
335
+ arguments = prefix ? [prefix, pointers, values.length] : [pointers, values.length]
336
+ types = prefix ? [P, P, Fiddle::TYPE_SIZE_T] : [P, Fiddle::TYPE_SIZE_T]
337
+ glib.fn(function, types, P).call(*arguments)
338
+ end
339
+ def dict(key, value) = glib.fn(:g_variant_new_dict_entry, [P, P], P).call(key, value)
340
+ def variant(value) = glib.fn(:g_variant_new_variant, [P], P).call(value)
341
+ def string(value) = glib.fn(:g_variant_new_string, [P], P).call(value.to_s)
342
+ def object_path(value) = glib.fn(:g_variant_new_object_path, [P], P).call(value)
343
+ def int16(value) = glib.fn(:g_variant_new_int16, [Fiddle::TYPE_SHORT], P).call(value)
344
+ def int32(value) = glib.fn(:g_variant_new_int32, [I], P).call(value)
345
+ def uint32(value) = glib.fn(:g_variant_new_uint32, [U], P).call(value)
346
+ def boolean(value) = glib.fn(:g_variant_new_boolean, [I], P).call(value ? 1 : 0)
347
+ def double(value) = glib.fn(:g_variant_new_double, [Fiddle::TYPE_DOUBLE], P).call(value)
348
+
349
+ def return_error(invocation, error)
350
+ gio.fn(:g_dbus_method_invocation_return_dbus_error, [P, P, P], Fiddle::TYPE_VOID)
351
+ .call(invocation, "org.a11y.atspi.Error.Failed", error.message)
352
+ end
353
+
354
+ def pointer_text(pointer) = pointer.to_i.zero? ? "" : Fiddle::Pointer.new(pointer).to_s.force_encoding(Encoding::UTF_8)
355
+ def gio = (@gio ||= Zaniah::FFI::Library.new("libgio-2.0.so.0"))
356
+ def glib = (@glib ||= Zaniah::FFI::Library.new("libglib-2.0.so.0"))
357
+ def object = (@object ||= Zaniah::FFI::Library.new("libgobject-2.0.so.0"))
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "linux/service"
5
+
6
+ module Zaniah
7
+ module Accessibility
8
+ module Linux
9
+ module_function
10
+
11
+ def publish(window, root, _changes)
12
+ return false unless root
13
+ service = services[window.object_id] ||= Service.new(window)
14
+ service.update(root)
15
+ rescue Fiddle::DLError, Errno::ENOENT, Error
16
+ false
17
+ end
18
+ def poll(window) = services[window.object_id]&.poll
19
+ def close(window) = services.delete(window.object_id)&.close
20
+ def services = (@services ||= {})
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Accessibility
5
+ module Mac
6
+ ELEMENTS = {}
7
+ ROLES = {
8
+ button: "AXButton", checkbox: "AXCheckBox", radio: "AXRadioButton", switch: "AXCheckBox",
9
+ textbox: "AXTextField", searchbox: "AXTextField", combobox: "AXComboBox", listbox: "AXList",
10
+ dialog: "AXDialog", table: "AXTable", row: "AXRow", cell: "AXCell", columnheader: "AXButton",
11
+ tree: "AXOutline", treeitem: "AXRow", list: "AXList", listitem: "AXRow", slider: "AXSlider",
12
+ progressbar: "AXProgressIndicator", meter: "AXLevelIndicator", link: "AXLink", image: "AXImage",
13
+ heading: "AXHeading", text: "AXStaticText", separator: "AXSplitter", toolbar: "AXToolbar",
14
+ menubar: "AXMenuBar", menu: "AXMenu", menuitem: "AXMenuItem", tab: "AXRadioButton",
15
+ tabpanel: "AXGroup", tooltip: "AXHelpTag", status: "AXStaticText", navigation: "AXGroup",
16
+ radiogroup: "AXRadioGroup", form: "AXGroup", group: "AXGroup"
17
+ }.freeze
18
+ NATIVE_ACTIONS = {
19
+ "AXPress" => %i[press toggle select sort expand collapse],
20
+ "AXIncrement" => %i[increment],
21
+ "AXDecrement" => %i[decrement],
22
+ "AXCancel" => %i[dismiss]
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ def publish(window, root, _changes)
28
+ return unless root && defined?(Platform::Mac::O) && window.respond_to?(:view)
29
+ install
30
+ ELEMENTS.delete_if { |_element, (owner, _node)| owner.equal?(window) }
31
+ tree = NativeTree.new(root)
32
+ children = mutable_array
33
+ append(children, native_element(window, tree, tree.root, window.view))
34
+ object = window.view
35
+ Platform::Mac::O.send(object, "setAccessibilityElement:", 1, args: [:bool], result: :void)
36
+ Platform::Mac::O.send(object, "setAccessibilityRole:", Platform::Mac::O.string("AXGroup"), args: [:pointer], result: :void)
37
+ Platform::Mac::O.send(object, "setAccessibilityLabel:", Platform::Mac::O.string("Zaniah application"), args: [:pointer], result: :void)
38
+ Platform::Mac::O.send(object, "setAccessibilityChildren:", children, args: [:pointer], result: :void)
39
+ window.instance_variable_set(:@native_accessibility_tree, tree)
40
+ window.instance_variable_set(:@native_accessibility_children, children)
41
+ notify(window, object)
42
+ rescue Fiddle::DLError
43
+ nil
44
+ end
45
+
46
+ def install
47
+ return if @installed
48
+ Platform::Mac::O.subclass("ZaniahAccessibilityElement", "NSAccessibilityElement") do |klass|
49
+ Platform::Mac::O.method(klass, "accessibilityActionNames", result: :pointer, encoding: "@@:") do |receiver, _|
50
+ action_names(receiver)
51
+ end
52
+ Platform::Mac::O.method(klass, "accessibilityPerformAction:", args: [:pointer], encoding: "v@:@") do |receiver, _, name|
53
+ perform_native(receiver, Platform::Mac::O.text(name))
54
+ end
55
+ {
56
+ "accessibilityPerformPress" => "AXPress",
57
+ "accessibilityPerformIncrement" => "AXIncrement",
58
+ "accessibilityPerformDecrement" => "AXDecrement"
59
+ }.each do |selector, action|
60
+ Platform::Mac::O.method(klass, selector, result: :bool, encoding: "B@:") do |receiver, _|
61
+ perform_native(receiver, action) ? 1 : 0
62
+ end
63
+ end
64
+ end
65
+ @installed = true
66
+ end
67
+
68
+ def native_element(window, tree, entry, parent)
69
+ o = Platform::Mac::O
70
+ node = entry.node
71
+ element = o.send(o.klass("ZaniahAccessibilityElement"), "accessibilityElementWithRole:frame:label:parent:",
72
+ o.string(ROLES.fetch(node.role, "AXGroup")), screen_bounds(window, tree.bounds(entry)),
73
+ o.string(node.label || node.value&.to_s || node.role.to_s), parent,
74
+ args: [:pointer, :rect, :pointer, :pointer])
75
+ ELEMENTS[element] = [window, node, tree.bounds(entry)]
76
+ o.send(element, "setAccessibilityFrameInParentSpace:", parent_bounds(tree, entry), args: [:rect], result: :void)
77
+ o.send(element, "setAccessibilityEnabled:", node.states[:disabled] ? 0 : 1, args: [:bool], result: :void)
78
+ o.send(element, "setAccessibilityValue:", native_value(node.value), args: [:pointer], result: :void) unless node.value.nil?
79
+ set_boolean(element, "setAccessibilitySelected:", node.states[:selected]) if node.states.key?(:selected)
80
+ set_boolean(element, "setAccessibilityExpanded:", node.states[:expanded]) unless node.states[:expanded].nil?
81
+ unless entry.children.empty?
82
+ children = mutable_array
83
+ entry.children.each { |child| append(children, native_element(window, tree, child, element)) }
84
+ o.send(element, "setAccessibilityChildren:", children, args: [:pointer], result: :void)
85
+ end
86
+ element
87
+ end
88
+
89
+ def screen_bounds(window, bounds)
90
+ bounds ||= Bounds.new(0, 0, window.content_size.width, window.content_size.height)
91
+ Platform::Mac::O.send(window.handle, "convertRectToScreen:",
92
+ [bounds.x, window.content_size.height - bounds.bottom, bounds.width, bounds.height], args: [:rect], result: :rect)
93
+ end
94
+
95
+ def parent_bounds(tree, entry)
96
+ bounds = tree.bounds(entry)
97
+ parent = entry.parent && tree.bounds(entry.parent)
98
+ [bounds.x - (parent&.x || 0), bounds.y - (parent&.y || 0), bounds.width, bounds.height]
99
+ end
100
+
101
+ def action_names(receiver)
102
+ _window, node, _bounds = ELEMENTS[receiver]
103
+ names = mutable_array
104
+ NATIVE_ACTIONS.each { |name, actions| append(names, Platform::Mac::O.string(name)) if node && (node.actions & actions).any? }
105
+ names
106
+ end
107
+
108
+ def perform_native(receiver, name)
109
+ window, node, bounds = ELEMENTS[receiver]
110
+ action = NATIVE_ACTIONS[name]&.find { |candidate| node&.actions&.include?(candidate) }
111
+ !!(action && Accessibility.perform(window, node, action, bounds: bounds))
112
+ rescue StandardError
113
+ false
114
+ end
115
+
116
+ def close(window) = ELEMENTS.delete_if { |_element, (owner, _node, _bounds)| owner.equal?(window) }
117
+
118
+ def native_value(value)
119
+ Platform::Mac::O.string(value.is_a?(Array) || value.is_a?(Hash) ? value.inspect : value.to_s)
120
+ end
121
+
122
+ def mutable_array = Platform::Mac::O.send(Platform::Mac::O.klass("NSMutableArray"), "array")
123
+ def append(array, value) = Platform::Mac::O.send(array, "addObject:", value, args: [:pointer], result: :void)
124
+ def set_boolean(element, selector, value) = Platform::Mac::O.send(element, selector, value ? 1 : 0, args: [:bool], result: :void)
125
+
126
+ def notify(window, object)
127
+ function = Platform::Mac::APPKIT.fn(:NSAccessibilityPostNotification,
128
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
129
+ function.call(object, Platform::Mac::O.string("AXLayoutChanged"))
130
+ if window.accessibility_tree.changes.any? { |change| change.after&.role == :status }
131
+ function.call(object, Platform::Mac::O.string("AXAnnouncementRequested"))
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Accessibility
5
+ NativeEntry = Data.define(:path, :node, :parent, :children, :runtime_id)
6
+
7
+ class NativeTree
8
+ include Enumerable
9
+
10
+ attr_reader :root
11
+
12
+ def initialize(root)
13
+ @entries = {}
14
+ @next_runtime_id = 1
15
+ @root = build(root, nil, []) if root
16
+ end
17
+
18
+ def each(&block) = block ? @entries.each_value(&block) : enum_for(__method__)
19
+ def [](path) = @entries[path]
20
+
21
+ def bounds(entry)
22
+ current = entry
23
+ while current
24
+ return current.node.bounds if current.node.bounds
25
+ current = current.parent
26
+ end
27
+ Bounds.new(0, 0, 0, 0)
28
+ end
29
+
30
+ def hit(point)
31
+ select { |entry| bounds(entry).contains?(point) }.max_by { |entry| entry.path.length }
32
+ end
33
+
34
+ def focused(window)
35
+ bounds = window.dispatcher.focused&.bounds
36
+ bounds && hit(Point.new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0))
37
+ end
38
+
39
+ private
40
+
41
+ def build(node, parent, path)
42
+ entry = NativeEntry.new(path: path.freeze, node: node, parent: parent, children: [], runtime_id: next_runtime_id)
43
+ @entries[entry.path] = entry
44
+ children = node.children.map.with_index { |child, index| build(child, entry, path + [index]) }.freeze
45
+ entry.children.replace(children)
46
+ entry.children.freeze
47
+ entry
48
+ end
49
+
50
+ def next_runtime_id = (@next_runtime_id += 1) - 1
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Accessibility
5
+ Node = Data.define(:role, :label, :value, :bounds, :states, :children, :actions)
6
+
7
+ def self.node(role:, label: nil, value: nil, bounds: nil, states: {}, children: [], actions: [])
8
+ Node.new(role: role.to_sym, label: label&.to_s, value: value, bounds: bounds,
9
+ states: states.dup.freeze, children: children.dup.freeze, actions: actions.map(&:to_sym).freeze)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Accessibility
5
+ Change = Data.define(:kind, :path, :before, :after)
6
+
7
+ class Tree
8
+ attr_reader :root, :changes, :revision
9
+
10
+ def initialize
11
+ @root, @changes, @revision = nil, [], 0
12
+ end
13
+
14
+ def update(renderable, context, overlays: [])
15
+ nodes = [renderable, *overlays].filter_map { |item| semantic(item, context) }
16
+ next_root = nodes.length == 1 ? nodes.first : nodes.empty? ? nil : Accessibility.node(role: :group, label: "Application", children: nodes)
17
+ @changes = diff(@root, next_root).freeze
18
+ return false if @changes.empty?
19
+ @root = next_root
20
+ @revision += 1
21
+ true
22
+ end
23
+
24
+ def each
25
+ return enum_for(__method__) unless block_given?
26
+ walk(@root) { |node, path| yield node, path } if @root
27
+ end
28
+
29
+ def find(&predicate) = each.find { |node, _path| predicate.call(node) }&.first
30
+
31
+ private
32
+
33
+ def semantic(renderable, context)
34
+ return unless renderable
35
+ node = renderable.accessibility_node(context) if renderable.respond_to?(:accessibility_node)
36
+ return node if node.is_a?(Node)
37
+ children = renderable.respond_to?(:children) ? renderable.children : []
38
+ nodes = children.filter_map { |child| semantic(child, context) }
39
+ Accessibility.node(role: :group, children: nodes) unless nodes.empty?
40
+ end
41
+
42
+ def diff(before, after, path = [])
43
+ return [] if before == after
44
+ return [Change.new(kind: before ? :removed : :added, path: path.freeze, before: before, after: after)] unless before && after
45
+ changes = []
46
+ if before.with(children: []) != after.with(children: [])
47
+ changes << Change.new(kind: :updated, path: path.freeze, before: before, after: after)
48
+ end
49
+ [before.children.length, after.children.length].max.times do |index|
50
+ changes.concat(diff(before.children[index], after.children[index], path + [index]))
51
+ end
52
+ changes
53
+ end
54
+
55
+ def walk(node, path = [], &block)
56
+ yield node, path.freeze
57
+ node.children.each_with_index { |child, index| walk(child, path + [index], &block) }
58
+ end
59
+ end
60
+ end
61
+ end