zaniah 0.6.4 → 0.8.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/docs/adr/013-public-inspection-api.md +16 -0
  4. data/docs/adr/014-unified-command-dispatch.md +40 -0
  5. data/docs/adr/015-mime-clipboard-types.md +25 -0
  6. data/docs/components.md +18 -6
  7. data/docs/gallery/command-palette-dark.png +0 -0
  8. data/docs/gallery/command-palette-high_contrast.png +0 -0
  9. data/docs/gallery/command-palette-light.png +0 -0
  10. data/docs/gallery/components-dark.png +0 -0
  11. data/docs/gallery/components-high_contrast.png +0 -0
  12. data/docs/gallery/components-light.png +0 -0
  13. data/docs/inspection.md +19 -0
  14. data/docs/layout.md +21 -0
  15. data/docs/menus.md +64 -0
  16. data/docs/native.md +49 -0
  17. data/docs/text.md +19 -0
  18. data/docs/tui.md +3 -2
  19. data/examples/gallery.rb +1 -0
  20. data/examples/native_menu.rb +61 -0
  21. data/lib/zaniah/accessibility/tree.rb +9 -0
  22. data/lib/zaniah/app.rb +10 -1
  23. data/lib/zaniah/clipboard.rb +40 -0
  24. data/lib/zaniah/configuration.rb +1 -1
  25. data/lib/zaniah/devtools/inspector.rb +5 -29
  26. data/lib/zaniah/element.rb +14 -2
  27. data/lib/zaniah/input/action_registry.rb +12 -3
  28. data/lib/zaniah/input/dispatcher.rb +49 -3
  29. data/lib/zaniah/input/focus_handle.rb +3 -2
  30. data/lib/zaniah/input/focus_tree.rb +1 -0
  31. data/lib/zaniah/input/keymap.rb +39 -5
  32. data/lib/zaniah/inspection.rb +151 -0
  33. data/lib/zaniah/menu.rb +104 -0
  34. data/lib/zaniah/platform/headless/window.rb +155 -5
  35. data/lib/zaniah/platform/linux/wayland_window.rb +164 -13
  36. data/lib/zaniah/platform/linux/window.rb +296 -27
  37. data/lib/zaniah/platform/mac/app.rb +59 -4
  38. data/lib/zaniah/platform/mac/clipboard_data.rb +37 -0
  39. data/lib/zaniah/platform/mac/native_menu.rb +168 -0
  40. data/lib/zaniah/platform/mac/window.rb +246 -27
  41. data/lib/zaniah/platform/tui/window.rb +11 -0
  42. data/lib/zaniah/platform/windows/clipboard_data.rb +113 -0
  43. data/lib/zaniah/platform/windows/native_menu.rb +165 -0
  44. data/lib/zaniah/platform/windows/window.rb +264 -32
  45. data/lib/zaniah/task_executor.rb +2 -0
  46. data/lib/zaniah/text.rb +44 -1
  47. data/lib/zaniah/text_buffer.rb +2 -0
  48. data/lib/zaniah/ui/choice_fields.rb +12 -6
  49. data/lib/zaniah/ui/component.rb +7 -1
  50. data/lib/zaniah/ui/controls.rb +2 -0
  51. data/lib/zaniah/ui/data.rb +31 -1
  52. data/lib/zaniah/ui/editors.rb +52 -0
  53. data/lib/zaniah/ui/grid.rb +24 -1
  54. data/lib/zaniah/ui/matcher.rb +88 -0
  55. data/lib/zaniah/ui/modal.rb +128 -10
  56. data/lib/zaniah/ui/overlays.rb +141 -11
  57. data/lib/zaniah/ui/primitives.rb +41 -0
  58. data/lib/zaniah/ui/rich_text_surface.rb +3 -8
  59. data/lib/zaniah/ui.rb +1 -0
  60. data/lib/zaniah/unicode.rb +28 -0
  61. data/lib/zaniah/version.rb +1 -1
  62. data/lib/zaniah/window_state.rb +36 -0
  63. data/lib/zaniah.rb +4 -0
  64. data/sig/accessibility.rbs +1 -0
  65. data/sig/app.rbs +4 -0
  66. data/sig/clipboard.rbs +26 -0
  67. data/sig/elements.rbs +7 -1
  68. data/sig/input.rbs +34 -3
  69. data/sig/inspection.rbs +57 -0
  70. data/sig/menu.rbs +31 -0
  71. data/sig/native.rbs +5 -5
  72. data/sig/platform.rbs +33 -7
  73. data/sig/ui.rbs +42 -4
  74. data/sig/window_state.rbs +11 -0
  75. data/sig/zaniah.rbs +8 -3
  76. metadata +21 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 985d3d62a8ffa112effba1aed1976c2b7403a7e6ef2a49dca9b817c80d49b3da
