zaniah 0.7.0 → 0.9.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/adr/005-instance-layout.md +4 -4
  4. data/docs/adr/015-mime-clipboard-types.md +25 -0
  5. data/docs/adr/016-semantic-vector-recording.md +33 -0
  6. data/docs/adr/017-analytic-shadows.md +31 -0
  7. data/docs/adr/018-gradient-ramps.md +30 -0
  8. data/docs/components.md +9 -6
  9. data/docs/gallery/components-dark.png +0 -0
  10. data/docs/gallery/components-high_contrast.png +0 -0
  11. data/docs/gallery/components-light.png +0 -0
  12. data/docs/layout.md +21 -0
  13. data/docs/menus.md +64 -0
  14. data/docs/native.md +49 -0
  15. data/docs/tui.md +2 -1
  16. data/docs/vector.md +60 -0
  17. data/docs/vector_and_list.md +13 -4
  18. data/examples/gallery.rb +1 -0
  19. data/examples/metal_effect_check.rb +30 -0
  20. data/examples/native_menu.rb +61 -0
  21. data/lib/zaniah/app.rb +8 -1
  22. data/lib/zaniah/clipboard.rb +40 -0
  23. data/lib/zaniah/element.rb +10 -1
  24. data/lib/zaniah/gpu/instance_packing.rb +2 -0
  25. data/lib/zaniah/gpu/metal.rb +27 -5
  26. data/lib/zaniah/gpu/open_gl.rb +29 -6
  27. data/lib/zaniah/gpu/software.rb +58 -6
  28. data/lib/zaniah/gpu/vulkan/scene.frag +26 -4
  29. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  30. data/lib/zaniah/gpu/vulkan/scene.vert +2 -0
  31. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  32. data/lib/zaniah/image.rb +2 -2
  33. data/lib/zaniah/input/dispatcher.rb +2 -2
  34. data/lib/zaniah/input/focus_tree.rb +1 -0
  35. data/lib/zaniah/input/keymap.rb +8 -0
  36. data/lib/zaniah/inspection.rb +5 -0
  37. data/lib/zaniah/menu.rb +104 -0
  38. data/lib/zaniah/platform/headless/window.rb +149 -7
  39. data/lib/zaniah/platform/linux/wayland_window.rb +164 -13
  40. data/lib/zaniah/platform/linux/window.rb +296 -27
  41. data/lib/zaniah/platform/mac/app.rb +59 -4
  42. data/lib/zaniah/platform/mac/clipboard_data.rb +37 -0
  43. data/lib/zaniah/platform/mac/native_menu.rb +168 -0
  44. data/lib/zaniah/platform/mac/window.rb +246 -27
  45. data/lib/zaniah/platform/tui/window.rb +35 -5
  46. data/lib/zaniah/platform/windows/clipboard_data.rb +113 -0
  47. data/lib/zaniah/platform/windows/native_menu.rb +165 -0
  48. data/lib/zaniah/platform/windows/window.rb +264 -32
  49. data/lib/zaniah/scene.rb +167 -27
  50. data/lib/zaniah/style/gradient.rb +6 -1
  51. data/lib/zaniah/svg/rasterizable.rb +54 -0
  52. data/lib/zaniah/svg.rb +157 -14
  53. data/lib/zaniah/text_system/renderer.rb +31 -1
  54. data/lib/zaniah/ui/component.rb +7 -1
  55. data/lib/zaniah/ui/controls.rb +2 -0
  56. data/lib/zaniah/ui/data.rb +31 -1
  57. data/lib/zaniah/ui/grid.rb +24 -1
  58. data/lib/zaniah/ui/modal.rb +11 -4
  59. data/lib/zaniah/ui/overlays.rb +141 -11
  60. data/lib/zaniah/ui/primitives.rb +41 -0
  61. data/lib/zaniah/vector.rb +63 -0
  62. data/lib/zaniah/version.rb +1 -1
  63. data/lib/zaniah/window_state.rb +36 -0
  64. data/lib/zaniah.rb +3 -0
  65. data/sig/app.rbs +2 -0
  66. data/sig/clipboard.rbs +26 -0
  67. data/sig/elements.rbs +4 -0
  68. data/sig/input.rbs +5 -1
  69. data/sig/menu.rbs +31 -0
  70. data/sig/native.rbs +5 -5
  71. data/sig/platform.rbs +28 -7
  72. data/sig/scene.rbs +9 -1
  73. data/sig/ui.rbs +15 -3
  74. data/sig/vector.rbs +116 -0
  75. data/sig/window_state.rbs +11 -0
  76. data/sig/zaniah.rbs +1 -1
  77. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74d3240032d7ae845a8a1f8f3208cb102ef0b68637cc184ba0f8860ac57b05ee
