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,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Zaniah
6
+ module Accessibility
7
+ module Windows
8
+ module Provider
9
+ P = Fiddle::TYPE_VOIDP
10
+ I = Fiddle::TYPE_INT
11
+ U = Fiddle::TYPE_UINT
12
+ N = Fiddle::TYPE_INTPTR_T
13
+ S_OK = 0
14
+ E_NOINTERFACE = -2_147_467_262
15
+ E_FAIL = -2_147_467_259
16
+ UIA_APPEND_RUNTIME_ID = -1
17
+
18
+ GUIDS = {
19
+ unknown: "00000000-0000-0000-c000-000000000046",
20
+ simple: "d6dd68d1-86fd-4332-8666-9abedea2d24c",
21
+ fragment: "f7063da8-8359-439c-9297-bbc5299a7d87",
22
+ root: "620ce2a5-ab8f-40a9-86cb-de3c75599b58",
23
+ invoke: "54fcb24b-e18e-47a2-b4d3-eccbe77599a2"
24
+ }.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
+ CONTROL_TYPES = {
27
+ button: 50_000, checkbox: 50_002, switch: 50_002, combobox: 50_003, textbox: 50_004,
28
+ searchbox: 50_004, link: 50_005, image: 50_006, listitem: 50_007, list: 50_008,
29
+ listbox: 50_008, menu: 50_009, menubar: 50_010, menuitem: 50_011,
30
+ progressbar: 50_012, meter: 50_012, radio: 50_013, slider: 50_015, status: 50_017,
31
+ tabpanel: 50_018, tab: 50_019, text: 50_020, heading: 50_020, toolbar: 50_021,
32
+ tooltip: 50_022, tree: 50_023, treeitem: 50_024, group: 50_026, radiogroup: 50_026,
33
+ form: 50_026, table: 50_036, row: 50_029, cell: 50_029, columnheader: 50_035,
34
+ dialog: 50_032, navigation: 50_033, separator: 50_038
35
+ }.freeze
36
+
37
+ class Bridge
38
+ attr_reader :window, :tree, :root_provider
39
+
40
+ def initialize(window)
41
+ @window, @providers = window, {}
42
+ end
43
+
44
+ def update(root)
45
+ @tree = NativeTree.new(root)
46
+ @tree.each do |entry|
47
+ (@providers[entry.path] ||= Element.new(self, entry.path)).entry = entry
48
+ end
49
+ @root_provider = @providers[@tree.root.path]
50
+ end
51
+
52
+ def provider(entry) = entry && @providers[entry.path]
53
+ def entry(path) = @tree[path]
54
+
55
+ def screen_bounds(entry)
56
+ bounds = @tree.bounds(entry)
57
+ point = [(bounds.x * scale).round, (bounds.y * scale).round].pack("l2")
58
+ user.fn(:ClientToScreen, [P, P], I).call(@window.handle, point)
59
+ x, y = point.unpack("l2")
60
+ [x.to_f, y.to_f, bounds.width * scale, bounds.height * scale]
61
+ rescue NoMethodError
62
+ [bounds.x, bounds.y, bounds.width, bounds.height]
63
+ end
64
+
65
+ def provider_at(x, y)
66
+ point = [x.round, y.round].pack("l2")
67
+ user.fn(:ScreenToClient, [P, P], I).call(@window.handle, point)
68
+ px, py = point.unpack("l2")
69
+ provider(@tree.hit(Point.new(px / scale, py / scale)))
70
+ rescue NoMethodError
71
+ provider(@tree.hit(Point.new(x, y)))
72
+ end
73
+
74
+ def focused_provider = provider(@tree.focused(@window)) || @root_provider
75
+
76
+ def return_provider(wparam, lparam)
77
+ core.fn(:UiaReturnRawElementProvider, [P, N, N, P], N)
78
+ .call(@window.handle, wparam, lparam, @root_provider.pointer(:simple))
79
+ end
80
+
81
+ def host_provider(output)
82
+ core.fn(:UiaHostProviderFromHwnd, [P, P], I).call(@window.handle, output)
83
+ end
84
+
85
+ def bstr(value)
86
+ bytes = value.to_s.encode("UTF-16LE").b + "\0\0".b
87
+ ole.fn(:SysAllocString, [P], P).call(Fiddle::Pointer[bytes])
88
+ end
89
+
90
+ def safe_array(values)
91
+ array = ole.fn(:SafeArrayCreateVector, [Fiddle::TYPE_USHORT, Fiddle::TYPE_LONG, U], P).call(3, 0, values.length)
92
+ data = [0].pack("J")
93
+ return 0 if array.to_i.zero? || ole.fn(:SafeArrayAccessData, [P, P], I).call(array, data) != S_OK
94
+ Fiddle::Pointer.new(data.unpack1("J"))[0, values.length * 4] = values.pack("l*")
95
+ ole.fn(:SafeArrayUnaccessData, [P], I).call(array)
96
+ array
97
+ end
98
+
99
+ private
100
+
101
+ def scale = @window.scale_factor || 1.0
102
+ def user = @window.instance_variable_get(:@user)
103
+ def core = (@core ||= Zaniah::FFI::Library.new("UIAutomationCore.dll"))
104
+ def ole = (@ole ||= Zaniah::FFI::Library.new("oleaut32.dll"))
105
+ end
106
+
107
+ class Element
108
+ INTERFACES = {}
109
+ class << self
110
+ attr_reader :vtables, :closures
111
+
112
+ def install
113
+ return if @vtables
114
+ @closures = []
115
+ unknown = [closure(I, [P, P, P]) { |this, iid, output| interface(this).query(iid, output) },
116
+ closure(U, [P]) { |this| interface(this).add_ref }, closure(U, [P]) { |this| interface(this).release }]
117
+ simple = unknown + [closure(I, [P, P]) { |_this, output| write_int(output, 1) },
118
+ closure(I, [P, I, P]) { |this, pattern, output| interface(this).pattern(pattern, output) },
119
+ closure(I, [P, I, P]) { |this, property, output| interface(this).property(property, output) },
120
+ closure(I, [P, P]) { |this, output| interface(this).host(output) }]
121
+ fragment = unknown + [closure(I, [P, I, P]) { |this, direction, output| interface(this).navigate(direction, output) },
122
+ closure(I, [P, P]) { |this, output| interface(this).runtime_id(output) },
123
+ closure(I, [P, P]) { |this, output| interface(this).bounding_rectangle(output) },
124
+ closure(I, [P, P]) { |_this, output| write_pointer(output, 0) },
125
+ closure(I, [P]) { |this| interface(this).set_focus },
126
+ closure(I, [P, P]) { |this, output| interface(this).fragment_root(output) }]
127
+ root = unknown + [closure(I, [P, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE, P]) { |this, x, y, output| interface(this).from_point(x, y, output) },
128
+ closure(I, [P, P]) { |this, output| interface(this).focus(output) }]
129
+ invoke = unknown + [closure(I, [P]) { |this| interface(this).invoke }]
130
+ @vtables = {simple: vtable(simple), fragment: vtable(fragment), root: vtable(root), invoke: vtable(invoke)}.freeze
131
+ end
132
+
133
+ def closure(result, arguments, &block)
134
+ value = Fiddle::Closure::BlockCaller.new(result, arguments) do |*values|
135
+ block.call(*values)
136
+ rescue StandardError
137
+ result == I ? E_FAIL : 1
138
+ end
139
+ @closures << value
140
+ value
141
+ end
142
+
143
+ def vtable(functions)
144
+ pointer = Fiddle::Pointer.malloc(functions.length * Fiddle::SIZEOF_VOIDP)
145
+ pointer[0, functions.length * Fiddle::SIZEOF_VOIDP] = functions.map(&:to_i).pack("J*")
146
+ pointer
147
+ end
148
+
149
+ def interface(pointer) = INTERFACES.fetch(pointer.to_i)
150
+ def write_pointer(output, value) = (Fiddle::Pointer.new(output)[0, Fiddle::SIZEOF_VOIDP] = [value.to_i].pack("J"); S_OK)
151
+ def write_int(output, value) = (Fiddle::Pointer.new(output)[0, 4] = [value].pack("l"); S_OK)
152
+ end
153
+
154
+ attr_accessor :entry
155
+ attr_reader :bridge
156
+
157
+ def initialize(bridge, path)
158
+ self.class.install
159
+ @bridge, @path, @references, @interfaces = bridge, path, 1, {}
160
+ %i[simple fragment root invoke].each do |kind|
161
+ pointer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
162
+ pointer[0, Fiddle::SIZEOF_VOIDP] = [self.class.vtables.fetch(kind).to_i].pack("J")
163
+ @interfaces[kind] = pointer
164
+ INTERFACES[pointer.to_i] = self
165
+ end
166
+ end
167
+
168
+ def pointer(kind) = @interfaces.fetch(kind).to_i
169
+
170
+ def query(iid, output)
171
+ requested = GUIDS.key(Fiddle::Pointer.new(iid)[0, 16])
172
+ requested = :simple if requested == :unknown
173
+ supported = requested && (requested != :root || @entry.parent.nil?) && (requested != :invoke || invokable?)
174
+ return self.class.write_pointer(output, 0) && E_NOINTERFACE unless supported
175
+ add_ref
176
+ self.class.write_pointer(output, pointer(requested))
177
+ end
178
+
179
+ def add_ref = (@references += 1)
180
+ def release = (@references = [@references - 1, 1].max)
181
+
182
+ def pattern(pattern, output)
183
+ return self.class.write_pointer(output, 0) unless pattern == 10_000 && invokable?
184
+ add_ref
185
+ self.class.write_pointer(output, pointer(:invoke))
186
+ end
187
+
188
+ def property(property, output)
189
+ variant = Fiddle::Pointer.new(output)
190
+ variant[0, 16] = "\0" * 16
191
+ case property
192
+ when 30_003 then variant_i4(variant, CONTROL_TYPES.fetch(@entry.node.role, 50_025))
193
+ when 30_005 then variant_bstr(variant, @entry.node.label || @entry.node.value&.to_s || @entry.node.role.to_s)
194
+ when 30_008 then variant_bool(variant, @bridge.focused_provider.equal?(self))
195
+ when 30_009 then variant_bool(variant, focusable?)
196
+ when 30_010 then variant_bool(variant, !@entry.node.states[:disabled])
197
+ when 30_012 then variant_bstr(variant, "Zaniah")
198
+ when 30_016, 30_017 then variant_bool(variant, true)
199
+ when 30_022 then variant_bool(variant, @bridge.tree.bounds(@entry).empty?)
200
+ when 30_024 then variant_bstr(variant, "Zaniah")
201
+ end
202
+ S_OK
203
+ end
204
+
205
+ def host(output) = @entry.parent ? self.class.write_pointer(output, 0) : @bridge.host_provider(output)
206
+
207
+ def navigate(direction, output)
208
+ target = case direction
209
+ when 0 then @entry.parent
210
+ when 1, 2
211
+ siblings = @entry.parent&.children || []
212
+ index = siblings.index(@entry)
213
+ index && siblings[index + (direction == 1 ? 1 : -1)] if index && (direction == 1 || index.positive?)
214
+ when 3 then @entry.children.first
215
+ when 4 then @entry.children.last
216
+ end
217
+ provider = @bridge.provider(target)
218
+ provider&.add_ref
219
+ self.class.write_pointer(output, provider&.pointer(:fragment) || 0)
220
+ end
221
+
222
+ def runtime_id(output)
223
+ array = @bridge.safe_array([UIA_APPEND_RUNTIME_ID, @entry.runtime_id])
224
+ self.class.write_pointer(output, array)
225
+ end
226
+
227
+ def bounding_rectangle(output)
228
+ Fiddle::Pointer.new(output)[0, 32] = @bridge.screen_bounds(@entry).pack("d4")
229
+ S_OK
230
+ end
231
+
232
+ def set_focus
233
+ bounds = @bridge.tree.bounds(@entry)
234
+ Accessibility.focus_at(@bridge.window, Point.new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0))
235
+ S_OK
236
+ end
237
+
238
+ def fragment_root(output)
239
+ root = @bridge.root_provider
240
+ root.add_ref
241
+ self.class.write_pointer(output, root.pointer(:root))
242
+ end
243
+
244
+ def from_point(x, y, output)
245
+ provider = @bridge.provider_at(x, y) || @bridge.root_provider
246
+ provider.add_ref
247
+ self.class.write_pointer(output, provider.pointer(:fragment))
248
+ end
249
+
250
+ def focus(output)
251
+ provider = @bridge.focused_provider
252
+ provider.add_ref
253
+ self.class.write_pointer(output, provider.pointer(:fragment))
254
+ end
255
+
256
+ 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))
259
+ S_OK
260
+ end
261
+
262
+ private
263
+
264
+ def invokable? = @entry.node.actions.any? { |action| %i[press toggle select sort expand collapse dismiss].include?(action) }
265
+ def focusable? = !@entry.node.states[:disabled] && (!@entry.node.actions.empty? || %i[textbox searchbox slider combobox].include?(@entry.node.role))
266
+ def variant_i4(pointer, value) = (pointer[0, 2] = [3].pack("v"); pointer[8, 4] = [value].pack("l"))
267
+ def variant_bool(pointer, value) = (pointer[0, 2] = [11].pack("v"); pointer[8, 2] = [value ? -1 : 0].pack("s"))
268
+ def variant_bstr(pointer, value) = (pointer[0, 2] = [8].pack("v"); pointer[8, Fiddle::SIZEOF_VOIDP] = [@bridge.bstr(value)].pack("J"))
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "windows/provider"
4
+
5
+ module Zaniah
6
+ module Accessibility
7
+ module Windows
8
+ EVENT_OBJECT_REORDER = 0x8004
9
+ OBJID_CLIENT = -4
10
+ CHILDID_SELF = 0
11
+
12
+ module_function
13
+
14
+ def publish(window, root, _changes)
15
+ return unless root && window.respond_to?(:handle)
16
+ bridge = bridges[window.handle.to_i] ||= Provider::Bridge.new(window)
17
+ bridge.update(root)
18
+ user = window.instance_variable_get(:@user)
19
+ return unless user
20
+ user.fn(:NotifyWinEvent,
21
+ [Fiddle::TYPE_UINT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_VOID)
22
+ .call(EVENT_OBJECT_REORDER, window.handle, OBJID_CLIENT, CHILDID_SELF)
23
+ rescue Fiddle::DLError
24
+ nil
25
+ end
26
+
27
+ def provider_result(window, wparam, lparam)
28
+ return unless (lparam.to_i & 0xffffffff) == 0xffffffe7
29
+ bridge = bridges[window.handle.to_i]
30
+ return unless bridge&.root_provider
31
+ bridge.return_provider(wparam, lparam)
32
+ end
33
+
34
+ def close(window) = bridges.delete(window.handle.to_i)
35
+ def bridges = (@bridges ||= {})
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "accessibility/node"
4
+ require_relative "accessibility/tree"
5
+ require_relative "accessibility/native_tree"
6
+
7
+ module Zaniah
8
+ module Accessibility
9
+ autoload :Mac, File.expand_path("accessibility/mac", __dir__)
10
+ autoload :Windows, File.expand_path("accessibility/windows", __dir__)
11
+ autoload :Linux, File.expand_path("accessibility/linux", __dir__)
12
+
13
+ module_function
14
+
15
+ def publish(window, root, changes)
16
+ adapter = case window.class.name
17
+ when /::Mac::/ then Mac
18
+ when /::Windows::/ then Windows
19
+ when /::Linux::/ then Linux
20
+ end
21
+ adapter&.publish(window, root, changes)
22
+ end
23
+
24
+ def poll(window)
25
+ Linux.poll(window) if window.class.name.match?(/::Linux::/)
26
+ end
27
+
28
+ def close(window)
29
+ case window.class.name
30
+ when /::Mac::/ then Mac.close(window)
31
+ when /::Windows::/ then Windows.close(window)
32
+ when /::Linux::/ then Linux.close(window)
33
+ end
34
+ end
35
+
36
+ def perform(window, node, action, bounds: node&.bounds)
37
+ return false unless node && node.actions.include?(action)
38
+ if action == :dismiss
39
+ window.input(Input::KeyDown.new("esc", false))
40
+ return true
41
+ end
42
+ return false unless bounds
43
+ position = Point.new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0)
44
+ if %i[increment decrement].include?(action)
45
+ focus_at(window, position)
46
+ window.dispatcher.key(action == :increment ? "right" : "left")
47
+ else
48
+ window.input(Input::MouseDown.new(position, :left, [], 1))
49
+ window.input(Input::MouseUp.new(position, :left, []))
50
+ end
51
+ true
52
+ end
53
+
54
+ def focus_at(window, position)
55
+ hit = window.dispatcher.hits.reverse.find { |candidate| candidate.bounds&.contains?(position) }
56
+ owner = hit&.owner
57
+ owner = owner.parent while owner && !owner.respond_to?(:focus_handle)
58
+ handle = owner&.focus_handle
59
+ window.dispatcher.focus(handle, origin: :programmatic) if handle
60
+ !handle.nil?
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Animation
5
+ attr_reader :key, :from, :to, :started_at, :duration, :easing
6
+
7
+ def initialize(key, from:, to:, started_at:, duration:, easing:, on_complete: nil)
8
+ @key, @from, @to, @started_at = key, from, to, started_at
9
+ @duration, @easing, @on_complete = duration, easing, on_complete
10
+ end
11
+
12
+ def sample(now)
13
+ progress = duration.zero? ? 1.0 : ((now - started_at) / duration).clamp(0, 1)
14
+ [Animation.interpolate(from, to, easing.call(progress)), progress >= 1]
15
+ end
16
+
17
+ def complete = @on_complete&.call
18
+
19
+ def self.interpolatable?(from, to)
20
+ (from.is_a?(Numeric) && to.is_a?(Numeric)) ||
21
+ (from.is_a?(Color) && to.is_a?(Color)) ||
22
+ (from.is_a?(Length) && to.is_a?(Length) && from.unit == to.unit) ||
23
+ (from.is_a?(Transform) && to.is_a?(Transform))
24
+ end
25
+
26
+ def self.interpolate(from, to, progress)
27
+ return from + (to - from) * progress if from.is_a?(Numeric) && to.is_a?(Numeric)
28
+ return Color.new(*from.to_a.zip(to.to_a).map { |left, right| left + (right - left) * progress }) if from.is_a?(Color) && to.is_a?(Color)
29
+ return Length.new(from.value + (to.value - from.value) * progress, from.unit) if from.is_a?(Length) && to.is_a?(Length) && from.unit == to.unit
30
+ return Transform.new(*from.to_a.zip(to.to_a).map { |left, right| left + (right - left) * progress }) if from.is_a?(Transform) && to.is_a?(Transform)
31
+ progress >= 1 ? to : from
32
+ end
33
+ end
34
+
35
+ class Animator
36
+ attr_reader :reduced_motion
37
+
38
+ def initialize(clock:, reduced_motion: false)
39
+ @clock, @reduced_motion = clock, reduced_motion
40
+ @animations, @values = {}, {}
41
+ end
42
+
43
+ def reduced_motion=(value)
44
+ @reduced_motion = !!value
45
+ @animations.keys.each { |key| finish(key) } if @reduced_motion
46
+ @reduced_motion
47
+ end
48
+
49
+ def animate(key, from:, to:, duration:, easing: :linear, &on_complete)
50
+ duration = Float(duration)
51
+ raise ArgumentError, "duration must be finite and nonnegative" unless duration.finite? && duration >= 0
52
+ duration = 0.0 if reduced_motion
53
+ animation = Animation.new(key, from: from, to: to, started_at: @clock.call,
54
+ duration: duration, easing: Easing.resolve(easing), on_complete: on_complete)
55
+ @values[key] = from
56
+ if duration.zero?
57
+ @values[key] = to
58
+ animation.complete
59
+ else
60
+ @animations[key] = animation
61
+ end
62
+ animation
63
+ end
64
+
65
+ def spring(key, to:, stiffness: 170, damping: 26, mass: 1, from: value(key, 0), &on_complete)
66
+ easing = Easing.spring(stiffness: stiffness, damping: damping, mass: mass)
67
+ animate(key, from: from, to: to, duration: easing.settling_duration, easing: easing, &on_complete)
68
+ end
69
+
70
+ def sample(now = @clock.call)
71
+ @animations.dup.each do |key, animation|
72
+ @values[key], finished = animation.sample(now)
73
+ finish(key) if finished
74
+ end
75
+ @values.dup.freeze
76
+ end
77
+
78
+ def active? = !@animations.empty?
79
+ def animating?(key) = @animations.key?(key)
80
+ def value(key, fallback = nil) = @values.fetch(key, fallback)
81
+
82
+ def cancel(key)
83
+ @animations.delete(key)
84
+ @values.delete(key)
85
+ end
86
+
87
+ def finish(key)
88
+ animation = @animations.delete(key)
89
+ return @values[key] unless animation
90
+ @values[key] = animation.to
91
+ animation.complete
92
+ @values[key]
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Easing
5
+ module_function
6
+
7
+ def linear = ->(value) { value }
8
+ def ease_in = cubic_bezier(0.42, 0, 1, 1)
9
+ def ease_out = cubic_bezier(0, 0, 0.58, 1)
10
+ def ease_in_out = cubic_bezier(0.42, 0, 0.58, 1)
11
+
12
+ def resolve(value)
13
+ return value if value.respond_to?(:call)
14
+ public_send(value || :linear)
15
+ rescue NoMethodError
16
+ raise ArgumentError, "unknown easing #{value.inspect}"
17
+ end
18
+
19
+ def cubic_bezier(x1, y1, x2, y2)
20
+ values = [x1, y1, x2, y2].map { |value| Float(value) }
21
+ raise ArgumentError, "cubic-bezier x values must be between 0 and 1" unless values.values_at(0, 2).all? { |value| value.between?(0, 1) }
22
+ x1, y1, x2, y2 = values
23
+ lambda do |input|
24
+ input = input.to_f.clamp(0, 1)
25
+ low, high = 0.0, 1.0
26
+ 16.times do
27
+ time = (low + high) / 2.0
28
+ sample = bezier(time, x1, x2)
29
+ sample < input ? low = time : high = time
30
+ end
31
+ bezier((low + high) / 2.0, y1, y2)
32
+ end
33
+ end
34
+
35
+ def spring(stiffness: 170, damping: 26, mass: 1)
36
+ Spring.new(stiffness: stiffness, damping: damping, mass: mass)
37
+ end
38
+
39
+ def bezier(time, first, second)
40
+ inverse = 1 - time
41
+ 3 * inverse * inverse * time * first + 3 * inverse * time * time * second + time**3
42
+ end
43
+ private_class_method :bezier
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Spring
5
+ attr_reader :stiffness, :damping, :mass, :settling_duration
6
+
7
+ def initialize(stiffness:, damping:, mass: 1)
8
+ @stiffness, @damping, @mass = Float(stiffness), Float(damping), Float(mass)
9
+ raise ArgumentError, "spring values must be finite and positive" unless [@stiffness, @damping, @mass].all? { |value| value.finite? && value.positive? }
10
+ @settling_duration = [-Math.log(0.001) / slowest_decay, 10.0].min
11
+ end
12
+
13
+ def call(progress)
14
+ time = progress.to_f.clamp(0, 1) * settling_duration
15
+ ratio = damping / (2 * Math.sqrt(stiffness * mass))
16
+ frequency = Math.sqrt(stiffness / mass)
17
+ return 1 - Math.exp(-frequency * time) * (1 + frequency * time) if (ratio - 1).abs < 1e-6
18
+ if ratio < 1
19
+ damped = frequency * Math.sqrt(1 - ratio * ratio)
20
+ 1 - Math.exp(-ratio * frequency * time) * (Math.cos(damped * time) + ratio * frequency / damped * Math.sin(damped * time))
21
+ else
22
+ root = Math.sqrt(ratio * ratio - 1)
23
+ first, second = -frequency * (ratio - root), -frequency * (ratio + root)
24
+ 1 - (second * Math.exp(first * time) - first * Math.exp(second * time)) / (second - first)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def slowest_decay
31
+ ratio = damping / (2 * Math.sqrt(stiffness * mass))
32
+ frequency = Math.sqrt(stiffness / mass)
33
+ ratio <= 1 ? ratio * frequency : frequency * (ratio - Math.sqrt(ratio * ratio - 1))
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ Transition = Data.define(:properties, :duration, :easing)
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "animation/easing"
4
+ require_relative "animation/spring"
5
+ require_relative "animation/animator"
6
+ require_relative "animation/transition"
data/lib/zaniah/app.rb CHANGED
@@ -10,7 +10,7 @@ module Zaniah
10
10
  attr_reader :windows, :executor
