zaniah 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/docs/adr/013-public-inspection-api.md +16 -0
- data/docs/adr/014-unified-command-dispatch.md +40 -0
- data/docs/components.md +11 -2
- data/docs/gallery/command-palette-dark.png +0 -0
- data/docs/gallery/command-palette-high_contrast.png +0 -0
- data/docs/gallery/command-palette-light.png +0 -0
- data/docs/inspection.md +19 -0
- data/docs/text.md +19 -0
- data/docs/tui.md +1 -1
- data/lib/zaniah/accessibility/tree.rb +9 -0
- data/lib/zaniah/app.rb +3 -1
- data/lib/zaniah/configuration.rb +1 -1
- data/lib/zaniah/devtools/inspector.rb +5 -29
- data/lib/zaniah/element.rb +4 -1
- data/lib/zaniah/input/action_registry.rb +12 -3
- data/lib/zaniah/input/dispatcher.rb +47 -1
- data/lib/zaniah/input/focus_handle.rb +3 -2
- data/lib/zaniah/input/keymap.rb +31 -5
- data/lib/zaniah/inspection.rb +146 -0
- data/lib/zaniah/platform/headless/window.rb +10 -1
- data/lib/zaniah/platform/tui/window.rb +8 -0
- data/lib/zaniah/task_executor.rb +2 -0
- data/lib/zaniah/text.rb +44 -1
- data/lib/zaniah/text_buffer.rb +2 -0
- data/lib/zaniah/ui/choice_fields.rb +12 -6
- data/lib/zaniah/ui/editors.rb +52 -0
- data/lib/zaniah/ui/matcher.rb +88 -0
- data/lib/zaniah/ui/modal.rb +121 -10
- data/lib/zaniah/ui/rich_text_surface.rb +3 -8
- data/lib/zaniah/ui.rb +1 -0
- data/lib/zaniah/unicode.rb +28 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/accessibility.rbs +1 -0
- data/sig/app.rbs +2 -0
- data/sig/elements.rbs +3 -1
- data/sig/input.rbs +29 -2
- data/sig/inspection.rbs +57 -0
- data/sig/platform.rbs +5 -0
- data/sig/ui.rbs +28 -2
- data/sig/zaniah.rbs +7 -2
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74d3240032d7ae845a8a1f8f3208cb102ef0b68637cc184ba0f8860ac57b05ee
|
|
4
|
+
data.tar.gz: 4166002c76247745610218aed5d9090459889b9b383b97b669b199adac46761b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3e80aba0a90f7e946ce916e63e3eed342cac2b1c0eda9cb9dd14112f1c1a9affd4d0ee13bc2cfc81769bcbd12afa4288849825fe1a093396b6ee43893f68a7d
|
|
7
|
+
data.tar.gz: a1bd580a3da60ac7705f4329d7f7a4b435bdd85638e074710a84b2339a40744e5b1e82d7df243ac9eff439a29ca359e6737e7fa731cb86b6dbedd62b585e5db3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
- Add a stable frame inspection API with accessibility queries and DevTools integration.
|
|
6
|
+
- Unify focused and application commands with validation, availability, and checked state.
|
|
7
|
+
- Add headless and TUI clipboard support, including OSC 52 output.
|
|
8
|
+
- Add desktop editing shortcuts and copy, cut, paste, undo, and redo for plain and rich text while leaving conflicting TUI shortcuts unbound.
|
|
9
|
+
- Add configurable matching and accessible, highlighted results to the command palette and combobox.
|
|
10
|
+
|
|
3
11
|
## 0.6.4 — 2026-09-24
|
|
4
12
|
|
|
5
13
|
- Share Cartesian chart scales, axes, grid lines, ticks, and color-keyed legends across chart types.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ADR 013: Expose a stable inspection boundary
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
External test drivers and DevTools currently read render state through internal instance variables or maintain duplicate tree walkers. Internal layout and platform state can change without notice, which makes downstream tooling fragile. One option is to expose each underlying object directly; another is to publish a read-only view of the latest frame.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Publish `Zaniah::Inspection.snapshot(window)` as the external read boundary, backed by small, explicit read accessors on the existing objects. DevTools uses the same boundary. Snapshot values are frozen; the `Entry#element` reference remains live only until the next frame. During the 0.x series, a breaking change to this public API requires at least one minor release of deprecation before removal.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Inspection is constructed only when requested, avoiding per-frame traversal cost. The small accessors and compatibility period constrain internal refactors, but test drivers no longer depend on private instance variables. Native accessibility actions still require a current node and window.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR 014: Route UI commands through focused handlers before app actions
|
|
2
|
+
|
|
3
|
+
- Status: Proposed
|
|
4
|
+
- Date: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Keyboard actions currently reach only focused handlers. Menus, palettes, context
|
|
9
|
+
menus, and accessibility actions need the same behavior, including a way to ask
|
|
10
|
+
whether an item is enabled without executing it. Routing each source separately
|
|
11
|
+
would make focus precedence and disabled states diverge.
|
|
12
|
+
|
|
13
|
+
Native menu accelerators introduce a separate risk: macOS can process a
|
|
14
|
+
`keyEquivalent` in addition to the framework keymap. Keeping both active could
|
|
15
|
+
execute one command twice.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
Use `Dispatcher#perform` for all command sources. Focused handlers take precedence
|
|
20
|
+
from leaf to root, followed by `App#actions`. A focused handle can provide a
|
|
21
|
+
side-effect-free `validate` callback: `false` blocks the command, `true` enables
|
|
22
|
+
it, and `nil` defers to the next handle. `available?` asks validators and the app
|
|
23
|
+
registry without invoking action handlers. Legacy focused handlers without a
|
|
24
|
+
validator remain executable but cannot advertise availability by themselves.
|
|
25
|
+
|
|
26
|
+
An unvalidated legacy focus handler still executes before a disabled app
|
|
27
|
+
command, even though `available?` can only report the app command as disabled.
|
|
28
|
+
Such focused actions need `validate` to advertise their own availability.
|
|
29
|
+
|
|
30
|
+
Keyboard focus movement remains a dispatcher concern. Native menu shortcuts
|
|
31
|
+
must be routed through the Zaniah keymap exactly once; platform adapters must
|
|
32
|
+
verify that native `keyEquivalent` processing does not also invoke the command.
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
Menus and palettes can share dispatch and validation with keyboard input, and
|
|
37
|
+
app-wide commands remain available when no focus handler consumes them. Focused
|
|
38
|
+
actions intended for menus must provide `validate`; an opaque handler alone
|
|
39
|
+
cannot safely report support without being run. Native shortcut behavior needs
|
|
40
|
+
platform verification before this ADR can be accepted.
|
data/docs/components.md
CHANGED
|
@@ -59,7 +59,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
|
|
|
59
59
|
| L2 | `NumberInput` | TextField plus `min:`, `max:`, `step:`; `increment`, `decrement` | numeric | textbox |
|
|
60
60
|
| L2 | `TagInput` | `(tags, separator:, ...)`; `on_tags_change` | badge list + editor | textbox |
|
|
61
61
|
| L2 | `Select` | `(items, label:, value:, disabled:)`; `on_change` | single choice | combobox |
|
|
62
|
-
| L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:)`; `on_change` | editable, filtered choices | combobox |
|
|
62
|
+
| L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:, matcher:)`; `on_change` | editable, filtered choices with highlighted matches | combobox |
|
|
63
63
|
| L2 | `MultiSelect` | `(items, value:, label:, disabled:)`; `on_change` | multiple selected badges | listbox |
|
|
64
64
|
| L2 | `DatePicker` | `(value, min:, max:, label:, disabled:)`; `on_change` | ISO date, day/week keyboard steps | combobox |
|
|
65
65
|
| L2 | `TimePicker` | `(value, step:, label:, disabled:)`; `on_change` | 24-hour time, minute/hour keyboard steps | combobox |
|
|
@@ -77,7 +77,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
|
|
|
77
77
|
| L3 | `Modal`, `Dialog` | `(content, title:, open:, close_on_scrim:, width:)` | focus trap, scrim, Esc | dialog/modal |
|
|
78
78
|
| L3 | `Drawer` | Modal plus `side:` | left/right | dialog/modal |
|
|
79
79
|
| L3 | `Toast` | `(message, variant:, queue:)`; `dismiss` | info/success/warning/danger | live status |
|
|
80
|
-
| L3 | `CommandPalette` | `(commands, open:, placeholder:)` | searchable modal | dialog/list |
|
|
80
|
+
| L3 | `CommandPalette` | `(commands, open:, placeholder:, matcher:)`; `.from(app.actions)` | searchable modal, Up/Down/Enter | dialog/list |
|
|
81
81
|
| L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
|
|
82
82
|
| L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
|
|
83
83
|
| L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
|
|
@@ -100,6 +100,15 @@ All input components are keyboard operable. Disabled controls remain visible but
|
|
|
100
100
|
removed from focus traversal. Overlay components close on Esc; modal overlays restore
|
|
101
101
|
the previous focus. See [TUI](tui.md) for terminal representations.
|
|
102
102
|
|
|
103
|
+
`Combobox` and `CommandPalette` default to case-insensitive substring matching in
|
|
104
|
+
input order. Pass `matcher:` to either component, or set a default with
|
|
105
|
+
`Zaniah.configure { |config| config.matcher = matcher }`. A matcher implements
|
|
106
|
+
`match(query, labels)` and returns `UI::Matcher::Match` values with an original
|
|
107
|
+
label `index`, descending `score`, and half-open UTF-8 byte `ranges` for highlighting.
|
|
108
|
+
It may also implement `refine(previous_matches, query)` for incremental queries.
|
|
109
|
+
`CommandPalette.from(app.actions)` uses registered action titles and disables
|
|
110
|
+
unavailable actions; shortcuts will be added with `UI::Kbd` in the menu phase.
|
|
111
|
+
|
|
103
112
|
`UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
|
|
104
113
|
`bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
|
|
105
114
|
center, end, and non-final-line justification, plus bullet/ordered lists and levels.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/docs/inspection.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Frame inspection
|
|
2
|
+
|
|
3
|
+
`Zaniah::Inspection` gives test drivers and DevTools a public, read-only view of the latest rendered frame. It works with headless and native windows.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
window.render(root)
|
|
7
|
+
snapshot = Zaniah::Inspection.snapshot(window)
|
|
8
|
+
save = snapshot.find(test_id: "save")
|
|
9
|
+
buttons = snapshot.where(type: Zaniah::UI::Button)
|
|
10
|
+
frontmost = snapshot.at(Zaniah::Point.new(120, 40))
|
|
11
|
+
node, path = snapshot.accessibility.query(role: :button, label: /save/i).first
|
|
12
|
+
Zaniah::Inspection.perform(window, node, :press)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`snapshot.root` and its children expose type, key, test ID, window-space bounds, resolved style, tooltip, context menu, handlers, and focus state. `snapshot.overlays` exposes tooltip, popup, and menu state; `snapshot.text_runs` contains rendered text; `snapshot.frame` contains a monotonically increasing frame number and timing statistics. `snapshot.find` returns the first match, `where` returns all matches, and `at` uses the frontmost registered hit region. A newly opened window has no root until its first render.
|
|
16
|
+
|
|
17
|
+
The snapshot containers and copied collections are frozen. `entry.element` is deliberately a live element reference: use it only in the same frame, because the next render may replace it. Accessibility nodes are copied for inspection; pass one to `Inspection.perform` to invoke an action on its current source node. Actions require a node from the current frame. To wait for a settled UI, call `Zaniah::Inspection.idle?(app)` after draining queued work and rendering dirty windows.
|
|
18
|
+
|
|
19
|
+
The public API is declared in [`sig/inspection.rbs`](../sig/inspection.rbs), and its independently runnable contract test is [`test/inspection_contract_test.rb`](../test/inspection_contract_test.rb). Breaking changes receive at least one minor version of deprecation during 0.x, as recorded in [ADR 013](adr/013-public-inspection-api.md).
|
data/docs/text.md
CHANGED
|
@@ -164,6 +164,25 @@ Supported inline styles are `bold`, `italic`, `size`, `color`, `font`, and
|
|
|
164
164
|
receives `(text, rich_text)`; selection changes are reported by `on_select`.
|
|
165
165
|
IME composition uses the existing `TextBuffer` composition path.
|
|
166
166
|
|
|
167
|
+
## Editing shortcuts
|
|
168
|
+
|
|
169
|
+
Editable text fields and rich text share the same actions from keyboard, menu,
|
|
170
|
+
and command dispatch. The default desktop keys are:
|
|
171
|
+
|
|
172
|
+
| Action | macOS | Windows | Linux |
|
|
173
|
+
| --- | --- | --- | --- |
|
|
174
|
+
| Undo / redo | Cmd+Z / Cmd+Shift+Z | Ctrl+Z / Ctrl+Y or Ctrl+Shift+Z | Ctrl+Z / Ctrl+Shift+Z |
|
|
175
|
+
| Cut / copy / paste / select all | Cmd+X/C/V/A | Ctrl+X/C/V/A | Ctrl+X/C/V/A |
|
|
176
|
+
| Previous / next word | Option+Left/Right | Ctrl+Left/Right | Ctrl+Left/Right |
|
|
177
|
+
| Document start / end | Cmd+Up/Down | Ctrl+Home/End | Ctrl+Home/End |
|
|
178
|
+
|
|
179
|
+
Shift with a word arrow extends the selection. Up/Down moves between logical
|
|
180
|
+
lines in multiline fields; Home/End moves to the current line's edges. Copy
|
|
181
|
+
requires a selection, and password fields never copy or cut. Paste and undo
|
|
182
|
+
are disabled during IME composition. The TUI does not assign these desktop
|
|
183
|
+
editing shortcuts by default because terminal control keys can conflict;
|
|
184
|
+
applications may provide a custom keymap.
|
|
185
|
+
|
|
167
186
|
## Inline and block overlays
|
|
168
187
|
|
|
169
188
|
Attach non-editable UI elements to text without inserting bytes into the source:
|
data/docs/tui.md
CHANGED
|
@@ -20,7 +20,7 @@ tests and non-window integrations.
|
|
|
20
20
|
| Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
|
|
21
21
|
| ScrollView, Scrollbar | clipped child content and `│`/`─` track |
|
|
22
22
|
| Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
|
|
23
|
-
| Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; focus stays inside until dismissed |
|
|
23
|
+
| Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; palette filters results and supports Up/Down/Enter; focus stays inside until dismissed |
|
|
24
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 |
|
|
@@ -36,6 +36,15 @@ module Zaniah
|
|
|
36
36
|
|
|
37
37
|
def find(&predicate) = each.find { |node, _path| predicate.call(node) }&.first
|
|
38
38
|
|
|
39
|
+
def query(role: nil, label: nil, states: {})
|
|
40
|
+
raise ArgumentError, "label must be a String or Regexp" unless label.nil? || label.is_a?(String) || label.is_a?(Regexp)
|
|
41
|
+
each.select do |node, _path|
|
|
42
|
+
(role.nil? || node.role == role) &&
|
|
43
|
+
(label.nil? || (node.label && (label.is_a?(Regexp) ? label.match?(node.label) : node.label == label))) &&
|
|
44
|
+
states.all? { |key, value| node.states.key?(key) && node.states[key] == value }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
39
48
|
def perform(node, action)
|
|
40
49
|
owner = @action_owners&.[](node.object_id)
|
|
41
50
|
owner&.accessibility_action(node, action)
|
data/lib/zaniah/app.rb
CHANGED
|
@@ -7,13 +7,14 @@ require_relative "task_executor"
|
|
|
7
7
|
|
|
8
8
|
module Zaniah
|
|
9
9
|
class App
|
|
10
|
-
attr_reader :windows, :executor
|
|
10
|
+
attr_reader :windows, :executor, :actions
|
|
11
11
|
|
|
12
12
|
def initialize(clock: MONOTONIC_CLOCK)
|
|
13
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)
|
|
17
|
+
@actions = Input::ActionRegistry.new
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def new_entity
|
|
@@ -107,6 +108,7 @@ module Zaniah
|
|
|
107
108
|
options[:clock] ||= @clock
|
|
108
109
|
window = Platform.open_window(**options)
|
|
109
110
|
window.app = self
|
|
111
|
+
window.dispatcher.window = window
|
|
110
112
|
@windows << window
|
|
111
113
|
@globals[:theme] = platform_theme(window)
|
|
112
114
|
window.on_appearance do |appearance|
|
data/lib/zaniah/configuration.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
module DevTools
|
|
5
|
-
Entry =
|
|
5
|
+
Entry = Inspection::Entry
|
|
6
6
|
|
|
7
7
|
class Inspector
|
|
8
8
|
attr_reader :window, :snapshot, :selected
|
|
@@ -32,8 +32,9 @@ module Zaniah
|
|
|
32
32
|
true
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def frame(
|
|
36
|
-
@
|
|
35
|
+
def frame(_element)
|
|
36
|
+
@inspection = Inspection.snapshot(window)
|
|
37
|
+
@snapshot = @inspection.root
|
|
37
38
|
return unless visible?
|
|
38
39
|
@selected = hovered
|
|
39
40
|
paint_highlight(@selected.bounds) if @selected&.bounds
|
|
@@ -42,33 +43,8 @@ module Zaniah
|
|
|
42
43
|
|
|
43
44
|
private
|
|
44
45
|
|
|
45
|
-
def collect(object, seen = {})
|
|
46
|
-
return if !object || seen[object.object_id]
|
|
47
|
-
seen[object.object_id] = true
|
|
48
|
-
node = object.respond_to?(:layout_node) ? object.layout_node : nil
|
|
49
|
-
style = if object.respond_to?(:resolved_style) && object.resolved_style
|
|
50
|
-
object.resolved_style.to_h
|
|
51
|
-
elsif object.respond_to?(:root) && object.root&.respond_to?(:resolved_style) && object.root.resolved_style
|
|
52
|
-
object.root.resolved_style.to_h
|
|
53
|
-
else {}
|
|
54
|
-
end
|
|
55
|
-
children = object.respond_to?(:children) ? object.children.filter_map { |child| collect(child, seen) } : []
|
|
56
|
-
Entry.new(object: object, name: object.class.name.sub("Zaniah::", ""),
|
|
57
|
-
key: object.respond_to?(:identity_key) ? object.identity_key : nil,
|
|
58
|
-
test_id: object.respond_to?(:test_id) ? object.test_id : nil,
|
|
59
|
-
bounds: node&.bounds, style: style.freeze, children: children.freeze)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
46
|
def hovered
|
|
63
|
-
|
|
64
|
-
find_entry(@snapshot, owner) || @snapshot
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def find_entry(entry, object)
|
|
68
|
-
return unless entry && object
|
|
69
|
-
return entry if entry.object.equal?(object)
|
|
70
|
-
entry.children.each { |child| return found if (found = find_entry(child, object)) }
|
|
71
|
-
nil
|
|
47
|
+
@inspection.at(window.pointer_position) || @snapshot
|
|
72
48
|
end
|
|
73
49
|
|
|
74
50
|
def paint_highlight(bounds)
|
data/lib/zaniah/element.rb
CHANGED
|
@@ -52,16 +52,19 @@ module Zaniah
|
|
|
52
52
|
def opacity(value) = style(opacity: value)
|
|
53
53
|
def ring(width, color = nil, offset = 0) = style(ring: Ring.new(width, color, offset))
|
|
54
54
|
def cursor(value) = style(cursor: value)
|
|
55
|
-
def focusable(tab_index: 0, context: {}, &on_action)
|
|
55
|
+
def focusable(tab_index: 0, context: {}, validate: (validate_omitted = true), &on_action)
|
|
56
56
|
@focus_handle ||= Input::FocusHandle.new(owner: self)
|
|
57
57
|
@focus_handle.tab_index, @focus_handle.focusable = Integer(tab_index), true
|
|
58
58
|
@focus_handle.context.merge!(context)
|
|
59
|
+
@focus_handle.validate = validate unless validate_omitted
|
|
59
60
|
@focus_handle.on_action = on_action if on_action
|
|
60
61
|
self
|
|
61
62
|
end
|
|
62
63
|
def focus_handle = @focus_handle
|
|
63
64
|
def tooltip(text) = (@tooltip = text.to_s; self)
|
|
65
|
+
def tooltip_text = @tooltip
|
|
64
66
|
def context_menu(items) = (@context_menu = items; self)
|
|
67
|
+
def context_menu_items = @context_menu
|
|
65
68
|
def flex = style(display: :flex)
|
|
66
69
|
def flex_row = style(flex_direction: :row)
|
|
67
70
|
def flex_col = style(flex_direction: :column)
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
module Input
|
|
5
|
+
Command = Data.define(:name, :title, :category, :enabled, :checked, :handler)
|
|
6
|
+
|
|
5
7
|
class ActionRegistry
|
|
6
8
|
def initialize = @actions = {}
|
|
7
|
-
|
|
8
|
-
def
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
def register(name, description: nil, title: nil, category: nil, enabled: nil, checked: nil, &block)
|
|
11
|
+
title ||= description || name
|
|
12
|
+
@actions[name.to_s] = Command.new(name, title, category, enabled, checked, block)
|
|
13
|
+
[title, block]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def command(name) = @actions[name.to_s]
|
|
17
|
+
def call(name, *args) = @actions.fetch(name.to_s).handler.call(*args)
|
|
18
|
+
def entries = @actions.transform_values(&:title).freeze
|
|
10
19
|
end
|
|
11
20
|
end
|
|
12
21
|
end
|
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
module Input
|
|
5
5
|
Hit = Data.define(:bounds, :owner)
|
|
6
|
+
HitRegion = Data.define(:bounds, :owner, :transform, :clip) do
|
|
7
|
+
def contains?(point)
|
|
8
|
+
return false if clip && !clip.contains?(point)
|
|
9
|
+
bounds.contains?(transform.inverse.apply(point))
|
|
10
|
+
rescue ArgumentError
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
end
|
|
6
14
|
|
|
7
15
|
class Dispatcher
|
|
8
16
|
attr_reader :focused, :focus_origin, :focus_tree
|
|
17
|
+
attr_accessor :window
|
|
9
18
|
|
|
10
19
|
def initialize(keymap: Keymap.default_ui)
|
|
11
20
|
@keymap, @hits = keymap, []
|
|
@@ -45,10 +54,46 @@ module Zaniah
|
|
|
45
54
|
focus(target, origin: :keyboard)
|
|
46
55
|
return action
|
|
47
56
|
end
|
|
48
|
-
|
|
57
|
+
perform(action, source: :keyboard)
|
|
49
58
|
action
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
def perform(action, source: :keyboard)
|
|
62
|
+
chain = @focused ? @focused.ancestors : []
|
|
63
|
+
chain.each do |handle|
|
|
64
|
+
if handle.validate
|
|
65
|
+
state = handle.validate.call(action)
|
|
66
|
+
return false if state == false
|
|
67
|
+
next if state.nil?
|
|
68
|
+
end
|
|
69
|
+
return true if handle.on_action&.call(action)
|
|
70
|
+
end
|
|
71
|
+
command = @window&.app&.actions&.command(action)
|
|
72
|
+
return false unless command&.handler
|
|
73
|
+
context = FrameContext.new(@window)
|
|
74
|
+
return false if command.enabled&.call(context) == false
|
|
75
|
+
command.handler.call(context)
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def available?(action)
|
|
80
|
+
chain = @focused ? @focused.ancestors : []
|
|
81
|
+
chain.each do |handle|
|
|
82
|
+
next unless handle.validate
|
|
83
|
+
state = handle.validate.call(action)
|
|
84
|
+
return state ? :enabled : :disabled unless state.nil?
|
|
85
|
+
end
|
|
86
|
+
command = @window&.app&.actions&.command(action)
|
|
87
|
+
return :unhandled unless command
|
|
88
|
+
return :disabled unless command.handler
|
|
89
|
+
command.enabled&.call(FrameContext.new(@window)) == false ? :disabled : :enabled
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def checked?(action)
|
|
93
|
+
command = @window&.app&.actions&.command(action)
|
|
94
|
+
command&.checked&.call(FrameContext.new(@window))
|
|
95
|
+
end
|
|
96
|
+
|
|
52
97
|
def register_focus(handle)
|
|
53
98
|
owner = handle.owner&.respond_to?(:parent) ? handle.owner.parent : nil
|
|
54
99
|
while owner
|
|
@@ -75,6 +120,7 @@ module Zaniah
|
|
|
75
120
|
@transforms.replace([Transform.identity])
|
|
76
121
|
end
|
|
77
122
|
def hits = @hits.map { |bounds, _, owner, _, clip| Hit.new(clip ? bounds.intersect(clip) : bounds, owner) }.freeze
|
|
123
|
+
def hit_regions = @hits.map { |bounds, _, owner, transform, clip| HitRegion.new(bounds, owner, transform, clip) }.freeze
|
|
78
124
|
|
|
79
125
|
def hover_chain(position)
|
|
80
126
|
return [] unless position
|
|
@@ -4,11 +4,12 @@ module Zaniah
|
|
|
4
4
|
module Input
|
|
5
5
|
class FocusHandle
|
|
6
6
|
attr_reader :parent, :children, :context, :owner
|
|
7
|
-
attr_accessor :on_action, :on_focus, :on_input, :tab_index, :focusable, :bounds
|
|
7
|
+
attr_accessor :on_action, :on_focus, :on_input, :validate, :tab_index, :focusable, :bounds
|
|
8
8
|
|
|
9
|
-
def initialize(parent: nil, context: {}, owner: nil, tab_index: nil, focusable: true, bounds: nil)
|
|
9
|
+
def initialize(parent: nil, context: {}, owner: nil, tab_index: nil, focusable: true, bounds: nil, validate: nil)
|
|
10
10
|
@parent, @context, @owner, @children = parent, context, owner, []
|
|
11
11
|
@tab_index, @focusable, @bounds = tab_index, focusable, bounds
|
|
12
|
+
@validate = validate
|
|
12
13
|
parent&.children&.push(self)
|
|
13
14
|
end
|
|
14
15
|
|
data/lib/zaniah/input/keymap.rb
CHANGED
|
@@ -6,6 +6,10 @@ require_relative "context_predicate"
|
|
|
6
6
|
|
|
7
7
|
module Zaniah
|
|
8
8
|
module Input
|
|
9
|
+
module StandardActions
|
|
10
|
+
EDIT = %i[undo redo cut copy paste select_all].freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
9
13
|
class Keymap
|
|
10
14
|
class << self
|
|
11
15
|
def default_ui(platform: RUBY_PLATFORM, **options)
|
|
@@ -13,8 +17,11 @@ module Zaniah
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def platform_defaults(platform, **options)
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
mac = platform.to_s.match?(/darwin|mac/)
|
|
21
|
+
windows = platform.to_s.match?(/mingw|mswin|windows/)
|
|
22
|
+
tui = platform.to_s == "tui"
|
|
23
|
+
primary = mac ? "cmd" : "ctrl"
|
|
24
|
+
map = new(**options)
|
|
18
25
|
.bind("tab", :focus_next)
|
|
19
26
|
.bind("shift-tab", :focus_previous)
|
|
20
27
|
.bind("left", :focus_left, context: "!in_text_field && !in_list")
|
|
@@ -30,7 +37,6 @@ module Zaniah
|
|
|
30
37
|
.bind("pagedown", :page_down, context: "in_list")
|
|
31
38
|
.bind("up", :previous_option, context: "in_list")
|
|
32
39
|
.bind("down", :next_option, context: "in_list")
|
|
33
|
-
.bind("#{primary}-a", :select_all, context: "in_text_field || in_list")
|
|
34
40
|
.bind("left", :move_left, context: "in_text_field")
|
|
35
41
|
.bind("right", :move_right, context: "in_text_field")
|
|
36
42
|
.bind("shift-left", :select_left, context: "in_text_field")
|
|
@@ -73,7 +79,6 @@ module Zaniah
|
|
|
73
79
|
.bind("end", :last, context: "in_table")
|
|
74
80
|
.bind("pageup", :page_up, context: "in_table")
|
|
75
81
|
.bind("pagedown", :page_down, context: "in_table")
|
|
76
|
-
.bind("#{primary}-a", :select_all, context: "in_table")
|
|
77
82
|
.bind("up", :previous_option, context: "in_grid")
|
|
78
83
|
.bind("down", :next_option, context: "in_grid")
|
|
79
84
|
.bind("left", :previous_column, context: "in_grid")
|
|
@@ -85,17 +90,38 @@ module Zaniah
|
|
|
85
90
|
.bind("home", :first, context: "in_grid")
|
|
86
91
|
.bind("end", :last, context: "in_grid")
|
|
87
92
|
.bind("enter", :activate, context: "in_grid")
|
|
88
|
-
.bind("#{primary}-a", :select_all, context: "in_grid")
|
|
89
93
|
.bind("up", :previous_option, context: "in_combobox")
|
|
90
94
|
.bind("down", :next_option, context: "in_combobox")
|
|
91
95
|
.bind("home", :first, context: "in_combobox")
|
|
92
96
|
.bind("end", :last, context: "in_combobox")
|
|
93
97
|
.bind("enter", :choose_option, context: "in_combobox")
|
|
94
98
|
.bind("esc", :dismiss, context: "in_combobox")
|
|
99
|
+
.bind("up", :previous_option, context: "in_palette")
|
|
100
|
+
.bind("down", :next_option, context: "in_palette")
|
|
101
|
+
.bind("enter", :choose_option, context: "in_palette")
|
|
95
102
|
.bind("left", :previous_option, context: "in_chart")
|
|
96
103
|
.bind("right", :next_option, context: "in_chart")
|
|
97
104
|
.bind("home", :first, context: "in_chart")
|
|
98
105
|
.bind("end", :last, context: "in_chart")
|
|
106
|
+
.bind("up", :line_up, context: "in_text_field && multiline")
|
|
107
|
+
.bind("down", :line_down, context: "in_text_field && multiline")
|
|
108
|
+
return map if tui
|
|
109
|
+
|
|
110
|
+
word = mac ? "alt" : "ctrl"
|
|
111
|
+
map.bind("#{primary}-a", :select_all)
|
|
112
|
+
.bind("#{primary}-z", :undo)
|
|
113
|
+
.bind("#{primary}-shift-z", :redo)
|
|
114
|
+
.bind("#{primary}-x", :cut)
|
|
115
|
+
.bind("#{primary}-c", :copy)
|
|
116
|
+
.bind("#{primary}-v", :paste)
|
|
117
|
+
.bind("#{word}-left", :word_left, context: "in_text_field")
|
|
118
|
+
.bind("#{word}-right", :word_right, context: "in_text_field")
|
|
119
|
+
.bind("#{word}-shift-left", :select_word_left, context: "in_text_field")
|
|
120
|
+
.bind("#{word}-shift-right", :select_word_right, context: "in_text_field")
|
|
121
|
+
.bind(mac ? "cmd-up" : "ctrl-home", :document_start, context: "in_text_field")
|
|
122
|
+
.bind(mac ? "cmd-down" : "ctrl-end", :document_end, context: "in_text_field")
|
|
123
|
+
map.bind("ctrl-y", :redo) if windows
|
|
124
|
+
map
|
|
99
125
|
end
|
|
100
126
|
end
|
|
101
127
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Inspection
|
|
5
|
+
SOURCE_NODES = ObjectSpace::WeakMap.new
|
|
6
|
+
|
|
7
|
+
Entry = Data.define(:element, :type, :key, :test_id, :bounds, :style, :tooltip,
|
|
8
|
+
:context_menu, :handlers, :focusable, :focused, :children) do
|
|
9
|
+
def name = type
|
|
10
|
+
def object = element
|
|
11
|
+
def focusable? = focusable
|
|
12
|
+
def focused? = focused
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Overlays = Data.define(:tooltip, :popup, :menu)
|
|
16
|
+
FrameInfo = Data.define(:number, :stats)
|
|
17
|
+
|
|
18
|
+
AccessibilitySnapshot = Data.define(:root) do
|
|
19
|
+
def query(role: nil, label: nil, states: {})
|
|
20
|
+
raise ArgumentError, "label must be a String or Regexp" unless label.nil? || label.is_a?(String) || label.is_a?(Regexp)
|
|
21
|
+
return [].freeze unless root
|
|
22
|
+
|
|
23
|
+
found = []
|
|
24
|
+
pending = [[root, []]]
|
|
25
|
+
until pending.empty?
|
|
26
|
+
node, path = pending.pop
|
|
27
|
+
if (!role || node.role == role) &&
|
|
28
|
+
(!label || (node.label && (label.is_a?(Regexp) ? label.match?(node.label) : node.label == label))) &&
|
|
29
|
+
states.all? { |key, value| node.states.key?(key) && node.states[key] == value }
|
|
30
|
+
found << [node, path.freeze].freeze
|
|
31
|
+
end
|
|
32
|
+
node.children.each_with_index.reverse_each { |child, index| pending << [child, path + [index]] }
|
|
33
|
+
end
|
|
34
|
+
found.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Snapshot = Data.define(:root, :overlays, :text_runs, :frame, :accessibility, :hits) do
|
|
39
|
+
def find(test_id: nil, type: nil) = where(test_id: test_id, type: type).first
|
|
40
|
+
|
|
41
|
+
def where(test_id: nil, type: nil)
|
|
42
|
+
return [].freeze unless root
|
|
43
|
+
found = []
|
|
44
|
+
pending = [root]
|
|
45
|
+
until pending.empty?
|
|
46
|
+
entry = pending.pop
|
|
47
|
+
if (!test_id || entry.test_id == test_id) &&
|
|
48
|
+
(!type || (type.is_a?(Class) ? entry.element.is_a?(type) : entry.type == type))
|
|
49
|
+
found << entry
|
|
50
|
+
end
|
|
51
|
+
pending.concat(entry.children.reverse)
|
|
52
|
+
end
|
|
53
|
+
found.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def at(point)
|
|
57
|
+
return unless point
|
|
58
|
+
owner = hits.reverse_each.find { |hit| hit.contains?(point) }&.owner
|
|
59
|
+
while owner
|
|
60
|
+
pending = [root].compact
|
|
61
|
+
until pending.empty?
|
|
62
|
+
entry = pending.pop
|
|
63
|
+
return entry if entry.element.equal?(owner)
|
|
64
|
+
pending.concat(entry.children)
|
|
65
|
+
end
|
|
66
|
+
owner = owner.respond_to?(:parent) ? owner.parent : nil
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
def snapshot(window)
|
|
75
|
+
raise ArgumentError, "expected a rendered window" unless window.respond_to?(:last_root) && window.respond_to?(:dispatcher)
|
|
76
|
+
|
|
77
|
+
seen = {}
|
|
78
|
+
root = entry(window.last_root, window, seen)
|
|
79
|
+
popup = window.popup if window.respond_to?(:popup)
|
|
80
|
+
popup = Platform::Headless::Popup.new(labels: copy(popup.labels), enabled: copy(popup.enabled),
|
|
81
|
+
selected_index: popup.selected_index, bounds: popup.bounds) if popup
|
|
82
|
+
overlays = Overlays.new(tooltip: copy(window.tooltip_state), popup: popup,
|
|
83
|
+
menu: window.app&.respond_to?(:menu_bar) ? window.app.menu_bar : nil)
|
|
84
|
+
text_runs = copy(window.text_runs)
|
|
85
|
+
frame = FrameInfo.new(number: window.frame_number, stats: copy(window.frame_stats))
|
|
86
|
+
Snapshot.new(root: root, overlays: overlays, text_runs: text_runs, frame: frame,
|
|
87
|
+
accessibility: AccessibilitySnapshot.new(root: copy_accessibility(window.accessibility_tree.root)),
|
|
88
|
+
hits: window.dispatcher.hit_regions)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def idle?(app)
|
|
92
|
+
app.executor.idle? && app.windows.all? { |window| window.closed? || (!window.dirty? && !window.animation_active?) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def perform(window, node, action) = Accessibility.perform(window, SOURCE_NODES[node] || node, action)
|
|
96
|
+
|
|
97
|
+
def copy_accessibility(node)
|
|
98
|
+
return unless node
|
|
99
|
+
result = Accessibility::Node.new(role: node.role, label: copy(node.label), value: copy(node.value),
|
|
100
|
+
bounds: node.bounds, states: copy(node.states),
|
|
101
|
+
children: node.children.map { |child| copy_accessibility(child) }.freeze, actions: copy(node.actions))
|
|
102
|
+
SOURCE_NODES[result] = node
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
private_class_method :copy_accessibility
|
|
106
|
+
|
|
107
|
+
def entry(object, window, seen)
|
|
108
|
+
return unless object && !seen[object.object_id]
|
|
109
|
+
seen[object.object_id] = true
|
|
110
|
+
style = object.resolved_style if object.respond_to?(:resolved_style)
|
|
111
|
+
style ||= object.root&.resolved_style if object.respond_to?(:root)
|
|
112
|
+
handle = object.focus_handle if object.respond_to?(:focus_handle)
|
|
113
|
+
items = object.context_menu_items if object.respond_to?(:context_menu_items)
|
|
114
|
+
menu = items&.map { |label, callback| [label.dup.freeze, !callback.nil?].freeze }&.freeze if items.is_a?(Array)
|
|
115
|
+
Entry.new(element: object, type: object.class.name.to_s.delete_prefix("Zaniah::").freeze,
|
|
116
|
+
key: object.respond_to?(:identity_key) ? copy(object.identity_key) : nil,
|
|
117
|
+
test_id: object.respond_to?(:test_id) ? copy(object.test_id) : nil,
|
|
118
|
+
bounds: object.respond_to?(:layout_node) ? object.layout_node&.bounds : nil,
|
|
119
|
+
style: snapshot_style(style),
|
|
120
|
+
tooltip: object.respond_to?(:tooltip_text) ? copy(object.tooltip_text) : nil,
|
|
121
|
+
context_menu: menu, handlers: object.respond_to?(:handlers) ? copy(object.handlers) : [].freeze,
|
|
122
|
+
focusable: !!handle&.focusable, focused: !!(handle && window.dispatcher.focused.equal?(handle)),
|
|
123
|
+
children: object.respond_to?(:children) ? object.children.filter_map { |child| entry(child, window, seen) }.freeze : [].freeze)
|
|
124
|
+
end
|
|
125
|
+
private_class_method :entry
|
|
126
|
+
|
|
127
|
+
def snapshot_style(style)
|
|
128
|
+
values = style ? style.to_h : {}.freeze
|
|
129
|
+
return values if values.frozen? && values.values.none? { |value| value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash) }
|
|
130
|
+
copy(values)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :snapshot_style
|
|
133
|
+
|
|
134
|
+
def copy(value)
|
|
135
|
+
case value
|
|
136
|
+
when String then value.dup.freeze
|
|
137
|
+
when Array then value.map { |item| copy(item) }.freeze
|
|
138
|
+
when Hash then value.to_h { |key, item| [copy(key), copy(item)] }.freeze
|
|
139
|
+
else value
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_class_method :copy
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.bundled_font_path = File.expand_path("../../assets/fonts/Abel-Regular.ttf", __dir__)
|
|
146
|
+
end
|