zaniah 0.7.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/docs/adr/015-mime-clipboard-types.md +25 -0
- data/docs/components.md +9 -6
- data/docs/gallery/components-dark.png +0 -0
- data/docs/gallery/components-high_contrast.png +0 -0
- data/docs/gallery/components-light.png +0 -0
- data/docs/layout.md +21 -0
- data/docs/menus.md +64 -0
- data/docs/native.md +49 -0
- data/docs/tui.md +2 -1
- data/examples/gallery.rb +1 -0
- data/examples/native_menu.rb +61 -0
- data/lib/zaniah/app.rb +8 -1
- data/lib/zaniah/clipboard.rb +40 -0
- data/lib/zaniah/element.rb +10 -1
- data/lib/zaniah/input/dispatcher.rb +2 -2
- data/lib/zaniah/input/focus_tree.rb +1 -0
- data/lib/zaniah/input/keymap.rb +8 -0
- data/lib/zaniah/inspection.rb +5 -0
- data/lib/zaniah/menu.rb +104 -0
- data/lib/zaniah/platform/headless/window.rb +148 -7
- data/lib/zaniah/platform/linux/wayland_window.rb +164 -13
- data/lib/zaniah/platform/linux/window.rb +296 -27
- data/lib/zaniah/platform/mac/app.rb +59 -4
- data/lib/zaniah/platform/mac/clipboard_data.rb +37 -0
- data/lib/zaniah/platform/mac/native_menu.rb +168 -0
- data/lib/zaniah/platform/mac/window.rb +246 -27
- data/lib/zaniah/platform/tui/window.rb +8 -5
- data/lib/zaniah/platform/windows/clipboard_data.rb +113 -0
- data/lib/zaniah/platform/windows/native_menu.rb +165 -0
- data/lib/zaniah/platform/windows/window.rb +264 -32
- data/lib/zaniah/ui/component.rb +7 -1
- data/lib/zaniah/ui/controls.rb +2 -0
- data/lib/zaniah/ui/data.rb +31 -1
- data/lib/zaniah/ui/grid.rb +24 -1
- data/lib/zaniah/ui/modal.rb +11 -4
- data/lib/zaniah/ui/overlays.rb +141 -11
- data/lib/zaniah/ui/primitives.rb +41 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah/window_state.rb +36 -0
- data/lib/zaniah.rb +3 -0
- data/sig/app.rbs +2 -0
- data/sig/clipboard.rbs +26 -0
- data/sig/elements.rbs +4 -0
- data/sig/input.rbs +5 -1
- data/sig/menu.rbs +31 -0
- data/sig/native.rbs +5 -5
- data/sig/platform.rbs +28 -7
- data/sig/ui.rbs +15 -3
- data/sig/window_state.rbs +11 -0
- data/sig/zaniah.rbs +1 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e4185eb60f475f07fb3deee4bf07f0e7e16240771ee9199f9783d3fe13130cb
|
|
4
|
+
data.tar.gz: 05cafb090e57d8552cda0826d8db0ff1fa980107eab05ab9841824d6a18187c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5da9a69b48d186dcb04cf71cf48922d9e72fd019ff54919451f028cc211262fd8c49ecf85eb6dbbfd8b48213ad324ab98aefc9079473ace7d8a289401079ae4
|
|
7
|
+
data.tar.gz: f2b7ada32c16d932143db6cf68a9c2073e085ef0cda762aa7ed2f07ae7fdee5b9d5b1f5c3a5289b5fc54930fab9dbef1c0720e8035656c8ac700a2904012f431
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.7.0 — 2026-09-24
|
|
4
11
|
|
|
5
12
|
- Add a stable frame inspection API with accessibility queries and DevTools integration.
|
|
@@ -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)` |
|
|
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
|
|
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
|
|
110
|
-
unavailable actions
|
|
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.
|
|
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
|
|
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/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
|
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)
|
|
@@ -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
|
data/lib/zaniah/element.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
class Element
|
|
5
5
|
include LengthUnits
|
|
6
|
+
WINDOW_CONTROLS = %i[close minimize maximize restore fullscreen].freeze
|
|
6
7
|
|
|
7
8
|
attr_reader :layout_node, :parent, :resolved_style
|
|
8
9
|
|
|
@@ -65,6 +66,14 @@ module Zaniah
|
|
|
65
66
|
def tooltip_text = @tooltip
|
|
66
67
|
def context_menu(items) = (@context_menu = items; self)
|
|
67
68
|
def context_menu_items = @context_menu
|
|
69
|
+
def window_drag_region = (@window_drag_region = true; self)
|
|
70
|
+
def window_drag_region? = !!@window_drag_region
|
|
71
|
+
def window_control(kind)
|
|
72
|
+
raise ArgumentError, "unknown window control" unless WINDOW_CONTROLS.include?(kind)
|
|
73
|
+
@window_control_kind = kind
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
def window_control_kind = @window_control_kind
|
|
68
77
|
def flex = style(display: :flex)
|
|
69
78
|
def flex_row = style(flex_direction: :row)
|
|
70
79
|
def flex_col = style(flex_direction: :column)
|
|
@@ -150,7 +159,7 @@ module Zaniah
|
|
|
150
159
|
@focus_handle.focusable = !@static_flags.include?(:disabled)
|
|
151
160
|
cx.dispatcher.register_focus(@focus_handle)
|
|
152
161
|
end
|
|
153
|
-
unless @handlers.empty? && !@tooltip && !@context_menu && !@style_set.interactive? && !@focus_handle
|
|
162
|
+
unless @handlers.empty? && !@tooltip && !@context_menu && !@style_set.interactive? && !@focus_handle && !@window_drag_region && !@window_control_kind
|
|
154
163
|
cx.dispatcher.hit(bounds, owner: self) do |event|
|
|
155
164
|
if event.is_a?(Input::MouseDown) && event.button == :right && @context_menu
|
|
156
165
|
cx.window.context_menu(@context_menu, position: event.position)
|
|
@@ -13,7 +13,7 @@ module Zaniah
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
class Dispatcher
|
|
16
|
-
attr_reader :focused, :focus_origin, :focus_tree
|
|
16
|
+
attr_reader :focused, :previous_focused, :focus_origin, :focus_tree, :keymap
|
|
17
17
|
attr_accessor :window
|
|
18
18
|
|
|
19
19
|
def initialize(keymap: Keymap.default_ui)
|
|
@@ -30,7 +30,7 @@ module Zaniah
|
|
|
30
30
|
return
|
|
31
31
|
end
|
|
32
32
|
@focused&.on_focus&.call(false)
|
|
33
|
-
@focused, @focus_origin = handle, origin
|
|
33
|
+
@previous_focused, @focused, @focus_origin = @focused, handle, origin
|
|
34
34
|
handle&.on_focus&.call(true)
|
|
35
35
|
reveal(handle)
|
|
36
36
|
end
|
data/lib/zaniah/input/keymap.rb
CHANGED
|
@@ -166,6 +166,14 @@ module Zaniah
|
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def bindings = @bindings.dup.freeze
|
|
169
|
+
|
|
170
|
+
def shortcut_for(action)
|
|
171
|
+
@bindings.reverse_each.find { |binding|
|
|
172
|
+
same_action = binding.action == action ||
|
|
173
|
+
((binding.action.is_a?(Symbol) || binding.action.is_a?(String)) && binding.action.to_s == action.to_s)
|
|
174
|
+
same_action && binding.predicate.call({})
|
|
175
|
+
}&.keys&.join(" ")
|
|
176
|
+
end
|
|
169
177
|
end
|
|
170
178
|
end
|
|
171
179
|
end
|
data/lib/zaniah/inspection.rb
CHANGED
|
@@ -112,6 +112,11 @@ module Zaniah
|
|
|
112
112
|
handle = object.focus_handle if object.respond_to?(:focus_handle)
|
|
113
113
|
items = object.context_menu_items if object.respond_to?(:context_menu_items)
|
|
114
114
|
menu = items&.map { |label, callback| [label.dup.freeze, !callback.nil?].freeze }&.freeze if items.is_a?(Array)
|
|
115
|
+
if items.is_a?(Menu)
|
|
116
|
+
menu = items.resolve(registry: window.app&.actions, keymap: window.dispatcher.keymap).map do |item|
|
|
117
|
+
[item.title.to_s.freeze, item.submenu? || (!!item.action && window.dispatcher.available?(item.action) == :enabled)].freeze
|
|
118
|
+
end.freeze
|
|
119
|
+
end
|
|
115
120
|
Entry.new(element: object, type: object.class.name.to_s.delete_prefix("Zaniah::").freeze,
|
|
116
121
|
key: object.respond_to?(:identity_key) ? copy(object.identity_key) : nil,
|
|
117
122
|
test_id: object.respond_to?(:test_id) ? copy(object.test_id) : nil,
|
data/lib/zaniah/menu.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
class Menu
|
|
5
|
+
Item = Data.define(:action, :title, :shortcut, :role, :children, :dynamic) do
|
|
6
|
+
def separator? = role == :separator
|
|
7
|
+
def submenu? = role == :submenu
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Builder
|
|
11
|
+
attr_reader :items
|
|
12
|
+
|
|
13
|
+
def initialize(owner: nil)
|
|
14
|
+
@items, @owner = [], owner
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def item(action, title: nil, shortcut: nil, role: nil)
|
|
18
|
+
raise ArgumentError, "menu action must be a Symbol or String" unless action.is_a?(Symbol) || action.is_a?(String)
|
|
19
|
+
raise ArgumentError, "menu title must be a String" unless title.nil? || title.is_a?(String)
|
|
20
|
+
raise ArgumentError, "menu shortcut must be a String" unless shortcut.nil? || shortcut.is_a?(String)
|
|
21
|
+
raise ArgumentError, "menu role must be a Symbol" unless role.nil? || role.is_a?(Symbol)
|
|
22
|
+
@items << Item.new(action: action, title: title, shortcut: shortcut, role: role, children: [].freeze, dynamic: nil)
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def submenu(title, items: nil, &block)
|
|
27
|
+
raise ArgumentError, "provide either items: or a block" if items && block
|
|
28
|
+
raise ArgumentError, "submenu title must be a String" unless title.is_a?(String)
|
|
29
|
+
raise ArgumentError, "items: must be callable" if items && !items.respond_to?(:call)
|
|
30
|
+
children = block ? self.class.new(owner: @owner).tap { |builder| builder.instance_eval(&block) }.items : []
|
|
31
|
+
@items << Item.new(action: nil, title: title, shortcut: nil, role: :submenu,
|
|
32
|
+
children: children.freeze, dynamic: items)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def separator
|
|
37
|
+
@items << Item.new(action: nil, title: nil, shortcut: nil, role: :separator, children: [].freeze, dynamic: nil)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def app_menu = (special_menu(:app_menu); self)
|
|
42
|
+
def window_menu = (special_menu(:window_menu); self)
|
|
43
|
+
|
|
44
|
+
def standard_edit_items
|
|
45
|
+
item :undo
|
|
46
|
+
item :redo
|
|
47
|
+
separator
|
|
48
|
+
%i[cut copy paste select_all].each { |action| item(action) }
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
55
|
+
return super unless @owner&.respond_to?(name, true)
|
|
56
|
+
@owner.__send__(name, *args, **kwargs, &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def respond_to_missing?(name, include_private = false) = @owner&.respond_to?(name, true) || super
|
|
60
|
+
|
|
61
|
+
def special_menu(role)
|
|
62
|
+
@items << Item.new(action: nil, title: nil, shortcut: nil, role: role, children: [].freeze, dynamic: nil)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :items
|
|
67
|
+
|
|
68
|
+
def self.build(&block)
|
|
69
|
+
raise ArgumentError, "menu block is required" unless block
|
|
70
|
+
new(Builder.new(owner: block.binding.receiver).tap { |builder| builder.instance_eval(&block) }.items)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(items)
|
|
74
|
+
raise ArgumentError, "menu items must be Menu::Item values" unless items.is_a?(Array) && items.all? { |item| item.is_a?(Item) }
|
|
75
|
+
@items = items.dup.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolve(registry: nil, keymap: nil, platform: RUBY_PLATFORM)
|
|
79
|
+
@items.filter_map { |item| resolve_item(item, registry, keymap, platform) }.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def children_for(item, registry: nil, keymap: nil, platform: RUBY_PLATFORM)
|
|
83
|
+
children = item.dynamic ? dynamic_items(item.dynamic.call) : item.children
|
|
84
|
+
self.class.new(children).resolve(registry: registry, keymap: keymap, platform: platform)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def resolve_item(item, registry, keymap, platform)
|
|
90
|
+
return if %i[app_menu window_menu].include?(item.role) && !platform.to_s.match?(/darwin|mac/)
|
|
91
|
+
return item if item.separator?
|
|
92
|
+
|
|
93
|
+
command = registry&.command(item.action) if item.action
|
|
94
|
+
item.with(title: item.title || command&.title&.to_s || item.action&.to_s,
|
|
95
|
+
shortcut: item.shortcut || (item.action && keymap&.shortcut_for(item.action)))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def dynamic_items(value)
|
|
99
|
+
value = value.items if value.is_a?(Menu)
|
|
100
|
+
raise ArgumentError, "dynamic menu items must be Menu::Item values" unless value.is_a?(Array) && value.all? { |item| item.is_a?(Item) }
|
|
101
|
+
value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|