4
- data.tar.gz: 8e641aad423d506fcbf18fe5c6c22afa8beff2ae5b321ae73559cbec8ed26181
3
+ metadata.gz: 0e4185eb60f475f07fb3deee4bf07f0e7e16240771ee9199f9783d3fe13130cb
4
+ data.tar.gz: 05cafb090e57d8552cda0826d8db0ff1fa980107eab05ab9841824d6a18187c6
5
5
  SHA512:
6
- metadata.gz: e71ed6d538a8ba9f48d3521def68d05957c637d631116dbf5676a47672d750b1128d361b7b8af48cd5231c01742db7ca88b6846961fc30c58339d4bf696e05a0
7
- data.tar.gz: d6d48b77d5a4793d0e4aa2c4a5e23ccd34f4d5d690c858ecba51ad3585571275a12fa3b5ec882c1a03abdc2db174f757e6002d642d9b13b42aafe46b53f1ca23
6
+ metadata.gz: e5da9a69b48d186dcb04cf71cf48922d9e72fd019ff54919451f028cc211262fd8c49ecf85eb6dbbfd8b48213ad324ab98aefc9079473ace7d8a289401079ae4
7
+ data.tar.gz: f2b7ada32c16d932143db6cf68a9c2073e085ef0cda762aa7ed2f07ae7fdee5b9d5b1f5c3a5289b5fc54930fab9dbef1c0720e8035656c8ac700a2904012f431
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-09-24
4
+
5
+ - Add native macOS and Windows menus, declarative context menus, and platform-aware shortcut labels.
6
+ - Add MIME-keyed clipboard content on desktop backends, including HTML, PNG, file URLs, and X11 incremental transfers.
7
+ - Add Grid and Table copy/paste hooks with TSV and HTML data.
8
+ - Add window state persistence, frameless windows, and draggable title regions across native backends.
9
+
10
+ ## 0.7.0 — 2026-09-24
11
+
12
+ - Add a stable frame inspection API with accessibility queries and DevTools integration.
13
+ - Unify focused and application commands with validation, availability, and checked state.
14
+ - Add headless and TUI clipboard support, including OSC 52 output.
15
+ - Add desktop editing shortcuts and copy, cut, paste, undo, and redo for plain and rich text while leaving conflicting TUI shortcuts unbound.
16
+ - Add configurable matching and accessible, highlighted results to the command palette and combobox.
17
+
3
18
  ## 0.6.4 — 2026-09-24
4
19
 
5
20
  - 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.
@@ -0,0 +1,25 @@
1
+ # ADR 015: Use MIME names for clipboard representations
2
+
3
+ - Status: Proposed
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ Applications need to place text, HTML, images, and private formats on the
9
+ clipboard at the same time. Each operating system names these formats
10
+ differently. Exposing those native names would make application copy/paste
11
+ logic platform-specific; using a closed enum would exclude private formats.
12
+
13
+ ## Decision
14
+
15
+ Use MIME strings as the public representation keys. Platform adapters map
16
+ common MIME names to native formats and preserve other names where the system
17
+ allows it. Items contain eagerly supplied, immutable data. The toolkit owns
18
+ transport and format negotiation, not conversion of application data.
19
+
20
+ ## Consequences
21
+
22
+ Applications can offer multiple formats through one API without depending on
23
+ an OS adapter. Adapters must maintain the native mappings and account for
24
+ platform-specific limits. Lazy data providers may be added later if eager
25
+ copies become a measured problem.
data/docs/components.md CHANGED
@@ -31,6 +31,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
31
31
  | L0 | `Spacer` | `(size = nil)` | fixed or flexible | none |