4
- data.tar.gz: 4166002c76247745610218aed5d9090459889b9b383b97b669b199adac46761b
3
+ metadata.gz: cc06ae1720a8d05abbc0da78849a355f7c36565c62b3907f941c80dd066594c5
4
+ data.tar.gz: fd00f6d9bd08c097a5ffa35ce079a83f949c07874d6ff7c76cdc802f7ddd8129
5
5
  SHA512:
6
- metadata.gz: d3e80aba0a90f7e946ce916e63e3eed342cac2b1c0eda9cb9dd14112f1c1a9affd4d0ee13bc2cfc81769bcbd12afa4288849825fe1a093396b6ee43893f68a7d
7
- data.tar.gz: a1bd580a3da60ac7705f4329d7f7a4b435bdd85638e074710a84b2339a40744e5b1e82d7df243ac9eff439a29ca359e6737e7fa731cb86b6dbedd62b585e5db3
6
+ metadata.gz: 769bfa73b348d773f2139eb34039a7250f6878986375bdd25c361c3922b16d61ab68a3d14c59f8ef7f3cfd68e04a43d868286b624d1996d2fc2afdb92b23f861
7
+ data.tar.gz: a9642fa580bd9595e15c48903e698b95dfc5a6f876ca0d71438e488899522e2855351cef18a124e005dae5636e53b500aa46e90e101575c12796fc4e9ae80992
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-09-24
4
+
5
+ - Add semantic vector recording for UI scenes, including text, images, and raster fallbacks for unsupported drawing.
6
+ - Render shadows analytically across backends; their edge appearance changes from earlier releases.
7
+ - Add shared multi-stop and conic gradient ramps across Software, OpenGL, Metal, and Vulkan rendering.
8
+ - Support SVG gradients, dashed strokes, and alpha and luminance masks.
9
+
10
+ ## 0.8.0 — 2026-09-24
11
+
12
+ - Add native macOS and Windows menus, declarative context menus, and platform-aware shortcut labels.
13
+ - Add MIME-keyed clipboard content on desktop backends, including HTML, PNG, file URLs, and X11 incremental transfers.
14
+ - Add Grid and Table copy/paste hooks with TSV and HTML data.
15
+ - Add window state persistence, frameless windows, and draggable title regions across native backends.
16
+
3
17
  ## 0.7.0 — 2026-09-24
4
18
 
5
19
  - Add a stable frame inspection API with accessibility queries and DevTools integration.
@@ -26,10 +26,10 @@ fields for source UVs. Triangle vertices reuse rect and radii fields.
26
26
  | 32–37 | affine transform (`a,b,c,d,tx,ty`) | affine transform |
27
27
  | 38–39 | reserved shadow spread / inset or border flags | unused |
28
28
 
29
- Opacity is multiplied into both fill and border alpha before packing. Linear
30
- and radial gradients use exactly two stops; a future multi-stop implementation
31
- may use a 1D texture without another stride change. Shadows are expanded into
32
- ordinary quad instances, so the reserved shadow fields remain available.
29
+ Opacity is multiplied into both fill and border alpha before packing. The
30
+ original two-stop and expanded-quad behavior has since been extended without
31
+ changing this layout: see [ADR 017](017-analytic-shadows.md) for the shadow
32
+ fields and [ADR 018](018-gradient-ramps.md) for multistop ramps and conic fills.
33
33
 
34
34
  Metal, OpenGL, and Vulkan consume the layout directly, Software consumes the
35
35
  equivalent 40-float quad layout, and cached text sprite batches use the same
