zaniah 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6787d1edc51a6a692859fe62834270cfa2339ade8b00e596e8861632eb59913
4
- data.tar.gz: 7407abf9f7a64c80fc307843ffeadd9d130cfb45a12673a85f6cbedf2710a3e4
3
+ metadata.gz: 0a864e71f3de289770aafa36f520440c0adab078b763412544bca3f9fb32cacb
4
+ data.tar.gz: fc0e6fb0c05f8d0769305954cc5a730258947845b8438328df6d66ca172e80ad
5
5
  SHA512:
6
- metadata.gz: 35cdfc811f06f704d21202e60029c2869eb5b01698c4e52cde3ce2b7c0e45eaa0c6c5a60ec46e9e9f4e15fd2e1b7f88a75b71b9ec829423540259238739cf468
7
- data.tar.gz: beaae799ef7227cf30d354819b4d896ee1957666c1617d4a43a15fd8d0112180b0dcf74dadf930c6df8376eaa0045663c83400cca2f6bf019142ab9cb2af0592
6
+ metadata.gz: 16ff7f0e3cc0ce444b502eaff32faaab6cf503af5bf0a4ad547a305aa9394c91d9960052e60bc94ce775521bd54e42ddadfff49b6f896147ca00771e3e4aa59f
7
+ data.tar.gz: 959e485686a3e92a155916a306afc0eb7848f6556d5c1bc6cbfe9c30a605701213dd37c79c978d49b807c60b25e9a47a3cc1e3fff9731cf0ae5a0432a7522f51
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.1 — 2026-09-16
6
+
7
+ - Let lazy tree views accept externally loaded children and invalidate cached subtrees without blocking the UI thread.
8
+ - Prevent replacement tree sources from inheriting stale lazy children.
9
+
10
+ ## 0.5.0 — 2026-09-15
11
+
12
+ - Make tree views lazy and viewport-virtualized while preserving stable-ID state across source rebuilds.
13
+ - Add bounded, thread-safe low-resolution text textures with per-line invalidation and repeated-shape raster reuse for minimaps.
14
+ - Add stable-ID pointer and keyboard reordering with virtual targets and live accessibility status.
15
+ - Add arbitrary stable-ID pane grids with resizable fixed, fractional, and minmax tracks.
16
+ - Preserve screen-reader identity across virtualization and reordering, with direct tree and pane actions plus native focus and live-region events.
17
+
5
18
  ## 0.4.0 — 2026-09-15
6
19
 
7
20
  - Add inline and block text overlays with wrapping-aware layout, hit testing, and row-local relayout.
data/README.md CHANGED
@@ -37,10 +37,12 @@ the drawing surface beneath the editor.
37
37
  - Gradients, transforms, paths, SVG, shadows, clipping, and GPU glyph atlases
38
38
  - Retained element state, subscriptions, and non-blocking background tasks
39
39
  - Scroll views, inertial input, and uniform or variable-height virtual lists
40
- - OpenType shaping, font fallback, Japanese wrapping, text overlays, selection, editing, and IME
40
+ - Stable-ID pointer and keyboard reordering without materializing virtual collections
41
+ - Stable-ID split-pane grids with fixed, fractional, and minmax tracks
42
+ - OpenType shaping, font fallback, Japanese wrapping, text overlays, low-resolution text caching, selection, editing, and IME
41
43
  - Themes, state styles, keyed animation, springs, and reduced-motion support
42
- - Opt-in controls, overlays, tables, trees, charts, forms, and terminal fallbacks
43
- - Spatial keyboard focus and a diffed cross-platform accessibility tree
44
+ - Opt-in controls, overlays, tables, externally completable lazy virtual trees, charts, forms, and terminal fallbacks
45
+ - Spatial keyboard focus and a stable-ID, event-diffed cross-platform accessibility tree
44
46
  - F12 inspector, frame statistics, hot reload, and headless golden-image tests
45
47
  - Native input, IME, clipboard, file-drop, display, and filesystem events
46
48
  - RBS declarations for the public API
@@ -114,7 +116,8 @@ Native backends use the operating system libraries through Ruby's Fiddle. See
114
116
  - [Native backends](docs/native.md) — windows, displays, file watching, and terminals