32
32
  | L0 | `Card` | `(*children)`, `child` | theme surface | group |
33
33
  | L0 | `Badge` | `(text, variant:)` | neutral/accent/success/warning/danger | text |
34
+ | L0 | `Kbd` | `(keys, platform:)`; `.for(action, keymap:)` | OS shortcut notation, including multi-stroke bindings | text |
34
35
  | L0 | `Avatar` | `(name, image:, size:)` | initials or PNG | image |
35
36
  | L0 | `Skeleton` | `(width:, height:)` | pulsing loading placeholder | progressbar/busy |
36
37
  | L0 | `EmptyState` | `(title, message:, icon:, action:)` | compositional | group |
@@ -50,7 +51,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
50
51
  | L2 | `Tooltip` | `(text, anchor:, side:, open:)` | top/bottom/left/right | tooltip |
51
52
  | L2 | `Popover` | `(content, anchor:, side:, width:, height:, open:, modal:)` | flipped and viewport-clamped | group |
52
53
  | L2 | `ContextMenu`, `Menu` | `(items, anchor:, open:)` | pointer + arrows/Home/End/Enter/Esc | menu/menuitem |
53
- | L2 | `MenuBar` | `(menus)` | compositional | menubar |
54
+ | L2 | `MenuBar` | `(menus)` or `.from(app.menu_bar)` | declarative menu model or legacy pairs | menubar |
54
55
  | L2 | `Dropdown` | `(label, items:, value:)`; `on_change` | menu-backed | button |
55
56
  | L2 | `TextField` | `(value, placeholder:, label:, prefix:, suffix:, error:, max_length:, clearable:)` | IME, selection, counter | textbox |
56
57
  | L2 | `TextArea` | TextField plus `rows:` | multiline/wrapped | textbox/multiline |
@@ -59,7 +60,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
59
60
  | L2 | `NumberInput` | TextField plus `min:`, `max:`, `step:`; `increment`, `decrement` | numeric | textbox |
60
61
  | L2 | `TagInput` | `(tags, separator:, ...)`; `on_tags_change` | badge list + editor | textbox |
61
62
  | 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 |