@@ -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.
@@ -0,0 +1,33 @@
1
+ # ADR 016: Record semantic drawing alongside the GPU scene
2
+
3
+ - Status: Proposed
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The GPU scene contains packed quads and atlas sprites. Paths have already been
9
+ rasterized and text has lost its font, glyph IDs, and source clusters by the
10
+ time a renderer consumes those commands. Exporters need this information to
11
+ produce searchable text and scalable artwork. Reconstructing it from packed
12
+ GPU instances is impossible; maintaining a second application-specific layout
13
+ for export makes screen and export drift.
14
+
15
+ Two viable boundaries were considered: make the GPU scene itself semantic, or
16
+ keep its fast packed representation and optionally observe the drawing calls.
17
+
18
+ ## Decision
19
+
20
+ Keep the GPU scene unchanged and attach an optional vector sink to `Scene`.
21
+ Emit semantic commands before paths and text are flattened. When a source has
22
+ no supported vector form, preserve its pixels as a raster command instead.
23
+ The sink is absent by default, so normal frames do not allocate vector data.
24
+ Page assembly and document formats remain outside Zaniah.
25
+
26
+ ## Consequences
27
+
28
+ Exporters can reuse the same element tree and retain font and outline data
29
+ without adding a dependency on a PDF library. The optional path incurs a
30
+ nil check when unused and stores additional frame data when enabled. Raster
31
+ fallbacks keep output complete but cannot scale as cleanly as true vectors.
32
+ This choice should be revisited if packed GPU commands become a lossless
33
+ semantic representation or a common display list can serve both consumers.
@@ -0,0 +1,31 @@
1
+ # ADR 017: Draw each shadow as one analytic instance
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The previous shadow approximation layered eight rounded quads. It produced
9
+ visible bands and made a scene with 100 shadows submit 800 instances.
10
+
11
+ ## Decision
12
+
13
+ Keep the 40-float instance layout from ADR 005. Primitive kind `4` in field 31
14
+ is a shadow; field 30 is blur sigma, field 38 is spread, and field 39 is the
15
+ inset flag. Bounds 0–3 are expanded by `spread + 3 * blur + 1` for outer
16
+ shadows. Inset shadows retain the original bounds. Corner radii occupy 12–15.
17
+
18
+ Software and all three GPU fragment shaders evaluate the signed distance to
19
+ the original rounded rectangle. A Gaussian edge is approximated by
20
+ `0.5 - 0.5 * erf((distance - spread) / (sqrt(2) * blur))`, using the same bounded
21
+ exponential approximation of `erf` in every backend. Zero blur uses direct
22
+ coverage. Inset shadows multiply interior coverage by the inverted transition.
23
+ The existing clipping, transforms, and alpha blending remain unchanged.
24
+
25
+ ## Consequences
26
+
27
+ Shadow appearance intentionally changes; the golden images and changelog must
28
+ record this. `bench/shadow.rb` compares 100 shadows against the old eight-quad
29
+ approximation and requires the analytic Software path to be faster. GPU native
30
+ capture tests remain platform-dependent; `script/compile_shaders` regenerates
31
+ the bundled Vulkan SPIR-V from the checked-in GLSL.
@@ -0,0 +1,30 @@
1
+ # ADR 018: Use bounded ramps for multistop gradients
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The shared instance format carries only two inline stop colors. Extending its
9
+ stride would affect every renderer and text/sprite packing.
10
+
11
+ ## Decision
12
+
13
+ Two-stop linear and radial gradients retain their inline color path. Two-stop
14
+ conic gradients use kind `3` and the same interpolation path. Three or more
15
+ stops use kinds `4` (linear), `5` (radial), or `6` (conic), and one row of a
16
+ 256-by-256 RGBA8 atlas baked from the ordered stops. Field 8 stores the row
17
+ index; all renderers sample that row at the calculated gradient position. A
18
+ `Scene` caches rows by immutable `Gradient` value in an LRU capped at 256
19
+ entries. Rows used in the current frame cannot be evicted; if a frame exceeds
20
+ 256 distinct ramps, it uses reusable overflow atlases for the remainder of
21
+ that frame. Normal quads continue to use packed-byte fast paths; only ramp
22
+ quads need texture-aware batching.
23
+
24
+ ## Consequences
25
+
26
+ No instance layout migration is required. Sampling precision is limited to
27
+ 256 positions. Adjacent ramps on the shared atlas batch together, while
28
+ overflows can add batches. SVG gradient fills use the same ordered-stop
29
+ interpolation semantics and fall back to a raster snapshot in vector recording
30
+ because a `Vector::Path` currently stores solid fills only.
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 |
@@ -83,8 +84,8 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
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 |
@@ -106,8 +107,8 @@ input order. Pass `matcher:` to either component, or set a default with
106
107
  `match(query, labels)` and returns `UI::Matcher::Match` values with an original