11
11
 
12
12
  def initialize(clock: MONOTONIC_CLOCK)
13
- @slots, @generations, @free, @windows, @globals = [], [], [], [], {}
13
+ @slots, @generations, @free, @windows, @globals = [], [], [], [], {theme: Theme.dark}
14
14
  @listeners, @effects, @updating, @flushing = {}, [], 0, false
15
15
  @clock = clock
16
16
  @executor = TaskExecutor.new(clock: clock)
@@ -106,25 +106,45 @@ module Zaniah
106
106
  def open_window(**options, &render)
107
107
  options[:clock] ||= @clock
108
108
  window = Platform.open_window(**options)
109
+ window.app = self
109
110
  @windows << window
111
+ @globals[:theme] = platform_theme(window)
112
+ window.on_appearance do |appearance|
113
+ @globals[:theme] = platform_theme(window, appearance)
114
+ @windows.each(&:request_frame)
115
+ end
110
116
  window.draw(&render) if render
111
117
  window
112
118
  end
113
119
 
114
120
  def run
115
121
  until @windows.empty? || @windows.all?(&:closed?)
122
+ @hot_reloads&.each(&:poll)
116
123
  @executor.drain
117
124
  @windows.reject(&:closed?).each(&:tick)
118
- @executor.wait(0.05) unless @windows.any?(&:dirty?)
125
+ @executor.wait(0.05) unless @windows.any? { |window| window.dirty? || window.animation_active? }
119
126
  end