63
+ | L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:, matcher:)`; `on_change` | editable, filtered choices with highlighted matches | combobox |
63
64
  | L2 | `MultiSelect` | `(items, value:, label:, disabled:)`; `on_change` | multiple selected badges | listbox |
64
65
  | L2 | `DatePicker` | `(value, min:, max:, label:, disabled:)`; `on_change` | ISO date, day/week keyboard steps | combobox |
65
66
  | L2 | `TimePicker` | `(value, step:, label:, disabled:)`; `on_change` | 24-hour time, minute/hour keyboard steps | combobox |
@@ -77,14 +78,14 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
77
78
  | L3 | `Modal`, `Dialog` | `(content, title:, open:, close_on_scrim:, width:)` | focus trap, scrim, Esc | dialog/modal |
78
79
  | L3 | `Drawer` | Modal plus `side:` | left/right | dialog/modal |
79
80
  | L3 | `Toast` | `(message, variant:, queue:)`; `dismiss` | info/success/warning/danger | live status |
80
- | L3 | `CommandPalette` | `(commands, open:, placeholder:)` | searchable modal | dialog/list |
81
+ | L3 | `CommandPalette` | `(commands, open:, placeholder:, matcher:)`; `.from(app.actions)` | searchable modal, Up/Down/Enter | dialog/list |
81
82
  | L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
82
83
  | L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
83
84
  | L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
84
85
  | L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
85
86
  | L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
86
- | L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit` | virtual rows, sorting, resizing, editing | table/row/cell |
87
- | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize` | two-axis virtualization, frozen panes, visible-cell resize/fill callbacks | table |
87
+ | L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit`, `on_copy`, `on_paste` | virtual rows, sorting, resizing, editing, typed clipboard hooks | table/row/cell |
88
+ | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize`, `on_copy`, `on_paste` | two-axis virtualization, frozen panes, visible-cell resize/fill and typed clipboard hooks | table |
88
89
  | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
89
90
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
90
91
  | L5 | `LineChart`, `BarChart`, `StackedBarChart`, `AreaChart` | `(series, width:, height:, colors:, label:)`; `AreaChart(stacked:)` | shared axes, ticks, grid lines, color-keyed legend, tooltip | image |
@@ -100,6 +101,15 @@ All input components are keyboard operable. Disabled controls remain visible but
100
101
  removed from focus traversal. Overlay components close on Esc; modal overlays restore
101
102
  the previous focus. See [TUI](tui.md) for terminal representations.
102
103
 
104
+ `Combobox` and `CommandPalette` default to case-insensitive substring matching in
105
+ input order. Pass `matcher:` to either component, or set a default with
106
+ `Zaniah.configure { |config| config.matcher = matcher }`. A matcher implements
107
+ `match(query, labels)` and returns `UI::Matcher::Match` values with an original
108
+ label `index`, descending `score`, and half-open UTF-8 byte `ranges` for highlighting.
109
+ It may also implement `refine(previous_matches, query)` for incremental queries.
110
+ `CommandPalette.from(app.actions)` uses registered action titles, disables
111
+ unavailable actions, and displays shortcuts with `UI::Kbd`. See [Menus](menus.md).
112
+
103
113
  `UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