115
117
  - [Text system](docs/text.md) — font discovery, shaping, rasterization, and caching
116
118
  - [SVG and lists](docs/vector_and_list.md) — static vector icons and virtual lists
117
- - [Layout and scrolling](docs/layout.md) — Grid, ScrollView, sticky positioning, and RTL foundations
119
+ - [Drag-and-drop reordering](docs/drag_drop.md) — stable IDs, virtual targets, and keyboard access
120
+ - [Layout and scrolling](docs/layout.md) — Grid, resizable pane grids, scrolling, sticky positioning, and RTL foundations
118
121
  - [Themes](docs/theme.md) — semantic tokens and state styles
119
122
  - [Components](docs/components.md) — controls, overlays, data views, charts, and forms
120
123
  - [Animation](docs/animation.md) — easing, springs, transitions, and reduced motion
@@ -1,9 +1,9 @@
1
1
  # Accessibility
2
2
 
3
3
  Every window maintains a backend-neutral `Accessibility::Tree` beside its render
4
- tree. Components expose role, label, value, bounds, states, children, and actions
5
- through `Accessibility::Node`. The window publishes a notification only when the
6
- semantic tree changes.
4
+ tree. Components expose a stable ID, role, label, value, bounds, states, children,
5
+ and actions through `Accessibility::Node`. The window publishes notifications only
6
+ when the semantic tree changes.
7
7
 
8
8
  ```ruby
9
9
  window.accessibility_tree.each do |node, path|
@@ -11,10 +11,13 @@ window.accessibility_tree.each do |node, path|
11
11
  end
12
12
  ```
13
13
 
14
- `window.accessibility_revision` increments after each semantic change, and
15
- `window.accessibility_tree.changes` contains `added`, `removed`, or `updated`
16
- records. This makes accessibility assertions deterministic on the headless
17
- backend without mocking an operating system API.
14
+ `window.accessibility_revision` increments after each semantic change.
15
+ `window.accessibility_tree.changes` contains `added`, `removed`, `updated`, or
16
+ `moved` records; stable IDs let a reorder remain a move instead of two unrelated
17
+ updates. IDs must be immutable and unique among siblings. The accompanying
18
+ `events` reduce those changes to deterministic `structure`, `property`, `layout`,
19
+ `focus`, and `announcement` events. This makes accessibility assertions
20
+ deterministic on the headless backend without mocking an operating system API.
18
21
 
19
22
  ## Component semantics
20
23
 
@@ -30,6 +33,7 @@ Custom components implement the same method:
30
33
  def accessibility_node(_cx)