107
108
  label `index`, descending `score`, and half-open UTF-8 byte `ranges` for highlighting.
108
109
  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.
110
+ `CommandPalette.from(app.actions)` uses registered action titles, disables
111
+ unavailable actions, and displays shortcuts with `UI::Kbd`. See [Menus](menus.md).
111
112
 
112
113
  `UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
113
114
  `bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
@@ -143,7 +144,9 @@ viewport.
143
144
  In a table, Up/Down/Home/End/Page keys move and select rows, Shift+Up/Down extends
144
145
  a range, and Cmd/Ctrl+A selects every row in multiple-selection mode. Sortable
145
146
  headers and resize handles are separate Tab stops; Enter sorts and arrow/Page keys
146
- 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,
147
150
  and Left to collapse or return to the parent.
148
151
 
149
152
  Run `bundle exec ruby tools/generate_component_gallery.rb` to rebuild the dark,
Binary file
Binary file
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/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,7 +17,7 @@ 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 |
data/docs/vector.md ADDED
@@ -0,0 +1,60 @@
1
+ # Vector recording
2
+
3
+ Zaniah records the drawing it already performs without taking ownership of PDF
4
+ pages, paper sizes, or export dialogs. Require `zaniah/vector` and render a normal
5
+ element tree offscreen:
6
+
7
+ ```ruby
8
+ document = Zaniah::Vector.record(width: 960, height: 540,
9
+ theme: Zaniah::Theme.light) { slide }
10
+ document.commands.each { |command| export_command(command) }
11
+ ```
12
+
13
+ To inspect a displayed frame, attach a recorder before rendering. `Scene#clear`
14
+ resets its commands each frame; save `recorder.document` before the next frame.
15
+
16
+ ```ruby
17
+ recorder = Zaniah::Vector::Recorder.new
18
+ window.scene.vector_sink = recorder
19
+ window.tick
20
+ document = recorder.document
21
+ window.scene.vector_sink = nil
22
+ ```
23
+
24
+ `Document#width` and `#height` are logical pixels. A `Recorder` attached to a
25
+ window receives that window's content size; a recorder attached to a bare
26
+ `Scene` may instead be initialized with `width:` and `height:`. Coordinates use
27
+ a top-left origin and downward y axis; colors are sRGB. Every command carries
28
+ its transform, clip, opacity, layer, and sequence. `Document#commands` is sorted
29
+ by layer and then sequence, matching `Scene#each_command`.
30
+
31
+ | Command | Export information |
32
+ |---|---|
33
+ | `Quad` | Bounds, fill color or gradient, corner radii, border widths/color/style |
34
+ | `Shadow` | Bounds, corner radii, color, blur, spread, inset |
35
+ | `Path` | Alhena outline, fill, stroke, stroke width, fill rule and stroke cap/join |
36
+ | `GlyphRun` | Alhena font, size, `[glyph_id, x, baseline_y]` triples, original text and UTF-8 byte clusters |
37
+ | `Image` | Decoded `Zaniah::Image`, bounds and source rectangle, plus a frozen pixel snapshot |
38
+ | `Underline` | Position, thickness and wave flag |
39
+ | `Raster` | Frozen source texture bytes, pixel dimensions/format, tint and source rectangle |
40
+
41
+ Ordinary sprites and packed sprite batches become `Raster`; bitmap-color text
42
+ uses that fallback for the whole line. Simple SVG fills and strokes become
43
+ `Path` commands. SVG clipping, gradients, dash arrays, masks, group opacity,
44
+ and references use a `Raster`
45
+ fallback, so they are not silently omitted. The `Raster` bytes are a snapshot
46
+ at recording time, even if the source texture changes later. The converter must
47
+ crop to `source`, apply `color` and `opacity`, then respect `transform` and
48
+ `clip`. It may rasterize a `Path` or `GlyphRun` when its output format lacks
49
+ an equivalent vector primitive.
50
+
51
+ The headless offscreen API works on every OS and does not require a GPU.
52
+ Attaching a recorder to macOS, Windows, X11, or Wayland uses the same `Scene`
53
+ path. The TUI backend captures its terminal cells as a single `Raster` fallback
54
+ at an 8-by-20 cell grid, using a bundled font approximation; terminal font
55
+ appearance is outside Zaniah's control. Use the offscreen API when scalable
56
+ text is required.
57
+
58
+ See [ADR 016](adr/016-semantic-vector-recording.md) for why this is separate
59
+ from the GPU command stream. [Vector and list](vector_and_list.md) documents
60
+ the SVG input subset.
@@ -13,15 +13,24 @@ icon = Zaniah::SVG.parse(
13
13
  ```
14
14
 
15
15
  The renderer supports SVG paths, basic shapes, groups, transforms, view boxes,
16
- inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`, and
17
- user-space clip paths. It is intended for static icons, not arbitrary web SVG.
16
+ inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`,
17
+ user-space clip paths, linear/radial gradients, stroke dash arrays and offsets,
18
+ and local alpha/luminance masks. It is intended for static icons, not arbitrary
19
+ web SVG.
18
20
 
19
- Scripts, external references, CSS stylesheets, gradients, filters, masks, images,
20
- text, markers, dash arrays, nested viewports, and object-bounding-box clips are
21
+ Scripts, external references, CSS stylesheets, filters, images,
22
+ text, markers, nested viewports, and object-bounding-box clips are
21
23
  unsupported and raise `ArgumentError`. Input is bounded to 2 MiB, 10,000 XML
22
24
  nodes, 64 levels, and 100,000 path operations; each output texture is limited to
23
25
  one megapixel.
24
26
 
27
+ SVG gradients support ordered stops, local references, `objectBoundingBox` and
28
+ `userSpaceOnUse` coordinates, and the default pad spread. Other spread methods
29
+ and external references remain unsupported. Masks rasterize within the same
30
+ one-megapixel output limit. Gradient, dashed, and masked shapes are represented
31
+ as a `Vector::Raster` fallback when recording; simple solid outlines remain
32
+ `Vector::Path` commands.
33
+
25
34
  ## Variable-height lists
26
35
 
27
36
  ```ruby
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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/zaniah"
4
+ abort "This check needs macOS and a graphical login" unless RUBY_PLATFORM.include?("darwin")
5
+
6
+ window = Zaniah::Platform.open_window(backend: :mac, gpu: :metal, width: 96, height: 72,
7
+ title: "Metal effect check")
8
+ begin
9
+ scene = Zaniah::Scene.new
10
+ scene.quad(4, 4, 30, 24, color: Zaniah::Gradient.linear(angle: 0,
11
+ stops: [[0, "#f00"], [0.5, "#0f0"], [1, "#00f"]]))
12
+ scene.quad(38, 4, 30, 24, color: Zaniah::Gradient.conic(stops: [[0, "#f00"], [1, "#00f"]]))
13
+ scene.shadow(28, 40, 32, 18, color: "#000", blur: 3, spread: 1, radius: 4)
14
+ expected = Zaniah::GPU::Software.new(96, 72).render(scene, clear: "#fff")
15
+ window.device.render(scene, clear: "#fff")
16
+ actual = window.device.pixels
17
+ scale = window.scale_factor
18
+ pixel_width = (96 * scale).round
19
+ samples = [[6, 15], [18, 15], [31, 15], [60, 16], [22, 49], [28, 49], [44, 49], [75, 49]]
20
+ samples.each do |x, y|
21
+ index = (y * 96 + x) * 4
22
+ native_index = (((y + 0.5) * scale).floor * pixel_width + ((x + 0.5) * scale).floor) * 4
23
+ left, right = expected.byteslice(index, 4).bytes, actual.byteslice(native_index, 4).bytes
24
+ raise "effect mismatch at #{x},#{y}: #{right.inspect} != #{left.inspect}" unless
25
+ left.zip(right).all? { |a, b| (a - b).abs <= 8 }
26
+ end
27
+ puts "Metal analytic shadow and gradient check: #{samples.length} samples passed"
28
+ ensure
29
+ window.close
30
+ end
@@ -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
data/lib/zaniah/app.rb CHANGED
@@ -7,7 +7,7 @@ require_relative "task_executor"
7
7
 
8
8
  module Zaniah
9
9
  class App
10
- attr_reader :windows, :executor, :actions
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}
@@ -131,6 +131,13 @@ module Zaniah
131
131
  @executor.shutdown
132
132
  end
133
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
+
134
141
  def hot_reload(paths, **options, &block)
135
142
  require_relative "devtools"
136
143
  reload = DevTools::HotReload.new(self, paths, **options, &block)