104
114
  `bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
105
115
  center, end, and non-final-line justification, plus bullet/ordered lists and levels.
@@ -134,7 +144,9 @@ viewport.
134
144
  In a table, Up/Down/Home/End/Page keys move and select rows, Shift+Up/Down extends
135
145
  a range, and Cmd/Ctrl+A selects every row in multiple-selection mode. Sortable
136
146
  headers and resize handles are separate Tab stops; Enter sorts and arrow/Page keys
137
- resize. Tree views use Up/Down to select, Right to expand or enter the first child,
147
+ resize. Grid and Table clipboard hooks receive half-open `Grid::Area` ranges;
148
+ Table areas follow the current display order without changing its stable-ID
149
+ selection. See [Layout](layout.md#two-axis-virtual-grid). Tree views use Up/Down to select, Right to expand or enter the first child,
138
150
  and Left to collapse or return to the parent.
139
151
 
140
152
  Run `bundle exec ruby tools/generate_component_gallery.rb` to rebuild the dark,
Binary file
Binary file
Binary file
Binary file
@@ -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/layout.md CHANGED
@@ -75,6 +75,27 @@ apply a validated batch with a single frame request; `row_hidden?` and
75
75
  destination areas; the application owns cell values and fill semantics. Shift
76
76
  extends a range; Cmd/Ctrl-click toggles an additional cell range.
77
77
 
78
+ Copy and paste are opt-in: the grid never owns cell values. `on_copy` receives
79
+ the selected `Grid::Area` values and returns MIME-keyed content, which Zaniah
80
+ writes to the clipboard. `on_paste` receives the same half-open areas and all
81
+ available clipboard formats; the application decides how to apply them.
82
+ Without the corresponding hook, the action is unhandled. With a hook but no
83
+ selection, the action is disabled.
84
+
85
+ ```ruby
86
+ grid.on_copy do |areas, _cx|
87
+ {"text/plain" => cells_as_tsv(areas), "text/html" => cells_as_html(areas)}
88
+ end
89
+ grid.on_paste do |areas, content, _cx|
90
+ paste_cells(areas, content.fetch("text/plain")) if content.types.include?("text/plain")
91
+ end
92
+ ```
93
+
94
+ `UI::Table` and `UI::DataGrid` use the same hooks. Their existing `selection`
95
+ remains a set of stable row IDs; hook arguments are `Grid::Area` ranges in the
96
+ current sorted display order, covering all columns. Disjoint selected rows
97
+ produce separate areas. The application retains ownership of table data.
98
+
78
99
  `bench/grid.rb` measures a headless 800×600 viewport over a 1,000,000×16,000
79
100
  grid at 23 sequential scroll positions, including cell construction, layout,
80
101
  prepaint, and paint. `BUDGET=1 ruby bench/grid.rb` asserts a 16.67 ms median
data/docs/menus.md ADDED
@@ -0,0 +1,64 @@
1
+ # Menus and shortcuts
2
+
3
+ `Zaniah::Menu` describes commands; the application owns what each command does.
4
+ Register actions once and attach a menu model to the app:
5
+
6
+ ```ruby
7
+ app.actions.register(:save, title: "Save") { |cx| save_document(cx) }
8
+ app.menu_bar = Zaniah::Menu.build do
9
+ app_menu
10
+ submenu "File" do
11
+ item :save
12
+ separator
13
+ submenu "Recent", items: -> { recent_items }
14
+ end
15
+ submenu "Edit" do
16
+ standard_edit_items
17
+ end
18
+ window_menu
19
+ end
20
+ ```
21
+
22
+ `recent_items` must return a `Zaniah::Menu` or an array of `Menu::Item` values.
23
+ The callback runs when that submenu opens, not when the menu bar is drawn.
24
+ `app_menu` and `window_menu` are macOS-only placeholders. They are omitted on
25
+ other platforms. A missing item title comes from `app.actions`; a missing
26
+ shortcut comes from the most recent keymap binding valid in an empty context.
27
+ Context-only bindings are not shown. Multi-stroke shortcuts appear in window
28
+ menus and `UI::Kbd`, but native menus show only single strokes.
29
+
30
+ For Linux, headless, and TUI, place `Zaniah::UI::MenuBar.from(app.menu_bar)` in
31
+ the application layout. Zaniah never inserts it automatically. Menu commands
32
+ use `dispatcher.perform(action, source: :menu)` and are enabled or checked by
33
+ `available?` and `checked?` when a menu opens. The in-window menu exposes a
34
+ Back row for nested submenus and restores the previously focused control so
35
+ standard editing commands target that control. `Inspection.snapshot(window).overlays.menu`
36
+ returns the app's current model. Existing `UI::MenuBar.new([[label, items]])`
37
+ and `UI::Menu.new([[label, callback]])` forms remain valid.
38
+
39
+ `UI::Kbd.new("cmd-shift-p", platform: :mac)` displays `⌘⇧P`; on Windows or
40
+ Linux it displays `Super+Shift+P`. `UI::Kbd.for(:save, keymap: keymap)` uses
41
+ the same binding as the menu. Its TUI text always uses readable names such as
42
+ `Ctrl+Shift+P`.
43
+
44
+ Windows attaches a native menu to each window. It refreshes enabled and checked
45
+ states when a menu opens and evaluates dynamic submenus at that time. Single-key
46
+ shortcuts appear after a tab in item labels; key input still runs only through
47
+ the Zaniah keymap, not a Win32 accelerator table. The menu model remains
48
+ available for inspection and in-window display on all backends; headless has
49
+ no system menu.
50
+
51
+ On macOS, `App#menu_bar` becomes the system main menu. The app menu uses native
52
+ About, Hide, Show All, and Quit items; `window_menu` uses the native Window menu.
53
+ Dynamic submenu callbacks run when that submenu opens. Enabled and checked
54
+ states are refreshed from the active window's dispatcher. Native shortcuts
55
+ display single-stroke keymap bindings and route key events through the keymap
56
+ before AppKit can run a matching menu item. A missing menu model keeps the
57
+ original Quit-only menu. Run `ruby examples/native_menu.rb` in a macOS graphical
58
+ session to inspect the native behavior.
59
+
60
+ `element.context_menu(Zaniah::Menu.build { item :save })` accepts the same
61
+ command model as the menu bar, including separators, nested submenus, dynamic
62
+ items, and current enabled/checked state. macOS and Windows use native context
63
+ menus for this model; headless, TUI, and `UI::ContextMenu` use the in-window
64
+ menu. The older `[["Save", -> { save }]]` form remains supported.
data/docs/native.md CHANGED
@@ -42,6 +42,54 @@ file drops, fullscreen, cursors, URL opening, file dialogs, appearance changes,
42
42
  and PNG capture. Platform availability differs; see [the RBS declarations](../sig/native.rbs)