120
127
  ensure
128
+ @hot_reloads&.each(&:close)
121
129
  @executor.shutdown
122
130
  end
123
131
 
132
+ def hot_reload(paths, **options, &block)
133
+ require_relative "devtools"
134
+ reload = DevTools::HotReload.new(self, paths, **options, &block)
135
+ (@hot_reloads ||= []) << reload
136
+ reload
137
+ end
138
+
124
139
  def alive?(entity) = @generations[entity.id] == entity.generation && !@free.include?(entity.id) && entity.id < @slots.length
125
140
 
126
141
  private
127
142
 
143
+ def platform_theme(window, appearance = window.appearance)
144
+ theme = Theme.for(appearance)
145
+ window.reduced_motion? ? theme.with(motion: theme.motion.with(reduced: true)) : theme
146
+ end
147
+
128
148
  def validate(entity)
129
149
  raise Error, "entity has been released" unless alive?(entity)
130
150
  end
data/lib/zaniah/color.rb CHANGED
@@ -14,6 +14,7 @@ module Zaniah
14
14
  end
15
15
 
16
16
  def opacity(value) = Color.new(r, g, b, a * value)
17
+ def with_alpha(value) = Color.new(r, g, b, value.to_f.clamp(0, 1))
17
18
  def premultiplied = [r * a, g * a, b * a, a]
18
19
  def to_a = [r, g, b, a]
19
20
 
@@ -21,6 +22,21 @@ module Zaniah
21
22
  Color.new(*to_a.zip(Color.parse(other).to_a).map { |x, y| x + (y - x) * amount })
22
23
  end
23
24
 
25
+ def lighten(amount)
26
+ value = to_hsla
27
+ HSLA.new(value.h, value.s, (value.l + amount).clamp(0, 1), value.a).to_rgba
28
+ end
29
+
30
+ def darken(amount) = lighten(-amount)
31
+
32
+ def contrast_ratio(other)
33
+ values = [self, Color.parse(other)].map do |color|
34
+ channels = [color.r, color.g, color.b].map { |channel| channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055)**2.4 }
35
+ 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
36
+ end
37
+ (values.max + 0.05) / (values.min + 0.05)
38
+ end
39
+
24
40
  def to_hsla
25
41
  max, min = [r, g, b].max, [r, g, b].min
26
42
  delta, lightness = max - min, (max + min) / 2.0