31
34
  Zaniah::Accessibility.node(
32
35
  role: :button,
36
+ id: :refresh,
33
37
  label: "Refresh",
34
38
  states: {busy: @loading},
35
39
  actions: @loading ? [] : [:press]
@@ -37,20 +41,42 @@ def accessibility_node(_cx)
37
41
  end
38
42
  ```
39
43
 
44
+ If a component synthesizes semantic descendants, it can route their actions
45
+ without coordinate hit testing:
46
+
47
+ ```ruby
48
+ def accessibility_action(node, action)
49
+ refresh(node.id) if action == :press
50
+ end
51
+ ```
52
+
53
+ `TreeView` uses item IDs for its viewport-only descendants and reports the
54
+ selected descendant as focused while the tree owns keyboard focus. Its select,
55
+ expand, and collapse actions call the tree model directly. `PaneGrid` gives panes
56
+ and dividers stable IDs and routes divider increment, decrement, minimum, and
57
+ maximum actions through the same resizing path as the keyboard. A
58
+ `DragDrop::Reorder` exposes keyboard reorder actions and a stable, atomic polite
59
+ live region for move and cancellation results. Offscreen virtual rows remain
60
+ unmaterialized; when they re-enter the viewport, their IDs reconnect them to the
61
+ same native identity.
62
+
40
63
  Decorative content should return `nil`. Container elements without an explicit
41
64
  node retain semantic descendants in a generated `group` node.
42
65
 
43
66
  ## Native bridges
44
67
 
45
- - macOS publishes an `NSAccessibilityElement` hierarchy with roles, labels,
46
- values, state, bounds, and children, then posts `AXLayoutChanged`.
68
+ - macOS publishes an `NSAccessibilityElement` hierarchy with identifiers, roles,
69
+ labels, values, state, bounds, and children, then posts layout, value, focus,
70
+ and announcement notifications from semantic events.
47
71
  - Windows answers `WM_GETOBJECT` with `IRawElementProviderSimple`, fragment,
48
72
  fragment-root, and Invoke providers. UI Automation clients can navigate and
49
- query the semantic hierarchy rather than relying on refresh events alone.
73
+ query stable Automation IDs and receive structure, property, layout, focus,
74
+ and live-region events rather than relying on refresh events alone.
50
75
  - Linux registers `Accessible`, `Component`, `Action`, and `Application`
51
76
  objects on the AT-SPI D-Bus, embeds the application root in the registry,
52
- and emits object-change events. Actions route back through the normal input
53
- dispatcher on every platform.
77
+ retains stable `AccessibleId` values and object paths, and emits matching
78
+ object, state, and announcement events. Actions route back through the normal
79
+ input dispatcher or a component's direct semantic action on every platform.
54
80
 
55
81
  The Linux provider is exercised in CI inside a private D-Bus session. Native
56
82
  macOS and Windows objects retain the same `Accessibility::NativeTree` path and
@@ -0,0 +1,26 @@
1
+ # ADR 011: Match accessibility descendants by stable identity
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+ - Decision deadline: before screen-reader virtualization support
6
+
7
+ ## Context
8
+
9
+ Position-only semantic paths change when a virtual viewport scrolls or siblings
10
+ are reordered. Native bridges then expose a previously known item as a different
11
+ object, and coordinate-generated actions can target the wrong virtual row.
12
+
13
+ ## Decision
14
+
15
+ Let semantic nodes carry an optional immutable ID that is unique among siblings.
16
+ Match identified siblings by ID when diffing and retain native runtime IDs across
17
+ tree publications. Keep offscreen rows absent. Route actions on synthesized nodes
18
+ back to their owning component, and derive native structure, property, layout,
19
+ focus, and live-region events from the shared semantic diff.
20
+
21
+ ## Consequences
22
+
23
+ Virtual tree rows, reordered panes, and live status nodes regain their native
24
+ identity when their semantic node moves. Components remain responsible for stable
25
+ domain IDs and expose no platform objects. Unidentified nodes retain positional
26
+ matching and coordinate action fallback for compatibility.
data/docs/components.md CHANGED
@@ -79,11 +79,12 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
79
79
  | L3 | `Toast` | `(message, variant:, queue:)`; `dismiss` | info/success/warning/danger | live status |
80
80
  | L3 | `CommandPalette` | `(commands, open:, placeholder:)` | searchable modal | dialog/list |
81
81
  | L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
82
+ | L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
82
83
  | L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
83
84
  | L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
84
85
  | L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
85
86
  | L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit` | virtual rows, sorting, resizing, editing | table/row/cell |
86
- | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, lazy `children` proc | arrows/Home/End | tree/treeitem |
87
+ | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
87
88
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
88
89
  | L5 | `LineChart`, `BarChart` | `(series, width:, height:, colors:, label:)` | axes, legend, tooltip | image |
89
90
  | L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
@@ -98,12 +99,26 @@ the previous focus. See [TUI](tui.md) for terminal representations.
98
99
 
99
100
  Table columns are hashes with `key`, and optional `label`, `width`, `sortable`,
100
101
  `resizable`, `editable`, and `render`. Tree items accept hashes containing `id`,
101
- `label`, and either an array or lazy proc in `children`.
102
+ `label`, and either an array or lazy proc in `children`. A lazy proc receives the
103
+ item value, runs on first expansion, and is cached after it succeeds; a raised
104
+ exception leaves it available for retry. A loader can return a placeholder while
105
+ work runs elsewhere; call `replace_children(id, children)` on the UI thread to
106
+ apply the result without changing selection, focus, or expansion. It returns
107
+ `false` when the ID is missing or is not lazy. `invalidate(id)` likewise returns
108
+ `false` for missing or non-lazy items; otherwise it discards that lazy result and
109
+ its loaded descendants so the next expansion calls the loader again. Omit the ID
110
+ to invalidate all loaded results. `replace(items)` starts a
111
+ new source generation, so it never inherits loaded children from the old source.
112
+ Completed children must be an array with unique, non-nil stable IDs, at most 64
113
+ levels deep and 100,000 items total. Invalid results leave the previous children
114
+ unchanged. Tree rows and accessibility nodes are built only for the current
115
+ viewport.
102
116
 
103
117
  In a table, Up/Down/Home/End/Page keys move and select rows, Shift+Up/Down extends
104
118
  a range, and Cmd/Ctrl+A selects every row in multiple-selection mode. Sortable
105
119
  headers and resize handles are separate Tab stops; Enter sorts and arrow/Page keys
106
- resize. Tree views use Up/Down to select and Left/Right to collapse or expand.
120
+ resize. Tree views use Up/Down to select, Right to expand or enter the first child,
121
+ and Left to collapse or return to the parent.
107
122
 
108
123
  Run `bundle exec ruby tools/generate_component_gallery.rb` to rebuild the dark,
109
124
  light, and high-contrast component sheets plus overlay variants in all three themes.
data/docs/drag_drop.md ADDED
@@ -0,0 +1,46 @@
1
+ # Drag-and-drop reordering
2
+
3
+ `DragDrop::Reorder` coordinates pointer and keyboard reordering without owning the
4
+ application's collection. It identifies items by stable IDs and reports a
5
+ `DragDrop::Target` whose position is `before`, `after`, or `inside`.
6
+
7
+ ```ruby
8
+ reorder = Zaniah::DragDrop::Reorder.new(
9
+ threshold: 4,
10
+ locate: ->(point, source_id) { target_at(point, excluding: source_id) },
11
+ keyboard: ->(id, direction) { adjacent_target(id, direction) },
12
+ label: ->(id) { item_label(id) }
13
+ ).on_drop do |event|
14
+ move_item(event.source_id, event.target.id, event.target.position)
15
+ end
16
+
17
+ row
18
+ .on_mouse_down { |event, _| reorder.press(row_id, event.position) }
19
+ .on_drag { |event, _| reorder.move(event.position) }
20
+ .on_mouse_up { |event, _| reorder.release(event.position) }
21
+ .focusable(context: {reorderable: true}) { |action| reorder.action(row_id, action) }
22
+ ```
23
+
24
+ The pointer locator is called only after the drag threshold is crossed. Virtual
25
+ lists can derive a row with `List::HeightIndex#index_at`; uniform lists can use
26
+ division by row height. Tree views can resolve a visible segment and return an
27
+ `inside` target only for containers. No API requires enumerating or rendering the
28
+ whole collection. Return `nil` for collection-specific no-ops, such as placing an
29
+ item after its current predecessor.
30
+
31
+ Listen with `on_event` for `press`, `start`, `enter`, `over`, `leave`, `drop`, and
32
+ `cancel`. Call `remove(id)` when application data removes a source or current
33
+ target, and call `cancel` when an enclosing interaction ends. Callback failures
34
+ reset transient drag state before being re-raised. Callbacks may update the
35
+ application collection, but must not re-enter their `Reorder` controller; an
36
+ attempt raises `Zaniah::Error` and cancels the active operation.
37
+
38
+ For keyboard access, mark the focused element with `reorderable: true`. Alt+Up
39
+ and Alt+Down dispatch `reorder_before` and `reorder_after`; Escape dispatches
40
+ `cancel_reorder`. `accessibility_actions(id)` returns the actions a containing
41
+ item should expose, and `action(id, action)` routes them without pointer geometry.
42
+ `accessibility_node` exposes the last result as a stable, atomic polite `status`
43
+ node. Include that node in the containing component's accessibility children, or
44
+ forward `on_announce` to its existing live region. Native file drops are separate
45
+ and are not handled by this primitive. The controller belongs to its UI owner:
46
+ call it on that owner's event thread and `cancel` before the owner is discarded.
data/docs/layout.md CHANGED
@@ -17,6 +17,26 @@ Zaniah::Div.new.style(
17
17
  Place an item with `grid_column: 2` or span inclusive tracks with
18
18
  `grid_column: 1..3`. Named areas and `auto-fit`/`auto-fill` are not supported.
19
19
 
20
+ `UI::PaneGrid` turns a rectangular matrix into independently resizable rows and
21
+ columns. Each non-empty cell is a `PaneGrid::Pane`, an `id`/`content` hash, or
22
+ an `[id, content]` pair. IDs must be unique and remain stable when `replace` is
23
+ used so retained pane state survives source rebuilds. Tracks accept fixed pixel
24
+ numbers, `px`, `fr`, or `minmax` values.
25
+
26
+ ```ruby
27
+ grid = Zaniah::UI::PaneGrid.new(
28
+ [[[:editor, editor], [:terminal, terminal]]],
29
+ columns: [Zaniah.minmax(Zaniah.px(240), Zaniah.fr(2)), Zaniah.fr(1)],
30
+ rows: [Zaniah.fr(1)]
31
+ )
32
+ grid.on_resize { |event, _context| save_split(event.axis, event.divider, event.before) }
33
+ ```
34
+
35
+ Dividers support pointer drag, arrow keys, Page Up/Down, Home, and End. Their
36
+ stable accessibility IDs, focus state, value, orientation, and direct increment,
37
+ decrement, minimum, and maximum actions use the same clamped resize path. Nested
38
+ `PaneGrid` instances compose without owning editor, terminal, or document state.
39
+
20
40
  `ScrollView` accepts one child and clips it to the viewport. Its `axis` is
21
41
  `:vertical`, `:horizontal`, or `:both`; use `scroll_to` for programmatic
22
42
  scrolling and `scroll_state` for offsets and edge checks.
data/docs/text.md CHANGED
@@ -79,6 +79,24 @@ A missing, corrupt, or read-only cache does not prevent rendering. Applications
79
79
  own cache-directory retention. See [the RBS declarations](../sig/text.rbs) for
80
80
  the complete API.
81
81
 
82
+ For minimaps and other compact previews, cache Alhena's downsampled outlines as
83
+ one R8 texture per source line:
84
+
85
+ ```ruby
86
+ cache = Zaniah::TextSystem::LowResolutionTextCache.new(
87
+ width: 100, height: 2, scale: 0.1
88
+ )
89
+ texture = cache.texture(42, outlines: outlines)
90
+ cache.invalidate(42) # regenerate only the edited source line on next access
91
+ cache.close
92
+ ```
93
+
94
+ The cache is thread-safe, count- and byte-bounded, and uses least-recently-used
95
+ eviction. Textures already returned to a scene remain valid after eviction or
96
+ invalidation. A cache has fixed width, height, and scale; invalidate a line when
97
+ its outlines change for any reason, including a font change. Call `close` when
98
+ the cache is no longer needed.
99
+
82
100
  ## Development checks
83
101
 
84
102
  ```sh
data/docs/tui.md CHANGED
@@ -21,7 +21,7 @@ tests and non-window integrations.
21
21
  | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
22
22
  | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
23
23
  | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; focus stays inside until dismissed |
24
- | SplitPane, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
24
+ | SplitPane, PaneGrid, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
25
25
  | ListView | visible rows with `>` on the selected item |
26
26
  | Toast | live status text |
27
27
  | Table, DataGrid | header and visible rows separated with `|` |
@@ -84,15 +84,15 @@ module Zaniah
84
84
  parse_interfaces
85
85
  end
86
86
 
87
- def update(root)
87
+ def update(root, events: [])
88
88
  application = Accessibility.node(role: :application, label: window_title,
89
89
  bounds: root.bounds, children: [root])
90
- @tree = NativeTree.new(application)
90
+ @tree = NativeTree.new(application, previous: @tree)
91
91
  @entries = @tree.to_h { |entry| [path(entry), entry] }
92
92
  (@registrations.keys - @entries.keys).each { |object_path| unregister(object_path) }
93
93
  (@entries.keys - @registrations.keys).each { |object_path| register(object_path) }
94
94
  embed unless @embedded
95
- emit_change
95
+ emit_changes(events)
96
96
  true
97
97
  end
98
98
 
@@ -179,10 +179,23 @@ module Zaniah
179
179
  @embedded = true
180
180
  end
181
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)
182
+ def emit_changes(events)
183
+ events = [nil] if events.empty?
184
+ events.each do |event|
185
+ member, detail, active = case event&.kind
186
+ when :structure then ["ChildrenChanged", event.node.id.to_s, 1]
187
+ when :property then ["PropertyChange", event.node.id.to_s, 0]
188
+ when :focus then ["StateChanged", "focused", 1]
189
+ when :announcement then ["Announcement", event.node.label.to_s, 1]
190
+ else ["VisibleDataChanged", "", 0]
191
+ end
192
+ entry = event && @tree[[0, *event.path]] unless %i[structure layout].include?(event&.kind)
193
+ parameters = tuple(string(detail), int32(active), int32(0),
194
+ variant(string(event&.node&.label || "")), array("{sv}", []))
195
+ gio.fn(:g_dbus_connection_emit_signal, [P, P, P, P, P, P, P], I)
196
+ .call(@connection, 0, entry ? path(entry) : ROOT_PATH,
197
+ "org.a11y.atspi.Event.Object", member, parameters, 0)
198
+ end
186
199
  end
187
200
 
188
201
  def method_call(object_path, interface, method, parameters, invocation)
@@ -269,7 +282,9 @@ module Zaniah
269
282
  when [ACCESSIBLE, "Parent"] then reference(entry.parent)
270
283
  when [ACCESSIBLE, "ChildCount"] then int32(entry.children.length)
271
284
  when [ACCESSIBLE, "Locale"] then string(ENV["LANG"] || "C")
272
- when [ACCESSIBLE, "AccessibleId"] then string(entry.runtime_id.to_s)
285
+ when [ACCESSIBLE, "AccessibleId"]
286
+ id = entry.node.id
287
+ string((id.nil? ? entry.runtime_id : id).to_s)
273
288
  when [ACTION, "NActions"] then int32(entry.node.actions.length)
274
289
  when [APPLICATION, "ToolkitName"] then string("Zaniah")
275
290
  when [APPLICATION, "Version"], [APPLICATION, "ToolkitVersion"] then string(Zaniah::VERSION)
@@ -11,7 +11,7 @@ module Zaniah
11
11
  def publish(window, root, _changes)
12
12
  return false unless root
13
13
  service = services[window.object_id] ||= Service.new(window)
14
- service.update(root)
14
+ service.update(root, events: window.accessibility_tree.events)
15
15
  rescue Fiddle::DLError, Errno::ENOENT, Error
16
16
  false
17
17
  end
@@ -28,7 +28,8 @@ module Zaniah
28
28
  return unless root && defined?(Platform::Mac::O) && window.respond_to?(:view)
29
29
  install
30
30
  ELEMENTS.delete_if { |_element, (owner, _node)| owner.equal?(window) }
31
- tree = NativeTree.new(root)
31
+ previous = window.instance_variable_get(:@native_accessibility_tree)
32
+ tree = NativeTree.new(root, previous: previous)
32
33
  children = mutable_array
33
34
  append(children, native_element(window, tree, tree.root, window.view))
34
35
  object = window.view
@@ -72,8 +73,9 @@ module Zaniah
72
73
  o.string(ROLES.fetch(node.role, "AXGroup")), screen_bounds(window, tree.bounds(entry)),
73
74
  o.string(node.label || node.value&.to_s || node.role.to_s), parent,
74
75
  args: [:pointer, :rect, :pointer, :pointer])
75
- ELEMENTS[element] = [window, node, tree.bounds(entry)]
76
+ ELEMENTS[element] = [window, node, tree.bounds(entry), entry.runtime_id]
76
77
  o.send(element, "setAccessibilityFrameInParentSpace:", parent_bounds(tree, entry), args: [:rect], result: :void)
78
+ o.send(element, "setAccessibilityIdentifier:", o.string(node.id.to_s), args: [:pointer], result: :void) unless node.id.nil?
77
79
  o.send(element, "setAccessibilityEnabled:", node.states[:disabled] ? 0 : 1, args: [:bool], result: :void)
78
80
  o.send(element, "setAccessibilityValue:", native_value(node.value), args: [:pointer], result: :void) unless node.value.nil?
79
81
  set_boolean(element, "setAccessibilitySelected:", node.states[:selected]) if node.states.key?(:selected)
@@ -126,9 +128,22 @@ module Zaniah
126
128
  def notify(window, object)
127
129
  function = Platform::Mac::APPKIT.fn(:NSAccessibilityPostNotification,
128
130
  [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"))
131
+ notifications(window.accessibility_tree.events).each do |event, name|
132
+ runtime_id = window.instance_variable_get(:@native_accessibility_tree)&.[](event.path)&.runtime_id unless %i[structure layout].include?(event.kind)
133
+ target = ELEMENTS.find { |_element, (owner, _node, _bounds, id)| owner.equal?(window) && id == runtime_id }&.first || object
134
+ function.call(target, Platform::Mac::O.string(name))
135
+ end
136
+ end
137
+
138
+ def notifications(events)
139
+ events.filter_map do |event|
140
+ name = case event.kind
141
+ when :structure, :layout then "AXLayoutChanged"
142
+ when :property then "AXValueChanged"
143
+ when :focus then "AXFocusedUIElementChanged"
144
+ when :announcement then "AXAnnouncementRequested"
145
+ end
146
+ [event, name] if name
132
147
  end
133
148
  end
134
149
  end
@@ -9,9 +9,11 @@ module Zaniah
9
9
 
10
10
  attr_reader :root
11
11
 
12
- def initialize(root)
12
+ def initialize(root, previous: nil)
13
13
  @entries = {}
14
- @next_runtime_id = 1
14
+ @identities = {}
15
+ @previous_ids = previous ? previous.__send__(:runtime_ids).dup : {}
16
+ @next_runtime_id = (@previous_ids.values.max || 0) + 1
15
17
  @root = build(root, nil, []) if root
16
18
  end
17
19
 
@@ -32,6 +34,8 @@ module Zaniah
32
34
  end
33
35
 
34
36
  def focused(window)
37
+ semantic = find { |entry| entry.node.states[:focused] }
38
+ return semantic if semantic
35
39
  bounds = window.dispatcher.focused&.bounds
36
40
  bounds && hit(Point.new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0))
37
41
  end
@@ -39,14 +43,20 @@ module Zaniah
39
43
  private
40
44
 
41
45
  def build(node, parent, path)
42
- entry = NativeEntry.new(path: path.freeze, node: node, parent: parent, children: [], runtime_id: next_runtime_id)
46
+ path = path.freeze
47
+ key = [(parent && identity(parent)), node.id.nil? ? [:index, path.last] : [:id, node.id]].freeze
48
+ runtime_id = @previous_ids[key] ||= next_runtime_id
49
+ entry = NativeEntry.new(path: path, node: node, parent: parent, children: [], runtime_id: runtime_id)
43
50
  @entries[entry.path] = entry
51
+ @identities[entry.object_id] = key
44
52
  children = node.children.map.with_index { |child, index| build(child, entry, path + [index]) }.freeze
45
53
  entry.children.replace(children)
46
54
  entry.children.freeze
47
55
  entry
48
56
  end
49
57
 
58
+ def identity(entry) = @identities.fetch(entry.object_id)
59
+ def runtime_ids = @previous_ids
50
60
  def next_runtime_id = (@next_runtime_id += 1) - 1
51
61
  end
52
62
  end
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Zaniah
4
4
  module Accessibility
5
- Node = Data.define(:role, :label, :value, :bounds, :states, :children, :actions)
5
+ Node = Data.define(:role, :label, :value, :bounds, :states, :children, :actions) do
6
+ def id = states[:accessibility_id]
7
+ end
6
8
 
7
- def self.node(role:, label: nil, value: nil, bounds: nil, states: {}, children: [], actions: [])
9
+ def self.node(role:, id: nil, label: nil, value: nil, bounds: nil, states: {}, children: [], actions: [])
10
+ states = states.merge(accessibility_id: id) unless id.nil?
8
11
  Node.new(role: role.to_sym, label: label&.to_s, value: value, bounds: bounds,
9
12
  states: states.dup.freeze, children: children.dup.freeze, actions: actions.map(&:to_sym).freeze)
10
13
  end