43
43
  and [platform declarations](../sig/platform.rbs) for the exact API.
44
44
 
45
+ ## Clipboard representations
46
+
47
+ `Clipboard::Item` holds eager MIME-keyed data. `text/*` values are UTF-8 text;
48
+ other values are bytes. The window's `clipboard` and `clipboard=` methods remain
49
+ shortcuts for `text/plain`.
50
+
51
+ ```ruby
52
+ item = Zaniah::Clipboard::Item.new(
53
+ "text/plain" => "a\tb",
54
+ "text/html" => "<b>a</b>",
55
+ "image/png" => png_bytes
56
+ )
57
+ window.write_clipboard([item])
58
+ window.clipboard_types # available MIME names, without reading data
59
+ content = window.read_clipboard(types: ["text/html", "text/plain"])
60
+ content.types # formats found, in requested order
61
+ content.fetch("text/html")
62
+ ```
63
+
64
+ Headless keeps representations in per-window memory. TUI also keeps every
65
+ representation in memory and sends only `text/plain` writes to the terminal
66
+ with OSC 52; it cannot read the terminal's clipboard. Native macOS, Windows,
67
+ X11, and Wayland exchange `text/plain`, `text/html`, and `image/png` with other
68
+ applications. macOS also preserves arbitrary MIME types under reversible
69
+ `com.noxdea.zaniah.mime.<hex>` pasteboard identifiers; Windows, X11, and
70
+ Wayland use their native format mechanisms. Native clipboard exchange still
71
+ depends on a running desktop session and should be checked on each target OS.
72
+
73
+ ## Window state and decorations
74
+
75
+ `window.frame` uses logical screen coordinates; `window.state.to_h` can be saved
76
+ by the application and passed back to `window.restore_state`. Missing displays
77
+ and offscreen frames are corrected toward the primary display. `maximize`,
78
+ `minimize`, `restore`, `fullscreen?`, `always_on_top=`, and `on_state_change`
79
+ operate on the native window where supported. `decorations: :native` is the
80
+ default; `:hidden_titlebar` and `:none` enable a custom titlebar built from
81
+ `window_drag_region` and `window_control` elements. `min_size:`, `resizable:`,
82
+ and macOS-only `traffic_lights:` are creation options.
83
+
84
+ X11 window managers must support EWMH for state and drag requests. On X11,
85
+ `:hidden_titlebar` has the same no-decoration effect as `:none` through Motif
86
+ hints. Wayland does not expose a window position: its frame origin is `nil`
87
+ and setting a position raises. Wayland also cannot request always-on-top or
88
+ programmatically restore a minimized window; those operations raise rather
89
+ than claim success. Client-side decorations on Wayland depend on the compositor
90
+ and its decoration protocol. Verify native placement and drag behavior on each
91
+ target desktop before relying on it.
92
+
45
93
  ## Displays, file watching, and terminals
46
94
 
47
95
  `Zaniah::Platform.displays` returns the available displays for a backend.
@@ -73,6 +121,7 @@ ruby examples/native_smoke.rb --gl --check /tmp/zaniah-gl.png
73
121
  ruby examples/linux_smoke.rb
