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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/docs/adr/015-mime-clipboard-types.md +25 -0
  4. data/docs/components.md +9 -6
  5. data/docs/gallery/components-dark.png +0 -0
  6. data/docs/gallery/components-high_contrast.png +0 -0
  7. data/docs/gallery/components-light.png +0 -0
  8. data/docs/layout.md +21 -0
  9. data/docs/menus.md +64 -0
  10. data/docs/native.md +49 -0
  11. data/docs/tui.md +2 -1
  12. data/examples/gallery.rb +1 -0
  13. data/examples/native_menu.rb +61 -0
  14. data/lib/zaniah/app.rb +8 -1
  15. data/lib/zaniah/clipboard.rb +40 -0
  16. data/lib/zaniah/element.rb +10 -1
  17. data/lib/zaniah/input/dispatcher.rb +2 -2
  18. data/lib/zaniah/input/focus_tree.rb +1 -0
  19. data/lib/zaniah/input/keymap.rb +8 -0
  20. data/lib/zaniah/inspection.rb +5 -0
  21. data/lib/zaniah/menu.rb +104 -0
  22. data/lib/zaniah/platform/headless/window.rb +148 -7
  23. data/lib/zaniah/platform/linux/wayland_window.rb +164 -13
  24. data/lib/zaniah/platform/linux/window.rb +296 -27
  25. data/lib/zaniah/platform/mac/app.rb +59 -4
  26. data/lib/zaniah/platform/mac/clipboard_data.rb +37 -0
  27. data/lib/zaniah/platform/mac/native_menu.rb +168 -0
  28. data/lib/zaniah/platform/mac/window.rb +246 -27
  29. data/lib/zaniah/platform/tui/window.rb +8 -5
  30. data/lib/zaniah/platform/windows/clipboard_data.rb +113 -0
  31. data/lib/zaniah/platform/windows/native_menu.rb +165 -0
  32. data/lib/zaniah/platform/windows/window.rb +264 -32
  33. data/lib/zaniah/ui/component.rb +7 -1
  34. data/lib/zaniah/ui/controls.rb +2 -0
  35. data/lib/zaniah/ui/data.rb +31 -1
  36. data/lib/zaniah/ui/grid.rb +24 -1
  37. data/lib/zaniah/ui/modal.rb +11 -4
  38. data/lib/zaniah/ui/overlays.rb +141 -11
  39. data/lib/zaniah/ui/primitives.rb +41 -0
  40. data/lib/zaniah/version.rb +1 -1
  41. data/lib/zaniah/window_state.rb +36 -0
  42. data/lib/zaniah.rb +3 -0
  43. data/sig/app.rbs +2 -0
  44. data/sig/clipboard.rbs +26 -0
  45. data/sig/elements.rbs +4 -0
  46. data/sig/input.rbs +5 -1
  47. data/sig/menu.rbs +31 -0
  48. data/sig/native.rbs +5 -5
  49. data/sig/platform.rbs +28 -7
  50. data/sig/ui.rbs +15 -3
  51. data/sig/window_state.rbs +11 -0
  52. data/sig/zaniah.rbs +1 -1
  53. metadata +14 -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: 0e4185eb60f475f07fb3deee4bf07f0e7e16240771ee9199f9783d3fe13130cb
4
+ data.tar.gz: 05cafb090e57d8552cda0826d8db0ff1fa980107eab05ab9841824d6a18187c6
5
5
  SHA512:
6
- metadata.gz: d3e80aba0a90f7e946ce916e63e3eed342cac2b1c0eda9cb9dd14112f1c1a9affd4d0ee13bc2cfc81769bcbd12afa4288849825fe1a093396b6ee43893f68a7d
7
- data.tar.gz: a1bd580a3da60ac7705f4329d7f7a4b435bdd85638e074710a84b2339a40744e5b1e82d7df243ac9eff439a29ca359e6737e7fa731cb86b6dbedd62b585e5db3
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)` | 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/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
@@ -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
@@ -49,6 +49,7 @@ module Zaniah
49
49
 
50
50
  def restore = @traps.pop
51
51
  def release_trap(handle) = @traps.delete(handle)
52
+ def allows?(handle) = @traps.empty? || handle.ancestors.include?(@traps.last)
52
53
 
53
54
  private
54
55
 
@@ -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
@@ -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,
@@ -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