74
122
  ruby examples/linux_smoke.rb --wayland
75
123
  ruby examples/native_watch.rb
124
+ ruby examples/native_menu.rb # macOS only
76
125
  ```
77
126
 
78
127
  Linux XIM composition is exercised in CI with Xvfb and IBus/KKC. Native input,
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
@@ -7,6 +7,7 @@ tests and non-window integrations.
7
7
  | Component family | Terminal representation |
8
8
  | --- | --- |
9
9
  | Label, Badge, Avatar | text, `[badge]`, `(initials)` |
10
+ | Kbd | textual shortcut, such as `Ctrl+Shift+P`; multiple strokes are space-separated |
10
11
  | Icon | Unicode check/close/search/menu/info/warning symbol; unlabeled unknown icons are omitted |
11
12
  | Divider, Card | `─`/`│` and box-drawing characters |
12
13
  | Skeleton, Spinner, ProgressBar, Meter | shade cells, `◌`, or a ten-cell progress track; animation jumps to its terminal state |
@@ -16,11 +17,11 @@ tests and non-window integrations.
16
17
  | Text inputs | `[value]`; password values are masked and validation errors have `!` |
17
18
  | Select, Combobox, MultiSelect | labeled brackets, filtered menu marker, or comma-separated selected values |
18
19
  | DatePicker, TimePicker, ColorPicker | labeled ISO date, 24-hour time, or hex color in brackets |
19
- | Tooltip, Popover, Menu, Dropdown | status text or a box/menu with `>` selection marker |
20
+ | Tooltip, Popover, Menu, MenuBar, Dropdown | status text or a box/menu with `>` selection marker; nested menus expose a Back row |
20
21
  | Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
21
22
  | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
22
23
  | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
23
- | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; focus stays inside until dismissed |
24
+ | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; palette filters results and supports Up/Down/Enter; focus stays inside until dismissed |
24
25
  | SplitPane, PaneGrid, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
25
26
  | ListView | visible rows with `>` on the selected item |
26
27
  | Toast | live status text |
data/examples/gallery.rb CHANGED
@@ -22,6 +22,7 @@ module Gallery
22
22
  .child(Zaniah::Div.new.flex_row.items_center.gap(12).children([
23
23
  Zaniah::UI::Label.new("Label"), Zaniah::UI::Icon.new(:info, label: "Information"),
24
24
  Zaniah::UI::Badge.new("New", variant: :accent), Zaniah::UI::Avatar.new("Ruby UI"),
25
+ Zaniah::UI::Kbd.new("ctrl-shift-p", platform: :windows),
25
26
  Zaniah::UI::Spacer.new(4), Zaniah::UI::Skeleton.new]))
26
27
  .child(Zaniah::UI::Divider.new)
27
28
  .child(Zaniah::UI::EmptyState.new("No results", message: "Try another query").h(64))
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run on macOS with a graphical session. Check File, Edit, Window, Cmd+S,
4
+ # disabled and checked items, and the Recent submenu after opening it twice.
5
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
6
+ require "zaniah/ui"
7
+
8
+ raise "native_menu.rb requires macOS" unless RUBY_PLATFORM.include?("darwin")
9
+
10
+ app = Zaniah::App.new
11
+ count = 0
12
+ enabled = true
13
+ checked = false
14
+ window = nil
15
+ app.actions.register(:save, title: "Save", enabled: ->(_cx) { enabled }) do |_cx|
16
+ count += 1
17
+ puts "Save invoked #{count} time(s)"
18
+ window.request_frame
19
+ end
20
+ app.actions.register(:toggle, title: "Checked item", checked: ->(_cx) { checked }) do |_cx|
21
+ checked = !checked
22
+ window.request_frame
23
+ end
24
+ app.actions.register(:enable_save, title: "Enable Save") do |_cx|
25
+ enabled = !enabled
26
+ puts "Save enabled: #{enabled}"
27
+ end
28
+ app.menu_bar = Zaniah::Menu.build do
29
+ app_menu
30
+ submenu "File" do
31
+ item :save
32
+ item :enable_save
33
+ separator
34
+ submenu "Recent", items: -> { Zaniah::Menu.build { item :save, title: "Example.txt" }.items }
35
+ end
36
+ submenu "Edit" do
37
+ standard_edit_items
38
+ separator
39
+ item :toggle
40
+ end
41
+ window_menu
42
+ end
43
+ window = app.open_window(backend: :mac, width: 500, height: 180, title: "Zaniah native menu") do
44
+ Zaniah::Div.new.p(24).child(Zaniah::UI::Label.new("Open File/Edit/Window; press Cmd+S. Saves: #{count}"))
45
+ end
46
+ window.dispatcher.keymap.bind("cmd-s", :save)
47
+ if ARGV.include?("--check")
48
+ window.tick
49
+ cocoa = Zaniah::Platform::Mac::O
50
+ menu = cocoa.send(Zaniah::Platform::Mac::App.instance.handle, "mainMenu")
51
+ count = cocoa.send(menu, "numberOfItems", result: :long)
52
+ raise "native main menu was not installed (#{count} items)" unless count == 4
53
+ app.menu_bar = nil
54
+ window.tick
55
+ fallback = cocoa.send(Zaniah::Platform::Mac::App.instance.handle, "mainMenu")
56
+ raise "Quit-only menu was not restored" unless cocoa.send(fallback, "numberOfItems", result: :long) == 1
57
+ puts "Native menu installed"
58
+ window.close
59
+ else
60
+ app.run
61
+ end
@@ -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, :menu_bar
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|
@@ -129,6 +131,13 @@ module Zaniah
129
131
  @executor.shutdown
130
132
  end
131
133
 
134
+ def menu_bar=(menu)
135
+ raise ArgumentError, "menu_bar must be a Zaniah::Menu or nil" unless menu.nil? || menu.is_a?(Menu)
136
+ @menu_bar = menu
137
+ @windows.each(&:request_frame)
138
+ menu
139
+ end
140
+
132
141
  def hot_reload(paths, **options, &block)
133
142
  require_relative "devtools"
134
143
  reload = DevTools::HotReload.new(self, paths, **options, &block)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Clipboard
5
+ class Item
6
+ attr_reader :types, :formats
7
+
8
+ def initialize(formats)
9
+ raise TypeError, "clipboard formats must be a Hash" unless formats.is_a?(Hash)
10
+
11
+ @formats = formats.each_with_object({}) do |(type, value), result|
12
+ unless type.is_a?(String) && type.match?(/\A[^\s\/]+\/[^\s\/]+\z/)
13
+ raise ArgumentError, "invalid clipboard MIME type #{type.inspect}"
14
+ end
15
+ raise TypeError, "clipboard data must be a String" unless value.is_a?(String)
16
+
17
+ data = value.dup
18
+ if type.start_with?("text/")
19
+ data = data.encoding == Encoding::BINARY ? data.force_encoding(Encoding::UTF_8) : data.encode(Encoding::UTF_8)
20
+ raise Encoding::InvalidByteSequenceError, "invalid UTF-8 clipboard text" unless data.valid_encoding?
21
+ else
22
+ data.force_encoding(Encoding::BINARY)
23
+ end
24
+ result[type.dup.freeze] = data.freeze
25
+ end.freeze
26
+ @types = @formats.keys.freeze
27
+ freeze
28
+ end
29
+
30
+ def fetch(type) = @formats.fetch(type)
31
+
32
+ def ==(other) = other.instance_of?(self.class) && @formats == other.formats
33
+ alias eql? ==
34
+ def hash = [self.class, @formats].hash
35
+ end
36
+
37
+ class Content < Item
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- Configuration = Struct.new(:font_raster, :shaper, :font_db, :segmenter, keyword_init: true)
4
+ Configuration = Struct.new(:font_raster, :shaper, :font_db, :segmenter, :matcher, keyword_init: true)
5
5
  end